# Tổng quan Tài liệu Astra CMS: vì sao có Astra, cách xây dựng, 22 video demo và 21 bài viết chuyên sâu. Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/overview Astra là nền tảng metadata-driven kiểu Frappe/ERPNext viết lại bằng Java, thiết kế để thay đổi liên tục không downtime và AI-native. Bộ tài liệu này gồm ý tưởng, kiến trúc, loạt video demo ngắn và một bài chuyên sâu cho mỗi video. ## Bắt đầu từ đâu - [Vì sao có Astra](/astra-cms/vi/why-astra): bài nói dài đứng sau dự án. - [Kiến trúc](/astra-cms/vi/architecture): bức tranh một trang về đường đi của một lệnh ghi. - [Video demo](/astra-cms/vi/videos): 22 video ngắn xem theo thứ tự. - [Bài viết](/astra-cms/vi/quickstart): mỗi video một bài, có trích code từ repository. ## Nguồn Repository: [ZeroHostCMS/astra-cms](https://github.com/ZeroHostCMS/astra-cms). Dự án là lát cắt mỏng chạy được, chưa phải sản phẩm hoàn chỉnh; mỗi bài đều nêu phần chưa xây. --- # Vì sao có Astra English · Tiếng Việt · Về README Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/why-astra [English](/astra-cms/vi/why-astra) · **Tiếng Việt** · Về [README](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/README.vi.md) > Tài liệu để trình bày trước công chúng (meetup, talk, bài viết). Nói liền mạch trong 40 phút. Mỗi phần có **ý chính để nói**, **tham chiếu nền tảng lớn**, **lý do**, và **đánh đổi**. Số liệu hiệu năng của Astra là **mục tiêu thiết kế**, chưa đo; số của nền tảng khác là tuyên bố của chính họ. --- ## 0. Mở đầu: ERPNext đã chứng minh điều gì **Ý chính để nói:** ERPNext chứng minh một điều ngành phần mềm doanh nghiệp gần như đã bỏ cuộc: **một nền tảng mở có thể chạy nội dung, bán hàng, kế toán, kho, sản xuất, nhân sự, dự án của cả một công ty**, và team nhỏ tùy biến được mà không phải fork. Lý do không nằm ở một module cụ thể mà ở ý tưởng lõi của Frappe: **ứng dụng là metadata**. Một DocType định nghĩa field, validation, quyền, form, list, API và workflow của một loại chứng từ dưới dạng dữ liệu; mọi thứ còn lại (REST, UI, báo cáo, in, thông báo) được sinh ra từ đó. Astra lấy ý tưởng đó và xây lại cho bốn ràng buộc mà Frappe không được thiết kế để giải: 1. **Thông lượng và độ trễ ở quy mô cụm.** Java 25 virtual thread, Generational ZGC, Quarkus wiring lúc build, cô lập plugin trong tiến trình. Hàng nghìn tenant và tùy biến của họ trên một pod, không phải mỗi site một tiến trình. 2. **Đổi mỗi ngày mà không phá hôm qua.** DocType zero-DDL (tùy biến form không bao giờ khóa bảng), phiên bản app chạy song song, ghim theo tenant, canary tự rollback, SPI có chính sách deprecation, và tùy biến của tenant lưu dạng overlay sống sót qua mọi lần nâng cấp. 3. **AI vừa xây app vừa dùng app.** Agent xây app trên SPI nhỏ có kiểu với sandbox và testkit; agent dùng nền tảng qua MCP tool sinh từ chính DocType, dưới cùng quyền và audit. 4. **Tuân thủ là thuộc tính của metadata.** Mỗi field DocType mang phân loại, thời hạn lưu, cơ sở pháp lý, nơi lưu trữ. Hồ sơ xử lý dữ liệu, yêu cầu của chủ thể dữ liệu, lịch xoá, audit trail, rà soát truy cập đều sinh từ metadata đó. Nhà vận hành đi tới SOC 2 Type II, ISO 27001, GDPR, Luật Bảo vệ dữ liệu cá nhân Việt Nam mà không phải làm lại mô hình dữ liệu. > **Nói rõ hai điều ngay đầu:** (1) Astra đang ở giai đoạn thiết kế; đã có một **lát cắt mỏng chạy thật** (DocType CMS, tenancy bằng ScopedValue + row-level security, REST sinh ra, hook tầng 0, chạy trên k3d hai pod), phần còn lại là blueprint. (2) **Clean-room:** Frappe là MIT, ERPNext là GPL-3.0, Astra là MIT. Astra không dùng một dòng code, schema hay asset nào của cả hai; chỉ mượn khái niệm và từ vựng ("DocType"). Nói điều này trước để không ai hiểu nhầm là port. **Một câu tóm tắt:** *Astra là nền tảng ứng dụng metadata-driven kiểu Frappe, viết lại từ đầu bằng Java 25, nơi mọi thứ là plugin; ứng dụng đầu tiên là CMS, ứng dụng thứ hai là ERP; đổi mỗi ngày, không downtime, và mọi DocType đồng thời là tool cho AI agent.* --- ## 1. Học từ ai | Nền tảng | Họ làm gì | Astra mượn gì | | --- | --- | --- | | **Frappe / ERPNext** | DocType metadata điều khiển schema, form, API, quyền, workflow. App là đơn vị cài đặt có hook. Custom Field và Property Setter cho phép tùy biến DocType lõi mà không fork, sống qua nâng cấp. Chứng từ submittable (nháp → đã ghi sổ → hủy; amend tạo bản mới) cho bất biến cấp kế toán. Bench chạy nhiều site. | Mô hình DocType, app-là-plugin, tùy-biến-là-overlay, vòng đời submittable, kỷ luật "mọi thứ sinh từ metadata". Xây lại bằng Java với lưu trữ zero-DDL, rollout cụm, và các tầng cô lập Frappe không có. | | **Linux kernel** | Không bao giờ phá userspace. | `astra-spi` là userspace ABI. | | **Kubernetes** | API alpha → beta → GA; GA deprecate vẫn chạy 12 tháng hoặc 3 release; rolling update dưới PDB. | Mức trưởng thành SPI, cùng cửa sổ deprecation, nâng kernel rolling có drain giao dịch. | | **Stripe** | Phiên bản API theo ngày, ghim theo tài khoản. | Phiên bản app và API ghim theo tenant. | | **Erlang / OTP** | Code cũ và mới cùng sống; lời gọi đang chạy kết thúc trên code cũ. | Phiên bản plugin song song, drain trước khi giải phóng. | | **Shopify Functions** | Logic merchant chạy Wasm với giới hạn lệnh xác định. | Tầng 2 Wasm với ngân sách và metric theo lời gọi. | | **Cloudflare Workers** | Isolate trong tiến trình thay container; tuyên bố khởi động nhanh ~100 lần. | Cô lập trong tiến trình thay vì mỗi tùy biến một microservice. | | **Envoy / Proxy-Wasm** | Control plane phân phối filter Wasm tới mọi proxy. | Control plane plugin đưa cùng phiên bản tới mọi pod. | | **VS Code** | Extension host riêng, API ổn định có kiểu, hàng chục nghìn extension. | Extension point kiểu mạnh + cô lập. | | **Google SRE / Netflix** | Canary, error budget, rollback tự động. | Health gate, auto-rollback, DORA metric theo app. | | **Alibaba → Apache Seata** | Điều phối giao dịch ở quy mô Singles' Day. | AT trên PostgreSQL, TCC trên Redis Cluster, smart bypass. | | **MCP (Linux Foundation / AAIF)** | Chuẩn mở agent gọi tool; ChatGPT, Gemini, Copilot, Cursor, VS Code chấp nhận; 10.000+ server. | DocType và extension point chiếu thành MCP tool dưới cùng quyền. | **Cách kể:** "Chúng tôi không hỏi 'làm ERP thế nào'. Chúng tôi hỏi 'Frappe làm gì đúng, Frappe thiếu gì, và Erlang, Stripe, Kubernetes giải phần thiếu ra sao', rồi ghép lại cho Java 25." --- ## 2. DocType engine: trái tim của nền tảng **Ý chính để nói:** DocType engine là thứ biến Astra từ "CMS có plugin" thành nền tảng cùng hạng với Frappe. Một DocType mô tả đầy đủ một loại bản ghi nghiệp vụ. Mọi thứ người dùng hay agent chạm vào đều là DocType: bài viết, khách hàng, hoá đơn bán, bút toán sổ kho, định nghĩa workflow, thậm chí chính DocType. ### 2.1 DocType khai báo gì Tên, module, loại (master, transaction, child, setup, ledger, virtual), chiến lược lưu trữ, submittable hay không, quy tắc đánh số, theo dõi thay đổi, danh sách field (Link, Table, Currency, Select, fetch-from…), index trên cột chiếu, quyền theo role và perm level kèm điều kiện cấp hàng, hook vòng đời (validate, on_submit, on_cancel…), workflow, và cấu hình API sinh ra (REST, GraphQL, MCP, phiên bản theo ngày). ### 2.2 Lưu trữ: zero-DDL mặc định, ledger nơi cần Frappe tạo một bảng mỗi DocType và chạy DDL khi thêm Custom Field. Astra không bao giờ chạy DDL cho thay đổi form trong production, và vẫn có lưu trữ cấp kế toán nơi ERP cần. | `storage` | Dùng cho | Cách làm | DDL | | --- | --- | --- | --- | | `jsonb` (mặc định) | Master, chứng từ, cấu hình, nội dung | Metadata lõi ở cột quan hệ có index; field ở JSONB với GIN. Field đánh dấu `projected` nhận **expression index** (`CREATE INDEX CONCURRENTLY` trên `(data->>'field')`), online thật sự; truy vấn sinh ra dùng đúng biểu thức đó. Workload sort/aggregate nặng dùng `projected: materialised`: thêm cột nullable (chỉ metadata), backfill theo batch bằng job, kernel đồng bộ khi ghi, rồi index `CONCURRENTLY`. **Không dùng stored generated column** trên bảng có dữ liệu vì PostgreSQL phải rewrite cả bảng. | Không bao giờ cho field; expression index online, additive, đảo ngược được; cột materialised online nhưng cần thời gian backfill | | `ledger` | GL Entry, Stock Ledger Entry, bút toán lương, audit | Bảng quan hệ append-only, phân vùng theo kỳ, cột cố định. Không bao giờ update; sửa bằng bút toán đảo. Chỉ ghi trong `@AstraGlobalTransactional` bởi app sở hữu. | App sở hữu, tiến hóa bằng expand/contract | | `virtual` | Hệ thống ngoài | `DocTypeAdapter` ánh xạ DocType lên ERP/CRM/DB cũ. Cùng API, quyền, tool. | n/a | **Câu để nói:** "Một hoá đơn ở JSONB để tùy biến tự do; bút toán của nó ở ledger để bất biến và kiểm toán được. Hai chiến lược, một DocType engine." **Hai quy tắc để linh hoạt không ăn hiệu năng:** * **Quota chiếu và index.** Mỗi DocType có hạn mức chiếu và index (mặc định 8 và 12), mỗi tenant có hạn mức chiếu qua overlay; chiếu materialised trên bảng lớn cần bước phê duyệt. Vượt hạn mức thì bị chặn lúc kiểm tra metadata, trước khi tới database production. * **DocType "nóng" cần cột materialised.** `UPDATE` trong PostgreSQL ghi lại cả dòng, nên DocType có vài field đổi nhiều lần mỗi ngày (tồn kho, công nợ, bộ đếm, trạng thái) sẽ phình và làm vacuum quá tải nếu mọi field nằm trong JSONB. Quy tắc: field nóng để ở cột materialised cho JSONB lớn nằm yên trong TOAST; field nóng không index nếu có thể để update là HOT, đặt `fillfactor` dưới 100; bộ đếm và số dư ở `ledger` hoặc Redis; trạng thái đổi cực nhiều thì tách thành DocType phụ hẹp. `jsonb` là mặc định linh hoạt, không phải lựa chọn cho mọi trường hợp. ### 2.3 Vòng đời chứng từ, phiên bản, amend * **Submittable** mang `docstatus`: nháp, đã ghi sổ, hủy. Đã ghi sổ thì bất biến trừ field `allow-on-submit`. Hủy thì đảo bút toán; **amend** tạo bản mới (`INV-0001-1`) liên kết bản gốc. Đó là lý do hoá đơn được xử lý như chứng từ tài chính chứ không như bài viết. * **Track changes** lưu diff mỗi lần save; phiên bản truy vấn được. * **Link** kiểm tra khi ghi, bảo vệ toàn vẹn tham chiếu xuyên JSONB và ledger; `fetch-from` sao chép giá trị denormalized lúc ghi như chứng từ ERP cần. * **Child table** là sub-document có thứ tự. * **Naming series** an toàn trên cụm (bộ đếm Redis, chế độ không hở số cho chứng từ tài chính). ### 2.4 Tùy biến là overlay, sống qua nâng cấp Tenant tùy biến DocType của app mà không fork, và tùy biến **sống qua mọi lần nâng cấp app**: thêm field, đổi thuộc tính field, thêm quyền, gắn server script Wasm, gắn client script cho Desk. Overlay là dữ liệu, có phiên bản, ghim theo tenant, áp lúc nạp metadata. App nâng cấp mà bỏ field overlay phụ thuộc thì **conformance check** bắt được trước rollout. Đây chính là bài học lớn nhất từ Frappe: Custom Field và Property Setter là lý do khách hàng ERPNext nâng cấp được. Astra giữ ý tưởng, làm chặt hơn bằng kiểm tra tự động. ### 2.5 Mọi thứ sinh từ metadata REST `/api/doc/`, GraphQL, MCP tool, Desk UI (form, list, kanban, calendar, tree, report builder, dashboard, workspace), print/PDF, notification/email/webhook, search và vector index, OpenAPI/JSON Schema/client type. Viết DocType một lần, có tất cả. ### 2.6 Báo cáo và script Report Builder chạy trên cột chiếu. Query report là SQL có tham số trên chiếu và ledger, review như code. Script report và server script chạy ở tầng 2 Wasm có ngân sách, hoặc tầng 1 Java cho app tin cậy. Đây là ý tưởng Server Script của Frappe với sandbox thật thay vì interpreter bị hạn chế. --- ## 3. Mọi thứ là plugin ("app") **Ý chính để nói:** Một app là plugin mang DocType, hook vòng đời, cài đặt extension point, fixture, patch, asset Desk. Astra CMS và các module Astra ERP là app; module riêng của tenant cũng là app. Kernel **không có đường tắt đặc quyền**. * Một ứng dụng = kernel + danh sách app trong `astra.yaml`; `astra build` ra image bất biến. * App có thể **định nghĩa extension point mới** cho app khác, như `PricingRule` của module bán hàng được tenant cài đặt bằng Wasm. * Extension point là **interface Java có kiểu** với JSON Schema, kiểm tra lúc nạp, dispatch không reflection: hook DocType, workflow, report, print, Desk, route, event, job, nhánh TCC, ledger, adapter, auth, tool, agent, model provider, CLI. ### Ba tầng cô lập, một SPI | | Tầng 0: Shared (build-time) | Tầng 1: Isolated JVM (runtime) | Tầng 2: Wasm sandbox (runtime) | | --- | --- | --- | --- | | Nạp | Compile vào image, Quarkus `@BuildStep` + Jandex | ClassLoader riêng theo phiên bản app | Module Chicory, ABI tương thích Extism | | Chi phí gọi | Trực tiếp | `MethodHandle` bind lúc nạp | Bytecode compile từ Wasm; interpreter trên native | | Đổi code | Rebuild image, rolling | Hot-swap | Hot-swap | | Quyền | Tất cả, kể cả ledger và extension point mới | DocType, hook, workflow, report, route, event, job, TCC, tool, agent | Server script, script report, enricher, validator, tool | | Bán kính lỗi | Cả pod | Bulkhead: timeout, giới hạn đồng thời, circuit breaker | Sandbox cứng: bộ nhớ, deadline, ngắt, không host call chưa cấp | | Ngôn ngữ | Java/Kotlin | Java/Kotlin | Rust, Go, C/C++, C#, Zig, AssemblyScript, JS, Python (Extism PDK) | | Mặc định cho | App lõi (kế toán, kho), thứ chạy trên mọi request | Module tenant, tích hợp, bản địa hoá | Script tenant, marketplace, **code do AI sinh** | **Cách kể:** "Kế toán và kho ở tầng 0 vì mọi chứng từ đi qua. Bản địa hoá Việt Nam ở tầng 1 vì đổi theo thông tư. Script kiểm tra tín dụng của tenant ở tầng 2 vì ta chưa tin code đó nhưng muốn nó chạy ngay." Chuyển tầng là cờ build. Chicory: pure Java, không JNI, có compiler Wasm → bytecode. ABI Extism: PDK sẵn cho nhiều ngôn ngữ; Astra tự cài ABI trên Chicory vì SDK Chicory chính thức của Extism còn thử nghiệm. Đánh đổi: Chicory chưa có fuel metering (dùng deadline + bộ nhớ); native image chỉ interpreter; tầng 1 "chống rò rỉ" ClassLoader chứ không tuyệt đối. ### Manifest app: ít quyền nhất, có xuất xứ `astra-app.yaml`: id, version, khoảng SPI, tầng, phụ thuộc, danh sách DocType, overlay, fixture, patch, **capability** (host, secret, DocType, ledger chỉ đọc, ngân sách LLM), tenant, giới hạn runtime, compat, rollout, và **provenance** (ai hoặc công cụ nào sinh). Security review = review manifest. Ký Sigstore, SBOM, allow-list nhà phát hành, policy bắt code do AI sinh chạy tầng Wasm. ### Hot-swap toàn cụm Publish → xác minh (chữ ký, SPI, xuất xứ) → announce qua Redis Pub/Sub → canary theo tỷ lệ pod với health gate → nạp song song, warm-up, trỏ dispatch nguyên tử, drain, kiểm tra rò rỉ → tự rollback toàn cụm khi health fail → pod mới từ HPA kéo trạng thái lúc boot. --- ## 4. Thay đổi liên tục không phá cái cũ: hợp đồng tương thích **Ý chính để nói:** Kiến trúc sư doanh nghiệp nên nghe phần này đầu tiên. *Ship hàng ngày thì tính năng hôm qua còn chạy không, và làm sao giữ downtime bằng không?* ### 4.1 SPI là ABI App chỉ phụ thuộc `astra-spi`. Mức trưởng thành `alpha`/`beta`/`stable`. Deprecate `stable` vẫn chạy ít nhất **12 tháng hoặc 3 minor release** kèm cảnh báo có cấu trúc. **Kernel N chạy app SPI N-1** qua shim. **Conformance suite:** golden app từ mọi SPI còn hỗ trợ, **cộng mọi overlay tenant đã đăng ký**, chạy trong CI; phá một cái thì không ship. ### 4.2 Phiên bản cùng sống, tenant tự ghim N phiên bản app song song; bản cũ ở lại chừng nào còn tenant ghim hoặc còn lời gọi đang bay. Ghim theo tenant cho app và API. API sinh ra đánh phiên bản theo ngày, phục vụ song song. ### 4.3 Dữ liệu tiến hóa không khóa | Dữ liệu | Cơ chế | DDL | Rollback | | --- | --- | --- | --- | | Field DocType, overlay | Zero-DDL JSONB; upcaster khi đọc, ghi lại dần; chiếu online | Không cho field | Tức thì | | Ledger | Expand/contract do CI cưỡng chế; append-only | Additive, online | Bản trước vẫn đọc | | Data patch | Idempotent, có phiên bản, chạy theo tenant lúc rollout, resume được | n/a | Patch bù | | Event | `schemaVersion`; consumer khai báo khoảng; upcaster | n/a | Consumer cũ vẫn chạy | | Cache | Key gồm phiên bản app và schema; vô hiệu hóa toàn cụm | n/a | Không stale | ### 4.4 Ma trận zero-downtime (slide một trang) | Thay đổi | Cơ chế | Downtime | Rollback | | --- | --- | --- | --- | | App tầng 1/2, overlay, server script | Hot-swap, canary, drain | 0 | Giây, tự động | | App tầng 0 | Image mới, rolling dưới PDB, drain giao dịch | 0 | Phút | | Field, form, quyền, workflow của DocType | Phiên bản metadata, zero-DDL | 0 | Tức thì | | Schema ledger | Expand/contract | 0 | App cũ vẫn chạy | | Prompt, model, policy agent | Artifact có phiên bản, cùng đường rollout | 0 | Giây | | Kernel minor / major | Rolling với shim / blue-green, tenant di chuyển bằng ghim | 0 | Phút / chuyển traffic | | PostgreSQL / Redis / Seata TC | HA chuẩn; retry với TCC idempotent | 0 đọc, failover dưới giây cho ghi | n/a | ### 4.5 Thay đổi quan sát được Mỗi rollout là sự kiện gắn metric. **DORA metric theo app** có sẵn. --- ## 4A. Đa tenant: thuộc tính của kernel, không phải tính năng của app **Ý chính để nói:** Mọi dịch vụ kernel tự áp ranh giới tenant. App hay agent không thể vượt tenant kể cả khi cố, vì không API nào nhận tenant từ người gọi. ### 4A.1 Mô hình `Tenant` là DocType kiểu `setup` của kernel, nên onboarding, tạm ngưng, offboarding là vòng đời chứng từ bình thường, có audit. Một Tenant khai báo: trạng thái, tổ chức cha (tuỳ chọn), profile cô lập, region, cách nhận diện và nhà cung cấp danh tính (OIDC, SAML, SCIM), khoá (BYOK, xoay khoá), app được bật kèm ghim và cấu hình, overlay, phiên bản API, gói dịch vụ, quota, feature flag. ### 4A.2 Nhận diện và ngữ cảnh Thứ tự nhận diện: domain riêng hoặc subdomain, claim trong token OIDC, header cho tài khoản dịch vụ, tiền tố đường dẫn cho Desk. Kết quả bind một lần mỗi request vào `ScopedValue` và kế thừa sang mọi virtual thread, subtask, hook, job, agent. Một người dùng có thể thuộc nhiều tenant; đổi tenant là phiên mới với scope mới. ### 4A.3 Ba profile cô lập | Profile | Dữ liệu | Khoá | Compute | Dùng cho | | --- | --- | --- | --- | --- | | `shared-rls` (mặc định) | Một cụm PostgreSQL; cột `tenant_id` + row-level security trên mọi bảng kể cả ledger và audit; partition theo hash tenant cho bảng lớn | Khoá dữ liệu riêng theo tenant dưới KMS của tenant | Pod chung, bulkhead theo tenant và app | Phần lớn tenant; hàng nghìn tenant mỗi pod | | `database-per-tenant` | Database riêng, cùng schema, vẫn RLS | BYOK | Pod chung | Khách hàng bị quản lý chặt, tenant lớn | | `cluster-per-tenant` | PostgreSQL, Redis, storage, pod Astra riêng | HYOK / HSM | Riêng | Chủ quyền dữ liệu, air-gapped | Di chuyển giữa profile bằng `astra tenant migrate`: snapshot, giai đoạn ghi kép, cutover, kiểm chứng; đọc không gián đoạn, ghi dừng dưới giây lúc cutover. ### 4A.4 Cái gì được scope theo tenant Dữ liệu và ledger (RLS, partition), metadata (DocType gốc toàn cục; overlay, ghim, feature flag theo tenant; metadata hiệu lực cache theo cặp tenant và phiên bản), app (cài toàn cục, bật, ghim, cấu hình theo tenant), cache (key gồm tenant), event và job (fair scheduling, giới hạn đồng thời theo tenant), giao dịch (key Seata và hash tag Redis gồm tenant), search và vector, object storage, danh tính (IdP, MFA, SCIM theo tenant), AI (model, region, ngân sách, che PII, cổng phê duyệt, opt-out theo tenant), tuân thủ (RoPA, DSAR, retention, residency theo tenant), quan sát (mọi span, metric, log gắn tenant; dashboard và SLO theo tenant). ### 4A.5 Tổ chức và chia sẻ `Organisation` gom nhiều tenant (tập đoàn và công ty con, chuỗi nhượng quyền, agency và khách hàng). Trong tổ chức, DocType có thể khai báo `shareable` với policy rõ (master chỉ đọc như Item, mẫu hệ thống tài khoản; báo cáo hợp nhất). Chia sẻ luôn opt-in, có log, là metadata; mặc định vẫn cô lập chặt. Đa công ty trong một tenant là việc của DocType `Company`, không liên quan tenancy. ### 4A.6 Công bằng, quota, đo đếm Bulkhead theo tenant và app: giới hạn virtual thread, rate limit, đồng thời job, ngân sách token agent. Tenant ồn tự làm chậm mình, không làm chậm hàng xóm. Quota từ gói dịch vụ, ghi đè theo tenant, có soft limit trước hard limit. Đo đếm phát bản ghi sử dụng (request, dung lượng, job, token agent, seat) dưới dạng DocType `ledger` cho tích hợp tính phí. Astra không ship billing; Astra ship đồng hồ đo. ### 4A.7 Vòng đời tenant Provision (tạo Tenant, khoá, đặt region, bật app, chạy fixture và patch, kiểm chứng bằng conformance suite) → tùy biến (overlay, ghim, cấu hình, IdP; có phiên bản, có audit) → tạm ngưng (từ chối request với trạng thái riêng, job dừng, dữ liệu giữ) → export (toàn bộ DocType, file, audit, metadata, overlay ở định dạng mở) → offboard (áp retention, xoá bằng huỷ khoá, ledger giữ theo nghĩa vụ pháp lý với tham chiếu ẩn danh, ghi bằng chứng xoá) → migrate (giữa profile hoặc region). ### 4A.8 Chế độ một tenant `tenancy.mode: single` giữ mọi cơ chế nhưng cố định một tenant: triển khai tự host cho một công ty không trả overhead tenancy, và sau này chuyển sang `multi` mà không cần migrate dữ liệu. **Câu để nói:** "Một công ty tự host hôm nay, một SaaS phục vụ hàng nghìn công ty ngày mai, cùng một codebase, cùng một mô hình dữ liệu, không migration." --- ## 4B. Tuân thủ từ thiết kế SOC 2 Type II và ISO 27001 chứng nhận *nhà vận hành* (tổ chức chạy dịch vụ) qua một kỳ quan sát, không chứng nhận phần mềm. GDPR và các luật tương đương ràng buộc *bên kiểm soát và bên xử lý*. Không có mã nguồn mở nào "được chứng nhận SOC 2". Điều một nền tảng có thể làm là **khiến các kết quả đó rẻ hơn**: có sẵn control, tự sinh bằng chứng, và giữ mô hình dữ liệu tuân thủ ngay từ cấu trúc. Astra Cloud (nếu có) sẽ tự theo đuổi chứng nhận; người tự host thừa hưởng control và đường ống bằng chứng. **Ý chính để nói:** Phần lớn công sức tuân thủ là thu thập bằng chứng và biết dữ liệu nào nằm ở đâu, vì sao, bao lâu. Nếu DocType đã trả lời sẵn bốn câu đó cho từng field, thì RoPA, DSAR, retention, residency là **sinh ra**, không phải **làm tay**. ### 4B.1 Khung tuân thủ trong phạm vi | Khung | Phạm vi | Astra cung cấp | | --- | --- | --- | | **SOC 2 Type II** | Security, Availability, Processing Integrity, Confidentiality, Privacy | Control cho các tiêu chí CC6 (truy cập), CC7 (giám sát), CC8 (quản lý thay đổi), A1, PI1, P-series; xuất bằng chứng liên tục; ma trận control trong `docs/compliance/` | | **ISO/IEC 27001:2022** + 27017/27018/27701 | ISMS, cloud, PII trên cloud, quản lý quyền riêng tư | Ánh xạ Annex A, kiểm kê tài sản từ metadata DocType, workflow rà soát truy cập, sổ đăng ký nhà xử lý phụ | | **GDPR / UK GDPR** | Dữ liệu cá nhân EU/UK | Phân loại, cơ sở pháp lý, sinh RoPA, tự động DSAR, retention và xoá, quy trình vi phạm 72 giờ, mẫu DPIA, hồ sơ xử lý, profile lưu trữ EU | | **Luật Bảo vệ dữ liệu cá nhân Việt Nam** (Luật 91/2025/QH15, Nghị định 356/2025, hiệu lực 1/1/2026, thay Nghị định 13/2023) | Dữ liệu cá nhân của chủ thể Việt Nam | Phân loại dữ liệu cơ bản / nhạy cảm, hồ sơ đồng ý, hồ sơ đánh giá tác động, hồ sơ chuyển dữ liệu xuyên biên giới, thời hạn phản hồi yêu cầu, profile lưu trữ VN | | **CCPA/CPRA, LGPD, PDPA (SG), APPI, PIPL** | Luật riêng tư khu vực | Cùng một privacy engine với profile theo khu vực pháp lý; quy tắc nội địa hoá (PIPL, VN) cưỡng chế bởi `ResidencyPolicy` | | **SOX ITGC** (khách hàng ERP) | Quản lý thay đổi, truy cập, phân tách nhiệm vụ, audit trail | Rollout có ký, có phê duyệt, có audit; quy tắc SoD trên hành động DocType; ledger bất biến; khoá kỳ; rà soát truy cập | | **PCI DSS 4.0.1** | Dữ liệu thẻ | Thiết kế để dữ liệu thẻ **nằm ngoài phạm vi**: token hoá qua nhà cung cấp thanh toán, không lưu PAN | | **HIPAA** (profile tuỳ chọn) | Dữ liệu y tế Mỹ | Mã hoá cấp field, log truy cập, control sẵn cho BAA | | **EU AI Act** (theo giai đoạn 2025-2027; mốc high-risk đang được lùi theo Digital Omnibus) + **ISO/IEC 42001**, **NIST AI RMF** | Hệ thống AI và quản trị | Log hành động agent, cổng phê duyệt của con người, phiên bản model và prompt, thông báo minh bạch, che PII trước khi gọi model ngoài, tenant opt-out, kiểm kê AI | | **EU DORA** (tài chính, từ 1/2025), **NIS2** | Khả năng phục hồi vận hành, rủi ro bên thứ ba ICT | Hook kiểm thử phục hồi, workflow báo cáo sự cố, bằng chứng RTO/RPO, sổ nhà xử lý phụ | | **EU Cyber Resilience Act** (báo cáo từ 9/2026, áp dụng đầy đủ 12/2027) | Sản phẩm có yếu tố số | SBOM mỗi bản phát hành, quy trình xử lý lỗ hổng, công bố có phối hợp, chính sách cập nhật bảo mật; dự án Astra hoạt động như open-source steward | | **WCAG 2.2 AA**, **EU Accessibility Act** (6/2025), **Section 508** | Khả năng tiếp cận | Desk UI xây theo WCAG 2.2 AA; kiểm tra tự động và thủ công trong CI | | **OWASP ASVS, NIST SSDF, SLSA, OpenSSF Scorecard, CIS Kubernetes** | Kỹ thuật an toàn và chuỗi cung ứng | ASVS Level 2 nền, Level 3 cho auth, thanh toán, ledger; SDLC theo SSDF; build SLSA Level 3; Scorecard trong CI; Helm mặc định cứng hoá | ### 4B.2 Metadata tuân thủ trên DocType Khai báo tuân thủ ở đúng chỗ khai báo dữ liệu. Mỗi field có thể mang: `classification` (public → restricted), `pii` / `sensitive` (kể cả nhóm đặc biệt: sức khoẻ, sinh trắc), `legal-basis` (consent, contract, legal-obligation…), `retention` (thời hạn, mốc, lý do, rồi xoá / ẩn danh / lưu trữ), `residency` (tenant-region, eu-only, vn-only), `encrypt` (field, deterministic để tìm được), `mask` và `dsar` (export / redact / exclude). Từ đó nền tảng **sinh ra**: Record of Processing Activities, bản đồ dữ liệu theo tenant, gói DSAR, lịch retention, khung DPIA và hồ sơ đánh giá tác động theo luật Việt Nam, và kiểm kê dữ liệu mà ISO 27001 yêu cầu. **Câu để nói:** "Ở Astra, câu hỏi 'dữ liệu cá nhân nằm ở đâu' có câu trả lời bằng một truy vấn metadata, không phải một dự án ba tháng." ### 4B.3 Control trong kernel * **Danh tính và truy cập.** OIDC và SAML SSO, bắt buộc MFA, SCIM cấp/thu hồi, RBAC cộng quy tắc thuộc tính, mặc định ít quyền nhất, **rà soát truy cập** định kỳ sinh thành workflow, tài khoản break-glass có lý do bắt buộc và tự hết hạn. * **Phân tách nhiệm vụ (SoD).** Quy tắc cấp DocType: người tạo Payment Entry không được submit nó; chuỗi phê duyệt là trạng thái workflow; vi phạm bị chặn và ghi log. * **Audit trail chống sửa.** Mọi thay đổi DocType, quyền, rollout, hành động agent, hành động admin, export được ghi vào DocType `ledger` append-only có chuỗi hash, xuất được ra WORM và SIEM (`AuditSink`: OpenTelemetry, syslog, OCSF). Retention và legal hold áp cả cho audit. * **Mã hoá và khoá.** TLS 1.3, mã hoá at rest, mã hoá cấp field, khoá từ `KeyProvider` (AWS KMS, GCP KMS, Azure Key Vault, Vault, HSM), **BYOK theo tenant**, xoay khoá, crypto-shredding để xoá nơi bản ghi phải bất biến. * **Vận hành quyền riêng tư.** Cổng và API DSAR: truy cập, export (JSON/CSV), sửa, xoá, hạn chế, di chuyển, phản đối, theo dõi thời hạn theo khu vực pháp lý. Xoá trên ledger: ẩn danh tham chiếu chủ thể và huỷ khoá, giữ bản ghi tài chính theo cơ sở nghĩa vụ pháp lý. Đồng ý là DocType có mục đích phiên bản hoá. Workflow vi phạm với đồng hồ 72 giờ. * **Nơi lưu trữ và chủ quyền dữ liệu.** Tenant ghim region; `ResidencyPolicy` cưỡng chế trên lưu trữ chính, cache, backup, search, object storage, **và nhà cung cấp LLM**. Profile `eu`, `us`, `vn`, `sg`, `custom`. Không nhân bản PII xuyên region trừ khi hồ sơ chuyển dữ liệu cho phép. * **Quản lý thay đổi.** Artifact có ký, SBOM, xuất xứ; phê duyệt bốn mắt cho production; tách môi trường; mọi rollout có audit; canary và rollback là control bù; DORA metric là bằng chứng. * **Sẵn sàng và phục hồi.** HA mọi tầng, backup mã hoá có PITR, **kiểm thử restore là job định kỳ có bằng chứng**, RPO/RTO công bố, hook kiểm thử chaos và failover, workflow sự cố. * **Lỗ hổng và chuỗi cung ứng.** SCA, SAST, DAST, quét container trong CI; SLA phản hồi CVE; `SECURITY.md`; SBOM CycloneDX; xuất xứ SLSA Level 3; OpenSSF Scorecard; pentest bên thứ ba hàng năm cho triển khai tham chiếu. * **Cô lập tenant.** Row-level security, khoá theo tenant, bulkhead chống hàng xóm ồn, và profile **DB-per-tenant** hoặc **cluster-per-tenant** cho khách hàng bị quản lý chặt. * **Quản trị AI.** Mọi hành động agent log kèm model, phiên bản prompt, hash input, output; **cổng phê duyệt con người** cho hành động tác động lớn (ghi ledger, đổi quyền, thao tác hàng loạt) cấu hình theo tenant; che PII trước khi gọi model ngoài; model ghim region hoặc local; kiểm kê AI và thông báo minh bạch sinh từ metadata. * **Khả năng tiếp cận.** Desk theo WCAG 2.2 AA, kiểm tra axe tự động và audit thủ công. ### 4B.4 Tự động hoá bằng chứng `astra compliance evidence --framework soc2 --period 2026-Q3` xuất hồ sơ rà soát truy cập, hồ sơ thay đổi có phê duyệt, kết quả backup và restore, SLA lỗ hổng, log sự cố, chứng minh toàn vẹn audit trail. Connector đẩy cùng bằng chứng sang nền tảng tự động hoá tuân thủ (Vanta, Drata, Secureframe…). Catalogue control ở dạng máy đọc được (OSCAL) trong `docs/compliance/`. ### 4B.5 Ma trận trách nhiệm chia sẻ | Lĩnh vực | Nền tảng Astra | Nhà vận hành / tenant | | --- | --- | --- | | Control trong phần mềm | Cung cấp và kiểm thử | Cấu hình, rà soát | | Sinh bằng chứng | Tự động hoá và xuất | Lưu giữ, trình auditor | | Chính sách, đánh giá rủi ro, đào tạo, quản lý nhà cung cấp | Mẫu và sổ đăng ký | Sở hữu | | Chứng nhận (báo cáo SOC 2, chứng chỉ ISO) | Astra Cloud tự theo đuổi | Người tự host theo đuổi cho phạm vi của mình | | Vai trò pháp lý (controller, processor, DPO) | Hỗ trợ cả hai vai | Quyết định và ghi nhận | --- ## 5. Hướng tới ERP: Astra ERP trên Astra **Ý chính để nói:** Astra ERP là bằng chứng nền tảng đủ tổng quát. Xây đúng cách ERPNext xây trên Frappe: **mỗi module là app, mỗi bản ghi là DocType, mỗi bút toán là ledger entry**. Sao chép *hình dạng* của ERPNext, không sao chép code. | Module (app) | DocType lõi | Ledger | Ghi chú | | --- | --- | --- | --- | | `erp.core` | Company, Fiscal Year, Currency, Exchange Rate, UoM, Address, Contact | | Đa công ty, đa tiền tệ từ ngày đầu | | `erp.accounts` | Account (cây), Journal Entry, Payment Entry, Sales/Purchase Invoice, Tax Template, Cost Center, Period Closing | **GL Entry** | Kế toán kép; bút toán đảo, không sửa; khoá kỳ | | `erp.stock` | Item, Warehouse (cây), Stock Entry, Delivery Note, Purchase Receipt, Batch, Serial No | **Stock Ledger Entry** | FIFO / bình quân; repost lùi ngày là job; giữ hàng bằng Redis TCC cho flash sale | | `erp.selling` | Customer, Quotation, Sales Order, Pricing Rule | | Pricing Rule là extension point tenant cài đặt | | `erp.buying` | Supplier, RFQ, Purchase Order | | | | `erp.manufacturing` | BOM, Work Order, Job Card, Routing | | | | `erp.hr` | Employee, Leave, Attendance, Payroll Entry, Salary Slip | **Payroll Ledger** | | | `erp.projects` / `erp.crm` / `erp.assets` | Project, Task, Timesheet / Lead, Opportunity / Asset, Depreciation | GL qua `erp.accounts` | | Tính năng nền tảng mà ERP cần và kernel đã có sẵn: * **Ghi sổ một hoá đơn bán** chạm GL Entry, Stock Ledger Entry, công nợ khách, và giữ hàng trong Redis. Đó là **một** phương thức `@AstraGlobalTransactional`: Postgres qua Seata AT, Redis qua TCC, tất cả hoặc không gì. * **Chốt sổ cuối tháng** trên 50 pod: khoá kỳ là trạng thái DocType do kernel cưỡng chế trong hook vòng đời, không phải code ứng dụng nhớ kiểm tra. * **Bản địa hoá** (hoá đơn điện tử, VAT, hệ thống tài khoản Việt Nam) là app tầng 1 với overlay và fixture, ghim theo tenant, nâng cấp độc lập với `erp.accounts`. * **Agent trong back office:** "tạo bút toán cho dòng sao kê này", "giải thích chênh lệch này" chạy như `AgentContributor` với MCP tool theo role trên DocType, có ngân sách và audit. **So với Frappe/ERPNext (công bằng, không chê):** Frappe dùng Python và MariaDB/Postgres, một bảng mỗi DocType, DDL khi thêm Custom Field, worker Gunicorn và hàng đợi RQ, mỗi site một DB. Đó là lựa chọn hợp lý cho SME và cộng đồng rộng. Astra nhắm quy mô cụm, đa tenant mật độ cao, zero-DDL, cô lập nhiều tầng, giao dịch xuyên kho, và AI surface. Hai triết lý, cùng một ý tưởng lõi. --- ## 6. Hiệu năng: nguyên tắc và mục tiêu Không reflection trên đường nóng; đọc DocType từ cache L1/L2 theo phiên bản metadata; ghi qua cột chiếu để báo cáo không quét JSONB; bulkhead theo app; không tốn gì khi rảnh; cô lập trong tiến trình; structured concurrency cho fan-out. Mỗi mục tiêu đi kèm **điều kiện đo**. | Chỉ số | Điều kiện | Mục tiêu (kiểm chứng bằng benchmark công khai) | | --- | --- | --- | | Lấy DocType theo id | metadata ở L1, document ở L2 hoặc PostgreSQL theo khoá chính | p99 < 2 ms | | List 1 triệu dòng/tenant | lọc bằng/khoảng trên tối đa 2 chiếu có index, không join, trang 50 | p99 < 20 ms | | Lưu document, một kho | smart bypass, không giao dịch toàn cục, 1 hook | p99 < 15 ms | | Ghi sổ hoá đơn, giao dịch toàn cục | Seata AT trên PostgreSQL + TCC trên Redis, TC Raft cùng region, 2 nhánh | p50 < 40 ms, p99 < 150 ms | | Hook tầng 1, chỉ dispatch | `MethodHandle` so với gọi trực tiếp | < 1 µs | | Hook tầng 1, đầu cuối | dispatch + kiểm tra capability + truyền ngữ cảnh + span audit | < 50 µs | | Script tầng 2, ấm | module đã compile, pool sẵn, input nhỏ | p99 < 5 ms | | Script tầng 2, compile lạnh | Chicory Wasm→bytecode, làm lúc warm-up rollout, ngoài đường request | < 500 ms/module | | Rollout app, lan truyền | announce → mọi pod đã nạp và warm bản mới ở trạng thái chờ, 50 pod | < 2 s | | Rollout app, cutover | trỏ lại dispatch nguyên tử trên một pod | < 10 ms | | Rollout app, drain | lời gọi đang bay kết thúc trên bản cũ | giới hạn bởi timeout request | | Pod kernel sẵn sàng, JVM | tầng 0 wire lúc build, Flyway no-op, registry ấm | < 5 s | | Pod kernel sẵn sàng, native | như trên | < 1 s | Thêm ba điều nói thẳng: mật độ "hàng nghìn tenant mỗi pod" là **giả thuyết cho đến khi benchmark** với footprint thật của overlay, cache, ClassLoader; Seata TC là thành phần tập trung, giữ ngoài đường nóng bằng smart bypass và **shard theo transaction service group** (mỗi nhóm một cụm Raft) nên throughput TC tăng theo số nhóm chứ không theo số pod; heap JVM theo app tầng 1 **quan sát được nhưng không cưỡng chế được**, thêm một lý do để code không tin cậy ở tầng 2. **Java 25 và Quarkus (ngắn):** virtual thread không pin (JEP 491), Scoped Values final (JEP 506), Structured Concurrency preview 5 (JEP 505, bọc sau executor), Generational ZGC mặc định (JEP 490, `-XX:+UseZGC`). Quarkus build-time augmentation là cơ chế tầng 0; hỗ trợ đầy đủ JDK 25 từ 3.31, LTS 3.27. Java 21 chưa đủ; Spring Boot dựa reflection runtime, đi ngược tầng 0; cái giá là tự viết extension Seata. --- ## 7. AI-native: AI xây, AI dùng **Cho agent xây app:** SPI tổng thể khá lớn, gồm hơn hai mươi extension point, mô hình DocType, manifest, overlay và metadata tuân thủ. Điều làm nó dùng được cho agent là mỗi việc đều hẹp: một hook chạm hai interface, một DocType là một file YAML, một script Wasm là một hàm export. `AGENTS.md` tổ chức theo từng extension point để agent chỉ nạp lát cắt cần, `astra mcp --dev` trả lời "có gì" từ kernel đang chạy. Testkit là thứ làm app do agent viết đáng tin. Hai kỷ luật thêm cho bề mặt tool: namespace (`doc..`, `app..`), chỉ phơi DocType tenant khai báo, gói tool theo role, `tools/list` phân trang; và **grounding**: câu trả lời về dữ liệu nghiệp vụ phải trích dẫn DocType và id bút toán đã dùng, gateway từ chối câu trả lời không nguồn cho DocType tài chính. Testkit cho vòng lặp pass/fail (contract test, assertion capability, ngân sách, **assertion overlay sống qua nâng cấp**). Code AI sinh mặc định tầng 2 Wasm; `provenance.generator` ghi lại; policy bắt buộc sandbox. Canary + auto-rollback bắt lỗi dưới traffic thật. Repo có `AGENTS.md`, `CLAUDE.md` và `llms.txt`. **Cho agent dùng ứng dụng:** mọi DocType (search, get, create, submit, cancel), event, job, `ToolContributor` chiếu thành MCP tool với JSON Schema của DocType, theo role và tenant, audit như mọi lời gọi. `AgentContributor` chạy agent có ngân sách với bộ nhớ phạm vi (DocType + pgvector). `ModelProvider` trừu tượng Claude, OpenAI, Gemini, model local. Prompt và eval là artifact có phiên bản đi cùng đường rollout. **Câu để nói:** "AI viết, nền tảng kiểm chứng, cụm rollout, sai thì cụm tự lùi. Và kế toán viên AI làm việc trên cùng DocType, cùng quyền, cùng audit như kế toán viên người." --- ## 8. Bảng so sánh (slide một trang) | Nền tảng | Metadata-driven | Extension point kiểu mạnh | Hot-swap không restart | Phiên bản song song, ghim tenant | Cô lập | Zero-DDL khi tùy biến | Giao dịch xuyên kho | Tool surface cho agent | | --- | --- | --- | --- | --- | --- | --- | --- | --- | | Frappe / ERPNext | **Có** | Hook Python | Một phần (bench restart) | Không | Không | Không (DDL) | Không | Gắn thêm | | WordPress / Strapi | Không | Không | Có | Không | Không | Không | Không | Gắn thêm | | Keycloak SPI | Không | Có | Không | Không | Không | n/a | Không | Không | | OSGi / Liferay | Một phần | Có | Có | Một phần | ClassLoader | n/a | Không | Không | | Shopify Functions | n/a | Có | Có | Theo app | Wasm | n/a | n/a | n/a | | **Astra** | **Có** | **Có** | **Tầng 1 & 2** | **Có, theo tenant** | **Shared / JVM / Wasm** | **Có** | **Seata AT + TCC** | **MCP, theo capability** | --- ## 9. Rủi ro và đánh đổi (để trả lời Q&A) | Rủi ro | Mức | Cách xử lý | | --- | --- | --- | | Phạm vi rất lớn (nền tảng + CMS + ERP) | Cao | Lộ trình 9 phase; kernel và DocType engine trước, ERP sau; mỗi module ERP là app độc lập | | Độ phức tạp tổng thể | Cao | Mọi thứ tắt được; dự án nhỏ chạy một pod, Postgres + Redis đơn, không Seata, không MCP | | Extension Seata cho Quarkus tự viết; Seata còn incubating | Cao | Ưu tiên Phase 4; benchmark công khai; Seata dùng rộng ở TMĐT Trung Quốc | | JSONB cho ERP có đủ không | Trung bình | Cột chiếu cho lọc/báo cáo; ledger quan hệ append-only cho GL/SLE; benchmark 1 triệu dòng/tenant | | Chicory thiếu fuel metering; Extism Chicory SDK thử nghiệm | Trung bình | Deadline + memory cap; tự cài ABI | | Structured Concurrency preview | Trung bình | Bọc sau executor | | Rò rỉ ClassLoader tầng 1 | Thấp đến trung bình | Theo dõi lifecycle, khuyến nghị tầng 2 | | SPI và DocType model phải ổn định sớm | Trung bình | Mức trưởng thành, deprecation, shim, conformance suite có overlay | | Mục tiêu hiệu năng chưa đo | Trung bình | Benchmark Phase 2, sửa theo số thật | | Code AI sinh ra trong production | Trung bình | Sandbox mặc định, provenance, contract test, canary | | Nhầm lẫn giấy phép với ERPNext | Trung bình | Clean-room ghi trong README và CONTRIBUTING; không nhận PR chép code GPL | | Hứa "tuân thủ" quá tay | Trung bình | Nói rõ: nền tảng cung cấp control và bằng chứng; chứng nhận là của nhà vận hành; ma trận trách nhiệm chia sẻ công khai | | Luật thay đổi (AI Act lùi mốc, PDPL mới có nghị định) | Trung bình | Profile theo khu vực pháp lý là dữ liệu, cập nhật như app; không hard-code luật vào kernel | | Xoá dữ liệu cá nhân trên ledger bất biến | Trung bình | Ẩn danh tham chiếu + crypto-shredding; giữ bản ghi tài chính theo cơ sở nghĩa vụ pháp lý; ghi rõ trong DPIA | --- ## 10. Lộ trình Xây lại nền tảng cỡ Frappe cùng sandbox ba tầng, giao dịch phân tán, đa tenant, engine tuân thủ và bề mặt AI là nhiều năm làm việc của một đội; một repo không thể hoàn thành trong một năm. Lộ trình vì vậy đọc theo hai cách: **lát cắt mỏng** là thứ được xây và demo trước, đầu cuối; các phase sau xếp sao cho mỗi phase tự nó dùng được. Bảng tuân thủ là catalogue **profile bật dần**, không phải deliverable trọn gói của Phase 5. **Lát cắt mỏng (demo đầu tiên, đang làm):** một app DocType-driven (CMS `Article`), tenancy bằng `ScopedValue` và row-level security, lưu trữ JSONB zero-DDL với REST sinh ra, một hook tầng 0, chạy trên Docker Compose và k3d. Các bước kế tiếp theo thứ tự: ledger `GL Entry` tối thiểu với hoá đơn submittable; spike Seata chứng minh AT trên PostgreSQL + TCC trên Redis trên virtual thread; một script Wasm tầng 2; một canary rollout qua hai pod; MCP mỏng với `doc.search / get / create`. Mỗi bước kèm benchmark công khai. 1. **Phase 1 (đang làm):** khung Maven, `astra-spi`, mô hình DocType và YAML schema **kèm thuộc tính tuân thủ**, manifest, mức trưởng thành, deprecation, `AGENTS.md`/`llms.txt`, `SECURITY.md`, khung catalogue OSCAL. 2. **Phase 2:** kernel, plugin host ba tầng **có đếm lệnh cho tầng 2**, **lõi tenancy** (Tenant DocType, nhận diện, ngữ cảnh `ScopedValue`, RLS, profile `shared-rls`, bulkhead), DocType engine lõi (JSONB, chiếu bằng expression index có quota, naming, link, child table, quyền, submittable, phiên bản), **audit ledger chống sửa từ lần ghi đầu tiên**, REST sinh ra, **MCP mỏng** (`doc.search / get / create`), **spike Seata** (AT + TCC + virtual thread, quyết định go/no-go cho Phase 4), testkit, pipeline build SLSA Level 3, benchmark đầu tiên. 3. **Phase 3:** control plane và tương thích: registry, chữ ký, SBOM, phê duyệt bốn mắt, canary, shim, conformance suite có overlay, data patch, DORA. 4. **Phase 4:** extension Seata (hoặc phương án dự phòng do spike Phase 2 chọn: transactional outbox cộng TCC idempotent không cần coordinator tập trung), ledger storage, chiếu materialised có job backfill, cache bus, upcaster, API theo ngày. 5. **Phase 5: Gói tuân thủ và tenancy.** Danh tính (OIDC/SAML, MFA, SCIM theo tenant), rà soát truy cập, SoD, profile `database-per-tenant` và `cluster-per-tenant`, migrate tenant, tổ chức và policy chia sẻ, quota và đo đếm, `KeyProvider` với BYOK và mã hoá cấp field, `ResidencyPolicy` và profile region (eu, us, vn, sg), privacy engine (RoPA, DSAR, đồng ý, retention, crypto-shredding, workflow vi phạm), `AuditSink` ra SIEM và nền tảng tuân thủ, CLI xuất bằng chứng, công bố ánh xạ SOC 2 / ISO 27001 / GDPR / PDPL. 6. **Phase 6:** Desk theo WCAG 2.2 AA, overlay, workflow, report, print, notification. 7. **Phase 7:** AI surface và quản trị AI: MCP, LLM gateway có che PII và ghim region, agent runtime có ngân sách và cổng phê duyệt, prompt registry, kiểm kê AI. 8. **Phase 8:** Helm cứng hoá theo CIS, Operator, Seata Raft, OpenTelemetry, Prometheus, job bằng chứng backup/restore, kiểm thử failover, pentest bên thứ ba. 9. **Phase 9:** Astra CMS. 10. **Phase 10:** Astra ERP: core, accounts, stock, rồi selling, buying, manufacturing, hr, projects, crm, assets; bằng chứng SOX ITGC; bản địa hoá Việt Nam (hoá đơn điện tử, VAT, hồ sơ PDPL) là app tầng 1 tham chiếu; marketplace có ký. --- ## 11. Câu kết > *"Astra là nền tảng ứng dụng metadata-driven kiểu Frappe, viết lại từ đầu bằng Java 25. Mọi thứ là plugin, kể cả CMS và ERP. DocType là dữ liệu, tùy biến là overlay sống qua nâng cấp, bút toán là ledger bất biến, và tuân thủ là thuộc tính của metadata. App có phiên bản chạy song song, ghim theo tenant, hot-swap toàn cụm với canary tự rollback. Mọi thao tác ghi xuyên PostgreSQL và Redis là một giao dịch thật. Mọi DocType đồng thời là tool cho AI agent, dưới cùng quyền và audit. Ship mỗi ngày, không bao giờ phá hôm qua, và sẵn sàng cho auditor ở bất kỳ nước nào."* --- ## Phụ lục: câu hỏi thường gặp dự kiến **"Đây có phải port ERPNext sang Java không?"** Không. Frappe là MIT, ERPNext là GPL-3.0, Astra là MIT. Không dùng code, schema, fixture hay asset nào. Chỉ mượn khái niệm DocType, app, overlay, submittable, và hình dạng module ERP. Mọi cài đặt là nguyên bản. **"Sao không dùng luôn ERPNext?"** ERPNext rất tốt cho SME. Astra nhắm quy mô cụm, đa tenant mật độ cao, zero-DDL khi tùy biến, cô lập nhiều tầng cho code bên thứ ba và AI, giao dịch xuyên Postgres và Redis, và AI surface. Nếu bạn không cần những thứ đó, ERPNext là lựa chọn hợp lý hơn. **"JSONB có chịu được ERP không?"** Field linh hoạt ở JSONB, field lọc/báo cáo ở cột chiếu có index, bút toán ở ledger quan hệ append-only. Không có truy vấn báo cáo nào quét JSONB. Sẽ benchmark 1 triệu dòng/tenant ở Phase 2. **"Nâng app có phá tùy biến của tenant không?"** Overlay là dữ liệu có phiên bản, áp lúc nạp metadata; conformance suite chạy overlay của mọi tenant trước rollout; phá thì không ship. **"Zero downtime thật không?"** Tầng 1/2, overlay, DocType, prompt: 0 và rollback trong giây. Tầng 0 và kernel: rolling dưới PDB. Failover hạ tầng: dưới giây cho ghi, nói thẳng. **"Code AI sinh chạy production có an toàn không?"** Sandbox Wasm mặc định với ngân sách; xuất xứ ghi lại; contract test; canary + auto-rollback. Nhiều lớp hơn phần lớn code do người viết. **"Seata production-ready chưa?"** Vào Apache Incubator 10/2023, bản 2.6 (2/2026), đang graduate, dùng rộng ở TMĐT Trung Quốc. Phần chưa chín là cầu nối Quarkus, và đó là việc Astra làm. **"MCP có ổn định không?"** Trao cho Agentic AI Foundation thuộc Linux Foundation 12/2025, đồng sáng lập Anthropic, Block, OpenAI, hậu thuẫn Google, Microsoft, AWS. Chuẩn mở đa nhà cung cấp. **"Astra có được chứng nhận SOC 2 hay ISO 27001 không?"** Không có mã nguồn mở nào được chứng nhận; chứng nhận là của nhà vận hành qua kỳ quan sát. Astra cung cấp control, sinh bằng chứng, và giữ mô hình dữ liệu tuân thủ từ cấu trúc, để nhà vận hành tới chứng nhận nhanh và rẻ. Ma trận trách nhiệm chia sẻ công khai trong README. **"Dữ liệu khách hàng Việt Nam có phải lưu ở Việt Nam không?"** Tenant ghim region `vn`; `ResidencyPolicy` cưỡng chế trên lưu trữ, cache, backup, search, và cả lời gọi LLM. Hồ sơ chuyển dữ liệu xuyên biên giới theo Luật 91/2025 và Nghị định 356/2025 sinh từ metadata. **"Xoá dữ liệu cá nhân thế nào khi bút toán phải bất biến?"** Ẩn danh tham chiếu chủ thể và huỷ khoá mã hoá (crypto-shredding); bản ghi tài chính giữ lại theo cơ sở nghĩa vụ pháp lý; ghi rõ trong DPIA. Đây là cách GDPR cho phép và kế toán yêu cầu. **"AI agent ghi bút toán có cần người duyệt không?"** Mặc định có. Cổng phê duyệt con người cấu hình theo tenant cho hành động tác động lớn; mọi hành động agent log kèm model và phiên bản prompt; PII được che trước khi gọi model ngoài. **"Đa tenant có làm chậm hay rủi ro rò rỉ không?"** Ranh giới tenant do kernel áp qua `ScopedValue` và RLS, không API nào nhận tenant từ người gọi. Bulkhead theo tenant nên tenant ồn chỉ làm chậm chính mình. Khách hàng cần tách biệt hơn chọn DB riêng hoặc cụm riêng mà không đổi code. **"Code đâu? Số hiệu năng đâu?"** Có lát cắt mỏng chạy được trong repo: `make k3d-up && make demo` dựng cụm k3d hai pod và chứng minh cô lập tenant đầu cuối. Phần còn lại là blueprint; benchmark ở Phase 2. Minh bạch quan trọng hơn gây ấn tượng. **"Một bài học từ chính demo?"** Bản đầu tiên bỏ qua row-level security mà không có lỗi nào, vì ứng dụng kết nối bằng superuser của PostgreSQL. Giờ kernel từ chối khởi động nếu role có quyền bypass RLS. Đó là lý do "fail-closed" phải là mặc định, không phải tuỳ chọn. --- # Kiến trúc Một server Quarkus, một DocType engine, PostgreSQL với row-level security; app, Wasm, Seata, Keycloak và worker CDC xung quanh. Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/architecture ![Kiến trúc Astra: mọi lệnh ghi đi qua một engine](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/architecture/astra-architecture.vi.png) Client (giao diện Desk, app di động và AI agent qua MCP) gọi vào một server Quarkus. Request đi qua REST API và kernel gắn tenant vào DocType engine, engine ghi xuống PostgreSQL dưới row-level security. ## Trong hình có gì - **Kernel và DocType engine:** một bảng JSONB chứa mọi DocType; overlay thêm trường theo tenant mà không cần migration. - **App:** Java và YAML thuần trên `astra-spi`, gắn vào đường ghi (CMS, Express, HRM, STMS). - **Wasm host:** công thức nghiệp vụ riêng của từng tenant chạy trong sandbox có giới hạn thời gian. - **Giao dịch:** Seata (AT và TCC) cùng Redis điều phối các lệnh ghi trải qua nhiều service. - **Định danh:** Keycloak với OIDC, SAML SSO và cấp phát SCIM. - **Worker CDC:** worker Rust đọc write-ahead log và giữ các bản sao đọc luôn cập nhật. Bản tương tác là [astra-architecture.vi.html](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/architecture/astra-architecture.vi.html). Các bài viết đi sâu từng phần: [DocType](/astra-cms/vi/doctype), [đa tenant](/astra-cms/vi/tenants), [Seata](/astra-cms/vi/seata), [Keycloak](/astra-cms/vi/keycloak), [Wasm](/astra-cms/vi/wasm) và [CDC](/astra-cms/vi/cdc). --- # Build-time và Run-time trong Astra Trong kiến trúc của Astra, việc phân tách giữa Build-time (thời điểm biên dịch/đóng gói) và Run-time (thời điểm hệ thống đang chạy) được thiết kế để giải quyết bài toán cốt lõi: Làm sao để vừa có hiệu năng cực cao của… Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/build-time-run-time Trong kiến trúc của Astra, việc phân tách giữa **Build-time** (thời điểm biên dịch/đóng gói) và **Run-time** (thời điểm hệ thống đang chạy) được thiết kế để giải quyết bài toán cốt lõi: **Làm sao để vừa có hiệu năng cực cao của Quarkus, vừa cho phép thay đổi tính năng, thêm trường dữ liệu và cập nhật plugin mỗi ngày mà không bao giờ phải tắt hệ thống (Zero-downtime)**. Astra giải quyết bài toán này bằng cách chia các ứng dụng/plugin ("Apps") thành **3 tầng cô lập (Three Isolation Tiers)** trải dài từ Build-time sang Run-time. --- ## 1. Build-time hoạt động như thế nào? (Tier 0: Shared) **Build-time** là giai đoạn bạn chạy lệnh `astra build` để đóng gói nhân hệ thống (Kernel) cùng các ứng dụng lõi thành một container image bất biến (immutable image) trước khi đem đi triển khai. * **Cơ chế nạp và liên kết (Wiring):** Astra tận dụng triệt để cơ chế `@BuildStep` và bộ chỉ mục bytecode **Jandex** của Quarkus ngay trong lúc build. Toàn bộ việc quét class, kiểm tra phụ thuộc (CDI) và đăng ký các điểm mở rộng (Extension Points) của các ứng dụng **Tier 0** đều được giải quyết xong ở bước này thay vì đợi lúc khởi động server. * **Hiệu năng lúc chạy:** Vì mọi thứ đã được nối kết cứng từ lúc build, khi hệ thống chạy, việc gọi vào code của ứng dụng Tier 0 là **gọi hàm trực tiếp trong bộ nhớ (direct call, zero copy)**, không tốn chi phí trung gian. Nhờ vậy, một pod Kernel khởi động chỉ mất **dưới 5 giây trên JVM** và **dưới 1 giây nếu biên dịch ra GraalVM Native**. * **Quyền hạn và ứng dụng:** Chỉ các ứng dụng ở Build-time (Tier 0) mới có toàn quyền hệ thống, bao gồm quyền định nghĩa thêm các Extension Point mới và quyền ghi trực tiếp vào các bảng Sổ cái kế toán/kho (`ledger` storage). Đây là nơi đặt các phân hệ cốt lõi, chạy trên mọi request như Kế toán (`erp.accounts`) hay Kho (`erp.stock`). * **Điểm đánh đổi:** Vì nằm trong image, bất kỳ thay đổi nào ở Tier 0 đều yêu cầu phải **build lại image và thực hiện rolling deploy** (khởi động lại pod theo cụm). --- ## 2. Run-time hoạt động như thế nào? (Tier 1 & Tier 2) **Run-time** là giai đoạn hệ thống đang phục vụ người dùng thực tế. Để các khách hàng (tenants) có thể cài thêm tính năng riêng, viết thêm luật kiểm tra (validation) hoặc cho phép AI tự sinh code mới mà **không cần khởi động lại pod**, Astra cung cấp 2 tầng nạp động ở Run-time và kết hợp với cơ chế dữ liệu không khóa bảng: ### A. Tier 1: Isolated JVM (Nạp động bằng Java/Kotlin ở Run-time) Dành cho các module tích hợp hoặc bản địa hóa phức tạp (ví dụ: module Hóa đơn điện tử và Thuế Việt Nam). * **Cơ chế nạp (ClassLoader riêng biệt):** Khi bạn đẩy một bản cập nhật lên, hệ thống dùng một `PluginClassLoader` cô lập (theo phong cách PF4J) cho từng phiên bản ứng dụng để nạp thẳng file `.jar` vào bộ nhớ đang chạy. * **Gọi hàm không dùng Reflection:** Thông thường, việc nạp code động ở run-time hay dùng Java Reflection rất chậm. Astra loại bỏ hoàn toàn Reflection trên luồng xử lý chính (hot path) bằng cách gắn kết các **`MethodHandle`** ngay tại thời điểm nạp plugin (load time) và tính toán trước chuỗi gọi hàm (precomputed dispatch chains). Kết quả là chi phí điều phối lời gọi hàm chỉ mất **dưới 1 micro-giây (< 1 µs)**, gần như tương đương với gọi hàm trực tiếp. * **Cô lập sự cố (Bulkhead):** Nếu một plugin Tier 1 bị lỗi, nó không làm sập cả Pod như Tier 0 mà bị chặn lại bởi vách ngăn (giới hạn timeout, giới hạn số luồng đồng thời, circuit breaker). * **Giới hạn với GraalVM Native:** Do GraalVM Native Image đóng băng toàn bộ thế giới class lúc build (closed-world assumption) và không cho phép nạp bytecode Java động lúc run-time, nên **Tier 1 chỉ chạy được trên chế độ JVM tiêu chuẩn, không hỗ trợ GraalVM Native**. ### B. Tier 2: Wasm Sandbox (Chạy mã WebAssembly an toàn tuyệt đối ở Run-time) Dành cho các đoạn script tùy chỉnh của từng khách hàng, ứng dụng trên chợ (marketplace) hoặc **code do AI tự động viết ra**. * **Cơ chế nạp và biên dịch thông minh (Chicory Engine):** Developer hoặc AI có thể viết code bằng Rust, Go, JS, Python... rồi biên dịch ra chuẩn WebAssembly (Wasm). Astra dùng engine **Chicory** (chạy thuần trên JVM) để nạp module Wasm này: * **Khi chạy trên JVM:** Ngay lúc phát hành phiên bản mới (ở bước *warm-up*, nằm ngoài luồng request của người dùng), Chicory sẽ **dịch sẵn mã Wasm sang JVM Bytecode** (mất dưới 500ms). Đến khi có request thật gọi vào, đoạn code đó chạy nhanh như bytecode gốc với độ trễ **dưới 5ms**. * **Khi chạy trên GraalVM Native:** Vì trình biên dịch Wasm-sang-Bytecode của Chicory bắt buộc phải chạy ở *build-time* nếu dùng GraalVM Native, nên nếu bạn cập nhật nóng (hot-swap) một file Wasm mới vào lúc *run-time* trên bản Native Image, Astra vẫn chạy được bằng cách tự động chuyển sang **chế độ thông dịch (interpreted mode)**. * **Cô lập cứng (Hard Sandbox):** Code ở Tier 2 bị khóa chặt trong lồng kính: giới hạn cứng số trang bộ nhớ (memory pages), ngắt cưỡng bức nếu chạy quá thời gian (deadline/interrupt), và hoàn toàn không thể gọi ra mạng hay đọc database trừ khi được Kernel cấp quyền (host functions) rõ ràng. --- ## 3. Sự phối hợp giữa Build-time và Run-time khi có thay đổi (Hot-Swap & Zero-DDL) Để hệ thống "thay đổi mỗi ngày mà không bao giờ sập", Astra kết hợp khéo léo các bước chuẩn bị ngầm ở Run-time cho cả **Code** lẫn **Dữ liệu**: 1. **Quá trình thay thế Code đang chạy (Run-time Rollout):** * **Nạp song song & Làm nóng (Side-by-side Load & Warm-up):** Khi có plugin mới (Tier 1 hoặc Tier 2), thông báo được bắn qua Redis Pub/Sub tới toàn bộ các Pod. Các Pod tải bản mới về nằm **song song** với bản cũ, kiểm tra chữ ký bảo mật, dịch sẵn Wasm ra bytecode (hoặc gắn `MethodHandle`) và dựng sẵn chuỗi gọi hàm mới trong chưa tới **2 giây**. * **Chuyển mạch nguyên tử (Atomic Cutover):** Khi đã sẵn sàng, Pod chỉ việc trỏ lại con trỏ điều phối (dispatch re-point) sang bản mới. Thao tác này diễn ra tức thời (**< 10 ms**). * **Xả luồng cũ (Drain):** Những request của người dùng đang chạy dở dang ở mili-giây trước đó vẫn tiếp tục chạy trên phiên bản code cũ cho đến khi hoàn tất; các request mới sẽ vào code mới. Sau khi bản cũ không còn request nào và không còn khách hàng nào ghim (pin) nữa, hệ thống mới gỡ ClassLoader cũ và kiểm tra rò rỉ bộ nhớ (leak check). 2. **Thay đổi cấu trúc Dữ liệu lúc Run-time mà không khóa bảng (Zero-DDL):** * Thông thường, thêm một cột dữ liệu lúc hệ thống đang chạy (Run-time) sẽ phải chạy lệnh `ALTER TABLE` gây khóa bảng (lock table). * Astra tránh hoàn toàn việc này bằng cách lưu các trường dữ liệu của DocType và các bản vá tùy chỉnh của khách hàng (Overlays) vào cột **`JSONB`** của PostgreSQL. Khi một trường cần tìm kiếm hay sắp xếp nhanh, hệ thống tự động tạo **Expression Index** ngầm (`CREATE INDEX CONCURRENTLY`) hoàn toàn online mà không làm gián đoạn bất kỳ giao dịch nào đang chạy. --- ## Bảng tóm tắt đối chiếu nhanh | Tiêu chí | Build-time (Tier 0: Shared) | Run-time (Tier 1: Isolated JVM) | Run-time (Tier 2: Wasm Sandbox) | | --- | --- | --- | --- | | **Thời điểm nạp code** | Lúc chạy lệnh `astra build` tạo Image | Nạp nóng (Hot-swap) khi server đang chạy | Nạp nóng (Hot-swap) khi server đang chạy | | **Công nghệ cốt lõi** | Quarkus `@BuildStep` + Jandex + CDI | `PluginClassLoader` riêng + `MethodHandle`; | Chicory Wasm Engine + Extism ABI | | **Tốc độ gọi hàm** | Nhanh nhất (Direct call, zero copy) | Rất nhanh (< 1 µs cho bước điều phối) | Nhanh (< 5 ms khi đã dịch sẵn ra bytecode) | | **Cập nhật cần làm gì?** | Build lại Image + Rolling Deploy các Pod | Tự động thay thế nóng trong vài giây (0 downtime) | Tự động thay thế nóng trong vài giây (0 downtime) | | **Hỗ trợ GraalVM Native** | **Có** (Tối ưu hóa toàn diện lúc build) | **Không** (Do Native không cho nạp Class động) | **Có** (Chuyển sang chế độ thông dịch khi hot-swap) | | **Phạm vi ảnh hưởng lỗi** | Sập cả Pod nếu có lỗi nghiêm trọng | Bị chặn bởi Bulkhead (Timeout, Circuit Breaker) | Cô lập tuyệt đối (Giới hạn cứng RAM & thời gian) | | **Mục đích sử dụng** | Các module ERP/CMS cốt lõi, Sổ cái (Ledgers) | Module mở rộng theo vùng/khách hàng, Tích hợp | Script kiểm tra dữ liệu, Marketplace, **Code do AI sinh ra**; | --- # IAM: integrating the Flutter app How the mobile app signs users in to Astra and calls the API. The local stack (make k3d-up) serves the API and the Keycloak login page from one origin, http://localhost:8080 by default. Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: en Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/iam-mobile How the mobile app signs users in to Astra and calls the API. The local stack (`make k3d-up`) serves the API and the Keycloak login page from one origin, `http://localhost:8080` by default. ## What the app talks to | Item | Value (local stack) | | --- | --- | | Bootstrap config (public) | `GET {BASE}/api/iam/config` | | Issuer | `{BASE}/auth/realms/astra` | | Discovery | `{BASE}/auth/realms/astra/.well-known/openid-configuration` | | Client id | `astra-mobile` (public client, no secret) | | Flow | Authorization Code + PKCE (S256) in the system browser. Password grant is disabled for this client. | | Redirect URI | `io.astra.mobile:/oauthredirect` (also the post-logout redirect) | | Scopes | `openid profile email`, plus `offline_access` for "stay signed in" | | Languages | `vi` (default) and `en`. Pass `ui_locales` to pick one. | | API auth | `Authorization: Bearer ` | | Tenant | `X-Astra-Tenant: `. This header is optional when the user belongs to exactly one tenant. | `{BASE}` is the value of `ASTRA_PUBLIC_URL` that the cluster was started with. Keycloak writes it into every token as the issuer, and the server rejects tokens from any other issuer. The app must therefore reach the stack through exactly that origin. ## Reaching the laptop from a device | Device | Setup | | --- | --- | | iOS simulator | Works as is with `http://localhost:8080`. | | Android emulator or USB phone | Run `adb reverse tcp:8080 tcp:8080`, then use `http://localhost:8080`. | | Phone on Wi-Fi | Run `ASTRA_PUBLIC_URL=http://:8080 make k3d-up`, then use that URL in the app. | The local stack is plain HTTP. Allow it in debug builds only: * **Android:** pass `allowInsecureConnections: true` to `flutter_appauth` requests, and allow cleartext traffic to the dev host with a debug-only `network_security_config.xml`. * **iOS:** add `NSAppTransportSecurity` → `NSAllowsLocalNetworking = YES` to the debug `Info.plist`. If token calls to a LAN IP are still blocked, add a debug-only exception for that host. ## Flutter setup ```yaml # pubspec.yaml dependencies: flutter_appauth: ^12.1.0 flutter_secure_storage: ^11.2.0 http: ^1.6.0 ``` Android, `android/app/build.gradle`: ```groovy android { defaultConfig { manifestPlaceholders += [appAuthRedirectScheme: 'io.astra.mobile'] } } ``` iOS, `ios/Runner/Info.plist`: ```xml CFBundleURLTypes CFBundleURLSchemes io.astra.mobile ``` ## Sign in, register, refresh, sign out ```dart import 'dart:convert'; import 'package:flutter_appauth/flutter_appauth.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:http/http.dart' as http; class AstraAuth { AstraAuth(this.base); // e.g. 'http://localhost:8080' final String base; final _appAuth = const FlutterAppAuth(); final _store = const FlutterSecureStorage(); static const clientId = 'astra-mobile'; static const redirect = 'io.astra.mobile:/oauthredirect'; static const scopes = ['openid', 'profile', 'email', 'offline_access']; String get discovery => '$base/auth/realms/astra/.well-known/openid-configuration'; /// Opens the Astra login page. Set [register] to open the sign-up form instead. Future signIn({String locale = 'vi', bool register = false}) async { final r = await _appAuth.authorizeAndExchangeCode(AuthorizationTokenRequest( clientId, redirect, discoveryUrl: discovery, scopes: scopes, additionalParameters: { 'ui_locales': locale, if (register) 'prompt': 'create', }, allowInsecureConnections: true, // local HTTP stack only )); await _save(r.accessToken, r.refreshToken, r.idToken); } /// Exchanges the refresh token. Call on 401, and after joining a tenant or a role change. Future refresh() async { final rt = await _store.read(key: 'refresh_token'); if (rt == null) return false; try { final r = await _appAuth.token(TokenRequest(clientId, redirect, discoveryUrl: discovery, refreshToken: rt, scopes: scopes, allowInsecureConnections: true)); await _save(r.accessToken, r.refreshToken ?? rt, r.idToken); return true; } catch (_) { await _store.deleteAll(); // refresh token expired or revoked: sign in again return false; } } Future signOut() async { final idToken = await _store.read(key: 'id_token'); await _appAuth.endSession(EndSessionRequest( idTokenHint: idToken, postLogoutRedirectUrl: redirect, discoveryUrl: discovery, allowInsecureConnections: true, )); await _store.deleteAll(); } Future accessToken() => _store.read(key: 'access_token'); Future _save(String? at, String? rt, String? idt) async { await _store.write(key: 'access_token', value: at); await _store.write(key: 'refresh_token', value: rt); await _store.write(key: 'id_token', value: idt); } } /// Calls the Astra API with the bearer token and tenant header; retries once after a refresh on 401. class AstraApi { AstraApi(this.auth, {this.tenant}); final AstraAuth auth; String? tenant; Future send(String method, String path, {Object? body}) async { Future once() async { final req = http.Request(method, Uri.parse('${auth.base}$path')) ..headers['Authorization'] = 'Bearer ${await auth.accessToken()}' ..headers['Content-Type'] = 'application/json'; if (tenant != null) req.headers['X-Astra-Tenant'] = tenant!; if (body != null) req.body = jsonEncode(body); return http.Response.fromStream(await req.send()); } var res = await once(); if (res.statusCode == 401 && await auth.refresh()) res = await once(); return res; } } ``` ## After sign-in 1. Call `GET /api/iam/me`. It returns the user's profile, `platformAdmin`, and a `tenants` list. Each entry holds the user's `roles` in that tenant and the effective `permissions` per DocType, for example `{"Article": {"read": true, "create": true, "write": "own"}}`. Use these to show or hide actions. The server enforces the same rules either way. 2. If `tenants` is empty, the user just self-registered. Join the app's tenant with `POST /api/iam/tenants/{tenant}/join`, or let the user pick one from `GET /api/iam/tenants/public`. The user joins as `reader`. The response contains `"refreshToken": true`. Call `refresh()` before using the tenant, because the current access token was issued before the join. 3. If the user belongs to several tenants, send `X-Astra-Tenant` on every document call. Role changes made by a tenant admin reach the user's token at the next refresh, within 5 minutes. ## API reference | Method and path | Who | Purpose | | --- | --- | --- | | `GET /api/iam/config` | anyone | Issuer, discovery URL, client id, redirect URI, scopes, locales | | `GET /api/iam/me` | signed in | Profile, memberships, effective permissions | | `GET /api/iam/tenants` | signed in | Tenants the caller belongs to (all of them for a platform admin) | | `GET /api/iam/tenants/public` | signed in | Tenants open to self-join | | `POST /api/iam/tenants/{t}/join` | signed in | Join a public tenant as `reader`. Repeating it is harmless. | | `GET /api/iam/tenants/{t}/members` | tenant admin, platform admin | List members and their roles | | `POST /api/iam/tenants/{t}/members` | tenant admin, platform admin | `{username, email, firstName, lastName, role, temporaryPassword}`. Creates the user if new. The temporary password must be changed at first login, on the themed page. | | `PUT /api/iam/tenants/{t}/members/{userId}` | tenant admin, platform admin | `{role}`. Each user has one role per tenant. | | `DELETE /api/iam/tenants/{t}/members/{userId}` | tenant admin, platform admin | Remove the user from the tenant. The last admin cannot be removed or demoted (409). | | `POST /api/iam/tenants` | platform admin | `{id, name, publicSignup}`. Creates the tenant and its role groups. | | `PUT /api/iam/tenants/{t}` | platform admin | `{name, publicSignup}` | | `/api/doc/{DocType}` | tenant member | Generated document API, checked against the DocType's `permissions` | Tenant roles are `admin`, `editor`, `author` and `reader`. For `Article`, admins and editors can read, create and write everything. Authors can read and create, and can write only their own documents. Readers can only read. A platform admin manages tenants and members but gets no document access unless also made a member. Errors are JSON `{"error": "..."}`: | Status | Meaning | | --- | --- | | 401 | Token missing, expired, or from the wrong issuer | | 403 | Not a member of the tenant, or the role does not allow the action | | 400 | Tenant could not be resolved, or the request was invalid | | 409 | Conflict, such as a duplicate username, an existing tenant, or the last admin | ## Seeded local accounts All seeded accounts use the password `Astra@123`. They exist only in the local realm export. | Username | Access | | --- | --- | | `platform` | platform admin | | `lan` | acme admin | | `minh` | acme editor | | `hoa` | acme author | | `an` | acme reader | | `john` | globex admin | `acme` allows public sign-up and `globex` does not. The Keycloak admin console is at `{BASE}/auth/admin` (user `admin`; the password is in the `astra-keycloak` secret). ## Not in this release Forgot-password by email (no SMTP yet), MFA, social login, and filtering drafts out of readers' lists. ## Troubleshooting * **"Invalid parameter: redirect_uri":** the app's redirect must be exactly `io.astra.mobile:/oauthredirect`. * **401 on every API call right after sign-in:** the app reached the stack through a different origin than `ASTRA_PUBLIC_URL`, so the token issuer does not match. Compare `iss` in the token with `/api/iam/config`. * **403 "not a member of tenant ..." after joining:** refresh the token first. * **Run the checks:** `scripts/demo-iam.sh http://localhost:8080` exercises all of the above with real tokens. --- # Overlay và Wasm: tùy biến không fork Overlay (Lớp phủ tùy biến) trong Astra giống như một tấm kính trong suốt đặt đè lên bản vẽ thiết kế gốc, hoặc giống như cơ chế docker-compose.override.yml / CSS Override mà anh em làm kỹ thuật vẫn dùng: Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/overlay-wasm **Overlay (Lớp phủ tùy biến)** trong Astra giống như **một tấm kính trong suốt đặt đè lên bản vẽ thiết kế gốc**, hoặc giống như cơ chế `docker-compose.override.yml` / CSS Override mà anh em làm kỹ thuật vẫn dùng: Thay vì phải **sửa trực tiếp vào code gốc (fork code)** của phân hệ `BMS` hay `OMS` khi một khách hàng (Tenant) hoặc một chi nhánh quốc gia có yêu cầu riêng — việc sẽ khiến bạn **không thể nâng cấp bản Core sau này** vì sợ xung đột code — thì bạn giữ nguyên bản gốc 100% và chỉ viết một file mô tả **"những điểm khác biệt"** đè lên trên. Ví dụ một cách trực Cơ chế **Overlay + Wasm**: --- ### 1. Ví dụ Thực tế Dễ Hình Dung (Hoá đơn ở Việt Nam vs. Campuchia) Giả sử bạn có **DocType gốc** là `Sales Invoice` (Hoá đơn bán hàng) nằm trong plugin lõi `BMS` (Tier 0). Bản gốc này chỉ có các trường chuẩn quốc tế: `customer`, `grand_total`, `remarks`. Bây giờ, bạn triển khai cho 2 thị trường (hoặc 2 Tenant khác nhau) với yêu cầu đặc thù: * **Tenant A (Việt Nam - `acme-vn`):** Luật thuế Việt Nam bắt buộc hoá đơn từ 20 triệu VNĐ trở lên phải có trường **"Mã hoá đơn điện tử" (`e_invoice_code`)**, trường ghi chú (`remarks`) bắt buộc phải nhập, và phải cấp quyền xem cho vai trò `Tax Officer`. * **Tenant B (Campuchia):** Cần thêm trường **"Tỷ giá KHR/USD"** và một logic kiểm tra riêng. Nếu làm theo cách truyền thống: Bạn phải sửa code Java của `BMS`, chạy lệnh `ALTER TABLE` thêm cột vào Database, rồi build lại cả hệ thống. Với Overlay + Wasm trong Astra, bạn không đụng một dòng nào vào `BMS` gốc: 1. **Tạo lớp phủ dữ liệu & giao diện (`sales-invoice.overlay.yaml`):** Bạn chỉ cần khai báo một file YAML nhỏ gắn riêng cho Tenant `acme-vn`: * **Thêm trường mới (`add-fields`):** Thêm trường `e_invoice_code` (Mã hoá đơn điện tử) nằm ngay dưới trường `posting_date`. Vì Astra lưu dữ liệu dạng **Zero-DDL JSONB**, trường này xuất hiện ngay lập tức trên API và UI mà **không cần chạy lệnh `ALTER TABLE` gây khóa bảng PostgreSQL**. * **Sửa thuộc tính trường cũ (`set-properties`):** Đổi thuộc tính trường `remarks` từ không bắt buộc thành bắt buộc (`required: true`). * **Thêm quyền (`add-permissions`):** Cho phép role `Tax Officer` được quyền đọc (`read: true`). 2. **Đính kèm logic kiểm tra bằng Wasm (`scripts`):** * Trong file Overlay đó, bạn trỏ sự kiện `validate` vào một file Wasm nhỏ (`com.acme.e-invoice-check` viết bằng Rust/JS biên dịch ra `.wasm`). * File Wasm này kiểm tra: *"Nếu `grand_total >= 20,000,000` mà `e_invoice_code` bị trống thì báo lỗi chặn lại"*. --- ### 2. Bên dưới Hệ thống (Astra Kernel) Hoạt động Như Thế Nào? Khi một request của Tenant `acme-vn` gọi vào hệ thống, Astra xử lý qua 3 bước cực nhanh: ```text [1. Bản gốc (Base DocType)] [2. Lớp phủ (Tenant Overlay)] Sales Invoice (BMS Core) + sales-invoice.overlay.yaml (acme-vn) - customer - +field: e_invoice_code - grand_total - *prop: remarks (required=true) - remarks (required=false) - +hook: validate -> e-invoice-check.wasm \ / v v [3. Bản Hợp Nhất trong RAM (Effective Metadata)] Lưu vào L1/L2 Cache theo khóa: (tenant: acme-vn, version) -> Tự động sinh lại REST API, GraphQL, Form UI và chạy Wasm! ``` * **Trộn ở tầng RAM (Metadata Merge):** Ngay khi load metadata, Kernel lấy `Base DocType` trộn với `Overlay` của riêng Tenant đó để tạo ra bản **Effective Metadata** và lưu vào bộ nhớ đệm L1/L2 theo từng `(tenant, metadata-version)`. Tenant khác gọi vào vẫn nhìn thấy bản `Sales Invoice` gốc nguyên vẹn. * **Thực thi an toàn trong hộp cát (Wasm Sandbox):** Khi người dùng bấm Lưu hoá đơn, Kernel chạy các hook gốc của `BMS` trước, sau đó gọi tiếp module Wasm được gắn trong Overlay (chạy bằng Chicory/Extism trong vài mili-giây, giới hạn cứng RAM và CPU, nếu code Wasm lỗi cũng không thể làm sập JVM của hệ thống). --- ### 3. Tại sao Gộp "Overlay + Wasm" lại Tiết Kiệm Thời Gian Rollout Cực Lớn? Quyết định triển khai **Overlay đi kèm Wasm** của bạn giải quyết được bài toán khó nhất khi vận hành hệ thống lớn: 1. **Zero-Downtime & Hot-Swap trong vài giây (Không cần Build/Deploy lại Docker Image):** Nếu viết logic tùy biến bằng Java ở Tier 0, mỗi lần sửa 1 câu điều kiện cho khách hàng là phải `astra build`, đẩy Docker Image mới và Rolling Update cả cụm Kubernetes (mất vài phút). Với **Overlay (YAML) + Wasm (Tier 2)**, cả hai đều được coi là dữ liệu/tài nguyên nạp động lúc runtime: bạn đẩy lên là hệ thống **Hot-swap trong vài giây, downtime = 0**. 2. **Nâng cấp Core thoải mái không sợ vỡ (Survive Upgrades):** Tháng sau, đội Core của bạn nâng cấp phân hệ `BMS` từ bản `1.2` lên `1.3`. Vì mọi tùy biến của khách hàng nằm tách biệt hoàn toàn ở lớp `Overlay + Wasm`, bạn cứ thế nâng cấp `BMS` mà không bị conflict code. Hơn nữa, bộ kiểm thử `conformance check` của Astra sẽ tự động lấy bản `BMS 1.3` lắp thử với tất cả các `Overlay` hiện có xem có trường nào bị xóa nhầm không trước khi cho phép rollout. 3. **Sẵn sàng cho AI tự viết tính năng (AI-Generated Code):** Sau này khi bạn dùng AI Agent để tự động sinh thêm trường dữ liệu và viết logic nghiệp vụ theo yêu cầu khách hàng, AI chỉ cần sinh ra 1 file `.overlay.yaml` và 1 hàm `.wasm` chạy trong sandbox cô lập hoàn toàn — vừa nhanh tức thì vừa an toàn tuyệt đối cho hệ thống lõi. --- # Use case 01: công ty chuyển phát nhanh Bản đề xuất triển khai trên nền tảng Astra Platform (Java 25 + Quarkus, DocType Engine Zero-DDL, 3-Tier Plugin Isolation, Apache Seata, Compliance-by-Design) với mô hình Multi-Schema Ownership + Rust CDC (supabase/etl)… Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/use-case Bản đề xuất triển khai trên nền tảng **Astra Platform** (Java 25 + Quarkus, DocType Engine Zero-DDL, 3-Tier Plugin Isolation, Apache Seata, Compliance-by-Design) với mô hình **Multi-Schema Ownership + Rust CDC (`supabase/etl`) + Sync-Aware LSN + Keycloak Deep Auth** để triển khai 7 phân hệ của 1 hệ thống chuyển phát. --- # PHẦN 1: KIẾN TRÚC TỔNG THỂ HỆ THỐNG (OVERALL ARCHITECTURE) ### 1. Sơ đồ Kiến trúc Hợp nhất (Astra Core + 7 Enterprise Apps) ```text +---------------------------------------------------------------------------------------------------+ | CLIENT LAYER & API GATEWAY (Keycloak OIDC + Sync-Aware LSN) | | Desk UI (WCAG 2.2 AA) • REST (/api/doc/) • GraphQL • gRPC • MCP Endpoint (AI Agents) | | [Header In/Out: Authorization (Bearer + Scope), X-Astra-Tenant, X-Tx-LSN / X-Required-LSN] | +---------------------------------------------------------------------------------------------------+ | +---------------------------------------------------------------------------------------------------+ | ASTRA KERNEL PODS (Java 25 LTS • Virtual Threads • Gen-ZGC • Quarkus 3.x) | | | | +---------------------------------------------------------------------------------------------+ | | | 1. KERNEL GUARD & CONTEXT (ScopedValue) | | | | • TenantResolver + RLS Guard (Fail-closed) • Keycloak Policy Enforcer (RBAC/ABAC/Scope) | | | | • Sync-Aware LSN Interceptor (Watermark) • Compliance & Audit Hash-Chain (SOC2/PDPL) | | | +---------------------------------------------------------------------------------------------+ | | +---------------------------------------------------------------------------------------------+ | | | 2. DOCTYPE ENGINE (Multi-Schema Aware) | | | | • Storage: jsonb (Zero-DDL + Expression/Materialised Index) | ledger (Append-only) | | | | • Lifecycle: Draft(0) -> Submitted(1) -> Cancelled(2) -> Amend | Tenant Overlays (YAML) | | | +---------------------------------------------------------------------------------------------+ | | +---------------------------------------------------------------------------------------------+ | | | 3. THREE-TIER PLUGIN HOST ("ASTRA APPS") | | | | • Tier 0 (Shared Build-time CDI/Jandex): [OMS] [OPS] [BMS] [HRM] [STMS] [CARE] [NOC] | | | | • Tier 1 (Isolated JVM - MethodHandle): Localization (VN E-Invoice, Tax), Custom Connectors | | | | • Tier 2 (Wasm Sandbox - Chicory/Extism): Tenant Server Scripts, Pricing Rules, AI Code | | | +---------------------------------------------------------------------------------------------+ | +---------------------------------------------------------------------------------------------------+ | (JTA / Seata AT & TCC) | (WAL Logical Replication) v v +----------------------------------------+ +---------------------------------------------------+ | POSTGRESQL 16+ & REDIS CLUSTER (HA) | | RUST CDC PIPELINE (supabase/etl Custom) | | • 1 App = 1 Schema (Strict Ownership): | | • Đọc WAL trực tiếp theo LSN, độ trễ < 10ms | | schema_oms, schema_ops, schema_bms, | ===> | • Đồng bộ bảng Replica (replica_*) giữa các Schema| | schema_hrm, schema_stms, schema_care | | • Cập nhật bảng _cdc_sync_watermark (last_lsn) | | • RLS (tenant_id) + Partitioning | | • Stream Load realtime sang Apache Doris | | • Redis: L2 Cache, Series, Seata TCC | +---------------------------------------------------+ +----------------------------------------+ | v +---------------------------------------------------+ | APACHE DORIS (OLAP) & AI INFRASTRUCTURE | | • Lưu trữ dữ liệu lớn phục vụ NOC Insights | | • kết nối trực tiếp với astra-ai (MCP + LLM GW) | +---------------------------------------------------+ ``` --- ### 2. Năm Trụ cột Kiến trúc Cốt lõi (Core Architectural Pillars) #### A. Mô hình Lưu trữ Lai: Multi-Schema + Astra DocType Storage (`jsonb` | `ledger` | `hot-column`) Thay vì đặt tất cả các phân hệ vào chung schema `public`, chúng ta mở rộng thuộc tính `schema` vào định nghĩa DocType của Astra: * **Cô lập theo Schema (`schema_`):** Mỗi phân hệ (`OMS`, `OPS`, `BMS`, `HRM`, `STMS`, `CARE`) sở hữu độc quyền 1 Schema riêng trên PostgreSQL 16+ kèm RLS (`tenant_id`). * **3 Chiến lược lưu trữ bên trong mỗi Schema:** 1. **`storage: jsonb` (Zero-DDL):** Dành cho chứng từ, danh mục, cấu hình cần tùy biến trường liên tục qua `Overlay` mà không bao giờ chạy lệnh `ALTER TABLE` gây khóa bảng. Các trường hay lọc/sắp xếp dùng `projected: true` (Expression Index `CONCURRENTLY`) hoặc `projected: materialised`. 2. **`storage: ledger` (Append-only Relational):** Dành cho sổ cái kế toán (`GL Entry`), sổ kho/chuyển phát, bảng lương (`Payroll Ledger`) và `Audit Trail`. Bảng có cột cố định, phân vùng theo kỳ (Partition by Period), không bao giờ `UPDATE`/`DELETE` (chỉ ghi bút toán đảo khi hủy). 3. **Hot-Field Materialisation:** Với các bảng thay đổi trạng thái liên tục hàng chục lần/ngày (như trạng thái bưu kiện trong `OPS` hoặc vị trí xe trong `STMS`), tách các cột nóng ra cột vật lý (Materialised Columns) hoặc bảng phụ hẹp với `fillfactor < 100` để tận dụng PostgreSQL HOT Update, tránh phình TOAST JSONB. #### B. Quản lý Giao dịch Đa tầng (Multi-Tier Transaction Guarantee) Kết hợp giữa cơ chế Local/Cross-Schema của PostgreSQL, Apache Seata 2.6 của Astra và Rust CDC: 1. **Giao dịch Nội bộ & Liên Schema Tức thời (ACID 100%):** * Khi các Core App chạy ở **Tier 0 (Shared JVM)** trên cùng cụm PostgreSQL: Sử dụng `@Transactional` (Narayana JTA) gọi qua `astra-spi` (hoặc `@AstraGlobalTransactional` với **Seata AT Mode** + **Redis TCC Mode** khi cần giữ chỗ tồn kho/hạn mức tức thời). * Cơ chế **Smart Bypass** của Astra đảm bảo các thao tác lưu đơn lẻ bỏ qua Seata Coordinator, giữ độ trễ `p99 < 15ms`. 2. **Giao dịch Bất đồng bộ & Đồng bộ Đọc (Rust CDC + Sync-Aware LSN):** * Dữ liệu tham chiếu chéo giữa các phân hệ (ví dụ: `BMS` cần thông tin đơn hàng từ `OMS` để lên hóa đơn; `CARE` cần hành trình đơn từ `OPS`) được **Rust CDC (`supabase/etl`)** đọc từ PostgreSQL WAL và ghi sang các bảng `replica_*` tại schema đích kèm cập nhật `last_synced_lsn` vào bảng `_cdc_sync_watermark`. * **Sync-Aware Guard:** Khi một mutation hoàn tất, Astra Kernel đính kèm `X-Tx-LSN` (lấy từ `pg_current_wal_lsn()`) vào response. Khi Client chuyển sang phân hệ khác và gửi `X-Required-LSN`, Filter của Astra kiểm tra bộ nhớ đệm L1/Watermark: nếu `last_synced_lsn < X-Required-LSN`, hệ thống trả về cảnh báo trạng thái đồng bộ (`202 Sync Pending`) và tự động mở khóa ngay khi Rust CDC đuổi kịp sau vài mili-giây. #### C. Kiến trúc Plugin 3 Tầng (Three-Tier Isolation) * **Tier 0 – Shared (Build-time Quarkus CDI + Jandex):** Biên dịch trực tiếp vào image qua `astra build`, gọi hàm trực tiếp (zero-copy), hỗ trợ GraalVM Native. **Dùng cho toàn bộ lõi của 7 phân hệ (`OMS`, `OPS`, `BMS`, `HRM`, `STMS`, `CARE`, `NOC`).** * **Tier 1 – Isolated JVM (Runtime `PluginClassLoader` + `MethodHandle`):** Cho phép Hot-swap không cần khởi động lại pod, cô lập bằng Bulkhead/Circuit Breaker. **Dùng cho các bản địa hóa (VN E-Invoice, Thuế GTGT, cổng thanh toán, kết nối đối tác 3PL).** * **Tier 2 – Wasm Sandbox (Chicory + Extism ABI):** Sandbox cứng về bộ nhớ và thời gian chạy (`timeout: 50ms`), viết bằng Rust/Go/JS/Python. Dùng cho Server Scripts của từng Tenant, công thức tính cước chuyển phát động (Pricing Rules), hoặc code do AI sinh ra. #### D. Bảo mật Chiều sâu, Đa thuê bao & Tuân thủ Quốc tế (Security, Multi-Tenancy & Compliance) * **Keycloak OIDC + ScopedValue Tenancy:** Xác thực qua Keycloak với Fine-Grained Scope cho từng microservice/app. Định danh `Tenant` và `SecurityIdentity` được đóng đinh vào Java 25 `ScopedValue` ngay từ đầu request và lan truyền tự động qua mọi Virtual Thread. * **Fail-Closed Row-Level Security (RLS):** Mọi câu lệnh SQL đều tự động gắn biến phiên `app.current_tenant` cho PostgreSQL RLS; Kernel từ chối khởi động nếu DB role có quyền `SUPERUSER` hoặc `BYPASSRLS`. * **Compliance-by-Design (SOC 2, ISO 27001, GDPR, Luật dữ liệu cá nhân VN - PDPL 91/2025/QH15):** Khai báo trực tiếp `pii`, `classification`, `retention`, `encrypt: field`, `residency: vn` ngay trên từng field của file `.doctype.yaml`. Tự động băm chuỗi (Hash-chain) mọi thay đổi vào bảng `Audit Ledger` bất biến và che dấu (redact) PII trước khi gửi sang LLM. --- # PHẦN 2: HƯỚNG TRIỂN KHAI CỤ THỂ CHO CORE & 7 PHÂN HỆ (IMPLEMENTATION BLUEPRINT) ### 1. Triển khai Nền tảng Lõi (`astra-kernel`, `astra-doctype`, `astra-cdc-sync`) Trước khi viết các phân hệ nghiệp vụ, cần hoàn thiện 4 mảnh ghép kỹ thuật trong Core của Astra: 1. **Mở rộng Schema cho DocType YAML (`astra-doctype`):** Thêm thuộc tính `db_schema` vào `DocTypeDefinition`. Khi `DocumentStore` hoặc `LedgerContributor` thực thi truy vấn, nó tự động định tuyến về đúng `.astra_doc` hoặc bảng Ledger của schema đó (ví dụ: `schema_oms.astra_doc`, `schema_bms.gl_entry`). 2. **Tích hợp `SyncAwareFilter` vào `astra-kernel`:** * Trên đường ra của các lệnh `POST/PUT/DELETE` (hoặc `submit/cancel`), lấy `pg_current_wal_lsn()` gắn vào Header `X-Tx-LSN`. * Trên đường vào của lệnh `GET`, nếu có Header `X-Required-LSN`, đối chiếu với LSN của schema hiện tại (được Rust CDC cập nhật liên tục vào Redis L2 / bảng `_cdc_sync_watermark`). 3. **Dịch vụ `astra-cdc-worker` (Rust - dựa trên `supabase/etl`):** * Kết nối vào PostgreSQL qua Logical Replication Slot (`pgoutput`). * Định nghĩa tệp cấu hình `cdc-routes.yaml` chỉ định: bảng gốc nào (ví dụ `schema_oms.astra_doc` có `doctype = 'WaybillOrder'`) sẽ được chiếu (project) và lọc những trường nào sang bảng đích (`schema_bms.replica_orders`, `schema_care.replica_orders`) và đẩy luồng Stream Load sang **Apache Doris**. --- ### 2. Thiết kế & Triển khai Chi tiết 7 Phân hệ (Astra Apps) #### Phân hệ 1: `astra-app-oms` (Order Management System – Quản lý Đơn Khách hàng) * **Schema & Phân tầng:** `schema_oms` | **Tier 0** (Lõi đơn hàng) + **Tier 2 Wasm** (Tính cước động / Khuyến mãi theo từng khách hàng B2B). * **Core DocTypes:** * `CustomerOrder` (`kind: transaction`, `storage: jsonb`, `submittable: true`, `naming: "ORD-.YYYYMMDD.-.#####"`). * `OrderPricingRule` (`kind: setup`, cho phép gắn script Wasm để tính giá cước linh hoạt theo trọng lượng/vùng miền). * `CustomerContract` & `CODReconciliation` (Đối soát tiền thu hộ COD). * **Tối ưu Hiệu năng & Luồng dữ liệu:** * Các trường `customer_id`, `order_status`, `cod_amount`, `created_at` được đánh dấu `projected: materialised` để chịu tải ghi và lọc danh sách triệu dòng với `p99 < 20ms`. * Khi `CustomerOrder` chuyển sang `on_submit`: kích hoạt `@AstraGlobalTransactional` để kiểm tra hạn mức công nợ khách hàng (qua Redis TCC) và phát sinh bản ghi vận đơn sang `OPS` (qua SPI trong cùng transaction hoặc qua CDC Outbox). * **Tuân thủ (PDPL/GDPR):** Trường `receiver_phone`, `receiver_address` được gắn cờ `pii: true, encrypt: field, mask: "***last4"`. #### Phân hệ 2: `astra-app-ops` (Core Express Operations – Lõi Vận hành Chuyển phát) * **Schema & Phân tầng:** `schema_ops` | **Tier 0** (Yêu cầu độ trễ cực thấp cho máy bắn mã vạch PDA/DWS tại bưu cục và kho phân loại). * **Core DocTypes:** * `Waybill` (Vận đơn vận hành - tách trường `current_hub`, `ops_status` thành **Materialised Hot Columns** với `fillfactor = 80` để cập nhật trạng thái liên tục không gây khóa hay phình bảng JSONB). * `SortingBag` (Bao hàng), `SealManifest` (Bảng kê giao nhận chặng), `ScanEventLedger` (`kind: ledger` – Bảng chỉ ghi thêm `Append-only`, lưu toàn bộ lịch sử quét mã vạch: *Nhận hàng, Nhập kho, Đóng bao, Xuất kho, Giao thành công*). * **Tối ưu Hiệu năng:** * Tận dụng tối đa **Java 25 Virtual Threads** (`@RunOnVirtualThread`) kết hợp **Redis Cluster** (thông qua `AstraRedisTccTemplate`) để kiểm tra trùng lặp mã quét và khóa trạng thái bao hàng trong `< 2ms`. * Mọi sự kiện ghi vào `ScanEventLedger` tự động được Rust CDC bắn sang `CARE` (để CSKH tra cứu tức thời) và `NOC` (để hiển thị bản đồ nhiệt tồn đọng bưu cục trên Apache Doris). #### Phân hệ 3: `astra-app-bms` (Billing & Accounting Management – Chuẩn Kế toán & Hoá đơn ERPNext) * **Schema & Phân tầng:** `schema_bms` | **Tier 0** (Lõi Sổ cái & Bút toán kép) + **Tier 1 Isolated JVM** (Plugin tích hợp Hóa đơn điện tử VNPT/Viettel/MISA, Thuế Việt Nam). * Core DocTypes (Thiết kế Clean-room theo hình mẫu `erp.accounts` của Astra): * `Account` (Hệ thống tài khoản kế toán dạng cây), `CostCenter`, `FiscalYear`, `PeriodClosingVoucher`. * `SalesInvoice`, `PurchaseInvoice`, `PaymentEntry`, `JournalEntry` (`kind: transaction`, `submittable: true` – áp dụng chặt chẽ vòng đời `Draft (0) -> Submitted (1) -> Cancelled (2)` và `Amend` tạo phiên bản `INV-0001-1`). * `GLEntry` (`kind: ledger`, `storage: ledger` – Sổ cái bất biến, phân vùng theo tháng/năm tài chính, sử dụng kiểu `BigDecimal` chuẩn xác tuyệt đối, chỉ ghi qua `GeneralLedger` SPI bên trong `@AstraGlobalTransactional`). * **Luồng Đồng bộ & Kiểm soát Nội bộ (SOX ITGC):** * Nhận dữ liệu đơn hàng hoàn tất/COD từ `schema_oms` qua bảng `schema_bms.replica_orders` (do Rust CDC đồng bộ) để tự động gom nhóm và sinh `SalesInvoice` hàng loạt. * Áp dụng quy tắc **Segregation of Duties (SoD)** ngay trên metadata của DocType: Kế toán viên tạo `PaymentEntry` không được phép bấm `Submit` chính phiếu đó. * Tùy biến Hóa đơn điện tử theo từng quốc gia/công ty bằng cơ chế **Tenant Overlay** (`sales-invoice.overlay.yaml`) mà không làm ảnh hưởng code lõi của `BMS`. #### Phân hệ 4: `astra-app-hrm` (Human Resource Management – Nhân sự & Tính lương chuẩn ERPNext HRMS) > **Trạng thái: đã có lát cắt mỏng (Video 19).** Bản đang chạy (`astra-apps/hrm`) khác thiết kế dưới đây ở vài điểm: chưa có schema riêng, `Contract`, `LeaveApplication` hay `PayrollLedger`; số kiện do ops nhập khi chấm công, chưa lấy qua Rust CDC; batch chạy trong request lưu `PayrollEntry`, chưa là `JobContributor`; bút toán ghi qua SPI `GeneralLedger` trong transaction của kernel, không qua Seata. Danh sách đầy đủ ở mục "Nói đúng" của Video 19 trong [demo-video-series.md](/astra-cms/vi/video-scripts). * **Schema & Phân tầng:** `schema_hrm` | **Tier 0** (Hồ sơ nhân sự, Chấm công, Sổ cái lương) + **Tier 2 Wasm** (Công thức tính KPI/Đơn giá giao hàng cho Bưu tá/Tài xế). * Core DocTypes (Theo hình mẫu `erp.hr` của Astra): * `Employee`, `Contract`, `Attendance`, `LeaveApplication`. * `SalaryStructure`, `PayrollEntry`, `SalarySlip` (`submittable: true`). * `PayrollLedger` (`kind: ledger`, `storage: ledger` – Bảng sổ cái chi lương bất biến). * **Cơ chế Tính lương Tự động & Kết nối Kế toán:** * Sản lượng giao/nhận thành công của từng Bưu tá/Tài xế được Rust CDC tổng hợp từ `OPS`/`STMS` sang bảng `schema_hrm.replica_staff_performance`. * Sử dụng `JobContributor` (với cơ chế bầu chọn Leader trên cụm K8s) để chạy Batch tính lương cuối tháng trên Virtual Threads. Công thức thưởng phạt phức tạp được thực thi an toàn qua **Tier 2 Wasm Script**. * Khi `PayrollEntry` được `Submit`, hệ thống gọi qua SPI của `BMS` (trong cùng `@AstraGlobalTransactional`) để tự động hạch toán chi phí lương và bảo hiểm vào bảng `GLEntry`. #### Phân hệ 5: `astra-app-noc` (Network Operations Center – Insights, Big Data Doris & MCP Ready) * **Schema & Phân tầng:** `olap_doris` (Apache Doris) + `astra-ai` | **Tier 0**. * **Kiến trúc Dữ liệu Lớn & AI Native:** * **Đường ống Realtime OLAP:** Rust CDC (`supabase/etl`) gom các luồng thay đổi từ `OMS`, `OPS`, `BMS`, `STMS`, `CARE` và đẩy trực tiếp vào **Apache Doris** qua giao thức *Routine Load / Stream Load*, cho phép truy vấn phân tích hàng trăm triệu vận đơn dưới 1 giây. * Virtual DocTypes (`storage: virtual`): Xây dựng `DorisDocTypeAdapter` triển khai `DocTypeAdapter` của Astra, biến các bảng tổng hợp trên Apache Doris thành các Virtual DocType (`NocSlaMetric`, `HubCongestionReport`, `RevenueRealtimeSummary`). Nhờ đó, toàn bộ cơ chế phân quyền RBAC/RLS và sinh giao diện Dashboard/Report của `astra-desk` đều dùng lại được 100%. * **Chuẩn hóa MCP Server (`astra-ai`):** * Mọi báo cáo và công cụ phân tích của NOC được khai báo qua `ToolContributor` và `AgentContributor`, tự động phơi bày thành chuẩn **MCP (Model Context Protocol)** có phân trang và giới hạn theo `ai.mcp.expose` của từng Tenant. * **Grounding & Bảo mật AI:** Khi ban giám đốc hỏi AI Agent *"Tại sao tỷ lệ rớt SLA tại kho Hà Nội tăng hôm nay?"*, `NOC Agent` gọi MCP Tool truy vấn Apache Doris, tự động ẩn dữ liệu nhạy cảm (PII Redaction) qua `LLM Gateway`, và bắt buộc trả về danh sách `sources` (mã chứng từ/báo cáo gốc) để chống ảo giác (hallucination). #### Phân hệ 6: `astra-app-stms` (Smart Transport Management System – Điều phối Xe Thông minh + AI) > **Trạng thái: đã có lát cắt mỏng (Video 20).** Bản đang chạy (`astra-apps/stms`) giải VRP bằng Timefold trong hook của `DispatchPlan` và qua tool MCP (`stms_optimize_route`, không phải `stms.optimize_route`); copilot dùng mô hình ngôn ngữ giả lập cho demo. Chưa có schema riêng, `Driver`, `RouteTemplate`, `HubGeofence`, `TripExecution`, dự báo tải Tier 1 hay dữ liệu từ CDC. Danh sách đầy đủ ở mục "Nói đúng" của Video 20 trong [demo-video-series.md](/astra-cms/vi/video-scripts). * **Schema & Phân tầng:** `schema_stms` | **Tier 0** (Tích hợp **Timefold Solver** trên Quarkus) + **Tier 1** (Thuật toán dự báo tải AI). * **Core DocTypes:** * `Vehicle`, `Driver`, `RouteTemplate`, `HubGeofence`. * `DispatchPlan` (`submittable: true` – Lệnh điều xe và ghép chuyến), `TripExecution` (Hành trình thực tế). * **Tối ưu Hóa & Điều phối Thông minh:** * Tích hợp **`timefold-solver-quarkus`** trực tiếp vào một `JobContributor` và `ToolContributor` của Astra để giải bài toán **VRP (Vehicle Routing Problem with Time Windows & Capacity)**: tự động tối ưu lộ trình ghép tải, gom hàng giữa hàng trăm điểm bưu cục và xe tải. * Nhận dữ liệu dự báo sản lượng hàng hóa sắp đổ về từ `OPS` và `OMS` (qua Rust CDC) kết hợp mô hình AI (qua `ModelProvider` của `astra-ai`) để đề xuất phương án điều xe sớm. * Các phương án điều phối cũng được xuất thành MCP Tool (`stms.optimize_route`) để nhân viên điều vận có thể tương tác với AI Copilot ngay trên màn hình `astra-desk`. #### Phân hệ 7: `astra-app-care` (CRM & Omnichannel Helpdesk – Quản lý Luồng & Chăm sóc Khách hàng) * **Schema & Phân tầng:** `schema_care` | **Tier 0** (Lõi CRM, Ticket, SLA Engine) + **Tier 2 Wasm** (Luồng tự động phân bổ Ticket & AI Triage). * **Core DocTypes:** * `Lead`, `Opportunity`, `CustomerProfile` (Kế thừa mô hình `erp.crm` của Astra). * `SupportTicket`, `SlaPolicy`, `ClaimCase` (Phiếu đền bù khiếu nại mất/hỏng hàng, `submittable: true`). * **Luồng Nghiệp vụ 360 độ Không Độ Trễ:** * `CARE` đọc trực tiếp từ các bảng `schema_care.replica_waybills` và `schema_care.replica_orders` (được Rust CDC đồng bộ liên tục từ `OMS` và `OPS`). Nhân viên CSKH có thể mở 1 màn hình duy nhất xem toàn bộ lịch sử đơn hàng, hành trình quét mã vạch và trạng thái thanh toán COD mà **không tạo bất kỳ tải truy vấn nào lên DB vận hành của `OPS` hay `OMS**`. * Khi một `ClaimCase` (Đền bù) được duyệt (`on_submit`), `CARE` gọi qua SPI của `BMS` để tự động tạo phiếu chi hoặc cấn trừ công nợ trên `GLEntry`. * Tích hợp `AgentContributor` sử dụng kho tri thức vector (`pgvector` trong PostgreSQL 16+) để tự động tóm tắt lịch sử khiếu nại và gợi ý câu trả lời cho tổng đài viên. --- # PHẦN 3: CẤU TRÚC REPOSITORY & LỘ TRÌNH KHỞI CHẠY (ACTIONABLE ROADMAP) ### 1. Cấu trúc Thư mục Dự án (Mở rộng trực tiếp từ `astra` Thin Slice hiện tại) ```text astra/ ├── astra-bom/ # Quản lý tập trung version: Quarkus 3.39+, JDK 25, Seata 2.6, Timefold, Chicory ├── astra-spi/ # Giao diện chuẩn (DocType, Hooks, MCP Tool, LSN Context, Ledger SPI) ├── astra-kernel/ # Tenant ScopedValue, RLS Guard, SyncAwareLsnFilter, Dispatch Chains ├── astra-doctype/ # Multi-schema JSONB + Expression/Materialised Projections + Append-only Ledgers ├── astra-plugin-host/ # Tier 0 (CDI), Tier 1 (Isolated ClassLoader), Tier 2 (Chicory Wasm) ├── astra-tx-seata/ # Quản lý giao dịch phân tán AT (Postgres) & TCC (Redis) + Smart Bypass ├── astra-compliance/ # Audit Hash-Chain Ledger, Field Encryption, PDPL/GDPR/SOC2 Engine ├── astra-ai/ # MCP Server, LLM Gateway (PII Redaction), Agent Runtime, pgvector ├── astra-desk/ # Giao diện tự sinh từ DocType (Form, List, Kanban, Dashboard WCAG 2.2 AA) ├── astra-cdc-rust/ # [Rust Workspace] Custom từ supabase/etl: Đọc WAL -> Sync Replica & Apache Doris ├── astra-apps/ # Các phân hệ nghiệp vụ viết trên chuẩn astra-spi: │ ├── cms/ # Astra CMS (Đã có thin slice Article) │ ├── oms/ # Quản lý đơn hàng (schema_oms) │ ├── ops/ # Core chuyển phát & quét mã vạch (schema_ops) │ ├── bms/ # Kế toán chuẩn ERPNext & Hoá đơn (schema_bms + GL Entry) │ ├── hrm/ # Nhân sự & Tính lương chuẩn ERPNext HRMS (schema_hrm + Payroll Ledger) │ ├── stms/ # Điều phối xe thông minh Timefold + AI (schema_stms) │ ├── care/ # CRM & Helpdesk (schema_care) │ └── noc/ # Insights + Apache Doris Virtual DocType + MCP Tools └── astra-server/ # Đóng gói thực thi (Modular Monolith hoặc tách Microservice theo Profile) ``` ### 2. Thứ tự Triển khai Thực chiến (Phased Execution) 1. **Bước 1 – Nâng cấp Core Astra (Tuần 1–3):** * Bổ sung hỗ trợ **Multi-Schema (`schema_`)** và **`storage: ledger`** (cho `GL Entry` và `ScanEventLedger`) vào `astra-doctype` hiện tại. * Hoàn thiện cơ chế **Submittable Lifecycle (`0 -> 1 -> 2`)** và **Audit Hash-Chain Ledger** ngay tại tầng `DocumentStore`. * Dựng nhánh **`astra-cdc-rust` (`supabase/etl`)** đọc WAL từ PostgreSQL dev stack, cập nhật bảng `_cdc_sync_watermark` và kiểm thử `SyncAwareLsnFilter` trên Quarkus. 2. **Bước 2 – Xây dựng Luồng Vận hành Cốt lõi `OMS` + `OPS` + `CARE` (Tuần 4–7):** * Định nghĩa các tệp `.doctype.yaml` cho `OMS` và `OPS` (tách cột nóng cho `Waybill`). * Thiết lập luồng đồng bộ Replica qua Rust CDC sang `CARE` để chứng minh hiệu năng đọc/ghi tách biệt và cơ chế cảnh báo chờ Sync trên giao diện. 3. **Bước 3 – Xây dựng Lõi Tài chính & Nhân sự `BMS` + `HRM` (Tuần 8–11):** * Triển khai `GLEntry` và `PayrollLedger` cùng `@AstraGlobalTransactional` (kết hợp Narayana JTA / Seata AT & Redis TCC). * Đóng gói module Hóa đơn điện tử Việt Nam ở **Tier 1 (Isolated JVM)** và công thức tính lương/cước ở **Tier 2 (Wasm)**. 4. **Bước 4 – Kích hoạt Trí tuệ Nhân tạo & Dữ liệu Lớn `NOC` + `STMS` (Tuần 12+):** * Nối luồng Rust CDC Stream Load vào **Apache Doris**, viết `DorisDocTypeAdapter` và bật **MCP Server (`astra-ai`)** cho `NOC`. * Tích hợp `timefold-solver-quarkus` vào `STMS` để chạy bài toán tối ưu điều phối xe tự động. --- # Video demo Toàn bộ video demo bản tiếng Anh và tiếng Việt, kèm bài viết đứng sau mỗi video. Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/videos Mỗi video là một demo ngắn có kịch bản, chạy bằng code thật trong repository. Xem theo thứ tự hoặc nhảy tới video trả lời câu hỏi của bạn. - **Giới thiệu ngắn.** [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-0-teaser-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-0-teaser-vi.mp4) - **Video 1: Astra là gì & chạy trong 5 phút** (2:32). Kiến trúc kernel + DocType engine + plugin host, rồi dựng cả cụm k3d bằng `make k3d-up`, nạp dữ liệu qua REST API và đăng nhập Desk. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-1-quickstart-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-1-quickstart-vi.mp4) · [Đọc bài viết](/astra-cms/vi/quickstart) - **Video 2: DocType: từ một file YAML đến API, giao diện và index** (2:38). Một file YAML sinh ra form, REST API, validation và index biểu thức online; mọi DocType chung một bảng JSONB, không ALTER TABLE. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-2-doctype-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-2-doctype-vi.mp4) · [Đọc bài viết](/astra-cms/vi/doctype) - **Video 3: Quy tắc nghiệp vụ: hook & validation** (2:53). Phí ship 73.000 ₫ do hook tính, lỗi validation trả về cùng lúc, Link kiểm tra trong tenant; Desk và API gặp cùng một luật. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-3-hooks-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-3-hooks-vi.mp4) · [Đọc bài viết](/astra-cms/vi/hooks) - **Video 4: Vòng đời chứng từ: Submit, Cancel, Amend** (3:10). Submit sinh vận đơn và bút toán trong một transaction; chứng từ đã duyệt bất biến; hủy ghi bút toán đảo; amend tạo bản sửa đổi. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-4-lifecycle-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-4-lifecycle-vi.mp4) · [Đọc bài viết](/astra-cms/vi/lifecycle) - **Video 5: Vận hành: máy trạng thái bưu kiện** (3:00). Lọc theo trường projected, timeline quét mã, không nhảy cóc trạng thái, lỗi rồi thành công trên cùng form, không hủy được đơn đã giao. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-5-ops-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-5-ops-vi.mp4) · [Đọc bài viết](/astra-cms/vi/ops) - **Video 6: CSKH nhìn thấy vận hành** (2:30). Ticket tự lên mức High và SLA +24h theo từ khoá; thẻ CARE replica đọc bản sao do worker CDC giữ. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-6-care-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-6-care-vi.mp4) · [Đọc bài viết](/astra-cms/vi/care) - **Video 7: Đa thuê bao** (2:28). Hai công ty trên cùng một server: cùng URL, dữ liệu khác hẳn; Link không vượt tenant, 403/400 khi không rõ tenant, RLS trả 0 dòng. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-7-tenants-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-7-tenants-vi.mp4) · [Đọc bài viết](/astra-cms/vi/tenants) - **Video 8: Bảo mật fail-closed** (2:02). Kết nối bằng superuser thì kernel từ chối khởi động, trước cả khi Flyway chạy migration; role ứng dụng NOSUPERUSER NOBYPASSRLS. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-8-failclosed-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-8-failclosed-vi.mp4) · [Đọc bài viết](/astra-cms/vi/failclosed) - **Video 9: Dành cho developer: viết một app Astra** (2:22). App là Java thường + YAML trên astra-spi; dev mode live reload thêm cột mới trong 0,73 s; 44 test tích hợp với PostgreSQL, Seata và Redis thật. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-9-developer-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-9-developer-vi.mp4) · [Đọc bài viết](/astra-cms/vi/developer) - **Video 10: Chạy trên Kubernetes** (2:06). Hai pod kernel ghi song song mà chuỗi số không trùng, không lỗ; rolling restart dưới tải và phần còn thiếu (chưa có drain). [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-10-k8s-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-10-k8s-vi.mp4) · [Đọc bài viết](/astra-cms/vi/k8s) - **Video 11: Keycloak: một danh tính, role theo tenant, quyền theo DocType** (2:18). Đăng nhập PKCE theme Astra, role là nhóm Keycloak, quyền khai báo trên DocType; người lập phiếu chi không tự duyệt được (403). [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-11-keycloak-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-11-keycloak-vi.mp4) · [Đọc bài viết](/astra-cms/vi/keycloak) - **Video 12: Sổ cái append-only** (2:32). Bút toán kép đi cùng transaction của chứng từ, hủy là đảo, trigger chặn UPDATE kể cả chủ bảng, phân vùng theo tháng. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-12-ledger-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-12-ledger-vi.mp4) · [Đọc bài viết](/astra-cms/vi/ledger) - **Video 13: Overlay theo tenant** (2:24). Hà Nội thêm trường VAT và vùng đảo bằng một file YAML, có hiệu lực ngay; app Express không đổi một dòng. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-13-overlay-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-13-overlay-vi.mp4) · [Đọc bài viết](/astra-cms/vi/overlay) - **Video 14: Tier 2 Wasm: công thức cước của riêng tenant** (2:20). Khuyến mãi Tết viết bằng Rust (421 byte), bật lên đổi phí 73.000 → 51.000; vòng lặp vô hạn bị dừng ở 50 ms. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-14-wasm-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-14-wasm-vi.mp4) · [Đọc bài viết](/astra-cms/vi/wasm) - **Video 15: MCP: agent AI dùng Astra bằng quyền của người dùng** (2:24). Claude thật gọi tool qua MCP: PII bị che, agent của nhân viên CSKH không tạo được đơn, kế toán hỏi COD trả lời từ sổ cái. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-15-mcp-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-15-mcp-vi.mp4) · [Đọc bài viết](/astra-cms/vi/mcp) - **Video 16: Rust CDC + Sync-Aware LSN** (2:36). Worker Rust đọc WAL, lệnh ghi trả X-Tx-LSN, đọc replica chưa kịp thì nhận 202 Sync Pending thay vì dữ liệu cũ. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-16-cdc-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-16-cdc-vi.mp4) · [Đọc bài viết](/astra-cms/vi/cdc) - **Video 17: Seata: một lần submit, ba tài nguyên** (2:26). Nhánh AT trừ hạn mức BMS, nhánh TCC giữ chỗ trên Redis, kernel commit cuối; một nhánh từ chối là tất cả quay lại. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-17-seata-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-17-seata-vi.mp4) · [Đọc bài viết](/astra-cms/vi/seata) - **Video 18: phần còn lại của use-case-01** (1:36). Những gì video 1–17 và 19–21 đã chạy thật (gồm HRM, STMS, SAML và SCIM) và những gì còn đang phát triển: multi-schema, Tier 1, streaming CDC, agent tự hành, audit hash-chain, mã hoá trường. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-18-roadmap-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-18-roadmap-vi.mp4) · [Đọc bài viết](/astra-cms/vi/roadmap) - **Video 19: HRM: nhân sự và tiền lương** (2:40). Theo khái niệm Frappe HRMS: chấm công quyết định ngày công, Payroll Entry tính phiếu lương song song, thưởng bưu tá bằng Wasm, một bút toán dồn tích cho cả kỳ, huỷ là đảo; agent AI chỉ thấy tên đã che. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-19-hrm-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-19-hrm-vi.mp4) · [Đọc bài viết](/astra-cms/vi/hrm) - **Video 20: STMS: điều phối xe với Timefold + AI copilot** (2:58). Timefold xếp tuyến theo tải trọng và khung giờ, giải lại khi xe hỏng, đơn gấp, kẹt xe; copilot nhận lệnh tiếng Việt và gọi tool thật. LLM của copilot là giả lập theo kịch bản demo, ghi rõ trên mọi khung hình. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-20-stms-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-20-stms-vi.mp4) · [Đọc bài viết](/astra-cms/vi/stms) - **Video 21: SAML SSO và SCIM** (2:32). Đăng nhập qua IdP SAML có chữ ký, nhóm của IdP thành role của tenant; SCIM 2.0 tạo, đổi nhóm, vô hiệu hoá và xoá user theo từng tenant, token của tenant khác bị chặn. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-21-sso-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-21-sso-vi.mp4) · [Đọc bài viết](/astra-cms/vi/sso) --- # Kịch bản video English version: demo-video-series.en.md. Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/video-scripts English version: [demo-video-series.en.md](/astra-cms/vi/video-scripts). Tài liệu này dùng để quay một loạt video marketing giới thiệu Astra. Câu chuyện xuyên suốt lấy từ [`use-case-01.md`](/astra-cms/vi/use-case): một công ty chuyển phát nhận đơn (**OMS**), vận hành bưu kiện (**OPS**), chăm sóc khách hàng (**CARE**) và quản lý công nợ, sổ cái (**BMS**). Mọi thứ chiếu trong video đều là **code đang chạy thật** trong repository, ở dạng *thin slice*: đủ để chứng minh cơ chế, chưa phải toàn bộ phạm vi của use-case. Các video về cơ chế nền tảng (9, 11 đến 17) và các video nghiệp vụ mới (19 đến 21) có mục **"Nói đúng"** ghi rõ giới hạn hiện tại; mỗi video link tới đoạn code để chiếu cho developer trong [`demo-code-snippets.md`](/astra-cms/vi/code-snippets) (code trích từ repo, kèm sơ đồ luồng). Nhạc nền của các video: "Corporate Tower" của Mare Production ([freesound.org/s/612264](https://freesound.org/s/612264/)), CC BY 4.0, đã cắt ghép. Giọng đọc tiếng Việt: giọng nam "Xuân Vĩnh" của [VieNeu-TTS](https://github.com/pnnbao97/VieNeu-TTS) v3 Turbo (Apache-2.0), tổng hợp bằng `scripts/video-voiceover.py` từ file `narration.json` trong từng thư mục video. Bản tiếng Anh dùng giọng `am_michael` của Kokoro-82M (Apache-2.0) qua `hyperframes tts`, tổng hợp bằng `scripts/video-voiceover.py --lang en` từ file `narration.en.json`. Font: Be Vietnam Pro (SIL OFL 1.1) cho chữ thường, JetBrains Mono cho mã. --- ## 1. Chuẩn bị môi trường quay Yêu cầu: JDK 25, Docker Desktop (cả stack chạy được với 8 GB RAM cấp cho Docker), `kubectl`, `k3d`, `jq`, **Bash 5** (`brew install bash`; các script tự chạy lại bằng Bash 5 nếu shell mặc định là Bash 3.2 của macOS), Rust (chỉ cần khi sửa module Wasm hoặc worker CDC). ```bash make k3d-up # cụm k3d: 2 pod kernel, PostgreSQL (wal_level=logical), Redis, Keycloak, Seata, worker CDC Rust make demo-seed # 2 tenant: saigon-express, hanoi-express; dữ liệu qua REST API, đăng nhập Keycloak open http://localhost:8080/desk/ # Desk (đăng nhập Keycloak, PKCE) make demo-reset && make demo-seed # làm sạch trước mỗi buổi quay (chứng từ, sổ cái, overlay, BMS, Redis) make demo-express SCENES="10 11" PAUSE=0 # chạy cảnh terminal; bỏ PAUSE=0 để bấm Enter qua từng bước ``` `make k3d-up` dựng lại image, đồng bộ realm Keycloak (`scripts/keycloak-sync-realm.sh`), chuẩn bị database (`scripts/db-setup.sh`: schema BMS, role/publication CDC) và triển khai mọi thứ. Video 8 (kernel từ chối superuser) và Video 9 (live reload) cần thêm stack Docker Compose. k3d và dev mode cùng mặc định cổng 8080, nên chạy dev mode ở cổng khác và trỏ mọi script vào đó: ```bash make infra-up && make build # PostgreSQL :5432, Redis, Keycloak :8180 (Video 8 chỉ cần tới đây) # infra-up chạy scripts/keycloak-compose-dev.sh: Keycloak dev nhận HTTP từ máy host + đồng bộ realm make dev DEV_ARGS=-Dquarkus.http.port=8081 # Video 9 BASE=http://localhost:8081 make demo-seed # seed vào dev mode; reset bằng: scripts/demo-reset.sh compose ``` ### Người dùng demo (mật khẩu chung: `Astra@123`, realm `deploy/keycloak/realm/astra-realm.json`) | User | Tenant | Role | Dùng cho | | --- | --- | --- | --- | | `mai` | saigon-express | admin | quản trị tenant, overlay | | `tuan` | saigon-express | sales | tạo / submit đơn | | `quang` | saigon-express | ops | quét mã bưu kiện | | `ha` | saigon-express | care | ticket CSKH, đọc replica | | `khoa`, `thu` | saigon-express | accountant | sổ cái, đối soát COD (maker / checker) | | `nam` | hanoi-express | admin | tenant thứ hai | | `demo` | cả hai | admin | seed dữ liệu, màn hình so sánh 2 tenant | | `hanh` | saigon-express | hr | nhân sự và tiền lương (Video 19) | | `linh` | cả hai | dispatcher | điều phối xe (Video 20) | ### Công cụ quay | Công cụ | Dùng để | Lệnh / URL | | --- | --- | --- | | **Astra Desk** | Giao diện sinh từ metadata: list, form, vòng đời, sổ cái, replica CDC, overlay | `/desk/` | | **So sánh 2 tenant** | Hai tenant cạnh nhau (đăng nhập `demo` trước) | `/desk/compare.html` | | **API inspector** | Request/response thật mà Desk gọi, kể cả `X-Tx-LSN` | nút "API inspector" | | **Script theo cảnh** | 15 cảnh terminal, mỗi cảnh ứng với một video | `scripts/demo-express.sh` | | **Claude Code + MCP** | Agent AI thật gọi Astra | `scripts/mcp-claude.sh tuan saigon-express` | Mẹo: cửa sổ 1440×900; terminal 16 đến 18 pt; quay ngay sau `demo-reset` + `demo-seed` để mã chứng từ khớp kịch bản (`ORD--00007`…). Ngày trong mã đơn là ngày chạy seed. --- ## 2. Bản đồ tính năng → nơi chứng minh | Tính năng (đang chạy thật) | Video | Cảnh | Code | | --- | --- | --- | --- | | App là plugin Tier 0, chỉ dùng `astra-spi` | 1, 9 | 1 | `astra-apps/express`, `astra-apps/cms` | | DocType YAML → REST + Desk tự sinh; JSONB, không ALTER TABLE | 2, 9 | 2 | `*.doctype.yaml`, `DocResource`, Desk | | `projected: true` → index biểu thức online | 2, 5 | 2, 5 | `ProjectionIndexer` | | Validation theo metadata, hook nghiệp vụ | 3 | 3 | `DocumentValidator`, `ShippingFeeHook` | | Vòng đời Draft → Submitted → Cancelled → Amend | 4 | 4 | `DocumentStore.submit/cancel/amend` | | Máy trạng thái + chứng từ liên kết trong 1 transaction | 5 | 5 | `WaybillScanHook`, `OrderWaybillHook` | | CARE thấy OPS, triage, SLA | 6 | 6 | `TicketTriageHook` | | Multi-tenant RLS, `ScopedValue` | 7 | 7 | `TenantContext`, `TenantResolverFilter` | | Fail-closed khi kết nối bằng superuser | 8 | 8 | `RowLevelSecurityGuard` | | **Keycloak OIDC, role theo tenant, quyền DocType, SoD** | 11 | 9 | `astra-iam`, `DocPermissions`, realm | | **Ledger append-only, phân vùng tháng, bút toán đảo** | 12 | 10 | `V3__ledger.sql`, `LedgerStore`, `GeneralLedger` SPI | | **Overlay theo tenant, áp dụng không restart** | 13 | 11 | `Overlays`, `DocTypeOverlays`, `tenants/*/overlays` | | **Tier 2 Wasm (Chicory 1.7.5), script Rust của tenant** | 14 | 12 | `astra-plugin-host`, `astra-apps/express/wasm` | | **MCP server, PII masking, tool do app đóng góp** | 15 | 13 | `astra-ai`, `McpTool` SPI | | **Rust CDC (pgoutput) + Sync-Aware LSN** | 16 | 14 | `astra-cdc-rust`, `SyncAwareLsnFilter` | | **Seata 2.6 AT + TCC: global transaction** | 17 | 15 | `astra-tx-seata`, `OrderCreditHook`, `PickupCapacity` | | Kubernetes: 2 pod, series an toàn giữa pod | 10 | (không có) | `deploy/k8s`, `SeriesService` | | **HRM: chấm công, Payroll Entry, phiếu lương, thưởng KPI bằng Wasm, bút toán dồn tích** | 19 | `demo-hrm` 1 đến 8 | `astra-apps/hrm`, `PayrollEntryHook`, `SalaryCalculator` | | **STMS: Timefold VRP, lập lại kế hoạch, tool MCP, copilot (LLM giả lập)** | 20 | `demo-stms` 1 đến 8 | `astra-apps/stms`, `RoutePlanner`, `Replanner`, `DispatchCopilot` | | **SAML SSO qua Keycloak, SCIM 2.0 theo tenant** | 21 | `demo-sso` 1 đến 8 | `deploy/keycloak/realm`, `ScimResource`, `ScimDirectory` | --- ## 3. Danh sách video Thời lượng gợi ý: teaser 60 đến 90 giây, video tính năng 3 đến 6 phút, video cho developer 6 đến 8 phút. ### Video 0: Teaser "Một nền tảng, mọi nghiệp vụ" (90 giây) **Code cho developer:** [S0](/astra-cms/vi/code-snippets) Dựng nhanh từ cảnh quay của các video sau: 1. File `customer-order.doctype.yaml` → Desk "Customer Order" xuất hiện. 2. Submit đơn → vận đơn tự sinh, bút toán Nợ 131 / Có 511 hiện dưới form. 3. Hai tenant cạnh nhau: Hà Nội có thêm trường VAT (overlay), Sài Gòn thì không. 4. Script Wasm vòng lặp vô hạn: "stopped after 50 ms". 5. Claude hỏi "bưu kiện WB-… đang ở đâu?", số điện thoại hiện `*******567`. 6. Thẻ "sync pending" → "in sync" sau vài chục ms (CDC Rust). 7. Seata: đơn vượt hạn mức, mọi thứ rollback, undo log sạch. 8. Kết: "Astra: Java 25, Quarkus, PostgreSQL, Rust. Mã nguồn mở." ### Video 1: Astra là gì & chạy trong 5 phút **Code cho developer:** [S0](/astra-cms/vi/code-snippets) | Bước | Thao tác | Lời dẫn | | --- | --- | --- | | 1 | Sơ đồ kiến trúc trong `README.md` | Kernel, DocType Engine, Plugin Host; hôm nay xem phần đã chạy thật. | | 2 | `make k3d-up` (tua nhanh), `kubectl -n astra get pods` | 2 pod kernel, PostgreSQL, Redis, Keycloak, Seata, worker CDC Rust. | | 3 | `make demo-seed` | Dữ liệu nạp hoàn toàn qua REST API, đăng nhập bằng Keycloak. | | 4 | `make demo-express SCENES=1` | `/api/app`: 2 app; `/api/doctype`: 8 DocType. | | 5 | Desk → đăng nhập `mai` → Installed apps | App Express đóng góp OMS, OPS, CARE, BMS. | ### Video 2: DocType: từ một file YAML đến API, giao diện và index **Code cho developer:** [S1](/astra-cms/vi/code-snippets), [S12](/astra-cms/vi/code-snippets) | Bước | Thao tác | Lời dẫn | | --- | --- | --- | | 1 | `SCENES=2`: YAML của `CustomerOrder` | Trường, Select, Link, read-only, `pii`, `submittable`, `transaction: global`. | | 2 | Desk → DocType metadata → CustomerOrder | Bảng trường đọc từ `GET /api/doctype` (cùng định nghĩa với `GET /api/doctype/CustomerOrder`, đã áp overlay của tenant). | | 3 | Mục Projections | `projected: true` → `CREATE INDEX CONCURRENTLY`, không khoá ghi. | | 4 | Terminal: `\d astra_doc` | Mọi DocType chung một bảng JSONB. | ### Video 3: Quy tắc nghiệp vụ: hook & validation **Code cho developer:** [S2](/astra-cms/vi/code-snippets) | Bước | Thao tác | Lời dẫn | | --- | --- | --- | | 1 | Desk (`tuan`) → New Customer Order: C001, Domestic, Express, 2.2 kg, COD 500.000 → Create | Mã từ series theo ngày; **phí 73.000 ₫** do `ShippingFeeHook` tính; "Priced by: Standard tariff". | | 2 | Giải thích phí | 30.000 + 3 × 5.000 (mỗi 0,5 kg trên kg đầu) = 45.000; ×1,5 (Express) = 67.500; phí COD max(5.000; 1%) = 5.000 → 72.500; làm tròn lên nghìn → 73.000. | | 3 | Sửa SĐT `123`, cân nặng `45` → Save | Hook trả 2 lỗi cùng lúc (422). | | 4 | Sửa lại hợp lệ, khách `C999` → Save | Kernel kiểm tra Link. | | 5 | `SCENES=3` + API inspector | Read-only, trường lạ, Select sai đều bị chặn. | ### Video 4: Vòng đời chứng từ: Submit, Cancel, Amend **Code cho developer:** [S3](/astra-cms/vi/code-snippets) | Bước | Thao tác | Lời dẫn | | --- | --- | --- | | 1 | Desk (`mai`) → New Customer Order cho C001 → Create | Thanh trạng thái 0 → 1 → 2 → Amend. (Đơn nháp `ORD-…-00005` của C002 dành cho Video 17: nó vượt hạn mức công nợ.) | | 2 | **Submit** | Vận đơn tự tạo, bút toán cước hiện ở thẻ "Ledger entries", trong cùng một transaction. | | 3 | Form bị khoá | Chứng từ đã duyệt là bất biến. | | 4 | **Cancel** | Vận đơn hủy theo; sổ cái ghi bút toán đảo (không sửa dòng cũ). | | 5 | **Amend**, rồi Amend lần nữa trên bản đã hủy | Bản sửa đổi `-1`; lần hai bị từ chối. | ### Video 5: Vận hành: máy trạng thái bưu kiện **Code cho developer:** [S3](/astra-cms/vi/code-snippets) | Bước | Thao tác | Lời dẫn | | --- | --- | --- | | 1 | Desk (`quang`) → Waybill, filter `Ops status = At Hub` | Filter trên trường projected (⚡). | | 2 | `WB-…-000001` | Timeline quét mã đầy đủ đến Delivered. | | 3 | Vận đơn `Created` → chọn `Delivered` | Không nhảy cóc trạng thái. | | 4 | `Picked Up`; rồi `At Hub` để trống hub; rồi nhập hub | Lỗi rồi thành công, timeline thêm mốc. | | 5 | Desk (`mai`) → hủy đơn đã giao | Bị từ chối; không dữ liệu nào thay đổi. | ### Video 6: CSKH nhìn thấy vận hành **Code cho developer:** [S3](/astra-cms/vi/code-snippets) (hook `TicketTriageHook` cùng kiểu với các hook ở S2/S3) | Bước | Thao tác | Lời dẫn | | --- | --- | --- | | 1 | Desk (`ha`) → New Support Ticket "Hàng bị vỡ, khách yêu cầu đền bù", chọn waybill | Priority tự lên High, SLA +24h, trạng thái waybill. | | 2 | Cuối form: thẻ "CARE replica (CDC)" | Thẻ này đọc bản sao do worker CDC giữ, không đọc thẳng bảng vận hành (chi tiết ở Video 16). Trường `waybill_status` của ticket thì do hook đọc Waybill lúc lưu. | | 3 | `SCENES=6` | Bản API. | ### Video 7: Đa thuê bao **Code cho developer:** [S4](/astra-cms/vi/code-snippets) | Bước | Thao tác | Lời dẫn | | --- | --- | --- | | 1 | Đăng nhập `demo`, mở `compare.html`, Screen `#/list/Customer` | Cùng URL, cùng DocType, hai tenant. | | 2 | Screen `#/form/Customer/C001` | Cùng mã, dữ liệu khác nhau. | | 3 | `SCENES=7` | Link không vượt tenant; `nam` vào saigon → 403; thiếu tenant → 400; SQL: chưa gắn tenant → 0 dòng. | ### Video 8: Bảo mật fail-closed **Code cho developer:** [S4](/astra-cms/vi/code-snippets) `make infra-up && make build && make demo-express SCENES=8`: kernel thứ hai kết nối bằng superuser và tự từ chối khởi động. Mở `deploy/docker/init/01-app-role.sql`: role ứng dụng `NOSUPERUSER NOBYPASSRLS`. ### Video 9: Dành cho developer, viết một app Astra **Code cho developer:** [S2](/astra-cms/vi/code-snippets), [S12](/astra-cms/vi/code-snippets) | Bước | Thao tác | Lời dẫn | | --- | --- | --- | | 1 | `make infra-up && make dev` | Dev mode live reload (Keycloak của Compose ở `localhost:8180`). | | 2 | Cây `astra-apps/express` | App = `ExpressApp` + YAML + hook + tool MCP + participant TCC; chỉ phụ thuộc `astra-spi`. | | 3 | `OrderWaybillHook`, `OrderPostingHook` | `DocumentService` và `GeneralLedger` tham gia transaction của chứng từ gốc. | | 4 | **Live**: thêm `- { name: zalo_id, type: Data, in-list: true }` vào `customer.doctype.yaml` | Không migration, không restart: cột mới xuất hiện ngay. | | 5 | `ExpressFlowTest`, `LedgerTest`, `SeataTest` | Test tích hợp với PostgreSQL thật; `SeataTest` chạy cả Seata Server và Redis thật. | **Nói đúng:** sau bước 4 hãy `make demo-reset && make demo-seed`, vì xoá trường khỏi DocType khiến chứng từ cũ còn trường đó không sửa được nữa; tiến hoá schema an toàn còn trên roadmap. ### Video 10: Chạy trên Kubernetes | Bước | Thao tác | Lời dẫn | | --- | --- | --- | | 1 | `kubectl -n astra get pods` | 2 replica kernel + hạ tầng (Keycloak, Seata, CDC, PostgreSQL, Redis). | | 2 | Desk → Customer Order | Mã đơn liên tục dù 2 pod cùng ghi: counter dùng row lock. | | 3 | `kubectl -n astra rollout restart deployment/astra-server` trong khi Desk mở | Rolling restart, `maxUnavailable: 0`: Desk vẫn dùng được. **Nói đúng:** chưa có preStop/drain, request rơi đúng lúc pod cũ dừng có thể lỗi (lần đo: 2/47). | ### Video 11: Keycloak, một danh tính, role theo tenant, quyền theo DocType (cảnh 9) **Code cho developer:** [S5](/astra-cms/vi/code-snippets) | Bước | Thao tác | Lời dẫn | | --- | --- | --- | | 1 | Desk → Sign in → trang đăng nhập theme Astra (vi/en) → `tuan` | Authorization Code + PKCE ngay trong trình duyệt; không mật khẩu nào đi qua Astra. | | 2 | Góc phải: "Tuấn Lê · sales"; `tuan` tạo một đơn rồi mở lại: nút Submit có, nút Cancel không (đơn của người khác chỉ đọc) | Desk đọc quyền hiệu lực từ `/api/iam/me` và ẩn nút không được phép. | | 3 | `SCENES=9` | `tuan` (sales) không quét mã, không hủy đơn; `quang` (ops) quét được; `ha` (care) mở ticket; `khoa` (accountant) hủy được. | | 4 | Nhóm Keycloak `/tenants/saigon-express/ops` | Role là nhóm trong Keycloak; kernel đọc từ token, không tin header. | | 5 | Desk (`khoa`) → CODRemittance `CODR-…-0002` (350.000, do `khoa` lập khi seed) → Submit; rồi đăng nhập `thu` → Submit | **Segregation of duties**: người lập không được tự duyệt (403); `thu` duyệt được. Quay ngay sau `demo-seed`: cảnh 10 chi thêm 100.000 cho C001, sau đó 350.000 sẽ vượt số đang nợ. | **Nói đúng:** điều kiện quyền theo từng dòng (row-level conditions) còn trên roadmap. SSO SAML và SCIM đã chạy, xem Video 21. ### Video 12: Sổ cái append-only (cảnh 10) **Code cho developer:** [S6](/astra-cms/vi/code-snippets) | Bước | Thao tác | Lời dẫn | | --- | --- | --- | | 1 | Desk (`khoa`) → General ledger | Bảng cân đối theo hệ tài khoản VN (TT200: 112, 131, 1388, 3388, 511). | | 2 | `tuan` submit đơn → form hiện thẻ "Ledger entries" | Nợ 131 / Có 511 trong cùng transaction với đơn. | | 3 | Hủy đơn | Hai dòng **đảo** mới (`reversal`), dòng cũ giữ nguyên. | | 4 | `quang` quét Delivered | Nợ 1388 / Có 3388: tiền COD bưu tá thu, nợ shop. | | 5 | Terminal: `UPDATE astra_ledger …` | `ERROR: astra_ledger is append-only`, chặn ở database, kể cả chủ bảng. | | 6 | Mục Partitions | Phân vùng theo tháng, kernel tạo trước. | | 7 | CODRemittance vượt số nợ shop → 422; maker/checker | Không chi quá số đang nợ; người lập ≠ người duyệt. | **Nói đúng:** trigger chặn UPDATE/DELETE; role sở hữu bảng vẫn có thể DROP/ALTER; production nên chạy kernel bằng role không sở hữu bảng. Chuỗi hash audit (hash-chain) còn trên roadmap. ### Video 13: Overlay theo tenant (cảnh 11) **Code cho developer:** [S7](/astra-cms/vi/code-snippets) | Bước | Thao tác | Lời dẫn | | --- | --- | --- | | 1 | `tenants/hanoi-express/overlays/customer-order.overlay.yaml` | Hà Nội thêm trường VAT, vùng "Island", bắt buộc ghi chú; app Express không đổi dòng nào. | | 2 | `compare.html`, Screen `#/new/CustomerOrder` | Bên Hà Nội có trường gắn nhãn "✚ tenant overlay". | | 3 | Desk (`mai`) → DocType metadata → CustomerOrder → Tenant overlay → thêm `gift_wrap` → Save overlay | Áp dụng ngay, không restart; tenant khác không bị ảnh hưởng. Quay xong bấm Remove overlay, để cảnh 11 vẫn cho thấy saigon-express không có overlay. | | 4 | Overlay đổi kiểu trường → 422; `tuan` sửa overlay → 403 | Overlay không được phá app; chỉ admin của tenant được sửa. | **Nói đúng:** overlay thêm/chỉnh trường, chưa thêm hook hay quyền; không xoá được trường của app. ### Video 14: Tier 2 Wasm, công thức cước của riêng tenant (cảnh 12) **Code cho developer:** [S8](/astra-cms/vi/code-snippets) | Bước | Thao tác | Lời dẫn | | --- | --- | --- | | 1 | `astra-apps/express/wasm/tet-promo/src/lib.rs` | Khuyến mãi Tết viết bằng Rust, biên dịch ra 421 byte WebAssembly. | | 2 | Desk (`mai`) → Pricing Rule → mở rule → `self_test` | Kernel chạy thử module trong sandbox khi lưu. | | 3 | Bật `active` → tạo đơn B2B (C001, Domestic, Express, 2.2 kg, COD 500.000) | Phí 73.000 → **51.000 ₫**, "Priced by: PRL-001 … (Wasm)". Quay xong tắt `active` lại, nếu không mọi đơn B2B ở các video khác sẽ không còn 73.000. | | 4 | New Pricing Rule → chọn file `astra-apps/express/wasm/modules/runaway.wasm` (vòng lặp vô hạn) → Create | "module rejected (timeout): script exceeded its 50 ms deadline and was stopped"; kernel vẫn chạy bình thường. | | 5 | Nói về giới hạn | Bộ nhớ tối đa 1 MiB, không host function, mỗi lần gọi một instance mới. | **Nói đúng:** Chicory chưa có đếm lệnh (fuel), nên giới hạn CPU là deadline thời gian. Tier 1 (hot-swap JVM) còn trên roadmap. ### Video 15: MCP, agent AI dùng Astra bằng quyền của người dùng (cảnh 13) **Code cho developer:** [S9](/astra-cms/vi/code-snippets) | Bước | Thao tác | Lời dẫn | | --- | --- | --- | | 1 | `SCENES=13` | Handshake MCP 2025-11-25, 7 tool (5 của kernel, 2 do app Express đóng góp). | | 2 | `search_documents` | Trường `pii: true` bị che trước khi tới mô hình: SĐT chỉ còn 3 số cuối (`*******567`), tên và địa chỉ chỉ còn chữ cái đầu mỗi từ (`L*** V*** D***`); `shipping_fee` giữ nguyên. | | 3 | `scripts/mcp-claude.sh ha saigon-express` → `claude` → "Bưu kiện WB-2610-000002 đang ở đâu, có khiếu nại không?" | Claude gọi `track_waybill`, trả lời kèm hành trình và ticket. | | 4 | Hỏi "tạo đơn hàng mới cho C001" | Bị từ chối: `ha` (care) không có quyền tạo đơn, agent không vượt quyền người dùng. | | 5 | `scripts/mcp-claude.sh khoa` → "Khách nào đang chờ trả COD?" | `cod_outstanding` đọc từ sổ cái. | **Nói đúng:** client dùng bearer token tĩnh (token 1 giờ của client dev); OAuth discovery cho MCP, AI agent tự hành và vector search còn trên roadmap. Che PII áp dụng cho kênh MCP, không áp cho Desk. ### Video 16: Rust CDC + Sync-Aware LSN (cảnh 14) **Code cho developer:** [S10](/astra-cms/vi/code-snippets) | Bước | Thao tác | Lời dẫn | | --- | --- | --- | | 1 | Desk → Replication (CDC) | Worker Rust giải mã WAL (`pgoutput`), lag vài ms, số byte còn lại. | | 2 | `SCENES=14`: tạm dừng worker, `quang` quét mã | Response có `X-Tx-LSN`. | | 3 | `ha` đọc replica với `X-Required-LSN` | **202 Sync Pending**, không trả dữ liệu cũ. | | 4 | Bật lại worker | 200 sau vài chục ms; trạng thái ổn định: replica bắt kịp vài chục tới hơn trăm ms sau lần ghi (đo mỗi lần mỗi khác). | | 5 | Desk: mở Waybill vừa quét → thẻ "CARE replica (CDC)" | "sync pending" → "in sync" tự động (read-your-writes) nếu worker bắt kịp trong 10 s Desk tự hỏi lại; worker tắt lâu hơn thì mở lại form. | | 6 | `pg_replication_slots` | Slot logical `astra_cdc` (cột `active = f` là bình thường: worker đọc slot bằng SQL peek/advance, không giữ kết nối streaming); worker ghi replica **dưới RLS**, không BYPASSRLS. | **Nói đúng:** worker đọc slot bằng `pg_logical_slot_peek_binary_changes` mỗi 50 ms (ít nhất một lần, upsert idempotent), chưa dùng giao thức streaming; chưa đẩy sang Apache Doris. ### Video 17: Seata, một lần submit trên ba tài nguyên (cảnh 15) **Code cho developer:** [S11](/astra-cms/vi/code-snippets) | Bước | Thao tác | Lời dẫn | | --- | --- | --- | | 1 | `customer-order.doctype.yaml`: `transaction: global` | Kernel mở global transaction quanh submit/cancel. | | 2 | `SCENES=15`: submit đơn C001 | Nhánh **AT** trừ hạn mức trong schema `bms` (role riêng); nhánh **TCC** giữ chỗ lấy hàng trên Redis; transaction của kernel commit cuối. | | 3 | Đơn C002 vượt hạn mức | 422; đơn vẫn nháp, không vận đơn, không bút toán, chỗ lấy hàng không bị chiếm (BMS từ chối trước khi tới nhánh TCC). | | 4 | Ngày đã kín chỗ: TCC từ chối **sau khi** AT đã commit | Seata hoàn hạn mức từ undo log; `undo_log` sạch; Redis reserved về 0. | | 5 | Log `Global transaction … rolled back` | Seata Server 2.6.0 (JDK 25) điều phối thật. | **Nói đúng:** Seata Server dùng store file (đủ cho demo; production dùng DB/Raft); TCC tự xử lý idempotent/empty-rollback/suspension theo branch, chưa dùng bảng fence của Seata; kernel commit transaction của mình cuối cùng (last-resource), chưa là một nhánh Seata. ### Video 19: HRM, nhân sự và tiền lương **Code cho developer:** `astra-apps/hrm` (`PayrollEntryHook`, `SalaryCalculator`, `astra-apps/hrm/wasm/courier-kpi`); test `HrmPayrollTest`. Mục tiêu: một app nhân sự và tiền lương viết trên SPI công khai, theo khái niệm Frappe HRMS (Employee, Salary Component, Salary Structure, Salary Structure Assignment, Attendance, Additional Salary, Payroll Entry, Salary Slip). Chấm công quyết định ngày công, thưởng bưu tá là Wasm của tenant, và mỗi Payroll Entry ghi đúng **một** bút toán dồn tích vào sổ cái. Chỉ lấy khái niệm; không chép code, schema hay giao diện của ERPNext/HRMS. Chuẩn bị: `make demo-seed` nạp tháng 9/2026 (26 ngày làm việc) cho saigon-express: NV001 đủ công, giao 1.067 kiện; NV002 vắng 15/9, nửa ngày 22/9, giao 1.007 kiện; NV003 vào làm 14/9, có thưởng 500.000; NV004 nghỉ không lương 29/9. Script đăng nhập `hanh` (hr). Nếu realm vừa đổi, chạy `scripts/keycloak-sync-realm.sh` để có `hanh`. | Bước | Thao tác | Lời dẫn | | --- | --- | --- | | 1 | `make demo-hrm SCENES=1` | App `io.astra.hrm`, 8 DocType module `hrm`, chỉ phụ thuộc `astra-spi`. 8 Salary Component, gồm BHXH 8 %, BHYT 1,5 %, BHTN 1 % (tài khoản 3383, 3384, 3386); Salary Structure COURIER và 4 assignment. | | 2 | `SCENES=2`: mã Rust trong `astra-apps/hrm/wasm/courier-kpi` | Thưởng theo bậc: 1.500 đ/kiện đến kiện 600, 2.000 đ đến kiện 1.000, 2.500 đ từ kiện 1.001, cộng 500.000 chuyên cần nếu đủ công. Khi lưu, kernel chạy thử: `kpi(1000 pieces, 26/26 days) = 2200000 VND`. Module vòng lặp vô hạn (LOOP) → 422 "module rejected (timeout)". | | 3 | `SCENES=3` | NV002: Absent 15/9, Half Day 22/9; NV004: On Leave / Leave Without Pay 29/9. `quang` (ops) tạo và submit chấm công hôm nay được, xem Salary Slip bị 403; `tuan` (sales) xem Payroll Entry bị 403. | | 4 | `SCENES=4` | Lưu nháp một Payroll Entry `PAY-2610-001` (series theo ngày tạo): 4 phiếu lương nháp, tính song song mỗi phiếu một virtual thread, trong cùng transaction với entry. | | 5 | `SCENES=5` | Phiếu NV002 từng dòng: `100% of 6500000 (base) × 24.5/26 days` = 6.125.000; KPI `Wasm kpi(1007 pieces, 24.5/26 days)` = 1.717.500; BHXH `8% of 6500000 (base)` = 520.000. NV003: 15 ngày công, BONUS 500.000. Sửa `net_pay` → 422 read-only. | | 6 | `SCENES=6`: submit entry | Phiếu lương và sổ cái trong một transaction. Entry: 4 nhân viên, gross 35.837.501, khấu trừ 3.517.500, net 32.320.001. Một bút toán dồn tích 5 dòng: Nợ 642 35.837.501; Có 3383 2.680.000; Có 3384 502.500; Có 3386 335.000; Có 334 32.320.001. Mỗi phiếu có `accrual_voucher` là tên entry. Cancel riêng phiếu NV001 → 422 "cancel the payroll entry instead"; chạy lương tháng 9 lần nữa → 422 "NV001 already has submitted salary slip …". | | 7 | `SCENES=7` | Tool MCP `payroll_summary` trả tổng và dòng phiếu, tên nhân viên đã che (`N*** V*** A***`); `tuan` gọi → `isError: true`, not permitted. | | 8 | `SCENES=8`: cancel entry | Các phiếu sang docstatus 2; bút toán của entry có thêm 5 dòng `is_reversal: true`; số dư các tài khoản lương về 0. Không dòng nào bị xoá. | **Nói đúng:** - Không tính thuế TNCN; net là gross trừ bảo hiểm phần người lao động. Không tính phần doanh nghiệp đóng (BHXH, BHYT, BHTN, KPCĐ). Tỷ lệ và mức trần bảo hiểm là cấu hình seed để demo, chưa đối chiếu với quy định hiện hành; chưa xử lý quy tắc nghỉ từ 14 ngày trở lên thì không đóng BHXH. - Chấm công tối giản: không có lịch ngày lễ, ca làm, check-in hay đơn nghỉ phép có số dư phép; tuần làm việc cố định Thứ 2 đến Thứ 7; ngày không chấm công tính là có đi làm. Số kiện do ops nhập khi chấm công, chưa lấy tự động từ Waybill hay STMS qua CDC như use-case mô tả. - Chỉ có 4 kiểu tính (Fixed, % base, % gross, Wasm), chưa có trình soạn công thức. Không có bút toán chi trả lương (Nợ 334 / Có 112), không xuất file ngân hàng. Bút toán dồn tích ghi tổng theo tài khoản, không ghi theo từng nhân viên trên 334; toàn bộ chi phí vào 642, không phân bổ theo trung tâm chi phí. - Batch chạy ngay trong request lưu entry (tối đa 1.000 nhân viên mỗi lượt), chưa có job nền cuối tháng hay bầu leader. Phiếu lương và sổ cái chung một transaction PostgreSQL, không dùng Seata. - Nhân viên chưa tự xem được phiếu lương của mình: quyền `if-owner` mới áp cho ghi, chưa áp cho đọc. - Chưa có Desk riêng cho tính lương: Desk chung hiển thị các dòng phiếu dưới dạng "N item(s)". - Số liệu ở bảng trên là kết quả mong đợi tính theo logic của hook; lấy số trên màn hình từ chính lần quay. ### Video 20: STMS, điều phối xe với Timefold + AI copilot **Code cho developer:** `astra-apps/stms` (`solver/RoutePlanner`, `Replanner`, `copilot/DispatchCopilot`, `copilot/MockLlmClient`); test `RoutePlannerTest`, `StmsDispatchTest`, `StmsMcpTest`. Mục tiêu: bài toán điều xe (VRP có tải trọng và khung giờ) giải bằng Timefold Solver ngay trong kernel; khi có sự cố, kế hoạch được giải lại từ tuyến hiện tại; người điều phối ra lệnh bằng tiếng Việt qua copilot, copilot gọi đúng các tool MCP đó. **Solver, việc lập lại kế hoạch và các tool là thật; mô hình ngôn ngữ của copilot là kịch bản giả lập cho demo** (`llm: "mock"`). Mọi khung hình có copilot phải hiện nhãn "LLM giả lập (kịch bản demo)". Chuẩn bị: `make demo-seed` tạo cho mỗi tenant một kho hư cấu (saigon-express: HCM-TB Kho Tân Bình), 3 xe và 20 điểm giao/nhận cho ngày chạy seed (`STMS_DATE`, mặc định hôm nay theo giờ Việt Nam; dùng cùng giá trị cho seed và demo). Script đăng nhập `linh` (dispatcher). Chạy các cảnh theo thứ tự: cảnh 3 đến 8 dùng plan do cảnh 2 tạo. | Bước | Thao tác | Lời dẫn | | --- | --- | --- | | 1 | `make demo-stms SCENES=1` | 5 DocType: Hub, Vehicle, DeliveryStop, DispatchPlan, DispatchEvent. Kho HCM-TB, 3 xe (300/250/250 kg), 20 điểm có khung giờ và mức ưu tiên Normal/VIP/Urgent. | | 2 | `SCENES=2` | Lưu DispatchPlan với `solve: true`: Timefold giải trong ngân sách 5 giây. Ràng buộc cứng: tải trọng, khung giờ; mềm: quãng đường, phục vụ điểm ưu tiên sớm, cân bằng tải. Kết quả Solved, `0hard/…soft` (khả thi), khoảng 99 km, 2 xe, khoảng 1 giây; in bảng tuyến. | | 3 | `SCENES=3`: copilot "Xe 51C-12345 hỏng ở Thủ Đức, chuyển hết đơn sang xe khác" | Response có `llm: "mock"` và `llm_note`; tool call `stms_vehicle_unavailable` rồi `stms_explain_plan`. Lời giải thích bắt đầu bằng `[LLM giả lập – kịch bản demo]`, nêu số điểm chuyển xe, điểm số trước → sau, vẫn 0hard. Solver giải lại từ tuyến hiện tại (warm start). | | 4 | `SCENES=4`: "Ưu tiên khách VIP trước 10h, cân bằng tải giữa các xe" | Copilot gọi `stms_set_weights` (priority 500, balance 10). Các điểm VIP/gấp được phục vụ trước 10:00, đổi lại quãng đường dài hơn. Điểm mềm đổi thang đo khi đổi trọng số, chỉ so sánh giữa các plan cùng trọng số. | | 5 | `SCENES=5`: "Thêm đơn gấp 30kg ở Bến Thành trước 11h" | `stms_add_urgent_stop` với toạ độ lấy từ danh sách địa danh cố định của bản giả lập; đơn được chèn vào một xe, các điểm VIP/gấp vẫn đúng hạn. | | 6 | `SCENES=6` | Không qua copilot: `stms_traffic_delay` (Thủ Đức, bán kính 4 km, chậm 2,5 lần) và `stms_change_time_window` (Linh kiện Tân Tiến → 08:00 đến 09:00). Mỗi lần in điểm số trước/sau, km, `solve_ms` và các điểm bị dời; vẫn 0hard. | | 7 | Desk (`linh`) → trang copilot điều phối, gõ một câu lệnh của cảnh 3 đến 5 | Trang gửi câu lệnh tới `POST /api/stms/copilot`, cùng copilot với tool `stms_copilot`; hiện nhãn LLM giả lập, các tool call đã chạy và lời giải thích. Mở DispatchPlan: `route_summary` (bảng tuyến dạng chữ) và `last_change` (các điểm bị dời). | | 8 | `SCENES=7` | `quang` (ops) gọi `stms_vehicle_unavailable` → "not permitted: read DispatchEvent"; `ha` (care) gọi `stms_explain_plan` → not permitted; cùng tên plan ở hanoi-express → "not found". | | 9 | `SCENES=8` | Nhật ký DispatchEvent: nguồn (Copilot hoặc MCP), điểm số trước → sau, điểm bị dời, `solve_ms`. Submit → docstatus 1, Dispatched; PUT `{solve: true}` → 422 "submitted documents are immutable"; `stms_set_weights` → "is dispatched and frozen; amend it to replan". | **Nói đúng:** - Copilot không gọi mô hình ngôn ngữ nào: `MockLlmClient` khớp câu lệnh bằng mẫu tiếng Việt cố định và một danh sách địa danh nhỏ; câu lệnh ngoài kịch bản không sinh tool call. Lời giải thích được viết từ kết quả thật của solver. Chỗ cắm nhà cung cấp LLM thật là interface `LlmClient`; chưa có bản cài đặt nào. - Không có bản đồ, mạng đường thật hay giao thông trực tiếp: khoảng cách là đường chim bay × 1,3, tốc độ cố định 25 km/h, "kẹt xe" là vùng chậm do người điều phối khai báo. Không geocoding: điểm mang sẵn toạ độ. - Lập lại kế hoạch là giải lại từ tuyến hiện tại, không giữ solver chạy cả ngày; thời gian không trôi, nên không khoá các điểm đã giao hay xe đang chạy. Thêm đơn gấp và giải lại là hai transaction: nếu giải lại lỗi, đơn mới vẫn được lưu. - Tải trọng là một tổng chung mỗi xe (giao và nhận cộng lại); không có ràng buộc lấy-trước-giao, nhiều chuyến, ca và nghỉ của tài xế, hạn quay về kho, loại xe hay chi phí xe. Mỗi plan một kho, không trung chuyển giữa kho. - Chưa có dự báo tải bằng AI (Tier 1), app tài xế, GPS hay cập nhật ETA từ hiện trường. Chưa có bản đồ tuyến: Desk hiện bảng tuyến dạng chữ và JSON. - Demo khoảng 20 điểm, giải trong 1 đến 2 giây; chưa đo với bài toán lớn hơn. Số trên màn hình lấy từ chính lần quay; số trong báo cáo phase đo trên dữ liệu test, sẽ lệch nhẹ so với dữ liệu seed. - DispatchEvent và đơn gấp do tool tạo mang chủ sở hữu `stms-mcp` / `stms-copilot`; người gọi thật được ghi trong log của MCP server. ### Video 21: SAML SSO và SCIM **Code cho developer:** `deploy/keycloak/realm/acme-corp-idp-realm.json`, `deploy/keycloak/realm/astra-realm.json` (identity provider `acme-corp`), `astra-iam` (`ScimResource`, `ScimDirectory`, `ScimPatch`); test `SamlBrokerTest`, `ScimTest`. Mục tiêu: nhân viên của một doanh nghiệp đăng nhập Astra bằng IdP của công ty mình qua SAML 2.0, nhóm bên IdP thành role của tenant; hệ thống quản lý danh tính của doanh nghiệp tạo, phân quyền, khoá và xoá tài khoản qua SCIM 2.0, chỉ trong tenant của mình. Chuẩn bị: Keycloak phải có realm `acme-corp-idp`, identity provider `acme-corp` và các client SCIM. `make infra-up` (chạy `scripts/keycloak-compose-dev.sh`) hoặc `make k3d-up` đồng bộ realm; nếu Keycloak đang chạy từ trước, chạy lại `scripts/keycloak-sync-realm.sh`, rồi khởi động lại astra-server để có `/scim/v2`. Tenant của video là `acme`. User `linh` và `bao` ở đây thuộc realm IdP `acme-corp-idp`, không phải `linh` dispatcher của Video 20. | Bước | Thao tác | Lời dẫn | | --- | --- | --- | | 1 | `make demo-sso SCENES=1` | Luồng SP-initiated phát lại bằng curl: Desk → Keycloak (`kc_idp_hint=acme-corp`) → AuthnRequest tới IdP → `linh` đăng nhập ở IdP → SAMLResponse có chữ ký quay về broker → mã OIDC. Assertion: NameID `linh`, Audience `urn:astra:sp`, `memberOf astra-editors`, ký RSA-SHA256 cả assertion lẫn response. Token: `preferred_username: acme-corp.linh`, `groups: ["/tenants/acme/editor"]`; `/api/iam/me` → acme `[editor]`; tạo Article → 201. | | 2 | `SCENES=2` | SAMLResponse của `bao` bị sửa `astra-readers` thành `astra-admins` trên đường đi → Keycloak trả 400 "Chữ ký không hợp lệ…", không cấp mã. | | 3 | `SCENES=3` | Discovery: `ServiceProviderConfig` (`patch: true`, filter tối đa 200, `bulk: false`, `etag: false`), ResourceTypes User và Group. Token client-credentials của `scim-acme`: `scim_tenant: acme`, role `scim`; tenant chỉ lấy từ claim này. | | 4 | `SCENES=4` | `POST /scim/v2/Users` → 201, `application/scim+json`; filter `userName eq "…"` → 1 kết quả; tạo trùng → 409 `uniqueness`. User mới chưa có role: `/api/doc/Article` → 403 "not a member of tenant acme". | | 5 | `SCENES=5` | Groups chính là role của tenant: admin, editor, author, reader. PATCH thêm thành viên vào editor → sau lần đăng nhập mới token có `/tenants/acme/editor`, tạo Article → 201. | | 6 | `SCENES=6` | PATCH `{"op":"Replace","path":"active","value":"False"}` (kiểu Entra gửi) → tài khoản bị khoá: password grant → `invalid_grant` "Account disabled", refresh token → "Session not active". Access token đã cấp vẫn dùng được đến khi hết hạn; cảnh in rõ thời điểm hết hạn. | | 7 | `SCENES=7` | Token của globex: user của acme → 404; filter → 0 kết quả; header `X-Astra-Tenant: acme` → 403; thêm user của acme vào role globex → 400 `invalidValue`; filter `co` → 400 `invalidFilter`. Lỗi theo định dạng RFC 7644. | | 8 | `SCENES=8` | `DELETE` → 204, GET lại → 404. User SCIM của demo bị xoá khi script kết thúc; tài khoản liên kết `acme-corp.linh`, `acme-corp.bao` được giữ như tài khoản liên kết thật. | **Nói đúng:** - IdP là một realm Keycloak (`acme-corp-idp`) trong cùng container, không phải Okta, Entra hay ADFS; giao thức SAML giữa hai bên là thật. Demo phát lại các bước trình duyệt bằng curl và không theo redirect về Desk. - Assertion và response có chữ ký và được kiểm; assertion **không mã hoá**, AuthnRequest **không ký**. Chỉ có SP-initiated; chưa cấu hình IdP-initiated; **Single Logout chưa nối và chưa test**. - Lần đăng nhập đầu Keycloak tự tạo tài khoản `acme-corp.`; trùng email với tài khoản local thì Keycloak hỏi liên kết, demo không chiếu bước này. Ánh xạ nhóm → role cố định trong cấu hình realm và chỉ cho tenant `acme`; mỗi doanh nghiệp khác cần IdP và mapper riêng. - SCIM chưa có Bulk, ETag/`meta.version`, sort, `/Me`, `meta.lastModified`, tham số `attributes`; `excludedAttributes` chỉ áp cho `members` của Groups. Bộ lọc chỉ có `userName eq` và `displayName eq`. `externalId` bị bỏ qua; chỉ giữ một email; không đổi được `userName`. - Groups cố định vì chính là role của tenant: tạo hoặc xoá group trả 501, chỉ đổi được thành viên. Client SCIM của tenant chỉ thấy và sửa user do chính nó cấp. Xoá qua SCIM xoá luôn tài khoản Keycloak. - Đổi role có hiệu lực từ lần đăng nhập hoặc refresh tiếp theo. Sau `active=false`, access token đã cấp vẫn hợp lệ đến khi hết hạn vì Astra xác minh JWT offline (5 phút với client thường, 1 giờ với client demo `astra-dev-cli`). - Liệt kê user lấy thành viên từ Keycloak và phân trang trong bộ nhớ, mỗi user thêm một lời gọi đọc group: đủ cho demo, chưa dành cho hàng trăm nghìn user. Secret của client SCIM trong realm JSON chỉ dùng cho môi trường local. ### Video 18: Roadmap, phần còn lại của use-case-01 *(Đang phát triển)* Mọi khung hình gắn nhãn **"Roadmap / Đang phát triển"**. | Đã có (Video 1 đến 17, 19 đến 21) | Còn trên roadmap | | --- | --- | | JSONB + projection index, ledger, schema riêng cho BMS | Multi-schema cho mọi module, hot-column materialised | | Seata AT + TCC | AT cho chính kernel, bảng fence TCC, Seata DB/Raft store | | Keycloak, quyền DocType, SoD | Điều kiện quyền theo dòng | | Tier 0, Tier 2 Wasm | Tier 1 Isolated JVM (hot-swap), đếm lệnh Wasm | | Rust CDC + LSN | Giao thức streaming, Apache Doris, NOC dashboards | | MCP + che PII | Agent tự hành, pgvector RAG, OAuth cho MCP | | Video 19: HRM (nhân sự, tiền lương), bút toán dồn tích qua `GeneralLedger` | Thuế TNCN, phần doanh nghiệp đóng bảo hiểm, chi trả lương, job lương nền cuối tháng, nhân viên tự xem phiếu lương | | Video 20: STMS (Timefold), lập lại kế hoạch, copilot điều phối | LLM thật cho copilot, bản đồ và giao thông thật, dự báo tải (Tier 1), app tài xế | | Video 21: SAML SSO, SCIM 2.0 theo tenant | Single Logout, SCIM Bulk/ETag, bộ lọc SCIM đầy đủ | | (chưa có) | Audit hash-chain, mã hoá trường | --- ## 4. Danh sách kiểm tra trước khi xuất bản - [ ] Mỗi video chỉ khẳng định những gì có trong bảng mục 2; đọc mục "Nói đúng" trước khi thu âm. - [ ] Video 18 và mọi khung hình roadmap có nhãn "Đang phát triển". - [ ] Video 20: mọi khung hình có copilot ghi rõ "LLM giả lập (kịch bản demo)"; không nói hay ngụ ý là có mô hình ngôn ngữ thật. - [ ] Số liệu hiệu năng (lag CDC, thời gian sandbox) lấy từ chính lần quay, không làm tròn đẹp hơn thực tế. - [ ] Dữ liệu khách hàng là dữ liệu seed hư cấu (`scripts/demo-seed.sh`). - [ ] Mật khẩu demo chỉ xuất hiện trên stack local; không quay màn hình có token. - [ ] Video có nhạc CC BY: mô tả video có dòng ghi công (Video 0: xem `docs/video/teaser/CREDITS.txt`). --- # Code snippet cho video Đi kèm demo-video-series.md. Mỗi mục S1…S12 là phần code để chiếu lên màn hình (hoặc chèn làm overlay) trong video tương ứng. Code được trích từ repository, chỉ rút gọn: … là chỗ lược bớt, comment // ← là chú thích thêm… Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/code-snippets Đi kèm [`demo-video-series.md`](/astra-cms/vi/video-scripts). Mỗi mục **S1…S12** là phần code để chiếu lên màn hình (hoặc chèn làm overlay) trong video tương ứng. Code được **trích từ repository**, chỉ rút gọn: `…` là chỗ lược bớt, comment `// ←` là chú thích thêm cho video. Đường dẫn file ghi ở đầu mỗi khối để người xem tự mở. | Mục | Video | Ý chính | | --- | --- | --- | | [S0](#s0-big-picture) | 0, 1 | Bức tranh toàn cảnh: một lần Submit đi qua những gì | | [S1](#s1-doctype-is-the-source-of-truth) | 2 | DocType YAML → API, Desk, index | | [S2](#s2-an-app-and-a-hook) | 3, 9 | App Tier 0 và hook nghiệp vụ | | [S3](#s3-lifecycle-in-one-transaction) | 4, 5 | Submit = một transaction, hook ghi chứng từ khác | | [S4](#s4-tenants-and-row-level-security) | 7, 8 | Tenant: `ScopedValue` → `set_config` → RLS | | [S5](#s5-keycloak-permissions-and-sod) | 11 | Quyền theo role, maker/checker | | [S6](#s6-append-only-ledger) | 12 | Bút toán kép, trigger append-only | | [S7](#s7-tenant-overlays) | 13 | Overlay YAML áp lúc chạy | | [S8](#s8-wasm-tier-2) | 14 | Rust → Wasm → sandbox Chicory | | [S9](#s9-mcp-for-ai-agents) | 15 | Tool MCP do app đóng góp, che PII | | [S10](#s10-rust-cdc-and-sync-aware-lsn) | 16 | WAL → replica, `X-Tx-LSN` / `202 Sync Pending` | | [S11](#s11-seata-at-and-tcc) | 17 | Global transaction: AT + TCC + kernel | | [S12](#s12-desk-generated-from-metadata) | 2, 9 | Desk không biết trước DocType nào | --- ## S0 Big picture Một lần bấm **Submit** đơn hàng chạm vào gần như mọi tính năng. Sơ đồ này nên chiếu đầu video 0 và 1. ```mermaid sequenceDiagram autonumber actor U as tuan (sales) — Desk / curl / MCP participant K as Astra kernel (Quarkus, virtual thread) participant TC as Seata Server participant BMS as PostgreSQL schema bms participant R as Redis participant PG as PostgreSQL astra_doc + astra_ledger participant CDC as astra-cdc (Rust) U->>K: POST /api/doc/CustomerOrder/ORD-…/submit (Bearer JWT, X-Astra-Tenant) K->>K: Keycloak token → tenant + roles → DocPermissions.requireSubmit K->>TC: begin global transaction (transaction: global) K->>BMS: AT branch: UPDATE customer_credit SET used = used + fee K->>R: TCC try: reserve pickup slot K->>PG: kernel tx (RLS): waybill + docstatus=1 + ledger Dr 131 / Cr 511 K->>TC: commit → AT: drop undo · TCC: confirm K-->>U: 200 + X-Tx-LSN: 0/2C6C928 PG-->>CDC: WAL (pgoutput) → replica_order, replica_waybill, watermark ``` --- ## S1 DocType is the source of truth `astra-apps/express/src/main/resources/META-INF/astra/doctypes/customer-order.doctype.yaml` ```yaml name: CustomerOrder module: oms kind: transaction submittable: true # Draft (0) -> Submitted (1) -> Cancelled (2) -> Amend transaction: global # submit/cancel span BMS credit (Seata AT) and pickup capacity (Seata TCC) naming: { series: "ORD-.YYYYMMDD.-.#####" } fields: - { name: customer, type: Link, options: Customer, required: true, in-list: true, projected: true } - { name: receiver_phone, type: Data, required: true, pii: true } - { name: zone, type: Select, options: [Same city, Domestic, Remote], default: Domestic, projected: true } - { name: weight_kg, type: Float, required: true, label: Weight (kg) } - { name: shipping_fee, type: Currency, read-only: true, in-list: true } # computed by ShippingFeeHook - { name: waybill, type: Link, options: Waybill, read-only: true } # created on submit # … permissions: - { role: sales, read: true, create: true, write: true, if-owner: true, submit: true } - { role: accountant, read: true, cancel: true } # … ``` Từ file này, không cần viết thêm dòng nào: | Dòng YAML | Kernel sinh ra | | --- | --- | | `name`, `fields` | `GET/POST/PUT /api/doc/CustomerOrder`, form + list trên Desk | | `projected: true` | `CREATE INDEX CONCURRENTLY astra_doc_p_zone ON astra_doc (tenant_id, doctype, (data->>'zone'))` | | `type: Link` | Kiểm tra `Customer` tồn tại **trong cùng tenant** | | `read-only` | Client gửi `shipping_fee` → 422; chỉ hook được ghi | | `naming.series` | `ORD-20261021-00001`, đếm riêng từng tenant | | `pii: true` | Bị che khi dữ liệu đi qua MCP tới mô hình AI | Lưu trữ là **một bảng** cho mọi DocType (`V1__astra_doc.sql`): ```sql CREATE TABLE astra_doc ( id uuid PRIMARY KEY, tenant_id text NOT NULL, doctype text NOT NULL, name text NOT NULL, docstatus smallint NOT NULL DEFAULT 0, owner text NOT NULL, version integer NOT NULL DEFAULT 1, data jsonb NOT NULL DEFAULT '{}'::jsonb, -- ← mọi trường nghiệp vụ nằm ở đây: Zero-DDL … ); ``` --- ## S2 An app and a hook App chỉ khai báo DocType của mình — `ExpressApp.java`: ```java @ApplicationScoped public class ExpressApp implements AstraApp { // ← chỉ phụ thuộc astra-spi public String id() { return "io.astra.express"; } public List doctypeResources() { return List.of("META-INF/astra/doctypes/customer.doctype.yaml", "META-INF/astra/doctypes/customer-order.doctype.yaml", /* … */); } } ``` Quy tắc nghiệp vụ là một CDI bean — `ShippingFeeHook.java` (rút gọn): ```java @ApplicationScoped public class ShippingFeeHook implements DocLifecycleHook { public String doctype() { return "CustomerOrder"; } @Override public void beforeSave(Document doc) { // ← chạy trên mọi kênh: Desk, REST, MCP List errors = new ArrayList<>(); if (!doc.getString("receiver_phone").matches("0\\d{9}")) errors.add("receiver_phone must be 10 digits starting with 0"); if (weight <= 0 || weight > MAX_WEIGHT_KG) errors.add("weight_kg must be between 0 and 30"); if (!errors.isEmpty()) throw new DocValidationException(errors); // ← HTTP 422, mọi lỗi cùng lúc doc.set("shipping_fee", fee(zone, service, weight, cod)); doc.set("priced_by", "Standard tariff"); } } ``` Kết quả: `30.000 + 3 × 5.000 = 45.000 → × 1,5 (Express) = 67.500 → + 5.000 COD = 73.000 ₫`. --- ## S3 Lifecycle in one transaction Kernel — `DocumentStore.submit` (rút gọn: bản thật trả `Optional.empty()` khi không tìm thấy): ```java public Optional submit(String doctype, String name) { DocTypeDefinition def = requireSubmittable(doctype); return global(def, "submit " + doctype + " " + name, () -> inTenantTx(conn -> { // ← Seata nếu transaction: global Document doc = get(doctype, name).orElseThrow(); requireDraft(doc); // ← docstatus phải là 0 hooks.onSubmit(doc); // ← mọi hook của app, cùng connection DocumentValidator.applyDefaultsAndValidate(def, doc); validateLinks(conn, def, doc); return Optional.of(write(conn, doc, 1)); // ← docstatus = 1, optimistic version check })); } ``` App — hook ghi một chứng từ **khác** và cùng commit/rollback (`OrderWaybillHook.java`): ```java @Inject DocumentService docs; @Override public void onSubmit(Document order) { Document waybill = docs.insert("Waybill", Map.of( // ← tham gia transaction đang mở "order", order.name(), "receiver_name", order.getString("receiver_name")), order.owner()); order.set("waybill", waybill.name()); } @Override public void onCancel(Document order) { Document waybill = docs.get("Waybill", order.getString("waybill")).orElseThrow(); if (!Set.of("Created", "Picked Up").contains(waybill.getString("ops_status"))) { throw new DocValidationException("cannot cancel " + order.name() + ": waybill … is already '…'"); } // ← veto: đơn và vận đơn không bao giờ lệch nhau docs.update("Waybill", waybill.name(), Map.of("ops_status", "Cancelled")); } ``` Máy trạng thái bưu kiện là một bảng dữ liệu (`WaybillScanHook.java`): ```java static final Map> NEXT = Map.of( "Created", Set.of("Picked Up", "Cancelled"), "Picked Up", Set.of("At Hub", "Cancelled"), "At Hub", Set.of("At Hub", "Out for Delivery", "Returned"), "Out for Delivery", Set.of("Delivered", "Failed Delivery"), /* … */ "Delivered", Set.of()); ``` --- ## S4 Tenants and row-level security ```mermaid flowchart LR A["Bearer JWT
groups: /tenants/saigon-express/ops"] --> B[TenantResolverFilter] H["X-Astra-Tenant"] --> B B --> C["TenantContext
(Java 25 ScopedValue)"] C --> D["set_config('astra.tenant', …, true)"] D --> E["PostgreSQL RLS policy
tenant_id = current_setting('astra.tenant')"] ``` `TenantTransactions.inTenantTx` (`astra-doctype`) — tenant không bao giờ là tham số của API nào: ```java String tenant = TenantContext.require(); // ← đọc từ ScopedValue, không từ caller try (Connection conn = dataSource.getConnection()) { conn.setAutoCommit(false); conn.prepareStatement("SELECT set_config('astra.tenant', ?, true)") … ; // ← chỉ sống trong transaction này T result = ScopedValue.where(TX, conn).call(() -> work.run(conn)); // ← hook lồng nhau dùng lại conn conn.commit(); return result; } ``` `V1__astra_doc.sql`: ```sql ALTER TABLE astra_doc ENABLE ROW LEVEL SECURITY; ALTER TABLE astra_doc FORCE ROW LEVEL SECURITY; -- ← áp dụng cả cho chủ bảng CREATE POLICY astra_doc_tenant_isolation ON astra_doc USING (tenant_id = current_setting('astra.tenant', true)) WITH CHECK (tenant_id = current_setting('astra.tenant', true)); ``` Fail-closed — `astra-doctype/…/store/RowLevelSecurityGuard.java` (rút gọn; `RowLevelSecurityFlywayCustomizer` gọi nó **trước khi Flyway migrate**, rồi gọi lại ở `StartupEvent`): ```java if (superuser || bypass) { String msg = "Database role '" + role + "' is " + (superuser ? "a SUPERUSER" : "BYPASSRLS") + ": row-level security would be ignored and tenants would see each other's data. …"; if (allowBypass) LOG.warn(msg + " … NEVER use this in production."); // ← chỉ khi astra.tenancy.allow-rls-bypass=true else throw new IllegalStateException(msg); // ← mặc định: kernel từ chối khởi động } ``` --- ## S5 Keycloak permissions and SoD Role là nhóm Keycloak, quyền là YAML trên DocType (`cod-remittance.doctype.yaml`): ```yaml submittable: true segregation-of-duties: true # maker/checker: whoever prepares a payout cannot approve it permissions: - { role: accountant, read: true, create: true, write: true, submit: true, cancel: true } ``` `DocPermissions.requireSubmit` (nguyên văn): ```java public void requireSubmit(String doctype, Set roles, String userId, Document existing) { DocTypeDefinition def = registry.require(doctype); if (!allows(def, roles, Action.SUBMIT, false)) { throw new AccessDeniedException("submit " + doctype); } if (def.segregationOfDuties() && userId != null && userId.equals(existing.owner())) { throw new AccessDeniedException("submit " + doctype + "/" + existing.name() + ": segregation of duties, the creator cannot submit their own document"); } } ``` Desk đăng nhập bằng Authorization Code + PKCE ngay trong trình duyệt (`desk.js`, rút gọn): ```js const verifier = b64url(crypto.getRandomValues(new Uint8Array(32))); const challenge = b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))); location.assign(oidc('auth') + '?' + new URLSearchParams({ client_id: 'astra-desk', response_type: 'code', scope: 'openid', code_challenge: challenge, code_challenge_method: 'S256', redirect_uri: location.origin + '/desk/' })); ``` --- ## S6 Append-only ledger App ghi sổ qua SPI, trong transaction của chứng từ (`OrderPostingHook.java`, nguyên văn): ```java @Inject GeneralLedger ledger; @Override public void onSubmit(Document order) { BigDecimal fee = BigDecimal.valueOf(Numbers.toDouble(order.get("shipping_fee"))); String customer = order.getString("customer"); ledger.post("CustomerOrder", order.name(), LocalDate.now(), List.of( Entry.debit(Accounts.RECEIVABLE, customer, fee), // ← Nợ 131 Phải thu khách hàng Entry.credit(Accounts.REVENUE, customer, fee)), // ← Có 511 Doanh thu dịch vụ "Shipping fee " + order.name()); } @Override public void onCancel(Document order) { ledger.reverse("CustomerOrder", order.name()); // ← bút toán đảo, không sửa dòng cũ } ``` Database tự bảo vệ lịch sử (`V3__ledger.sql`): ```sql CREATE TABLE astra_ledger ( …, debit numeric(18,2), credit numeric(18,2), …, CONSTRAINT astra_ledger_one_side CHECK (debit >= 0 AND credit >= 0 AND (debit = 0) <> (credit = 0)) ) PARTITION BY RANGE (posting_date); -- ← astra_ledger_2026_10, _11, _12, default CREATE FUNCTION astra_ledger_append_only() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'astra_ledger is append-only: post a reversal instead of %', TG_OP; END $$; CREATE TRIGGER astra_ledger_no_update_delete BEFORE UPDATE OR DELETE ON astra_ledger FOR EACH ROW EXECUTE FUNCTION astra_ledger_append_only(); ``` `LedgerStore.post` từ chối voucher lệch: `debit 10 ≠ credit 9` → 422. --- ## S7 Tenant overlays `tenants/hanoi-express/overlays/customer-order.overlay.yaml`: ```yaml doctype: CustomerOrder fields: add: - { name: vat_invoice, type: Check, label: "Xuất hóa đơn VAT", in-list: true } - { name: tax_code, type: Data, label: "Mã số thuế" } modify: - { name: zone, options: [Same city, Domestic, Remote, Island] } - { name: note, label: "Ghi chú giao hàng", required: true } ``` ```bash curl -X PUT http://localhost:8080/api/overlay/CustomerOrder \ -H "Authorization: Bearer $NAM" -H "X-Astra-Tenant: hanoi-express" \ -H "Content-Type: application/yaml" --data-binary @customer-order.overlay.yaml # ← có hiệu lực ngay ``` Kernel hỏi "DocType **của tenant này**" thay vì "DocType" (`DocTypeOverlays.effective`, rút gọn): ```java public DocTypeDefinition effective(String doctype) { DocTypeDefinition base = registry.require(doctype); // ← định nghĩa của app Optional tenant = TenantContext.current(); if (tenant.isEmpty()) return base; return get(doctype).map(o -> Overlays.apply(base, (String) o.get("yaml"))) // ← astra_overlay, dưới RLS .orElse(base); // (cache 2 s giữa các pod) } ``` Luật an toàn trong `Overlays.apply`: chỉ thêm trường hoặc đổi `label / required / options / default / in-list`; đổi `type` → `422 field property 'type' cannot be changed by an overlay`. --- ## S8 Wasm Tier 2 ```mermaid flowchart LR R["tet-promo/src/lib.rs"] -->|cargo build --target wasm32-unknown-unknown| W["tet-promo.wasm · 421 byte"] W -->|POST /api/doc/PricingRule (Attach, base64)| P["PricingRule document"] P -->|PricingRuleHook: self-test trong sandbox| S["Chicory 1.7.5
≤16 trang bộ nhớ · deadline 50 ms · không host import"] S -->|fee(weight_g, zone, express, cod)| F["ShippingFeeHook → shipping_fee"] ``` Công thức của tenant, viết bằng Rust (`astra-apps/express/wasm/tet-promo/src/lib.rs`): ```rust #[unsafe(no_mangle)] pub extern "C" fn fee(weight_g: i64, zone: i64, express: i64, cod: i64) -> i64 { let z = zone.clamp(0, 3) as usize; if z == 0 && weight_g <= 2_000 && express == 0 { return cod_fee(cod); // free same-city ≤ 2 kg } let extra_steps = ((weight_g - 1_000).max(0) + 499) / 500; let mut fee = ZONE_BASE[z] + extra_steps * ZONE_STEP[z]; if z > 0 { fee = fee * 80 / 100; } // −20 % outside the city if express != 0 { fee = fee * 130 / 100; } // Express +30 % instead of +50 % round_up_thousand(fee + cod_fee(cod)) } ``` Sandbox (`astra-plugin-host/…/WasmSandbox.java`, rút gọn): ```java Future call = threads.submit(() -> instantiate(wasm).export(function).apply(args)); // ← instance mới mỗi lần try { return call.get(deadline, TimeUnit.MILLISECONDS)[0]; } catch (TimeoutException e) { call.cancel(true); // ← ngắt luồng: interpreter dừng ở lần kiểm tra kế tiếp throw new ScriptException("timeout", "script exceeded its " + deadline + " ms deadline and was stopped", e); } ``` Script độc (`runaway/src/lib.rs`): `loop { i = core::hint::black_box(i.wrapping_add(1)); }` → `module rejected (timeout): script exceeded its 50 ms deadline and was stopped`. --- ## S9 MCP for AI agents Một tool do **app** đóng góp — kernel lo xác thực, tenant, quyền, log, che PII (`TrackWaybillTool.java`): ```java @ApplicationScoped public class TrackWaybillTool implements McpTool { public String name() { return "track_waybill"; } public String description() { return "Track a parcel by waybill number (WB-…): status, scans, order, tickets."; } public List reads() { return List.of("Waybill", "CustomerOrder", "SupportTicket"); } // ← kernel kiểm quyền đọc public Object call(Map arguments) { Document waybill = docs.get("Waybill", String.valueOf(arguments.get("waybill"))).orElseThrow(); return Map.of("waybill", doc(waybill), "order", doc(docs.get("CustomerOrder", waybill.getString("order")).orElseThrow()), "tickets", docs.find("SupportTicket", Map.of("waybill", waybill.name()), 20)); } } ``` Một lời gọi JSON-RPC thật và kết quả: ```bash curl -X POST http://localhost:8080/mcp -H "Authorization: Bearer $HA" -H "X-Astra-Tenant: saigon-express" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call", "params":{"name":"get_document","arguments":{"doctype":"CustomerOrder","name":"ORD-20261021-00001"}}}' ``` ```json { "name": "ORD-20261021-00001", "data": { "receiver_name": "L*** M*** T***", "receiver_phone": "*******678", "receiver_address": "1*** T*** P*** H*** C*** Đ*** N***", "shipping_fee": 61000 } } ``` Kết nối Claude Code: `scripts/mcp-claude.sh ha saigon-express`, tức là `claude mcp add --transport http astra http://localhost:8080/mcp --header "Authorization: Bearer …" --header "X-Astra-Tenant: saigon-express"`. --- ## S10 Rust CDC and Sync-Aware LSN ```mermaid sequenceDiagram participant OPS as quang (OPS) participant K as Kernel participant PG as PostgreSQL WAL participant W as astra-cdc (Rust) participant CARE as ha (CARE) OPS->>K: PUT /api/doc/Waybill/WB-3 {"ops_status":"Out for Delivery"} K-->>OPS: 200 · X-Tx-LSN: 0/284D968 CARE->>K: GET /api/replica/replica_waybill?name=WB-3 · X-Required-LSN: 0/284D968 K-->>CARE: 202 Sync Pending (watermark 0/284C3C0 chưa tới 0/284D968) PG->>W: peek pgoutput changes ≤ current LSN W->>PG: upsert replica_waybill (dưới RLS) + _cdc_sync_watermark = 0/284E510 CARE->>K: GET … · X-Required-LSN: 0/284D968 K-->>CARE: 200 · row ops_status = Out for Delivery ``` Định tuyến khai báo (`astra-cdc-rust/cdc-routes.yaml`): ```yaml routes: - doctype: Waybill target: replica_waybill columns: - { name: ops_status, from: ops_status } - { name: current_hub, from: current_hub } - { name: scans, from: scan_log, transform: length, type: integer } ``` Vòng lặp worker (`astra-cdc-rust/src/main.rs`, rút gọn): ```rust loop { let cur: String = client.query_one("SELECT pg_current_wal_lsn()::text", &[]).await?.get(0); let rows = client.query("SELECT lsn::text, data FROM pg_logical_slot_peek_binary_changes($1, $2::text::pg_lsn, NULL, \ 'proto_version', '1', 'publication_names', $3)", &[&cfg.slot, &cur, &cfg.publication]).await?; let tx = client.transaction().await?; for row in &rows { match pgoutput::decode(&row.get::<_, Vec>(1))? { // ← tự giải mã giao thức pgoutput v1 Message::Insert { relation, new } | Message::Update { relation, new } => apply(&tx, cfg, &upserts, &relations[&relation], &new, &lsn).await?, // set_config(tenant) + upsert /* Relation, Begin, … */ } } tx.execute("INSERT INTO _cdc_sync_watermark … last_lsn = $2", &[&cfg.slot, &cur]).await?; tx.commit().await?; client.execute("SELECT pg_replication_slot_advance($1, $2::text::pg_lsn)", &[&cfg.slot, &cur]).await?; // ← sau commit tokio::time::sleep(Duration::from_millis(cfg.poll_ms)).await; } ``` Cổng LSN trong kernel (`SyncAwareLsnFilter.java`, rút gọn): ```java // request: đọc replica nhưng worker chưa tới LSN client yêu cầu → không trả dữ liệu cũ if (!watermark.reached(required)) req.abortWith(Response.status(202).entity(Map.of("status", "sync-pending", …)).build()); // response: mọi lệnh ghi thành công báo vị trí WAL sau commit if (WRITES.contains(req.getMethod()) && res.getStatus() / 100 == 2) res.getHeaders().putSingle("X-Tx-LSN", watermark.currentLsn()); ``` --- ## S11 Seata AT and TCC ```mermaid flowchart TB subgraph G["Global transaction (Seata Server 2.6.0)"] A["AT branch · schema bms (role astra_bms)
UPDATE customer_credit SET used = used + fee
before/after image → bms.undo_log"] T["TCC branch · Redis
try: reserved+1 · confirm: booked+1 · cancel: reserved−1"] K["Kernel tx · schema public (RLS)
waybill · docstatus=1 · ledger"] end A --> T --> K K -->|ok| C["commit: AT xoá undo · TCC confirm"] K -->|lỗi ở bất kỳ bước nào| R["rollback: AT phục hồi từ undo_log · TCC cancel · kernel rollback"] ``` Một dòng YAML bật global transaction: `transaction: global`. Hook của app (`OrderCreditHook.java`, nguyên văn): ```java @Override public void onSubmit(Document order) { GlobalTransactions tx = coordinator(); if (tx == null) return; // ← không có Seata: chạy local như cũ CreditLimits.use(tx, order.getString("customer"), fee(order)); // ← AT: JDBC thường trên datasource đã proxy tx.tcc(PickupCapacity.NAME, LocalDate.now().toString(), // ← TCC: participant của app Map.of("capacity", DAILY_PICKUP_CAPACITY, "order", order.name())); } ``` Participant TCC — app không thấy một kiểu Seata nào (`PickupCapacity.java`, rút gọn): ```java public void prepare(String branchId, String day, Map args) { counters.add("tcc-try:" + branchId, 1, KEEP); if (counters.add(reservedKey(day), 1, KEEP) + counters.get(bookedKey(day)) > capacity) { throw new DocValidationException("no pickup slot left for " + day); // ← Seata sẽ gọi rollback } } public void commit(String branchId, String day, Map args) { if (!counters.markOnce("tcc-done:" + branchId, KEEP)) return; // ← idempotent khi Seata retry counters.add(reservedKey(day), -1, KEEP); counters.add(bookedKey(day), 1, KEEP); } public void rollback(String branchId, String day, Map args) { if (!counters.markOnce("tcc-done:" + branchId, KEEP)) return; if (counters.get("tcc-try:" + branchId) > 0) counters.add(reservedKey(day), -1, KEEP); // ← empty rollback an toàn } ``` Điều phối (`astra-tx-seata/…/SeataTransactions.inGlobal`, rút gọn): ```java GlobalTransaction gtx = GlobalTransactionContext.getCurrentOrCreate(); gtx.begin(timeoutMillis, name); try { T result = work.get(); // ← AT + TCC + kernel tx (kernel commit cuối) gtx.commit(); return result; } catch (Throwable e) { // ← cả Error: nhánh nào đã giữ global lock thì phải rollback gtx.rollback(); throw e; } ``` --- ## S12 Desk generated from metadata Desk không có một dòng code nào nhắc tới `CustomerOrder` hay `Waybill` (`desk.js`, rút gọn): ```js const res = await api('GET', '/api/doctype'); // ← DocType hiệu lực của tenant (đã áp overlay) for (const f of def.fields) { switch (f.type) { // ← mỗi kiểu trường → một control case 'SELECT': input = h('select', …, f.options.map((o) => h('option', null, o))); break; case 'LINK': input = h('input', { list: 'opts-' + f.name }); /* gợi ý từ /api/doc/ */ break; case 'CURRENCY': input = h('input', { type: 'number' }); break; case 'ATTACH': return attachControl(f, v, locked); // ← upload .wasm cho PricingRule /* … */ } } const perm = can(def.name); // ← /api/iam/me: quyền hiệu lực theo role if (doc.docstatus === 0 && perm.submit) actions.append(button('Submit')); if (doc.docstatus === 1 && perm.cancel) actions.append(button('Cancel')); ``` Thêm một trường vào YAML (hoặc overlay) → form, list, filter, validation, API và tool MCP cùng cập nhật. --- # Astra là gì & chạy trong 5 phút Tóm tắt một câu: Astra là một kernel nghiệp vụ hướng metadata; năm lệnh là bạn có một cụm Kubernetes thật trên laptop, hai công ty chuyển phát đã nạp sẵn dữ liệu và một Desk sinh ra từ các DocType mà các app đóng góp.… Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/quickstart ▶ [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-1-quickstart-vi.mp4) · [English](https://media.zerohostcms.com/astra-cms/astra-video-1-quickstart-en.mp4) · 2:32 · Kịch bản: [demo-video-series.md](/astra-cms/vi/video-scripts) · [Bản tiếng Anh](/astra-cms/vi/quickstart) > **Tóm tắt một câu:** Astra là một kernel nghiệp vụ hướng metadata; năm lệnh là bạn có một cụm Kubernetes thật trên laptop, hai công ty chuyển phát đã nạp sẵn dữ liệu và một Desk sinh ra từ các DocType mà các app đóng góp. > **Nên đọc nếu:** bạn muốn chạy toàn bộ stack trên máy và biết mỗi pod để làm gì (góc nhìn developer) / bạn muốn biết thứ gì thực sự đang chạy hôm nay, vận hành tốn bao nhiêu, và ranh giới giữa phần đã làm và phần mới thiết kế nằm ở đâu (góc nhìn tech lead). ## Vấn đề Astra Express là một công ty chuyển phát có hai chi nhánh hoạt động như hai doanh nghiệp riêng: `saigon-express` và `hanoi-express`. Công ty nhận đơn (OMS), vận chuyển bưu kiện (OPS), trả lời cuộc gọi của khách (CARE) và thu tiền hộ khi giao hàng (BMS). Mỗi mảng đó thường là một sản phẩm riêng, có database, đăng nhập và code tích hợp riêng. Mỗi chi nhánh mới hay quy tắc mới lại thành một dự án: một schema mới, một service mới, một lần triển khai mới. Câu trả lời quen thuộc cho yêu cầu "cho tôi xem nó chạy" là một bộ slide, hoặc một bản đồ chơi chạy trong một container, giấu đi những phần khó: đa thuê bao, danh tính, giao dịch trải trên nhiều kho dữ liệu, và nhiều hơn một application server. Rồi các đội phát hiện ra trên production rằng bộ đếm đánh số bị trùng giữa các pod, hoặc cách ly tenant chỉ là một mệnh đề `WHERE` mà ai đó quên viết. Frappe/ERPNext đã giải quyết nửa đầu của bài toán bằng một ý tưởng: ứng dụng chính là metadata. Một DocType mô tả một bản ghi nghiệp vụ, còn form, API và phân quyền sinh ra từ nó. Astra giữ ý tưởng đó, viết lại bằng Java 25 trên Quarkus, và thêm những ràng buộc mà một cụm máy đặt ra ([explain.md](/astra-cms/vi/why-astra)). Video 1 có mặt để cho thấy điểm xuất phát là một cụm thật, không phải đồ chơi: hai pod kernel ngay từ lệnh đầu tiên, nên đa thuê bao và naming series phải an toàn trên cụm từ ngày đầu. ## Video cho thấy gì | Cảnh | Bạn thấy gì | Điều đó chứng minh | | --- | --- | --- | | Kiến trúc | Sơ đồ trong README: kernel, DocType Engine, Plugin Host | App cắm vào qua metadata; lời thuyết minh tách rõ phần đang chạy và phần thiết kế | | Điều kiện cần | JDK 25, Docker Desktop, `kubectl`, `k3d`, `jq`, Bash 5; Rust chỉ cần cho Wasm | Bộ công cụ bình thường, không có trình cài đặt độc quyền | | `make k3d-up` | Output thật, tua nhanh; cụm đã có sẵn nên lệnh build lại ba image và triển khai lại mọi pod trong 1 phút 29 giây | Một lệnh build và triển khai cả stack | | `kubectl -n astra get pods` | Bảy pod, tất cả `Running` | Hai pod kernel sau một Service, cùng PostgreSQL, Redis, Keycloak, Seata và worker CDC viết bằng Rust | | `make demo-seed` | Nạp dữ liệu hai công ty chuyển phát | Mọi dữ liệu đi qua REST API với đăng nhập Keycloak; hook, vòng đời, sổ cái và overlay đều chạy trong lúc nạp; không có SQL | | Cảnh 1 của `demo-express` | `/api/app` và `/api/doctype` | Tại thời điểm quay: một server, hai app, tám DocType thuộc OMS, OPS, CARE, BMS và CMS | | Desk | Đăng nhập bằng `mai`, Installed apps, menu bên trái | Menu sinh ra từ các DocType mà app đóng góp | ![Hình 2: Năm lệnh từ laptop đến Desk](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-01-figure-2.png) *Năm lệnh từ laptop đến Desk.* ## Cách hoạt động ### Một server, app được biên dịch kèm Một app là một CDI bean hiện thực SPI `AstraApp` và liệt kê các file YAML DocType của nó. App chỉ phụ thuộc vào `astra-spi`, không bao giờ phụ thuộc vào phần bên trong của kernel. *`astra-spi/src/main/java/io/astra/spi/app/AstraApp.java`* ```java public interface AstraApp { /** Stable, reverse-DNS style identifier, e.g. {@code io.astra.cms}. */ String id(); /** Semantic version of this app build. */ String version(); /** * Classpath resource paths of DocType definitions (YAML) this app contributes, * e.g. {@code META-INF/astra/doctypes/article.doctype.yaml}. Explicit listing keeps * loading deterministic inside jars and native images. */ List doctypeResources(); } ``` *`astra-apps/express/src/main/java/io/astra/apps/express/ExpressApp.java`* ```java @ApplicationScoped public class ExpressApp implements AstraApp { @Override public String id() { return "io.astra.express"; } @Override public String version() { return "0.1.0"; } @Override public List doctypeResources() { return List.of( "META-INF/astra/doctypes/customer.doctype.yaml", "META-INF/astra/doctypes/customer-order.doctype.yaml", // … "META-INF/astra/doctypes/pricing-rule.doctype.yaml"); } } ``` Module server phụ thuộc vào từng module app, nên các app được biên dịch vào cùng một image. Đó là lý do API báo tier `0 (shared, build-time)`: *`astra-doctype/src/main/java/io/astra/doctype/api/AppResource.java`* ```java @GET public List> list() { return apps.apps().stream().map(app -> { Map m = new LinkedHashMap<>(); m.put("id", app.id()); m.put("version", app.version()); m.put("tier", "0 (shared, build-time)"); m.put("doctypes", registry.namesByApp(app.id())); return m; }).toList(); } ``` Vì sao liệt kê resource tường minh thay vì quét classpath: javadoc nói rằng cách này giữ việc nạp ổn định, có thể đoán trước, bên trong file jar và native image. Chỉ cần đọc một method là bạn biết app mang theo những DocType nào. ![Hình 3: Một app là metadata cộng code, biên dịch kèm](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-01-figure-3.png) *Một app là metadata cộng code, biên dịch kèm.* ### `make k3d-up` thực sự làm gì Target trong Makefile gọi [`scripts/k3d-up.sh`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/scripts/k3d-up.sh). Script tạo mới hoặc dùng lại cụm k3d `astra`, build các module Maven, build ba image (server, Keycloak kèm theme Astra, worker CDC Rust), import chúng vào k3d, apply `deploy/k8s/base`, chuẩn bị database bằng `scripts/db-setup.sh`, đồng bộ realm Keycloak, rồi khởi động lại các deployment để image được import lại với cùng tag được nhận. ```mermaid flowchart LR dev["make k3d-up"] --> mvn["./mvnw package"] mvn --> img["docker build
astra/server · astra/keycloak · astra/cdc"] img --> imp["k3d image import"] imp --> k8s["kubectl apply -k deploy/k8s/base"] k8s --> db["scripts/db-setup.sh k3d
schema BMS, role CDC"] db --> kc["keycloak-sync-realm.sh"] kc --> srv["rollout astra-server (2 pod)"] srv --> pub["db-setup.sh k3d lần nữa
publication CDC"] pub --> cdc["rollout astra-cdc"] ``` Publication CDC được tạo sau khi server đã khởi động, vì các migration Flyway của server tạo ra những bảng mà publication bao phủ; comment trong script nói thẳng điều này (`# now the CDC publication, on the migrated schema`). ### Bảy pod, và vì sao hai trong số đó là kernel *`deploy/k8s/base/astra-server.yaml`* ```yaml replicas: 2 # two pods from the start: tenancy and naming series must be cluster-safe ``` Mọi workload khác trong `deploy/k8s/base` (PostgreSQL, Redis, Keycloak, Seata, CDC) chạy một replica, cộng lại thành bảy pod như trong video. Khởi đầu với hai pod kernel là một điều kiện kiểm thử có chủ ý: một bộ đếm giữ trong bộ nhớ, hay một cache giả định chỉ có một bên ghi, sẽ hỏng ngay. Naming series được lưu trong PostgreSQL theo từng tenant (`astra_series`), nhờ vậy số đơn hàng luôn duy nhất giữa các pod (Video 10 chứng minh điều này khi chịu tải). | Pod | Vai trò trong demo | | --- | --- | | `astra-server` ×2 | Kernel Quarkus: REST, Desk, MCP, hook, DocType engine | | `postgres-0` | `astra_doc`, sổ cái, schema BMS, bảng replica; `wal_level=logical` cho CDC | | `redis` | Năng lực lấy hàng TCC (Video 17) | | `keycloak` | Đăng nhập OIDC, role theo tenant dưới dạng group (Video 11) | | `seata-server` | Bộ điều phối giao dịch toàn cục (Video 17) | | `astra-cdc` | Worker Rust giải mã `pgoutput` vào các bảng replica (Video 16) | ![Hình 1: Bảy pod và ai nói chuyện với ai](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-01-figure-1.png) *Bảy pod và ai nói chuyện với ai.* ### Nạp dữ liệu qua cửa chính [`scripts/demo-seed.sh`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/scripts/demo-seed.sh) đăng nhập qua Keycloak bằng user `demo` và tạo mọi thứ bằng lời gọi HTTP. Không có gì được chèn bằng SQL: *`scripts/demo-seed.sh`* ```bash customer() { call "$1" POST /api/doc/Customer "$2" >/dev/null; echo " customer $(jq -r .code <<<"$2")"; } order() { call "$1" POST /api/doc/CustomerOrder "$2" | jq -r .name; } submit() { call "$1" POST "/api/doc/CustomerOrder/$2/submit" | jq -r .data.waybill; } scan() { call "$1" PUT "/api/doc/Waybill/$2" "$3" >/dev/null; } ``` Vì sao điều này quan trọng: dữ liệu mẫu đi vòng qua API cũng đi vòng qua validation, hook, phân quyền và sổ cái. Nạp qua API nghĩa là dữ liệu demo được tạo ra bởi chính những quy tắc mà người dùng gặp, nên một khoản cước hay một bút toán bạn thấy trong Desk là do hệ thống tính, không phải gõ tay vào. ### Desk không biết trước điều gì Desk là một trang tĩnh do kernel phục vụ (`astra-server/src/main/resources/META-INF/resources/desk/`). Sau khi đăng nhập, Desk hỏi `/api/doctype` để lấy các DocType hiệu lực của tenant và dựng menu bên trái theo module. Video 2 nói về bộ dựng form; điều Video 1 muốn nhấn mạnh là chỉ cần cài app là các DocType của nó xuất hiện. ```mermaid sequenceDiagram autonumber actor M as mai (admin, saigon-express) participant D as Desk (trình duyệt) participant KC as Keycloak participant K as astra-server M->>D: mở /desk/ D->>KC: đăng nhập (PKCE) KC-->>D: token có /tenants/saigon-express/admin D->>K: GET /api/app, GET /api/doctype (Bearer, X-Astra-Tenant) K-->>D: app + định nghĩa DocType (đã áp overlay) D->>D: sidebar nhóm theo module: OMS, OPS, CARE, BMS ``` ## Ví dụ thực tế *Tình huống minh hoạ: công ty, con người và các con số là hư cấu; cơ chế và các quy tắc nó áp dụng là thật, như đã trình bày ở trên.* Một công ty logistics bên thứ ba cấp vùng ở Việt Nam vận hành hai pháp nhân, một ở Hà Nội và một ở TP. Hồ Chí Minh, và CTO muốn có một proof of concept trước khi cam kết đầu tư một đội. Câu hỏi rất hẹp: một nền tảng có phục vụ được cả hai pháp nhân với dữ liệu tách biệt, đăng nhập thật và quy tắc đơn hàng riêng của công ty hay không? Đầu vào: - Một laptop có Docker Desktop được cấp 8 GB RAM, JDK 25, `kubectl`, `k3d`, `jq`, Bash 5. - Repository, giữ nguyên không sửa. Astra làm gì, theo thứ tự: 1. `make k3d-up` build và triển khai bảy pod. 2. `make demo-seed` tạo dữ liệu của hai tenant qua API. Trong `saigon-express`, script tạo, cùng nhiều thứ khác, khách hàng `C001` "Shop Hoa Mai" (phân khúc B2B, hạn mức tín dụng 2.000.000 ₫) và tạo các đơn hàng được hook tính cước định giá, và khi Submit thì sinh vận đơn cùng bút toán sổ cái. 3. Đăng nhập bằng `mai` (admin) chỉ thấy `saigon-express`; `nam` (admin) chỉ thấy `hanoi-express`. Kết quả CTO có thể kiểm tra trong khoảng nửa giờ: - `kubectl -n astra get pods`: hai pod `astra-server` cộng năm pod hạ tầng. - `GET /api/app`: các app được biên dịch vào image và DocType của chúng. - Desk tại `http://localhost:8080/desk/`: các menu đơn hàng, vận hành, chăm sóc khách hàng và kế toán, sinh ra từ metadata. Những gì proof of concept này không trả lời, và không nên bị kéo giãn để trả lời: thông lượng, hành vi khi nâng cấp, hay mức độ sẵn sàng cho production. Những điều đó cần bài kiểm thử riêng. ## Dành cho tech lead - **Bạn đang đánh giá cái gì.** Một lát cắt mỏng chạy từ đầu đến cuối: DocType engine, hook, vòng đời, sổ cái, overlay, sandbox Wasm, MCP, CDC, Seata, Keycloak, hai pod kernel. Mục [Trạng thái dự án](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/README.vi.md#trạng-thái-dự-án) trong README là nguồn chuẩn về thứ đang chạy; phần còn lại của README là kiến trúc mục tiêu. - **Chi phí vận hành.** Bảy pod cho một môi trường demo, trong đó Keycloak, Seata và worker CDC mỗi thứ là một hệ thống phải vá và giám sát. Nếu bạn không cần giao dịch phân tán hay bản replica để đọc, đó là chi phí không đem lại lợi ích; kernel chỉ dùng Seata khi một DocType khai báo `transaction: global` và có sẵn bộ điều phối. - **App build-time.** Mọi app hiện nay đều là Tier 0: biên dịch vào image. Thêm hay sửa một app nghĩa là một image mới và một lần rollout. Nạp lúc chạy (Tier 1, JVM cô lập) và chạy song song nhiều phiên bản app nằm trong lộ trình. - **Đo gì trong một đợt thí điểm.** Thời gian từ `git clone` đến một Desk chạy được trên máy sạch; thời gian để một developer thêm một DocType và một hook (Video 2, 3, 9); đội bảo mật của bạn có chấp nhận row-level security làm ranh giới tenant hay không (Video 7, 8). - **Khi nào không nên dùng.** Nếu bạn cần một ERP hoàn chỉnh ngay hôm nay, ERPNext là sản phẩm trưởng thành còn Astra thì chưa. Nếu một tenant và một database là đủ, một service Spring hoặc Quarkus thông thường sẽ đơn giản hơn để vận hành. - **So với Frappe/ERPNext.** Cùng ý tưởng cốt lõi (DocType là metadata, app, chứng từ có thể Submit). Khác ở lưu trữ (một bảng JSONB, không có DDL cho mỗi field), virtual thread của Java 25 thay cho worker Python, và row-level security thay cho mỗi site một database. Astra viết theo kiểu clean-room: không có code, schema hay asset nào lấy từ hai dự án đó. ## Tự chạy thử Điều kiện cần: JDK 25, Docker Desktop được cấp khoảng 8 GB RAM, `kubectl`, `k3d`, `jq` và Bash 5 (`brew install bash` trên macOS; nếu shell mặc định là 3.2, các script tự chạy lại dưới Bash 5). Rust chỉ cần khi bạn muốn sửa các module Wasm hoặc worker CDC trên máy. ```bash make k3d-up # cluster, images, deploy, wait kubectl -n astra get pods # expect 7 pods Running: astra-server x2, postgres-0, redis, keycloak, seata-server, astra-cdc make demo-seed # seed saigon-express and hanoi-express through the REST API make demo-express SCENES=1 PAUSE=0 # /q/health/ready, /api/app, /api/doctype open http://localhost:8080/desk/ # log in as mai / Astra@123 ``` Output mong đợi của cảnh (rút gọn): ```text ━━━ Scene 1 · One server, many apps ━━━ $ curl -X GET http://localhost:8080/q/health/ready … HTTP 200 "UP" $ curl -X GET http://localhost:8080/api/app … HTTP 200 [ { "id": "io.astra.express", "version": "0.1.0", "tier": "0 (shared, build-time)", "doctypes": [ "Customer", "CustomerOrder", … ] }, … ] ``` Để chạy lại từ trạng thái sạch, dùng `make demo-reset && make demo-seed`. `scripts/demo-reset.sh` nhắm vào database của k3d khi có pod PostgreSQL, ngược lại thì nhắm vào Docker Compose; script dùng superuser bootstrap cục bộ, nên tuyệt đối không trỏ nó vào dữ liệu thật. `make k3d-down` xoá cụm. ## Nói đúng - **Các con số trong video phản ánh lúc quay.** Video cho thấy hai app và tám DocType (Express và CMS). Image server hiện tại còn đóng gói thêm app HRM và STMS, nên `/api/app` giờ trả về bốn app và `/api/doctype` trả về nhiều DocType hơn. - **Con số 1 phút 29 giây** là một lần build lại trên máy đã có sẵn cụm. Lần chạy đầu tiên còn phải kéo image nền và tạo cụm, nên mất lâu hơn. - **Tier 0 chỉ dành cho app biên dịch kèm.** Tier 2 Wasm đã chạy (Video 14). Nạp JVM cô lập Tier 1, chạy song song nhiều phiên bản app, rollout canary và rollback tự động nằm trong lộ trình, được đánh dấu ○ hoặc ◐ trong README. - **Desk là giao diện demo** sinh ra từ metadata: các màn hình danh sách, form, vòng đời, sổ cái, replica và overlay. Dashboard, báo cáo và Desk đầy đủ chưa được xây. - **k3d là cụm cục bộ.** Các manifest trong `deploy/k8s/base` không phải bản triển khai production: không có database được quản lý, không có backup, không có high availability cho PostgreSQL hay Keycloak. ## Hình minh hoạ - [Hình 1 · Bảy pod và ai nói chuyện với ai](#bảy-pod-và-vì-sao-hai-trong-số-đó-là-kernel) - [Hình 2 · Năm lệnh từ laptop đến Desk](#video-cho-thấy-gì) - [Hình 3 · Một app là metadata cộng code, biên dịch kèm](#một-server-app-được-biên-dịch-kèm) ## Liên quan - Tiếp theo: [Video 2 · DocType: từ một file YAML đến API, giao diện và index](/astra-cms/vi/doctype) - Sau đó: [Video 3 · Quy tắc nghiệp vụ: hook & validation](/astra-cms/vi/hooks) - Kịch bản và cách chuẩn bị quay: [demo-video-series.md](/astra-cms/vi/video-scripts) - Vì sao thiết kế như vậy: [explain.md](/astra-cms/vi/why-astra) - Thứ đang chạy hôm nay: [Trạng thái dự án trong README](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/README.vi.md#trạng-thái-dự-án) - Mã nguồn: [`scripts/k3d-up.sh`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/scripts/k3d-up.sh), [`scripts/demo-seed.sh`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/scripts/demo-seed.sh), [`scripts/demo-express.sh`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/scripts/demo-express.sh), [`deploy/k8s/base`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/deploy/k8s/base), [`AstraApp.java`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-spi/src/main/java/io/astra/spi/app/AstraApp.java), [`AppResource.java`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-doctype/src/main/java/io/astra/doctype/api/AppResource.java) --- # DocType: từ một file YAML đến API, giao diện và index Tóm tắt một câu: Một DocType là một file YAML; từ đó kernel phục vụ một REST API có validation, Desk dựng form và danh sách, các field projected có expression index tạo online, và mọi DocType được lưu chung trong một… Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/doctype ▶ [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-2-doctype-vi.mp4) · [English](https://media.zerohostcms.com/astra-cms/astra-video-2-doctype-en.mp4) · 2:38 · Kịch bản: [demo-video-series.md](/astra-cms/vi/video-scripts) · [Bản tiếng Anh](/astra-cms/vi/doctype) > **Tóm tắt một câu:** Một DocType là một file YAML; từ đó kernel phục vụ một REST API có validation, Desk dựng form và danh sách, các field `projected` có expression index tạo online, và mọi DocType được lưu chung trong một bảng JSONB duy nhất mà không cần `ALTER TABLE`. > **Nên đọc nếu:** bạn muốn biết chính xác key YAML nào biến thành hành vi runtime nào (góc nhìn developer) / bạn cần đánh giá lựa chọn lưu trữ zero-DDL: được gì, mất gì, và tới đâu thì không còn mở rộng được (góc nhìn tech lead). ## Vấn đề Astra Express cần một bản ghi Đơn hàng (Customer Order): ai gửi, ai nhận, vùng giao, mức dịch vụ, cân nặng, tiền thu hộ, và một khoản cước không ai được phép gõ tay. Trong một stack thông thường, chỉ một bản ghi đó đã kéo theo một migration, một entity class, một DTO, một controller, các annotation validation, một component form, một trang danh sách và một bước kiểm tra quyền. Sáu bảy thành phần phải thống nhất về cùng mười hai field, và khi nghiệp vụ thêm field thứ mười ba, tất cả lại phải sửa, kèm một lệnh `ALTER TABLE` trên một bảng đang bị ghi suốt cả ngày. Frappe/ERPNext đã loại bỏ phần lớn sự trùng lặp đó: DocType là định nghĩa duy nhất, phần còn lại được sinh ra. Nhưng Frappe vẫn tạo một bảng cho mỗi DocType và chạy DDL khi thêm một Custom Field ([explain.md](/astra-cms/vi/why-astra)). Trên một database đa thuê bao bận rộn, DDL nghĩa là khoá bảng, phải canh khung giờ triển khai, và tuỳ biến của một tenant đụng tới bảng của mọi tenant khác. Astra giữ ý tưởng DocType nhưng đổi cách lưu trữ: các field nghiệp vụ nằm trong JSONB của một bảng duy nhất, và DDL duy nhất mà một field được index mới có thể gây ra là một lệnh online `CREATE INDEX CONCURRENTLY`. ## Video cho thấy gì | Cảnh | Bạn thấy gì | Điều đó chứng minh | | --- | --- | --- | | YAML | `customer-order.doctype.yaml`: module, field, Select, Link, read-only, `pii`, `submittable`, `transaction: global` | Toàn bộ định nghĩa của một bản ghi nghiệp vụ nằm gọn trong một file trong app | | Suy ra | Giá trị Select sai bị 422; Link phải trỏ tới bản ghi trong tenant; client không ghi được field read-only; field projected có index | Hành vi đến từ metadata, không phải code riêng cho từng DocType | | Trang metadata của Desk | Bảng field của Customer Order | Dựng từ định nghĩa mà API trả về, đã áp overlay của tenant | | Bộ dựng giao diện | Code `desk.js` | Mỗi kiểu field là một control; quyền quyết định nút Submit có hiện hay không | | Projection | Hai index hợp lệ cho Customer Order | Tạo lúc kernel khởi động bằng `CREATE INDEX CONCURRENTLY`, không khoá ghi, không `ALTER TABLE` | | `\d astra_doc` | Một bảng, một index GIN, mười projection index tại thời điểm quay, RLS cưỡng bức, publication `astra_cdc` | Mọi DocType dùng chung một bảng JSONB | ## Cách hoạt động ### Định nghĩa *`astra-apps/express/src/main/resources/META-INF/astra/doctypes/customer-order.doctype.yaml`* ```yaml name: CustomerOrder module: oms kind: transaction submittable: true # Draft (0) -> Submitted (1) -> Cancelled (2) -> Amend transaction: global # submit/cancel span BMS credit (Seata AT) and pickup capacity (Seata TCC) naming: { series: "ORD-.YYYYMMDD.-.#####" } fields: - { name: customer, type: Link, options: Customer, required: true, in-list: true, projected: true } - { name: receiver_name, type: Data, required: true, in-list: true, pii: true } - { name: receiver_phone, type: Data, required: true, pii: true } # … - { name: zone, type: Select, options: [Same city, Domestic, Remote], default: Domestic, in-list: true, projected: true } - { name: service, type: Select, options: [Standard, Express], default: Standard, in-list: true } - { name: weight_kg, type: Float, required: true, label: Weight (kg) } - { name: cod_amount, type: Currency, default: 0, label: COD amount } - { name: shipping_fee, type: Currency, read-only: true, in-list: true } # computed by ShippingFeeHook # … permissions: - { role: admin, read: true, create: true, write: true, submit: true, cancel: true } - { role: sales, read: true, create: true, write: true, if-owner: true, submit: true } # … ``` Bộ nạp phân tích mỗi field thành một `FieldDefinition` bất biến và từ chối mọi tên không phải định danh snake_case. Bước kiểm tra này không phải để cho đẹp: tên field về sau được ghép thẳng vào biểu thức SQL (`data->>'zone'`), nên chính quy tắc đặt tên là thứ giữ cho các biểu thức đó an toàn. *`astra-doctype/src/main/java/io/astra/doctype/registry/DocTypeLoader.java`* ```java static FieldDefinition field(JsonNode f, String where) { String fieldName = f.path("name").asText(""); if (!fieldName.matches("[a-z][a-z0-9_]{0,50}")) { throw new IllegalStateException(where + ": field name '" + fieldName + "' must be snake_case"); } return new FieldDefinition( fieldName, FieldType.valueOf(f.path("type").asText("Data").toUpperCase(Locale.ROOT)), f.path("label").asText(null), f.path("required").asBoolean(false), f.path("read-only").asBoolean(false), f.path("in-list").asBoolean(false), f.path("projected").asBoolean(false), options(f), f.hasNonNull("default") ? YAML.convertValue(f.get("default"), Object.class) : null, f.path("pii").asBoolean(false)); } ``` ### Mỗi key biến thành gì | YAML | Hành vi runtime | Nơi xử lý | | --- | --- | --- | | `name`, `fields` | `GET/POST /api/doc/CustomerOrder`, `GET/PUT /api/doc/CustomerOrder/{name}`; form và danh sách trên Desk | `DocResource`, `desk.js` | | field không khai báo | 422 `unknown field '…' for DocType CustomerOrder` | `DocumentValidator` | | `required`, `default` | 422 khi thiếu; giá trị mặc định được điền trước khi hook chạy | `DocumentValidator` | | `type: Select` + `options` | 422 `field 'zone' must be one of [Same city, Domestic, Remote]` | `DocumentValidator` | | `read-only: true` | 422 `field 'shipping_fee' is read-only` khi client gửi lên; hook thì được phép đặt | `DocumentValidator.rejectReadOnly` | | `type: Link` + `options` | 422 `field 'customer': Customer '…' not found` trừ khi bản ghi đích tồn tại trong tenant này | `DocumentStore.validateLinks` | | `projected: true` | Expression index `astra_doc_p_`; bộ lọc danh sách dùng đúng biểu thức đó | `ProjectionIndexer` | | `naming.series` | `ORD--00001`, đếm theo tenant trong `astra_series` | `SeriesService` | | `submittable` | Các endpoint `/submit`, `/cancel`, `/amend` (Video 4) | `DocumentStore` | | `transaction: global` | Submit và Cancel chạy trong một giao dịch toàn cục Seata khi có bộ điều phối (Video 17) | `DocumentStore.global` | | `pii: true` | Bị che khi dữ liệu đi ra qua MCP tới một mô hình AI (Video 15) | `PiiMasking` | | `permissions` | Quyền read/create/write/submit/cancel theo role; `if-owner` giới hạn quyền ghi cho chủ sở hữu (Video 11) | `DocPermissions` | ```mermaid flowchart LR Y["customer-order.doctype.yaml"] --> L["DocTypeLoader
(kiểm tra snake_case)"] L --> R["DocTypeRegistry"] O["overlay của tenant
(Video 13)"] --> E["DocTypeOverlays.effective()"] R --> E E --> API["/api/doc/CustomerOrder
validation, Link, read-only"] E --> META["/api/doctype
→ form, danh sách, menu Desk"] E --> MCP["công cụ MCP
che pii"] R --> IDX["ProjectionIndexer
CREATE INDEX CONCURRENTLY"] IDX --> PG[("astra_doc
một bảng JSONB")] API --> PG ``` Để ý hai mũi tên đi vào `effective()`: API, Desk và MCP không bao giờ đọc thẳng YAML của app. Chúng đọc định nghĩa hiệu lực cho tenant đang xét, tức DocType của app đã áp overlay của tenant đó. Vì vậy một field chỉ dành cho Hà Nội xuất hiện trong form và API của Hà Nội mà app Express không cần thay đổi (Video 13). ![Hình 1: Một file YAML, nhiều đầu ra](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-02-figure-1.png) *Một file YAML, nhiều đầu ra.* ### Desk là bộ dựng giao diện, không phải một ứng dụng Desk hỏi API để lấy định nghĩa và ánh xạ kiểu field sang control. Code form rẽ nhánh theo kiểu field, không bao giờ theo tên DocType: *`astra-server/src/main/resources/META-INF/resources/desk/desk.js`* ```js function control(f, value, locked, linkOptions) { // … switch (f.type) { case 'SELECT': input = h('select', { disabled: locked }, f.required ? null : h('option', { value: '' }, ''), f.options.map((o) => h('option', { selected: String(v) === o }, o))); break; // … case 'INT': case 'FLOAT': case 'CURRENCY': input = h('input', { type: 'number', step: f.type === 'INT' ? '1' : 'any', disabled: locked, value: v }); break; // … case 'LINK': { const listId = 'opts-' + f.name; input = h('input', { list: listId, disabled: locked, value: v, placeholder: f.options[0] + ' name', spellcheck: 'false' }); // … } } // … } ``` Các nút bấm đi theo quyền hiệu lực của người dùng đang đăng nhập, không theo role viết cứng: *`astra-server/src/main/resources/META-INF/resources/desk/desk.js`* ```js if (doc.docstatus === 0 && perm.submit) actions.append(h('button', { class: 'btn', type: 'button', onclick: () => lifecycle(def, doc, 'submit', linkOptions) }, 'Submit')); if (doc.docstatus === 1 && perm.cancel) actions.append(h('button', { class: 'btn danger', type: 'button', onclick: () => lifecycle(def, doc, 'cancel', linkOptions) }, 'Cancel')); ``` Vì sao dựng Desk theo cách này: đây là bằng chứng rẻ nhất rằng metadata đã đầy đủ. Nếu Desk dựng và thao tác được một DocType nó chưa từng biết, thì một MCP client hay một app di động đọc cùng `/api/doctype` cũng làm được. ### Một bảng cho mọi DocType *`astra-doctype/src/main/resources/db/migration/V1__astra_doc.sql`* ```sql CREATE TABLE astra_doc ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id text NOT NULL, doctype text NOT NULL, name text NOT NULL, docstatus smallint NOT NULL DEFAULT 0, owner text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), modified_at timestamptz NOT NULL DEFAULT now(), version integer NOT NULL DEFAULT 1, data jsonb NOT NULL DEFAULT '{}'::jsonb, CONSTRAINT astra_doc_tenant_doctype_name_key UNIQUE (tenant_id, doctype, name) ); CREATE INDEX astra_doc_list_idx ON astra_doc (tenant_id, doctype, modified_at DESC); CREATE INDEX astra_doc_data_gin ON astra_doc USING gin (data jsonb_path_ops); ALTER TABLE astra_doc ENABLE ROW LEVEL SECURITY; ALTER TABLE astra_doc FORCE ROW LEVEL SECURITY; -- applies to the table owner too ``` Các cột là những thứ chính kernel cần trên mọi bản ghi: định danh, tenant, kiểu, trạng thái vòng đời (`docstatus`), chủ sở hữu, mốc thời gian và một `version` cho optimistic lock. Mọi thứ do nghiệp vụ định nghĩa đều nằm trong `data`. Vì vậy thêm một DocType hay một field là thay đổi metadata, không bao giờ là thay đổi schema. (Một migration sau này thêm cột `amended_from` cho chuỗi Amend ở Video 4.) ![Hình 2: Bảng JSONB dùng chung](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-02-figure-2.png) *Bảng JSONB dùng chung.* ### Projection: index mà không cần `ALTER TABLE` *`astra-doctype/src/main/java/io/astra/doctype/store/ProjectionIndexer.java`* ```java static String expression(FieldDefinition field) { return "(data->>'" + field.name() + "')"; } // … void onStart(@Observes StartupEvent ev) { Map fields = projectedFields(); try (Connection conn = dataSource.getConnection(); Statement st = conn.createStatement()) { conn.setAutoCommit(true); // CREATE INDEX CONCURRENTLY cannot run inside a transaction block for (FieldDefinition f : fields.values()) { try { st.execute("CREATE INDEX CONCURRENTLY IF NOT EXISTS " + indexName(f.name()) + " ON astra_doc (tenant_id, doctype, " + expression(f) + ")"); // … } catch (SQLException e) { // Another pod may be building the same index right now; the next start retries. // … } } } // … } ``` Ba lựa chọn thiết kế gói gọn trong đoạn này: 1. **`CONCURRENTLY`** tạo index mà không chặn ghi. Lệnh này không chạy được trong một transaction, nên mới có `setAutoCommit(true)`. 2. **Index được đặt khoá theo tên field, không theo DocType.** `astra_doc_p_zone` phục vụ mọi DocType có field `zone` được projected, và các cột dẫn đầu `(tenant_id, doctype, …)` giữ cho việc tra cứu hẹp. Nhờ đó số index tỷ lệ với số tên field projected khác nhau, không phải số DocType nhân số field. 3. **Bộ lọc danh sách dùng đúng biểu thức đó.** Planner chỉ dùng được expression index khi câu truy vấn lặp lại biểu thức y hệt, nên `DocumentStore.list` dựng mệnh đề `WHERE` bằng `ProjectionIndexer.expression(field)`: *`astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`* ```java FieldDefinition field = def.field(f.getKey()) .orElseThrow(() -> new DocValidationException("unknown filter '" + f.getKey() + "' for DocType " + doctype)); sql.append(" AND ").append(ProjectionIndexer.expression(field)).append(" = ?"); ``` Endpoint projections đọc lại kết quả từ catalog của PostgreSQL chứ không từ YAML, nên thứ Desk hiển thị chính là thứ database thực sự có: ```sql SELECT i.indisvalid, pg_get_indexdef(i.indexrelid) FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid WHERE c.relname = ? ``` ![Hình 3: Projection index tạo online lúc khởi động](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-02-figure-3.png) *Projection index tạo online lúc khởi động.* ## Ví dụ thực tế Một nhà phân phối chuỗi lạnh (giả định, cơ chế là thật) vận chuyển vắc-xin và hải sản từ TP. Hồ Chí Minh và cần tách đơn hàng đông lạnh, ướp lạnh và nhiệt độ thường. Điều phối viên lọc theo nhóm này suốt cả ngày. Đầu vào: thêm một field vào DocType đơn hàng của app và rollout một image mới. ```yaml - { name: temperature_class, type: Select, options: [Frozen, Chilled, Ambient], default: Ambient, in-list: true, projected: true } ``` Astra làm gì: 1. Khi khởi động, mỗi pod kernel chạy `CREATE INDEX CONCURRENTLY IF NOT EXISTS astra_doc_p_temperature_class ON astra_doc (tenant_id, doctype, (data->>'temperature_class'))`. Việc ghi vẫn tiếp tục trong lúc tạo index. Nếu pod thứ hai đụng với pod thứ nhất, nó ghi một cảnh báo và index được thử lại ở lần khởi động kế tiếp. 2. Các đơn hàng cũ không có `temperature_class` trong `data`. Khi một bản nháp được lưu lần tới, validator điền giá trị mặc định `Ambient`. Không có migration backfill nào chạy; các dòng đã Submit hoặc chưa được đụng tới đơn giản là thiếu key này, nên bộ lọc `?temperature_class=Ambient` không trả về chúng. 3. API từ chối `{"temperature_class":"Warm"}` với mã 422 và `field 'temperature_class' must be one of [Frozen, Chilled, Ambient]`. 4. Desk hiện một drop-down trên form và một cột trong danh sách, vì `in-list` là true. 5. `GET /api/doc/CustomerOrder?temperature_class=Frozen` lọc bằng biểu thức có index. Kết quả: một field mới, có validation, có index, không một lệnh `ALTER TABLE` nào và không sửa code Desk. Nếu chỉ một tenant cần field này, một overlay của tenant sẽ thêm nó lúc chạy mà không cần image mới (Video 13). ## Dành cho tech lead - **Zero-DDL đem lại gì.** Thêm DocType và field không bao giờ khoá bảng, và tuỳ biến của các tenant không thể va nhau trong schema. Rollout không còn cần khung giờ migration cho những thay đổi về form. - **Cái giá phải trả.** Mọi DocType dùng chung một bảng và một bộ index. An toàn kiểu dữ liệu chuyển từ database sang validator của kernel; một bên ghi có lỗi đi vòng qua kernel có thể lưu bất cứ thứ gì vào `data`. Kernel là bên ghi duy nhất theo thiết kế, và row-level security vẫn áp lên mọi câu lệnh. - **Ngữ nghĩa index là so sánh bằng trên chuỗi.** Biểu thức là `data->>'field'`, nên index phục vụ bộ lọc so sánh bằng trên text. Truy vấn theo khoảng trên số hay ngày, sắp xếp theo một field nghiệp vụ, hay tổng hợp đều chưa được các index này phục vụ. - **Field nóng.** PostgreSQL ghi lại toàn bộ dòng khi `UPDATE`. Một DocType có vài field thay đổi nhiều lần mỗi ngày (tồn kho, số dư, bộ đếm) sẽ làm phình dòng JSONB. Câu trả lời trong thiết kế là các cột projection vật lý hoá và sổ cái ([explain.md](/astra-cms/vi/why-astra)); projection vật lý hoá chưa được xây. Hãy đo tần suất cập nhật theo từng DocType trước khi đưa dữ liệu thay đổi liên tục vào `astra_doc`. - **Vòng đời index.** Indexer chỉ tạo. Bỏ `projected: true` không xoá index; hiện tại việc đó là một lệnh `DROP INDEX CONCURRENTLY` chạy tay. - **Đo gì.** Số index trên `astra_doc` và mức khuếch đại ghi của nó; `indisvalid` của mọi projection sau mỗi lần rollout (`GET /api/doctype/{name}/projections`); độ trễ p95 của danh sách theo từng bộ lọc. - **Khi nào không nên dùng.** Truy vấn phân tích nặng, báo cáo số liệu dạng rộng, hoặc dữ liệu cần ràng buộc database giữa các field nghiệp vụ. Những thứ đó thuộc về sổ cái, một schema riêng của module (như BMS làm cho tín dụng, Video 17) hoặc một kho phân tích. - **So với Frappe.** Frappe: mỗi DocType một bảng, cột thật, DDL cho mỗi Custom Field, kiểu dữ liệu chặt theo từng cột. Astra: một bảng JSONB, expression index tạo online, kiểu dữ liệu được kernel cưỡng chế. ## Tự chạy thử Điều kiện cần: stack từ [Video 1](/astra-cms/vi/quickstart) đang chạy (`make k3d-up`, `make demo-seed`). ```bash make demo-express SCENES=2 PAUSE=0 ``` Cảnh này in ra YAML, rồi trạng thái projection, rồi `\d astra_doc` từ pod PostgreSQL của k3d. Output projection mong đợi: ```text $ curl -X GET http://localhost:8080/api/doctype/CustomerOrder/projections … HTTP 200 [ { "field": "customer", "valid": true, "definition": "CREATE INDEX astra_doc_p_customer ON public.astra_doc USING btree (tenant_id, doctype, ((data ->> 'customer'::text)))" }, { "field": "zone", "valid": true, "definition": "CREATE INDEX astra_doc_p_zone ON public.astra_doc USING btree (tenant_id, doctype, ((data ->> 'zone'::text)))" } ] ``` `\d astra_doc` kết thúc bằng `Policies (forced row security enabled)` và `Publications: "astra_cdc"`. Trong Desk (`http://localhost:8080/desk/`, user `mai`): mở **DocType metadata**, chọn `CustomerOrder`, rồi mở rộng mục "Raw JSON from GET /api/doctype/CustomerOrder" để xem đúng định nghĩa mà trang đã dựng. ## Nói đúng - **`CONCURRENTLY` không hiện trong catalog.** `pg_index` chỉ lưu định nghĩa index, nên cột định nghĩa không bao giờ có chữ `CONCURRENTLY`; lời thuyết minh nói rõ điều này trên màn hình. - **Số index tuỳ từng bản cài.** Video cho thấy mười projection index. Image hiện tại còn đóng gói HRM và STMS, mà các field projected của chúng tạo thêm các index `astra_doc_p_*`. - **Projection index được tạo lúc kernel khởi động.** Một field do app thêm vào cần image mới; không có bộ tạo projection nào được kích hoạt bởi thay đổi metadata lúc chạy. - **`pii` chỉ che trên đường MCP.** Desk và REST vẫn hiện PII cho người dùng có role đọc được DocType. - **Chưa được xây:** projection vật lý hoá, expression index có kiểu, hạn mức projection và xoá index không dùng. Những thứ này xuất hiện trong README dưới dạng kiến trúc mục tiêu. Bảng con được khai báo trong enum `FieldType` của SPI (`TABLE`), nhưng cả validator của kernel lẫn Desk đều chưa xử lý. - **Field nào đã khai báo cũng lọc được**, projected hay không. Chỉ field projected mới có index đằng sau bộ lọc; lọc theo field khác sẽ quét các dòng của DocType đó trong tenant. - **Desk có một chỗ nhắc tới DocType cụ thể.** Code form, danh sách và menu không bao giờ gọi tên một DocType, nhưng `desk.js` ánh xạ `CustomerOrder` và `Waybill` sang các bảng replica CDC của chúng cho thẻ replica (Video 16). ## Hình minh hoạ - [Hình 1 · Một file YAML, nhiều đầu ra](#mỗi-key-biến-thành-gì) - [Hình 2 · Bảng JSONB dùng chung](#một-bảng-cho-mọi-doctype) - [Hình 3 · Projection index tạo online lúc khởi động](#projection-index-mà-không-cần-alter-table) ## Liên quan - Trước: [Video 1 · Astra là gì & chạy trong 5 phút](/astra-cms/vi/quickstart) - Tiếp theo: [Video 3 · Quy tắc nghiệp vụ: hook & validation](/astra-cms/vi/hooks) - Kịch bản: [demo-video-series.md](/astra-cms/vi/video-scripts); lý do thiết kế lưu trữ: [explain.md](/astra-cms/vi/why-astra); trích đoạn code cho developer: [demo-code-snippets.md](/astra-cms/vi/code-snippets) (S1, S12) - Mã nguồn: [`customer-order.doctype.yaml`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/express/src/main/resources/META-INF/astra/doctypes/customer-order.doctype.yaml), [`DocTypeLoader.java`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-doctype/src/main/java/io/astra/doctype/registry/DocTypeLoader.java), [`DocumentValidator.java`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-doctype/src/main/java/io/astra/doctype/store/DocumentValidator.java), [`ProjectionIndexer.java`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-doctype/src/main/java/io/astra/doctype/store/ProjectionIndexer.java), [`V1__astra_doc.sql`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-doctype/src/main/resources/db/migration/V1__astra_doc.sql), [`desk.js`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-server/src/main/resources/META-INF/resources/desk/desk.js) --- # Quy tắc nghiệp vụ: hook & validation Tóm tắt một câu: Một quy tắc nghiệp vụ là một CDI bean Java bình thường mà kernel gọi trước mỗi lần ghi DocType của nó, bên trong chính transaction của lần ghi đó, nên khoản cước 73.000 ₫ và các lỗi validation giống hệt… Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/hooks ▶ [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-3-hooks-vi.mp4) · [English](https://media.zerohostcms.com/astra-cms/astra-video-3-hooks-en.mp4) · 2:53 · Kịch bản: [demo-video-series.md](/astra-cms/vi/video-scripts) · [Bản tiếng Anh](/astra-cms/vi/hooks) > **Tóm tắt một câu:** Một quy tắc nghiệp vụ là một CDI bean Java bình thường mà kernel gọi trước mỗi lần ghi DocType của nó, bên trong chính transaction của lần ghi đó, nên khoản cước 73.000 ₫ và các lỗi validation giống hệt nhau dù đơn hàng đến từ Desk, REST hay một agent AI qua MCP. > **Nên đọc nếu:** bạn cần viết một hook và muốn biết chính xác nó chạy khi nào, được làm gì (góc nhìn developer) / bạn muốn biết quy tắc nghiệp vụ nằm ở đâu, được cưỡng chế trên các kênh ra sao, và cái giá là gì (góc nhìn tech lead). ## Vấn đề Astra Express tính phí giao hàng theo vùng, cân nặng và dịch vụ, cộng thêm phí khi thu hộ (COD), và từ chối bưu kiện trên 30 kg hoặc số điện thoại không đúng định dạng di động Việt Nam. Quy tắc thì đơn giản; giữ nó ở một chỗ duy nhất thì không. Một hệ thống điển hình rốt cuộc có công thức tính cước trong form web (để xem trước), lặp lại trong controller API, lặp lại trong app di động, và đôi khi trong một job chạy ban đêm. Chúng lệch nhau dần. Lần đầu tiên có người nhận ra là khi khách được báo một giá trên website nhưng bị tính một giá khác. Validation còn một vấn đề thứ hai, âm thầm hơn: lỗi đến từng cái một. Nhân viên sửa số điện thoại, lưu, rồi mới biết cân nặng cũng sai. Một controller Frappe điển hình cũng hành xử như vậy, vì `frappe.throw` dừng ở lỗi đầu tiên trừ khi developer tự tay gom các thông báo lại. Câu trả lời của Astra là hai lớp mà mọi kênh đều phải đi qua: - **DocType** cưỡng chế các quy tắc cấu trúc nó khai báo: field bắt buộc, lựa chọn Select, field lạ, field read-only, và Link phải tồn tại trong tenant; - **hook** cưỡng chế các quy tắc nghiệp vụ mà YAML không diễn đạt được, và có thể tính các field như cước phí. Cả hai chạy trong kernel, bên dưới Desk, REST và MCP, nên không client nào bỏ qua được. ![Hình 1: Ba kênh, một quy tắc](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-03-figure-1.png) *Ba kênh, một quy tắc.* ## Video cho thấy gì | Cảnh | Bạn thấy gì | Điều đó chứng minh | | --- | --- | --- | | Tạo | `tuan` (sales) tạo đơn cho `C001`: Domestic, Express, 2,2 kg, COD 500.000 ₫ | Mã đơn lấy từ series theo ngày; `ShippingFeeHook` đặt cước trong cùng transaction với lần ghi | | Cước | 30.000 + 3 × 5.000 = 45.000; × 1,5 = 67.500; + 5.000 COD = 72.500; làm tròn lên 73.000 ₫ | Quy tắc là phép tính nhìn thấy được, không phải hộp đen; "Priced by: Standard tariff" | | Lỗi | Số điện thoại `123`, cân nặng `45` → Save | Cả hai lỗi trong một phản hồi 422 duy nhất, trước khi bất cứ thứ gì được ghi | | Link | Giá trị hợp lệ, khách hàng `C999` | Không phải hook: kernel kiểm tra bản ghi đích của Link có tồn tại; bản nháp không đổi | | Các kênh | Cùng dữ liệu đó qua REST | Cùng hai thông báo, theo cùng thứ tự | | Code | `ExpressApp` và `ShippingFeeHook` | Hook là một CDI bean; không cần đăng ký, không cần cấu hình | | Quy tắc của API | Client gửi `shipping_fee`, field lạ `discount`, giá trị Select `Mars` | Tất cả bị từ chối chỉ nhờ YAML, dữ liệu không đổi | ## Cách hoạt động ### Hook *`astra-spi/src/main/java/io/astra/spi/hook/DocLifecycleHook.java`* ```java public interface DocLifecycleHook { /** The DocType this hook applies to, e.g. {@code "Article"}. */ String doctype(); /** Runs before a document is inserted or updated, after kernel validation of declared fields. */ default void beforeSave(Document doc) { } /** Runs after the document has been persisted in the same transaction. */ default void afterSave(Document doc) { } // … onSubmit and onCancel (Video 4) } ``` Quy tắc của app Express hiện thực interface này. Phần quan trọng là cấu trúc: gom mọi lỗi, ném một lần, rồi mới tính. *`astra-apps/express/src/main/java/io/astra/apps/express/ShippingFeeHook.java`* ```java @ApplicationScoped public class ShippingFeeHook implements DocLifecycleHook { static final double MAX_WEIGHT_KG = 30; static final long MAX_COD = 50_000_000; /** Zone → {price for the first kg, price per additional 0.5 kg}, in VND. */ private static final Map TARIFF = Map.of( "Same city", new long[] {18_000, 2_500}, "Domestic", new long[] {30_000, 5_000}, "Remote", new long[] {45_000, 7_000}); // … @Override public String doctype() { return "CustomerOrder"; } @Override public void beforeSave(Document doc) { List errors = new ArrayList<>(); String phone = doc.getString("receiver_phone"); if (phone != null && !phone.matches("0\\d{9}")) errors.add("receiver_phone must be 10 digits starting with 0"); double weight = Numbers.toDouble(doc.get("weight_kg")); if (weight <= 0 || weight > MAX_WEIGHT_KG) errors.add("weight_kg must be between 0 and " + (int) MAX_WEIGHT_KG); long cod = Math.round(Numbers.toDouble(doc.get("cod_amount"))); if (cod < 0 || cod > MAX_COD) errors.add("cod_amount must be between 0 and " + MAX_COD); if (!errors.isEmpty()) throw new DocValidationException(errors); String zone = doc.getString("zone"); // … a tenant's active Wasm PricingRule takes precedence (Video 14) doc.set("shipping_fee", fee(zone, doc.getString("service"), weight, cod)); doc.set("priced_by", "Standard tariff"); } // … static long fee(String zone, String service, double weightKg, long cod) { long[] t = TARIFF.getOrDefault(zone, TARIFF.get("Domestic")); long extraSteps = (long) Math.ceil(Math.max(0, weightKg - 1) / 0.5); double fee = t[0] + extraSteps * t[1]; if ("Express".equals(service)) fee *= 1.5; if (cod > 0) fee += Math.max(5_000, cod * 0.01); return (long) Math.ceil(fee / 1_000) * 1_000; } } ``` Cước của đơn demo, từng bước theo `fee(...)`: | Bước | Giá trị | | --- | --- | | Domestic, kg đầu tiên | 30.000 | | 2,2 kg − 1 = 1,2 kg → `ceil(1.2 / 0.5)` = 3 bước × 5.000 | + 15.000 = 45.000 | | Express × 1,5 | 67.500 | | COD: `max(5,000; 1% × 500,000)` | + 5.000 = 72.500 | | Làm tròn lên hàng nghìn | **73.000 ₫** | ![Hình 2: Cước phí, từng bước](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-03-figure-2.png) *Cước phí, từng bước.* ### Tự phát hiện: không cần đăng ký Lớp app chỉ liệt kê DocType. Hook được tìm thấy vì chúng là CDI bean hiện thực SPI. Lúc khởi động, kernel nhóm chúng theo `doctype()` một lần, nên việc điều phối trên đường ghi chỉ là duyệt một danh sách: *`astra-kernel/src/main/java/io/astra/kernel/extension/HookRegistry.java`* ```java @Inject public HookRegistry(Instance hooks) { this.chains = hooks.stream().collect(Collectors.groupingBy(DocLifecycleHook::doctype)); // … logs "DocType %s: %d lifecycle hook(s) [%s]" } public void beforeSave(Document doc) { for (DocLifecycleHook hook : chains.getOrDefault(doc.doctype(), List.of())) { hook.beforeSave(doc); } } ``` Vì sao dùng CDI mà không dùng file khai báo hook hay annotation quét theo tên: hook là Java bình thường, inject được (`ShippingFeeHook` inject `DocumentService` và `ScriptRuntime`), và test được như mọi bean khác. Tuy vậy, phần gắn kết vẫn là một chuỗi (`doctype()` trả về `"CustomerOrder"`) và registry không đối chiếu nó với các DocType đã nạp, nên gõ sai tên nghĩa là hook không bao giờ chạy. Dòng log lúc khởi động cho mỗi chuỗi hook là chỗ để bắt lỗi đó. ### Đường ghi, theo thứ tự Mọi thao tác tạo và cập nhật đều đi qua `DocumentStore`, bất kể kênh nào. `DocResource` (REST, cũng là thứ Desk gọi) và `McpServer` (agent AI) đều gọi `store.insert` / `store.update`. *`astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`* ```java public Document insert(String doctype, Map data, String owner) { DocTypeDefinition def = overlays.effective(doctype); DocumentValidator.rejectReadOnly(def, data); return inTenantTx(conn -> persistNew(conn, def, new Document(doctype, data).owner(owner))); } // … private Document persistNew(Connection conn, DocTypeDefinition def, Document doc) throws SQLException { DocumentValidator.applyDefaultsAndValidate(def, doc); hooks.beforeSave(doc); validateLinks(conn, def, doc); if (doc.name() == null) doc.name(assignName(conn, def, doc)); try (PreparedStatement ps = conn.prepareStatement( "INSERT INTO astra_doc (id, tenant_id, doctype, name, owner, data, amended_from) " + "VALUES (?, ?, ?, ?, ?, ?::jsonb, ?) RETURNING *")) { // … hooks.afterSave(saved); // … } } ``` ```mermaid sequenceDiagram autonumber actor U as tuan (Desk / curl / MCP) participant API as DocResource / McpServer participant S as DocumentStore participant H as ShippingFeeHook participant PG as PostgreSQL (RLS) U->>API: POST /api/doc/CustomerOrder API->>API: DocPermissions: sales được tạo API->>S: insert(CustomerOrder, data) S->>S: rejectReadOnly (có gửi shipping_fee? → 422) S->>PG: BEGIN; set_config('astra.tenant', …) S->>S: applyDefaultsAndValidate (field lạ, bắt buộc, Select) S->>H: beforeSave(doc) H-->>S: có lỗi → 422, hoặc shipping_fee = 73000 S->>PG: validateLinks: Customer C001 có tồn tại? (RLS: chỉ tenant này) S->>PG: astra_series +1 → ORD--0000n S->>PG: INSERT astra_doc … RETURNING * S->>PG: COMMIT API-->>U: 201 + chứng từ ``` Những lựa chọn thiết kế đáng chú ý: - **Hook chạy sau validation của chính kernel.** Giá trị mặc định đã được điền và giá trị Select đã được kiểm tra, nên hook không bao giờ thấy `zone: "Mars"`. Code hook chỉ lo nghiệp vụ, không lo hình dạng dữ liệu. - **Lỗi được báo theo từng tầng, và trong một tầng thì báo tất cả cùng lúc.** `DocValidationException` mang theo một danh sách, và exception mapper trả nguyên danh sách đó: *`astra-doctype/src/main/java/io/astra/doctype/api/ApiExceptionMappers.java`* ```java @ServerExceptionMapper public Response validation(DocValidationException e) { return Response.status(422).entity(Map.of("error", "validation", "errors", e.errors())).build(); } ``` - **Kiểm tra Link chạy bên trong transaction của tenant.** Câu truy vấn là `SELECT 1 FROM astra_doc WHERE doctype = ? AND name = ?`, không có điều kiện tenant trong SQL; row-level security tự thêm vào. Một khách hàng chỉ tồn tại trong `hanoi-express` sẽ là "not found" trong `saigon-express` (Video 7). - **Số chứng từ được lấy sau cùng.** Bộ đếm series chỉ tăng sau khi validation, hook và Link đều qua, và trong cùng transaction với lệnh insert. Một đơn bị từ chối không bao giờ tiêu tốn một số, nên series theo ngày không có lỗ hổng do lỗi validation. - **Read-only nghĩa là "thuộc quyền hook".** Client không được gửi `shipping_fee`, nhưng hook thì đặt nó. Vì vậy cước luôn đến từ quy tắc, không bao giờ từ một client tự tính theo cách khác. - **Đọc bên trong hook dùng cùng transaction.** `ShippingFeeHook` tra phân khúc của khách và quy tắc định giá đang hiệu lực của tenant qua `DocumentService`. `TenantTransactions` mang connection đang mở trong một `ScopedValue`, nên các lần đọc đó tham gia vào transaction của lần ghi và thấy cùng tenant. ![Hình 3: Đường ghi và nơi mỗi lần từ chối xảy ra](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-03-figure-3.png) *Đường ghi và nơi mỗi lần từ chối xảy ra.* ## Ví dụ thực tế Một công ty giao hàng chặng cuối ở Việt Nam (giả định, các con số lấy từ bảng cước chuẩn thật trong `ShippingFeeHook`, tenant không có `PricingRule` nào đang hiệu lực) nhận năm đơn từ một tích hợp sàn thương mại điện tử trong đợt đồng bộ buổi sáng: | Đơn | Đầu vào | Astra làm gì | Kết quả | | --- | --- | --- | --- | | A | Same city, Standard, 0,8 kg, không COD | 18.000; `ceil(max(0, −0.2) / 0.5)` = 0 bước thêm; không có phí COD | 201, cước **18.000 ₫** | | B | Domestic, Standard, 3 kg, COD 1.200.000 | 30.000 + 4 × 5.000 = 50.000; COD `max(5,000; 12,000)` = 12.000 | 201, cước **62.000 ₫** | | C | Remote, Express, 5 kg, COD 2.000.000 | 45.000 + 8 × 7.000 = 101.000; × 1,5 = 151.500; + 20.000 COD = 171.500; làm tròn lên | 201, cước **172.000 ₫** | | D | Domestic, số điện thoại `84901234567`, 31 kg | Hook gom hai lỗi, ném một lần | 422, `["receiver_phone must be 10 digits starting with 0", "weight_kg must be between 0 and 30"]`, không ghi gì, không tiêu tốn số | | E | Bưu kiện hợp lệ, tích hợp gửi kèm `"shipping_fee": 0` | Bị từ chối trước khi transaction mở | 422, `["field 'shipping_fee' is read-only"]` | Đơn E là đơn mà bộ phận tài chính quan tâm: một đối tác tích hợp không thể tự đặt giá của mình, dù client được viết thế nào. Nếu sau này đội kinh doanh của công ty cho một phân khúc B2B một công thức khuyến mãi, nó được triển khai dưới dạng một quy tắc Wasm của tenant và chính hook này sẽ áp dụng nó, với "Priced by" ghi lại quy tắc nào đã được dùng (Video 14). ## Dành cho tech lead - **Quy tắc nằm ở đâu.** Quy tắc cấu trúc trong YAML của DocType; quy tắc nghiệp vụ trong hook Java của app; công thức riêng của tenant trong Wasm (Video 14) hoặc overlay (Video 13). Cách phân chia này là quyết định thiết kế chính cần xem xét cùng đội của bạn. - **Điểm cưỡng chế.** Hook chạy trong `DocumentStore`, bên dưới mọi kênh. Thứ gì ghi qua kernel đều chịu quy tắc. Thứ gì ghi thẳng vào PostgreSQL thì không; hãy khoá chặt role database (Video 8). - **Chi phí trên đường ghi.** Mỗi hook chạy đồng bộ bên trong transaction database. Một hook chậm giữ một connection và các khoá dòng mà transaction đã lấy. `ShippingFeeHook` thêm các lần đọc (phân khúc khách, quy tắc định giá đang hiệu lực) vào mỗi lần lưu đơn hàng. Hãy đo độ trễ lưu p95 theo từng DocType và số hook trong mỗi chuỗi (được log lúc khởi động). - **Thứ tự.** SPI không có thuộc tính độ ưu tiên; các hook của một DocType chạy theo thứ tự CDI cung cấp. Nếu hai hook trên cùng một DocType phụ thuộc vào output của nhau, hãy gộp chúng lại hoặc làm rõ sự phụ thuộc trong một hook. - **Hợp đồng lỗi.** 422 với `{"error":"validation","errors":[…]}` là ổn định và giống nhau trên mọi kênh, giúp việc xử lý lỗi phía client và việc agent AI thử lại trở nên đơn giản. Lỗi đến theo từng tầng: lỗi cấu trúc của kernel trước, rồi lỗi của hook, rồi lỗi Link. Một client sửa xong một tầng có thể gặp tầng kế tiếp. - **Khi nào không nên dùng hook.** Việc chạy lâu, lời gọi tới service bên ngoài có thể treo, hoặc bất cứ thứ gì phải tồn tại sau rollback (gửi SMS). Những việc đó thuộc về giai đoạn sau commit, trong một job hoặc event, mà phần này chưa có trong lát cắt mỏng. - **So với Frappe.** Cùng ý tưởng với các method controller `validate` / `before_save` và `doc_events`. Khác biệt: một interface Java có kiểu thay cho các method Python được gọi theo tên, phát hiện qua CDI, và một danh sách lỗi trả về trong một phản hồi. ## Tự chạy thử Điều kiện cần: stack từ [Video 1](/astra-cms/vi/quickstart) đang chạy và đã nạp dữ liệu. ```bash make demo-express SCENES=3 PAUSE=0 ``` Cảnh này tạo một đơn nháp thật trong `saigon-express`, nên hãy chạy `make demo-reset && make demo-seed` sau đó nếu bạn muốn số đơn khớp với video. Những gì cảnh làm, theo thứ tự, cùng kết quả mong đợi: | Lời gọi | Mong đợi | | --- | --- | | `POST /api/doc/CustomerOrder` (C001, Domestic, Express, 2,2 kg, COD 500.000) | `HTTP 201`, `name` `ORD--0000n`, `shipping_fee` `73000` | | Như trên với số điện thoại `123`, cân nặng `45` | `HTTP 422`, cả hai lỗi của hook | | Khách hàng `C999` | `HTTP 422`, `field 'customer': Customer 'C999' not found` | | Client gửi `shipping_fee` | `HTTP 422`, `field 'shipping_fee' is read-only` | | `PUT` bản nháp với `zone: Mars`, `discount: 10` | `HTTP 422`, `unknown field 'discount' for DocType CustomerOrder` và `field 'zone' must be one of [Same city, Domestic, Remote]` | Trong Desk, đăng nhập bằng `tuan` / `Astra@123`, mở danh sách Customer Order, bấm **New**, và dùng nút **API inspector** để xem đúng các request và nội dung 422 đó. Bài integration test [`ExpressFlowTest`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-server/src/test/java/io/astra/server/ExpressFlowTest.java) kiểm tra cước 73.000, tên chứng từ theo ngày, và các lần từ chối do read-only, số điện thoại và Link trên một PostgreSQL thật; `make test` chạy toàn bộ bộ test. ## Nói đúng - **"Một vòng sửa" chỉ đúng trong một tầng.** Hook trả về mọi lỗi của nó cùng lúc, và validator trả về mọi lỗi cấu trúc cùng lúc, nhưng một request trượt ở tầng cấu trúc sẽ không bao giờ tới được hook. Trong video, cả hai lỗi đều đến từ hook, nên một vòng là đủ. - **Kiểm tra Link là của kernel, không phải của hook.** Video có nói điều này; hook không nhìn vào `customer` ngoại trừ để tìm phân khúc phục vụ việc tính giá. - **Phát hiện hook diễn ra lúc build.** Hook là CDI bean trong các app Tier 0 được biên dịch vào image. Sửa một hook nghĩa là một image mới. Hook Java nạp lúc chạy (Tier 1) chưa được xây; logic riêng của tenant lúc chạy đi qua Wasm (Video 14). - **Không có thứ tự hook hay hook sau commit.** `afterSave` chạy bên trong cùng transaction, trước khi commit. - **Làm tròn cước dùng số thực dấu phẩy động** (`double`) trước khi làm tròn lên hàng nghìn. Với các giá trị của bảng cước thì đủ chính xác; một quy tắc tính tiền trên production nên dùng `BigDecimal` hoặc đơn vị tiền nhỏ nhất dạng số nguyên. ## Hình minh hoạ - [Hình 1 · Ba kênh, một quy tắc](#vấn-đề) - [Hình 2 · Cước phí, từng bước](#hook) - [Hình 3 · Đường ghi và nơi mỗi lần từ chối xảy ra](#đường-ghi-theo-thứ-tự) ## Liên quan - Trước: [Video 2 · DocType: từ một file YAML đến API, giao diện và index](/astra-cms/vi/doctype) - Tiếp theo: [Video 4 · Vòng đời chứng từ: Submit, Cancel, Amend](/astra-cms/vi/lifecycle) - Bài đầu tiên: [Video 1 · Astra là gì & chạy trong 5 phút](/astra-cms/vi/quickstart) - Kịch bản: [demo-video-series.md](/astra-cms/vi/video-scripts); trích đoạn code cho developer: [demo-code-snippets.md](/astra-cms/vi/code-snippets) (S2) - Mã nguồn: [`ShippingFeeHook.java`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/express/src/main/java/io/astra/apps/express/ShippingFeeHook.java), [`DocLifecycleHook.java`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-spi/src/main/java/io/astra/spi/hook/DocLifecycleHook.java), [`HookRegistry.java`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-kernel/src/main/java/io/astra/kernel/extension/HookRegistry.java), [`DocumentStore.java`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java), [`DocumentValidator.java`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-doctype/src/main/java/io/astra/doctype/store/DocumentValidator.java), [`TenantTransactions.java`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-doctype/src/main/java/io/astra/doctype/store/TenantTransactions.java), [`ExpressFlowTest.java`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-server/src/test/java/io/astra/server/ExpressFlowTest.java) --- # Vòng đời chứng từ: Submit, Cancel, Amend Tóm tắt một câu: trong Astra, chứng từ đã Submit là bản chốt; muốn sửa thì Cancel (ghi bút toán đảo, không bao giờ sửa dòng cũ) rồi Amend (tạo đúng một bản nháp mới có liên kết), và mỗi bước chạy trong một transaction… Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/lifecycle ▶ [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-4-lifecycle-vi.mp4) · [English](https://media.zerohostcms.com/astra-cms/astra-video-4-lifecycle-en.mp4) · 3:10 · Kịch bản: [demo-video-series.md](/astra-cms/vi/video-scripts) · [Bản tiếng Anh](/astra-cms/vi/lifecycle) > **Tóm tắt một câu:** trong Astra, chứng từ đã Submit là bản chốt; muốn sửa thì Cancel (ghi bút toán đảo, không bao giờ sửa dòng cũ) rồi Amend (tạo đúng một bản nháp mới có liên kết), và mỗi bước chạy trong một transaction cơ sở dữ liệu duy nhất, cùng với mọi chứng từ và bút toán mà nó sinh ra. > > **Nên đọc nếu:** bạn viết hook và cần biết chính xác những gì chạy bên trong transaction submit/cancel (góc nhìn developer) / bạn đang quyết định một nền tảng kiểu ERP nên bảo đảm audit trail và tính nhất quán giữa đơn hàng, vận đơn và kế toán như thế nào (góc nhìn tech lead). ## Vấn đề Astra Express nhận đơn của khách, chuyển cho bộ phận vận hành dưới dạng vận đơn, rồi ghi cước vận chuyển vào công nợ phải thu của khách. Ba bản ghi do ba đội (OMS, OPS, BMS) quản lý cùng mô tả một sự kiện nghiệp vụ. Ở phần lớn các hệ thống tự viết, ba bản ghi này dần lệch nhau: - Đơn hàng đã "xác nhận" nhưng lệnh insert vận đơn thất bại, nên bưu kiện không bao giờ được lấy. - Ai đó sửa lỗi chính tả trên đơn đã xác nhận bằng một câu `UPDATE`, và hoá đơn đã gửi khách không còn khớp với cơ sở dữ liệu. - Kế toán "xoá" một dòng bút toán sai, và kiểm toán viên không thể dựng lại sổ sách tháng trước trông ra sao. Frappe/ERPNext đã giải quyết phần nghiệp vụ của bài toán này từ nhiều năm trước bằng `docstatus` (0 Draft, 1 Submitted, 2 Cancelled) và nút Amend, và ERPNext ghi bút toán GL đảo khi cancel. Astra giữ nguyên mô hình đó, vì kế toán và nhân viên vận hành vốn đã quen, và bổ sung hai điều mà kernel của một nền tảng có thể ép buộc: bước vòng đời và mọi thứ các hook của nó ghi ra dùng chung một transaction, và bảng sổ cái từ chối `UPDATE` và `DELETE` ngay ở tầng cơ sở dữ liệu. ![Hình 1: Ba trạng thái, một chiều](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-04-figure-1.png) *Ba trạng thái, một chiều.* ## Video cho thấy gì Video theo một đơn hàng mới của khách C001 trong tenant `saigon-express`, đăng nhập bằng `mai` (admin). | Cảnh | Bạn thấy gì | Điều được chứng minh | | --- | --- | --- | | Trạng thái | Draft → Submitted → Cancelled → Amend, một chiều | Chỉ có ba giá trị `docstatus`; Amend tạo chứng từ mới chứ không lùi trạng thái. | | Tạo | Bản nháp `ORD-20261021-00011`, cước 58,000 ₫ do hook tính giá đặt khi lưu, vận đơn trống | Tác dụng phụ chờ đến lúc Submit; bản nháp vẫn sửa thoải mái. | | Submit | Trạng thái thành Submitted, vận đơn `WB-2610-000008` xuất hiện, thẻ "Ledger entries" hiện Nợ 131 / Có 511, 58,000 | Vận đơn và bút toán được ghi trong cùng transaction với việc đổi trạng thái. | | Một transaction | Thẻ code từ `DocumentStore.submit` | Các hook tham gia kết nối đang mở; lỗi ở bất kỳ đâu sẽ rollback toàn bộ. | | Khoá | Các trường trên form bị khoá; một lệnh `PUT` qua API trả về 422 `submitted documents are immutable; cancel and amend instead` | Tính bất biến được kernel ép buộc, không chỉ là ẩn đi trên giao diện. | | Cancel | Vận đơn thành Cancelled; sổ cái có thêm hai dòng đảo (Nợ 511 / Có 131); hai dòng gốc giữ nguyên | Cancel không bao giờ sửa hay xoá; nó nối thêm một bút toán đối xứng. | | Amend | `ORD-20261021-00011-1`, Draft, "amends ORD-20261021-00011", vận đơn trống | Một phiên bản mới, liên kết với bản cũ; bản đã hủy vẫn nằm trong lịch sử. | | Amend lại | Amend thêm lần nữa trên bản gốc đã hủy → 422 `ORD-20261021-00011 was already amended as ORD-20261021-00011-1` | Mỗi chứng từ đã hủy chỉ có đúng một bản amend; thay đổi tiếp theo làm trên `-1`. | | API | `make demo-express SCENES=4`: tạo (201), submit (200), sửa bị từ chối (422), cancel (200), vận đơn `Cancelled`, amend (201) | Desk dùng chính các endpoint REST này; không có đường riêng cho giao diện. | Số chứng từ trong các cảnh Desk (`ORD-…-00011`, `WB-2610-000008`) khác với số trong log API (`ORD-20261021-00008`, `WB-2610-000006`) vì chúng được ghi ở hai lần chạy khác nhau. ## Cách hoạt động ### Ba bước chuyển nằm trong kernel Toàn bộ quy tắc vòng đời nằm trong một class, `DocumentStore`, nên mọi app đều có cùng hành vi. Một DocType chọn tham gia bằng `submittable: true`. *`astra-apps/express/src/main/resources/META-INF/astra/doctypes/customer-order.doctype.yaml`* ```yaml name: CustomerOrder module: oms kind: transaction submittable: true # Draft (0) -> Submitted (1) -> Cancelled (2) -> Amend transaction: global # submit/cancel span BMS credit (Seata AT) and pickup capacity (Seata TCC) naming: { series: "ORD-.YYYYMMDD.-.#####" } # … - { name: waybill, type: Link, options: Waybill, read-only: true, in-list: true } # created on submit ``` Submit và cancel đều ngắn và đối xứng nhau. Chú ý thứ tự thao tác: các hook chạy *trước* khi kernel ghi `docstatus` mới, trên cùng kết nối, và lệnh `write` cuối cùng có kiểm tra version kiểu optimistic. *`astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`* ```java public Optional submit(String doctype, String name) { DocTypeDefinition def = requireSubmittable(doctype); return global(def, "submit " + doctype + " " + name, () -> inTenantTx(conn -> { Optional existing = get(doctype, name); if (existing.isEmpty()) return Optional.empty(); Document doc = existing.get(); requireDraft(doc); requireCaller(doc, Action.SUBMIT); hooks.onSubmit(doc); DocumentValidator.applyDefaultsAndValidate(def, doc); validateLinks(conn, def, doc); return Optional.of(write(conn, doc, 1)); })); } public Optional cancel(String doctype, String name) { DocTypeDefinition def = requireSubmittable(doctype); return global(def, "cancel " + doctype + " " + name, () -> inTenantTx(conn -> { // … if (doc.docstatus() != 1) throw new DocValidationException("only submitted documents can be cancelled"); requireCaller(doc, Action.CANCEL); hooks.onCancel(doc); return Optional.of(write(conn, doc, 2)); })); } ``` Tính bất biến chỉ là một guard, dùng chung cho `update` và `submit`: *`astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`* ```java private static void requireDraft(Document doc) { if (doc.docstatus() == 1) throw new DocValidationException("submitted documents are immutable; cancel and amend instead"); if (doc.docstatus() == 2) throw new DocValidationException("cancelled documents are immutable; amend instead"); } ``` Vì sao đặt trong kernel: nếu tính bất biến chỉ là quy ước trên giao diện, bất kỳ script, tích hợp hay agent AI nào gọi API vẫn sửa được đơn đã Submit. Đặt kiểm tra trên con đường ghi duy nhất nghĩa là Desk, REST, công cụ MCP và hook đều gặp cùng một quy tắc. ### "Một transaction" là một ScopedValue, không phải quy ước Khẳng định "tất cả trong một transaction" dựa trên `TenantTransactions`. Lời gọi đầu tiên mở một kết nối, gắn tenant cho row-level security và lưu kết nối vào một `ScopedValue` của Java. Mọi lời gọi lồng bên trong (một hook insert vận đơn, sổ cái ghi một bút toán) đều tìm thấy kết nối đã gắn và tham gia vào nó. *`astra-doctype/src/main/java/io/astra/doctype/store/TenantTransactions.java`* ```java public T inTenantTx(SqlWork work) { if (TX.isBound()) { try { return work.run(TX.get()); } // … } String tenant = TenantContext.require(); try (Connection conn = dataSource.getConnection()) { conn.setAutoCommit(false); try { // … SELECT set_config('astra.tenant', ?, true) T result = ScopedValue.where(TX, conn).call(() -> work.run(conn)); conn.commit(); return result; } catch (Exception e) { conn.rollback(); // … ``` Vì sao dùng `ScopedValue` thay vì thread-local hay truyền `Connection` qua SPI: hook chỉ thấy `DocumentService` và `GeneralLedger` (từ `astra-spi`), nên code của app không bao giờ cầm kết nối, không thể làm rò rỉ kết nối, và không thể vô tình mở transaction thứ hai. `ScopedValue` bị giới hạn trong phạm vi lời gọi, chạy được với virtual thread, và biến mất khi thao tác bên ngoài trả về. ### Các hook đi kèm `CustomerOrder` có nhiều hook; ba hook liên quan đến video này. `HookRegistry` của kernel gom mọi `DocLifecycleHook` đã cài theo DocType và gọi lần lượt từng hook. *`astra-apps/express/src/main/java/io/astra/apps/express/OrderWaybillHook.java`* ```java @Override public void onSubmit(Document order) { Document waybill = docs.insert("Waybill", Map.of( "order", order.name(), "receiver_name", order.getString("receiver_name")), order.owner()); order.set("waybill", waybill.name()); } @Override public void onCancel(Document order) { String name = order.getString("waybill"); if (name == null) return; Document waybill = docs.get("Waybill", name).orElseThrow(() -> new DocValidationException("waybill " + name + " not found")); String status = waybill.getString("ops_status"); if (!CANCELLABLE.contains(status)) { throw new DocValidationException("cannot cancel " + order.name() + ": waybill " + name + " is already '" + status + "'"); } docs.update("Waybill", name, Map.of("ops_status", "Cancelled")); } ``` *`astra-apps/express/src/main/java/io/astra/apps/express/OrderPostingHook.java`* ```java /** Submitting an order bills its shipping fee (Dr 131 / Cr 511); cancelling it posts the reversal. */ // … @Override public void onSubmit(Document order) { BigDecimal fee = BigDecimal.valueOf(Numbers.toDouble(order.get("shipping_fee"))); String customer = order.getString("customer"); ledger.post("CustomerOrder", order.name(), LocalDate.now(), List.of( Entry.debit(Accounts.RECEIVABLE, customer, fee), Entry.credit(Accounts.REVENUE, customer, fee)), "Shipping fee " + order.name()); } @Override public void onCancel(Document order) { ledger.reverse("CustomerOrder", order.name()); } ``` Hook thứ ba, `OrderCreditHook`, giữ chỗ khoản cước trên hạn mức công nợ của khách và một suất lấy hàng khi có Seata coordinator (`transaction: global`); không có coordinator thì nó không làm gì. Con đường đó là chủ đề của [Video 17](/astra-cms/vi/seata). Các hook không phụ thuộc vào thứ tự của nhau. Nếu `OrderWaybillHook` chặn một lệnh cancel sau khi `OrderPostingHook` đã ghi bút toán đảo, exception sẽ rollback cả bút toán đảo đó, vì cả hai lần ghi đều đi qua cùng một kết nối. ```mermaid sequenceDiagram autonumber participant Desk as Desk / client REST participant DS as DocumentStore.submit participant TX as TenantTransactions (ScopedValue) participant WH as OrderWaybillHook participant PH as OrderPostingHook participant PG as PostgreSQL (một kết nối) Desk->>DS: POST /api/doc/CustomerOrder/{name}/submit DS->>TX: inTenantTx (mở, gắn astra.tenant) DS->>DS: requireDraft, requireCaller(SUBMIT) DS->>WH: onSubmit(order) WH->>TX: docs.insert("Waybill") tham gia tx đã gắn TX->>PG: INSERT astra_doc (Waybill, scan_log = [Created]) DS->>PH: onSubmit(order) PH->>TX: ledger.post tham gia tx đã gắn TX->>PG: INSERT astra_ledger 131 Dr / 511 Cr DS->>PG: UPDATE astra_doc SET docstatus = 1 … AND version = ? TX->>PG: COMMIT (hoặc ROLLBACK khi có bất kỳ exception nào) DS-->>Desk: 200, data.waybill = WB-… ``` ![Hình 2: Submit trong một transaction](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-04-figure-2.png) *Submit trong một transaction.* ### Cancel chỉ nối thêm, cơ sở dữ liệu ép buộc điều đó `LedgerStore.reverse` đọc các dòng gốc của chứng từ và insert bản đối xứng của chúng với `is_reversal = true`. Nó từ chối đảo cùng một chứng từ hai lần. *`astra-doctype/src/main/java/io/astra/doctype/ledger/LedgerStore.java`* ```java if (reversed) throw new DocValidationException("voucher " + voucherNo + " is already reversed"); if (original.isEmpty()) return null; LocalDate today = LocalDate.now(); ensurePartition(conn, today); List mirror = original.stream().map(e -> new Entry(e.account(), e.party(), e.credit(), e.debit())).toList(); insert(conn, voucherType, voucherNo, today, mirror, true, "Reversal of " + voucherNo); ``` Kể cả khi code ứng dụng cố sửa một dòng đã ghi sổ, PostgreSQL cũng từ chối: *`astra-doctype/src/main/resources/db/migration/V3__ledger.sql`* ```sql -- Corrections are reversal entries, never UPDATE or DELETE; a trigger enforces this for every role, -- including the table owner. Tenant isolation uses the same row-level security setting as astra_doc. -- … CREATE TRIGGER astra_ledger_no_update_delete BEFORE UPDATE OR DELETE ON astra_ledger FOR EACH ROW EXECUTE FUNCTION astra_ledger_append_only(); ``` Bút toán đảo mang ngày cancel, không phải ngày ghi sổ gốc. Đó đúng là cách thường dùng để điều chỉnh một tháng đã khoá sổ: kỳ cũ giữ nguyên như đã báo cáo, phần điều chỉnh xuất hiện ở kỳ hiện tại. ![Hình 3: Cancel chỉ nối thêm, không bao giờ sửa](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-04-figure-3.png) *Cancel chỉ nối thêm, không bao giờ sửa.* ### Amend: đúng một bản kế tiếp *`astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`* ```java if (cancelled.docstatus() != 2) throw new DocValidationException("only cancelled documents can be amended"); try (PreparedStatement ps = conn.prepareStatement( "SELECT name FROM astra_doc WHERE doctype = ? AND amended_from = ?")) { // … if (rs.next()) throw new DocValidationException(name + " was already amended as " + rs.getString(1)); } // Read-only fields belong to the kernel and hooks; the new revision recomputes them. Map copy = new LinkedHashMap<>(cancelled.data()); def.fields().stream().filter(FieldDefinition::readOnly).forEach(f -> copy.remove(f.name())); Document draft = new Document(doctype, copy).owner(owner) .name(amendedName(cancelled)).amendedFrom(name); return Optional.of(persistNew(conn, def, draft)); ``` Ở đây thấy rõ ba lựa chọn thiết kế: - **Mỗi phiên bản đã hủy có một bản kế tiếp.** Hai bản amend song song của cùng một đơn sẽ tạo ra hai "bản sửa" còn hiệu lực cho cùng một sự kiện. Từ chối bản thứ hai giữ cho chuỗi thẳng hàng: `ORD-…-00011` → `-1` → `-2`. `amendedName` tăng hậu tố khi bạn cancel rồi amend một bản amend. - **Các trường read-only bị bỏ đi.** `waybill`, `shipping_fee` và `priced_by` là đầu ra của kernel/hook, nên bản nháp mới tính lại chúng qua `beforeSave` thay vì thừa hưởng giá trị cũ. Đó là lý do đơn đã amend hiện vận đơn trống. - **Liên kết là một cột.** `amended_from` là cột thật trên `astra_doc` (`V2__amendments.sql`), nên có thể truy vấn chuỗi phiên bản mà không phải parse JSON. ## Ví dụ thực tế Một shop ở Sài Gòn gửi một bưu kiện Express đi Đà Nẵng: C001, Domestic, Express, 2,2 kg, COD 500,000 ₫. Đây là đầu vào của `make demo-express SCENES=4`, và `ExpressFlowTest` khẳng định cước tính ra là 73,000 ₫. 1. **Submit (09:00).** Nhân viên kinh doanh submit. Trong một lần commit: đơn thành `docstatus = 1`, vận đơn `WB-…` được insert ở trạng thái `Created`, và sổ cái có Nợ 131 (C001) 73,000 / Có 511 73,000. 2. **Khách gọi điện (09:20):** sai địa chỉ người nhận. Lệnh sửa bị từ chối với 422. Bưu kiện chưa được lấy, nên vận đơn vẫn ở `Created`, thuộc `CANCELLABLE`. 3. **Cancel.** Trong một lần commit: vận đơn chuyển `Created → Cancelled` (và có thêm mốc đó trong scan log), sổ cái nối thêm Nợ 511 / Có 131 73,000 đánh dấu là bút toán đảo, đơn thành `docstatus = 2`. 4. **Amend.** Một bản nháp mới `ORD-…-1` với cùng dữ liệu khách, vận đơn trống, cước tính lại khi lưu. Nhân viên sửa địa chỉ và submit: một vận đơn mới và một bút toán 73,000 mới. Những gì kiểm toán viên thấy cho C001 trên tài khoản 131: +73,000, −73,000, +73,000 = còn phải thu 73,000, với ba dòng, mỗi dòng trỏ về một số chứng từ. Đơn đã hủy, vận đơn đã hủy của nó và bản amend đều vẫn đọc được. Nếu cuộc gọi đến lúc 15:00, sau khi bưu kiện đã tới hub, bước 3 thất bại với `cannot cancel …: waybill … is already 'At Hub'`, và không có gì thay đổi: không bút toán đảo, không đổi trạng thái. Quyền phủ quyết đó là chủ đề của [Video 5](/astra-cms/vi/ops). Mẫu này cũng hợp với quy trình maker/checker cho lệnh thanh toán ở ngân hàng hay bảng lương cuối tháng: một khi đã duyệt, bản ghi không bao giờ bị sửa; nó được đảo và phát hành lại, và chính dữ liệu là dấu vết kiểm toán. ## Dành cho tech lead - **Mô hình nhất quán.** Mọi thứ một bước vòng đời gây ra bên trong PostgreSQL cùng commit hoặc cùng rollback. Đây là bảo đảm mạnh nhất mà bạn có được với chi phí thấp, và nó chỉ đúng với các lần ghi đi qua `DocumentService` và `GeneralLedger`. Một hook gọi HTTP API bên ngoài thì nằm ngoài transaction; cho trường hợp đó, Astra cung cấp Seata AT/TCC theo từng DocType (`transaction: global`, xem [Video 17](/astra-cms/vi/seata)). - **Kiểm toán ngay từ thiết kế.** Tính bất biến được ép buộc hai lần: trong kernel (`requireDraft`) cho chứng từ và bằng trigger cơ sở dữ liệu cho các dòng sổ cái. Kiểm toán viên có thể tin vào cơ sở dữ liệu, không phải vào sự kỷ luật của code ứng dụng. - **Chi phí.** Mỗi lần cancel+amend làm số dòng cho sự kiện đó tăng khoảng gấp đôi (chứng từ cũ, chứng từ mới, dòng gốc và dòng đảo). Với khối lượng đơn hàng và kế toán thì không đáng kể; phân vùng sổ cái theo tháng giữ cho nó trong tầm kiểm soát. - **Đồng thời.** `write` dùng `version = ?`; hai người cùng submit một bản nháp sẽ có một người thành công và một người nhận lỗi `concurrent modification`, không bị ghi sổ hai lần. - **Kỷ luật viết hook rất quan trọng.** Việc chạy lâu bên trong `onSubmit` giữ transaction (và row lock) mở. Hãy đo độ trễ submit p95 và thời lượng transaction theo DocType; để các tích hợp chậm ra ngoài hook. - **Nên đo gì.** Tỷ lệ cancel và tỷ lệ amend theo DocType (tỷ lệ cao báo hiệu vấn đề UX hoặc chất lượng dữ liệu ở khâu trước), tỷ lệ cancel bị hook chặn, độ trễ submit. - **Khi nào không nên dùng.** Những bản ghi vốn thay đổi tự nhiên (số điện thoại khách hàng, trạng thái ticket hỗ trợ) không nên để submittable; làm vậy chỉ thêm thủ tục mà không có giá trị kiểm toán. `Customer`, `Waybill` và `SupportTicket` trong demo cố ý không submittable. - **So với Frappe/ERPNext.** Cùng mô hình tư duy (`docstatus`, `amended_from`, hậu tố `-1`), nên người dùng ERPNext chuyển sang được ngay. Khác biệt: hook là Java có kiểu chạy trong cùng transaction thay vì controller Python, sổ cái append-only nhờ trigger, và việc cô lập tenant do row-level security của PostgreSQL ép buộc. ## Tự chạy thử Yêu cầu: JDK 25, Docker Desktop với khoảng 8 GB RAM, `kubectl`, `k3d`, `jq`, Bash 5 (`brew install bash` trên macOS). ```bash make k3d-up # cluster, kernel, PostgreSQL, Redis, Keycloak, Seata, CDC worker make demo-reset && make demo-seed # two tenants, demo users (password Astra@123) make demo-express SCENES=4 PAUSE=0 # the API half of this video open http://localhost:8080/desk/ # sign in as mai, New Customer Order for C001 → Create → Submit → Cancel → Amend ``` Output terminal dự kiến (các con số phụ thuộc vào ngày seed và số đơn hàng đang có): ```text HTTP 201 X-Tx-LSN: 0/… { "name": "ORD--000NN", "docstatus": 0 } HTTP 200 X-Tx-LSN: 0/… { "name": "ORD-…", "docstatus": 1, "waybill": "WB--0000NN" } HTTP 422 { "errors": ["submitted documents are immutable; cancel and amend instead"], "error": "validation" } HTTP 200 X-Tx-LSN: 0/… { "name": "ORD-…", "docstatus": 2 } HTTP 200 { "name": "WB-…", "ops_status": "Cancelled" } HTTP 201 X-Tx-LSN: 0/… { "name": "ORD-…-1", "docstatus": 0, "amendedFrom": "ORD-…", "waybill": null } ``` Cảnh này không gọi amend lần thứ hai; để thấy lỗi từ chối, hãy gửi lại đúng request amend đó (`POST /api/doc/CustomerOrder//amend`) và chờ HTTP 422 `… was already amended as …-1`. Hành vi này cũng được kiểm chứng mà không cần cụm trong `astra-server/src/test/java/io/astra/server/ExpressFlowTest.java` (`cancelCascadesAndAmendCreatesRevision`, `submitCreatesWaybillAndCancelIsVetoedOnceInNetwork`); `make test` chạy toàn bộ bộ test, Quarkus Dev Services khởi động PostgreSQL trong Docker. ## Nói đúng - **Lát cắt mỏng.** `CustomerOrder` và `CODRemittance` là các DocType submittable trong app Express. Hoá đơn, phiếu thanh toán và bút toán nhật ký mô tả cho BMS trong [use-case-01.md](/astra-cms/vi/use-case) là phạm vi mục tiêu, chưa được xây dựng. - **"Một transaction" nghĩa là một transaction PostgreSQL** cho chứng từ và sổ cái. Khi có kết nối Seata, việc giữ chỗ hạn mức công nợ (schema BMS riêng) và suất lấy hàng trên Redis tham gia một global transaction của Seata bao quanh nó; phần đó được trình bày ở [Video 17](/astra-cms/vi/seata), không phải ở đây. - **Thứ tự hook không cấu hình được.** `HookRegistry` gọi lần lượt mọi hook của DocType theo thứ tự CDI container trả về; tính đúng đắn ở đây dựa vào việc mọi lần ghi dùng chung transaction, không dựa vào thứ tự. - **Khoản cước 58,000 ₫** trong các cảnh Desk đến từ đơn nhập trên Desk; lần chạy API dùng một đơn khác (2,2 kg, Express, COD 500,000) có cước 73,000 ₫. - **Bút toán đảo mang ngày cancel.** Hiện chưa có tuỳ chọn ghi lùi ngày bút toán đảo về kỳ gốc. ## Hình minh hoạ - [Hình 1 · Ba trạng thái, một chiều](#vấn-đề) - [Hình 2 · Submit trong một transaction](#các-hook-đi-kèm) - [Hình 3 · Cancel chỉ nối thêm, không bao giờ sửa](#cancel-chỉ-nối-thêm-cơ-sở-dữ-liệu-ép-buộc-điều-đó) ## Liên quan - Trước: [Video 3 · Quy tắc nghiệp vụ: hook & validation](/astra-cms/vi/hooks). Tiếp: [Video 5 · Vận hành: máy trạng thái bưu kiện](/astra-cms/vi/ops). - Đào sâu: [Video 12 · Sổ cái append-only](/astra-cms/vi/ledger), [Video 17 · Seata: một lần submit, ba tài nguyên](/astra-cms/vi/seata), [Video 11 · Keycloak: một danh tính, role theo tenant, quyền theo DocType](/astra-cms/vi/keycloak) (ai được submit và cancel). - Trích đoạn code: [demo-code-snippets.md, S3](/astra-cms/vi/code-snippets). - Mã nguồn: `astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`, `astra-doctype/src/main/java/io/astra/doctype/store/TenantTransactions.java`, `astra-doctype/src/main/java/io/astra/doctype/ledger/LedgerStore.java`, `astra-apps/express/src/main/java/io/astra/apps/express/OrderWaybillHook.java`, `astra-apps/express/src/main/java/io/astra/apps/express/OrderPostingHook.java`, `astra-spi/src/main/java/io/astra/spi/hook/DocLifecycleHook.java`, `scripts/demo-express.sh` (`scene4`). --- # Vận hành: máy trạng thái bưu kiện Tóm tắt một câu: trong Astra, trạng thái của bưu kiện chỉ được đi theo một bảng chuyển trạng thái khai báo trong một Java map duy nhất, mỗi bước chuyển hợp lệ nối thêm một mốc vào scan log mà không người dùng nào ghi… Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/ops ▶ [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-5-ops-vi.mp4) · [English](https://media.zerohostcms.com/astra-cms/astra-video-5-ops-en.mp4) · 3:00 · Kịch bản: [demo-video-series.md](/astra-cms/vi/video-scripts) · [Bản tiếng Anh](/astra-cms/vi/ops) > **Tóm tắt một câu:** trong Astra, trạng thái của bưu kiện chỉ được đi theo một bảng chuyển trạng thái khai báo trong một Java map duy nhất, mỗi bước chuyển hợp lệ nối thêm một mốc vào scan log mà không người dùng nào ghi được, và đơn hàng không thể bị hủy khi bưu kiện đã vào mạng lưới, vì quyền phủ quyết và thao tác lan truyền dùng chung một transaction. > > **Nên đọc nếu:** bạn cần hiện thực một quy trình trạng thái có kiểm tra hợp lệ và lịch sử dưới dạng một hook bình thường (góc nhìn developer) / bạn đang cân nhắc giữa workflow engine và máy trạng thái dựa trên bảng cho dữ liệu vận hành như bưu kiện, ticket hay lệnh công việc (góc nhìn tech lead). ## Vấn đề Đội vận hành của một hãng chuyển phát quét mỗi bưu kiện nhiều lần trong ngày: đã lấy hàng, đến hub, chuyển sang hub khác, đang giao, giao thành công hoặc giao thất bại. Ở các hệ thống thông thường, ba điều hay hỏng: - **Nhảy cóc hoặc trạng thái vô lý.** Một app quét mã hay một tích hợp đặt `Delivered` cho bưu kiện chưa từng được lấy. Báo cáo khi đó có những lượt giao không có lượt lấy, và việc đối soát COD đổ vỡ. - **Mất lịch sử.** Cột trạng thái bị ghi đè sau mỗi lần quét, nên câu hỏi "thứ Ba bưu kiện này ở đâu" cần một bảng audit riêng mà ai đó đã quên ghi vào. - **Đơn hàng và bưu kiện vênh nhau.** Kinh doanh hủy đơn trong khi bưu kiện đã lên xe. Đơn ghi là đã hủy, tài xế vẫn giao, và không ai tính tiền. Các lời giải thường gặp là một BPMN workflow engine (quá nặng cho một trường trạng thái), ràng buộc enum trong cơ sở dữ liệu (không diễn đạt được "từ A chỉ sang B hoặc C"), hoặc các câu `if` rải rác trong controller (mỗi client tự viết lại). Frappe/ERPNext có DocType Workflow với trạng thái và bước chuyển cấu hình trên giao diện, chủ yếu nhắm tới chuỗi phê duyệt theo role. Astra Express chọn phương án nhỏ nhất dùng được: bảng chuyển trạng thái là dữ liệu trong một hook, và kernel bảo đảm mọi đường ghi đều đi qua hook đó. ## Video cho thấy gì Tenant `saigon-express`. Người dùng vận hành `quang` (role `ops`), sau đó là admin `mai`. | Cảnh | Bạn thấy gì | Điều được chứng minh | | --- | --- | --- | | Filter | Danh sách Waybill lọc theo `Ops status = At Hub` (⚡ trường projected); một vận đơn ở HCM-Tân Bình | Filter theo trạng thái dùng expression index, không quét JSONB. | | Timeline | `WB-2610-000001`: sáu mốc từ Created đến Delivered, mỗi mốc có thời gian và hub | Scan log là lịch sử của bưu kiện; chỉ `WaybillScanHook` nối thêm vào đó. | | Sơ đồ | Máy trạng thái vẽ từ bảng `NEXT`; Delivered, Cancelled và Returned là trạng thái cuối | Quy tắc là một bảng dữ liệu được kiểm tra trước mỗi lần lưu. | | Nhảy cóc | Một vận đơn mới ở `Created`, chọn `Delivered` → HTTP 422; scan log vẫn chỉ có một mốc | Không được nhảy cóc trạng thái; lần ghi bị từ chối không thay đổi gì. | | Hub | `Picked Up` thành công; `At Hub` để trống hub thì lỗi; có hub DN-Hải Châu thì thành công và timeline có thêm một mốc; replica CARE cũng lên ba mốc | Dữ liệu bắt buộc theo từng trạng thái; replica theo kịp (Video 16 giải thích cách làm). | | Cancel | `mai` hủy một đơn đã giao → bị từ chối; tải lại trang: đơn vẫn Submitted (version 2), vận đơn vẫn Delivered (version 6) | Quyền phủ quyết phía đơn hàng và thao tác lan sang vận đơn dùng chung một transaction. | | API | `make demo-express SCENES=5`: các bước chuyển hợp lệ trả 200; `At Hub → Delivered` trả 422; hủy đơn khi đang At Hub trả 422 và đơn giữ `docstatus 1` | Desk và REST đi qua cùng một hook. | | Code | Bảng `NEXT`, `beforeSave` và `OrderWaybillHook.onCancel` | Một bảng, một quyền phủ quyết. | ![Hình 2: Nhảy cóc trạng thái bị từ chối, không có gì được ghi](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-05-figure-2.png) *Nhảy cóc trạng thái bị từ chối, không có gì được ghi.* ## Cách hoạt động ### Vận đơn là một DocType bình thường *`astra-apps/express/src/main/resources/META-INF/astra/doctypes/waybill.doctype.yaml`* ```yaml name: Waybill module: ops kind: transaction naming: { series: "WB-.YYMM.-.######" } fields: - { name: order, type: Link, options: CustomerOrder, required: true, in-list: true } - { name: receiver_name, type: Data, in-list: true, pii: true } - { name: ops_status, type: Select, options: [Created, Picked Up, At Hub, Out for Delivery, Delivered, Failed Delivery, Returned, Cancelled], default: Created, in-list: true, projected: true, label: Ops status } - { name: current_hub, type: Data, in-list: true, projected: true } - { name: scan_log, type: JSON, read-only: true } # appended by WaybillScanHook on every status change permissions: - { role: admin, read: true, write: true } - { role: ops, read: true, write: true } # scanning is an OPS action only - { role: sales, read: true } - { role: care, read: true } ``` Bốn khai báo làm phần lớn công việc: - `Select` với tám lựa chọn: kernel từ chối mọi giá trị nằm ngoài danh sách trước khi hook chạy. - `projected: true` trên `ops_status` và `current_hub`: `ProjectionIndexer` tạo `CREATE INDEX CONCURRENTLY … ON astra_doc (tenant_id, doctype, (data->>'ops_status'))` lúc khởi động, và filter của danh sách dùng đúng biểu thức đó, nên planner có thể chọn index. - `read-only: true` trên `scan_log`: kernel từ chối mọi payload từ client có chứa trường này (`DocumentValidator.rejectReadOnly`), nên chỉ hook phía server mới ghi được lịch sử. - `permissions`: chỉ `ops` và `admin` được quét; `care` và `sales` chỉ được đọc. `Waybill` không `submittable`. Trạng thái bưu kiện vốn phải thay đổi; thứ không được thay đổi là lịch sử của nó, và điều đó do log read-only đảm nhận chứ không phải tính bất biến của chứng từ. ### Bảng chuyển trạng thái *`astra-apps/express/src/main/java/io/astra/apps/express/WaybillScanHook.java`* ```java static final Map> NEXT = Map.of( "Created", Set.of("Picked Up", "Cancelled"), "Picked Up", Set.of("At Hub", "Cancelled"), "At Hub", Set.of("At Hub", "Out for Delivery", "Returned"), "Out for Delivery", Set.of("Delivered", "Failed Delivery"), "Failed Delivery", Set.of("Out for Delivery", "Returned"), "Delivered", Set.of(), "Returned", Set.of(), "Cancelled", Set.of()); ``` ```mermaid stateDiagram-v2 [*] --> Created Created --> PickedUp: Picked Up Created --> Cancelled PickedUp --> AtHub: At Hub (bắt buộc có hub) PickedUp --> Cancelled AtHub --> AtHub: chuyển hub AtHub --> OutForDelivery: Out for Delivery AtHub --> Returned OutForDelivery --> Delivered OutForDelivery --> FailedDelivery: Failed Delivery FailedDelivery --> OutForDelivery: giao lại FailedDelivery --> Returned Delivered --> [*] Returned --> [*] Cancelled --> [*] ``` `At Hub → At Hub` được cho phép có chủ đích: bưu kiện đi từ HCM-Tân Bình sang DN-Hải Châu vẫn ở "At Hub" nhưng đổi hub, và lần chuyển đó là một mốc đáng ghi lại. `Failed Delivery → Out for Delivery` mô hình hoá lần giao thứ hai. ![Hình 1: Máy trạng thái bưu kiện](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-05-figure-1.png) *Máy trạng thái bưu kiện.* ### Kiểm tra và ghi log trong một method *`astra-apps/express/src/main/java/io/astra/apps/express/WaybillScanHook.java`* ```java @Override @SuppressWarnings("unchecked") public void beforeSave(Document doc) { List> log = doc.get("scan_log") instanceof List l ? new ArrayList<>((List>) l) : new ArrayList<>(); Map last = log.isEmpty() ? null : log.getLast(); String status = doc.getString("ops_status"); String hub = doc.getString("current_hub"); if (last != null) { String from = (String) last.get("status"); boolean same = from.equals(status) && Objects.equals(last.get("hub"), hub); if (same) return; if (!NEXT.getOrDefault(from, Set.of()).contains(status)) { throw new DocValidationException("ops_status cannot go from '" + from + "' to '" + status + "'"); } } else if (!"Created".equals(status)) { throw new DocValidationException("a new waybill starts as 'Created'"); } if ("At Hub".equals(status) && (hub == null || hub.isBlank())) { throw new DocValidationException("current_hub is required when ops_status is 'At Hub'"); } if ("Delivered".equals(status)) postCollectedCod(doc); Map scan = new LinkedHashMap<>(); scan.put("status", status); scan.put("hub", hub); scan.put("at", Instant.now().toString()); log.add(scan); doc.set("scan_log", log); } ``` Ghi chú thiết kế: - **Trạng thái "from" là mục log cuối cùng, không phải cột đang lưu.** Log là nguồn sự thật của lịch sử, nên phép kiểm tra so với những gì thực sự đã được ghi nhận. - **Lưu idempotent.** Nếu trạng thái và hub không đổi (ví dụ khi sửa một trường khác), hook trả về mà không thêm mốc. Gửi lại cùng một lần quét không làm lịch sử bị trùng. - **`beforeSave` chạy ở mọi lần insert và update.** `DocumentStore.update` gọi `hooks.beforeSave(doc)` trước lệnh `UPDATE`, và `persistNew` gọi nó trước lệnh `INSERT`; đó là lý do vận đơn mới tạo lúc submit đã có sẵn mốc `Created`. Một `DocValidationException` bị ném ra sẽ thành HTTP 422 và transaction rollback, nên chứng từ đang lưu không bị đụng tới. - **Giao hàng có tác dụng phụ về kế toán.** Khi `Delivered`, `postCollectedCod` ghi khoản COD đã thu: Nợ 1388 (COD tài xế đang giữ) / Có 3388 (phải trả cho shop), trong cùng transaction với việc đổi trạng thái. Nếu ghi sổ thất bại, bưu kiện không được đánh dấu đã giao. ### Quyền phủ quyết phía đơn hàng Hủy một đơn hàng (Video 4) sẽ lan sang vận đơn của nó, nhưng chỉ khi bưu kiện chưa vào mạng lưới. *`astra-apps/express/src/main/java/io/astra/apps/express/OrderWaybillHook.java`* ```java private static final Set CANCELLABLE = Set.of("Created", "Picked Up"); // … @Override public void onCancel(Document order) { String name = order.getString("waybill"); if (name == null) return; Document waybill = docs.get("Waybill", name).orElseThrow(() -> new DocValidationException("waybill " + name + " not found")); String status = waybill.getString("ops_status"); if (!CANCELLABLE.contains(status)) { throw new DocValidationException("cannot cancel " + order.name() + ": waybill " + name + " is already '" + status + "'"); } docs.update("Waybill", name, Map.of("ops_status", "Cancelled")); } ``` Để ý cách chúng ghép với nhau: thao tác lan truyền `docs.update("Waybill", …, "Cancelled")` cũng đi qua `WaybillScanHook.beforeSave`, nên nó được kiểm tra với `NEXT` và được nối vào scan log như mọi lần quét khác. `CANCELLABLE` và các mục `Cancelled` trong `NEXT` khớp nhau (`Created`, `Picked Up`), nên trên thực tế hai hook không thể mâu thuẫn; nếu có ngày chúng mâu thuẫn, hook chặt hơn sẽ thắng và cả lệnh cancel bị rollback. ```mermaid sequenceDiagram autonumber participant U as mai (Desk) participant DS as DocumentStore.cancel participant OW as OrderWaybillHook.onCancel participant WS as WaybillScanHook.beforeSave participant PG as PostgreSQL (một tx) U->>DS: POST /api/doc/CustomerOrder/{name}/cancel DS->>OW: onCancel(order) OW->>PG: đọc ops_status của Waybill alt status thuộc CANCELLABLE (Created, Picked Up) OW->>WS: docs.update(Waybill, ops_status = Cancelled) WS->>WS: kiểm tra NEXT, nối thêm mốc WS->>PG: UPDATE Waybill DS->>PG: UPDATE docstatus của đơn = 2, COMMIT DS-->>U: 200 else At Hub, Out for Delivery, Delivered, … OW-->>DS: DocValidationException DS->>PG: ROLLBACK (không ghi gì) DS-->>U: 422 cannot cancel …: waybill … is already 'At Hub' end ``` ![Hình 3: Quyền phủ quyết hủy đơn trên hai chứng từ](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-05-figure-3.png) *Quyền phủ quyết hủy đơn trên hai chứng từ.* ### Vì sao dùng hook mà không dùng workflow engine - Quy tắc gói gọn trong chín dòng dữ liệu; một BPMN engine sẽ thêm một runtime, một artifact triển khai và một mô hình lưu trữ thứ hai cho cùng một sự kiện. - Phép kiểm tra chạy bên trong transaction của chính chứng từ, nên nó ghép được với các hook khác (ghi sổ COD, hủy đơn) mà không cần điều phối phân tán. - Mọi kênh ghi (Desk, REST, các hook khác qua `DocumentService`) đều tới cùng một `beforeSave`, nên không có client nào vượt qua được bảng. ## Ví dụ thực tế Một công ty 3PL Việt Nam chạy tuyến Sài Gòn → Đà Nẵng. Bưu kiện dưới đây đi qua đúng các lần quét mà `scripts/demo-seed.sh` áp dụng cho vận đơn seed đầu tiên (đơn của C001, COD 850,000 ₫); giờ giấc và cú bấm `Delivered` quá sớm là phần thêm vào để minh hoạ. | Thời điểm | Lần quét (đầu vào) | Phản hồi của Astra | Số mốc trong scan log | | --- | --- | --- | --- | | 08:00 | Đơn được submit | Vận đơn được insert ở `Created` | 1 | | 09:10 | `Picked Up` | 200 | 2 | | 11:30 | `At Hub`, hub `HCM-Tân Bình` | 200 | 3 | | 18:00 | `At Hub`, hub `DN-Hải Châu` (xe trung chuyển đã tới) | 200, ghi nhận chuyển hub | 4 | | 07:30 hôm sau | `Delivered` (tài xế bấm quá sớm) | 422 `ops_status cannot go from 'At Hub' to 'Delivered'` | 4 | | 07:31 | `Out for Delivery` | 200 | 5 | | 10:05 | `Delivered` | 200, sổ cái: Nợ 1388 / Có 3388 850,000 | 6 | Trong khi đó, lúc 14:00 ngày đầu, shop nhờ kinh doanh hủy đơn. Quyền phủ quyết phía đơn hàng trả về `cannot cancel …: waybill … is already 'At Hub'`, và cả hai chứng từ giữ nguyên. Giờ đây CSKH xem được toàn bộ lịch sử trong scan log (và trong replica CDC, Video 6) thay vì gọi điện hỏi hub, còn kế toán thấy chính xác 850,000 ₫ tài xế đang giữ cho bưu kiện đó cho tới khi được nộp lại. ## Dành cho tech lead - **Quy tắc nằm ở đâu.** Trong code (`WaybillScanHook.NEXT`), được review và quản lý phiên bản như code. Thay đổi quy trình nghĩa là deploy lại module app. Đó là ưu điểm với các quy tắc vận hành cốt lõi và là hạn chế nếu người dùng nghiệp vụ muốn tự sửa bước chuyển; khác biệt theo từng tenant là việc của overlay ([Video 13](/astra-cms/vi/overlay)) hoặc script Wasm ([Video 14](/astra-cms/vi/wasm)), không phải của bảng này. - **Khuếch đại ghi.** `scan_log` nằm trong chứng từ JSONB, nên mỗi lần quét ghi lại toàn bộ chứng từ và log dài ra theo từng mốc. Với vài chục lần quét mỗi bưu kiện thì ổn; với tần suất quét rất cao, hãy đo kích thước dòng, mức tăng TOAST và độ trễ update. [use-case-01.md](/astra-cms/vi/use-case) mô tả thiết kế mục tiêu là chuyển các trường nóng sang cột vật lý hoặc bảng phụ hẹp; phần này chưa được xây dựng. - **Đồng thời.** Hai máy quét cùng cập nhật một vận đơn: kiểm tra `version` kiểu optimistic làm một bên thất bại với `concurrent modification`, thay vì đan xen hai lần nối log. - **Nên đo gì.** Tỷ lệ 422 theo từng bước chuyển (tăng đột biến nghĩa là một app quét mã hay tích hợp đang lệch nhịp), tỷ lệ lệnh hủy đơn bị chặn, thời gian ở mỗi trạng thái (SLA), độ trễ quét p95. - **Khi nào không nên dùng mẫu này.** Chuỗi phê duyệt có role, leo thang và hẹn giờ theo từng bước thì hợp với workflow engine hoặc cấu hình Workflow kiểu Frappe hơn. Dùng mẫu bảng cho các vòng đời vật lý hoặc kỹ thuật có tập trạng thái nhỏ và ổn định. - **Rủi ro.** Mọi quy tắc trong một hook nghĩa là chỉ có một chỗ để sai; các bước chuyển được kiểm chứng bởi `ExpressFlowTest.submitCreatesWaybillAndCancelIsVetoedOnceInNetwork`. ## Tự chạy thử Yêu cầu: JDK 25, Docker Desktop (khoảng 8 GB RAM), `kubectl`, `k3d`, `jq`, Bash 5. ```bash make k3d-up make demo-reset && make demo-seed make demo-express SCENES=5 PAUSE=0 open http://localhost:8080/desk/ # sign in as quang (ops), Waybill list, filter Ops status = At Hub ``` Output dự kiến, rút gọn từ một lần chạy đã ghi lại (`docs/video/v05-ops/assets/scene-05.log`): ```text ▸ Scan: picked up, then at hub HTTP 200 { "ops_status": "Picked Up" } HTTP 200 [ {"status":"Created",…}, {"status":"Picked Up",…}, {"hub":"DN-Hải Châu","status":"At Hub",…} ] ▸ Skipping a step is refused by WaybillScanHook HTTP 422 { "errors": ["ops_status cannot go from 'At Hub' to 'Delivered'"], "error": "validation" } ▸ Once the parcel is in the network, cancelling the order is vetoed — and nothing changes (one transaction) HTTP 422 { "errors": ["cannot cancel ORD-…: waybill WB-… is already 'At Hub'"], "error": "validation" } HTTP 200 { "name": "ORD-…", "docstatus": 1 } ▸ Filter on a projected field: every parcel currently At Hub HTTP 200 [ {"name":"WB-…","hub":"DN-Hải Châu"}, {"name":"WB-…","hub":"HCM-Tân Bình"} ] ``` Người dùng demo dùng chung mật khẩu `Astra@123`. Đăng nhập bằng `mai` để thử hủy đơn đã seed có vận đơn ở trạng thái Delivered. ## Nói đúng - **Lát cắt mỏng.** Một hook bao trọn vòng đời bưu kiện. Máy quét mã vạch, app di động cho tài xế, một `ScanEventLedger` riêng, schema theo module (`schema_ops`) và các cột cho trường nóng trong [use-case-01.md](/astra-cms/vi/use-case) là kiến trúc mục tiêu, chưa được xây dựng. - **Lần quét là lệnh cập nhật chứng từ.** Một lần quét là `PUT /api/doc/Waybill/{name}` với `ops_status` và, với `At Hub`, thêm `current_hub`. Không có endpoint quét riêng. - **Bảng không cấu hình được theo tenant.** Cả hai tenant dùng chung một map `NEXT`. - **Returned và Failed Delivery** có trong bảng nhưng không được dùng tới trong video. - **Ghi sổ COD khi Delivered** được trình bày ở [Video 12](/astra-cms/vi/ledger); video này chỉ cho thấy phía trạng thái. - **Số mốc trên replica** trong cảnh Hub đến từ worker Rust CDC (cột `scans` tính bằng độ dài của `scan_log`, xem `astra-cdc-rust/cdc-routes.yaml`); cách nó luôn cập nhật là chủ đề của Video 16. ## Hình minh hoạ - [Hình 1 · Máy trạng thái bưu kiện](#bảng-chuyển-trạng-thái) - [Hình 2 · Nhảy cóc trạng thái bị từ chối, không có gì được ghi](#video-cho-thấy-gì) - [Hình 3 · Quyền phủ quyết hủy đơn trên hai chứng từ](#quyền-phủ-quyết-phía-đơn-hàng) ## Liên quan - Trước: [Video 4 · Vòng đời chứng từ: Submit, Cancel, Amend](/astra-cms/vi/lifecycle). Tiếp: [Video 6 · CSKH nhìn thấy vận hành](/astra-cms/vi/care). - Đào sâu: [Video 2 · DocType và trường projected](/astra-cms/vi/doctype), [Video 12 · Sổ cái append-only](/astra-cms/vi/ledger) (ghi sổ COD), [Video 16 · Rust CDC + Sync-Aware LSN](/astra-cms/vi/cdc) (replica), [Video 11 · Keycloak](/astra-cms/vi/keycloak) (ai được quét). - Trích đoạn code: [demo-code-snippets.md, S3](/astra-cms/vi/code-snippets). - Mã nguồn: `astra-apps/express/src/main/java/io/astra/apps/express/WaybillScanHook.java`, `astra-apps/express/src/main/java/io/astra/apps/express/OrderWaybillHook.java`, `astra-apps/express/src/main/resources/META-INF/astra/doctypes/waybill.doctype.yaml`, `astra-doctype/src/main/java/io/astra/doctype/store/ProjectionIndexer.java`, `astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`, `scripts/demo-express.sh` (`scene5`). --- # CSKH nhìn thấy vận hành Tóm tắt một câu: khi nhân viên CSKH mở một ticket hỗ trợ gắn với một vận đơn, một hook nâng mức ưu tiên cho các khiếu nại mất hoặc hỏng hàng, đặt hạn SLA và chép trạng thái hiện tại của bưu kiện vào ticket, còn một thẻ… Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/care ▶ [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-6-care-vi.mp4) · [English](https://media.zerohostcms.com/astra-cms/astra-video-6-care-en.mp4) · 2:30 · Kịch bản: [demo-video-series.md](/astra-cms/vi/video-scripts) · [Bản tiếng Anh](/astra-cms/vi/care) > **Tóm tắt một câu:** khi nhân viên CSKH mở một ticket hỗ trợ gắn với một vận đơn, một hook nâng mức ưu tiên cho các khiếu nại mất hoặc hỏng hàng, đặt hạn SLA và chép trạng thái hiện tại của bưu kiện vào ticket, còn một thẻ bên dưới form hiển thị bưu kiện theo một replica CDC chứ không đọc từ bảng vận hành. > > **Nên đọc nếu:** bạn muốn xem một hook `beforeSave` nhỏ nối hai module với nhau mà không cần gọi service như thế nào (góc nhìn developer) / bạn đang quyết định cách cho đội hỗ trợ xem dữ liệu vận hành mà không đè tải lên cơ sở dữ liệu vận hành (góc nhìn tech lead). ## Vấn đề Một khách hàng của Astra Express gọi hotline: hàng gốm tới nơi đã vỡ, khách đòi bồi thường. Ở nhiều công ty, nhân viên CSKH khi đó phải: - mở CRM để ghi ticket, với mức ưu tiên chọn theo cảm tính; - chuyển sang hệ thống vận hành (hoặc gọi điện cho hub) để biết bưu kiện đang ở đâu; - tự tính hạn phản hồi từ một tài liệu chính sách. Bước nào cũng chậm và thiếu nhất quán: khiếu nại khẩn nằm ở mức Normal, và báo cáo SLA được dựng sau từ bất cứ thứ gì nhân viên đã gõ. Cách sửa phổ biến còn lại, cho CRM truy vấn thẳng cơ sở dữ liệu vận hành, lại đặt tải kiểu báo cáo lên chính những bảng mà máy quét ghi vào suốt ngày. DocType Issue của Frappe/ERPNext có mức ưu tiên và Service Level Agreement cấu hình trên giao diện, và liên kết được tới các chứng từ khác. Demo của Astra cho ra cùng kết quả nghiệp vụ bằng hai mảnh: một hook quy tắc cho các trường của ticket, và một replica CDC cho góc nhìn vận hành vốn đọc nhiều. ## Video cho thấy gì Tenant `saigon-express`, đăng nhập bằng `ha` (role `care`). | Cảnh | Bạn thấy gì | Điều được chứng minh | | --- | --- | --- | | Ticket mới | New Support Ticket "Hàng bị vỡ, khách yêu cầu đền bù", gắn với vận đơn đã giao `WB-2610-000001`; mức ưu tiên Normal; ba trường do hook điền đang trống | Nhân viên chỉ nhập những gì họ biết. | | Đã tạo | `TCK-00004`: mức ưu tiên High, ghi chú phân loại `auto-escalated: mentions 'vỡ'`, hạn SLA bằng thời điểm tạo + 24 h, trạng thái vận đơn `Delivered` | Một hook đặt mức ưu tiên, SLA và ảnh chụp trạng thái vận hành ngay lúc lưu. | | Code hook | `TicketTriageHook`: bảy từ khoá; chỉ nâng mức khi đang ở Low hoặc Normal | Quy tắc là code thuần; lời dẫn nói rõ phân loại bằng AI qua MCP vẫn nằm trong lộ trình. | | Kernel | `beforeSave` được gọi ngay trước lệnh `INSERT` | Hook ghi vào chính chứng từ đang được tạo, trong cùng transaction. | | Thẻ replica | "CARE replica (CDC) `replica_waybill`": in sync, độ trễ worker 47 ms | Thẻ đọc một replica do worker Rust CDC duy trì, không đọc bảng `astra_doc`. | | API | `make demo-express SCENES=6`: một khiếu nại hư hỏng được nâng lên High, có SLA và ảnh chụp trạng thái vận đơn; liệt kê các ticket High đang mở | Cùng một hook phía sau REST và Desk. | Có một điểm phân biệt mà video làm rất cẩn thận: trường `waybill_status` của ticket do hook đọc từ chứng từ Waybill khi ticket được lưu. Chỉ thẻ bên dưới form mới đọc replica. ## Cách hoạt động ### DocType ticket *`astra-apps/express/src/main/resources/META-INF/astra/doctypes/support-ticket.doctype.yaml`* ```yaml name: SupportTicket module: care kind: transaction naming: { series: "TCK-.#####" } fields: - { name: subject, type: Data, required: true, in-list: true } - { name: waybill, type: Link, options: Waybill, in-list: true } - { name: customer, type: Link, options: Customer } - { name: channel, type: Select, options: [Hotline, Zalo, Email, Web], default: Hotline } - { name: description, type: Text } - { name: priority, type: Select, options: [Low, Normal, High, Urgent], default: Normal, in-list: true, projected: true } - { name: status, type: Select, options: [Open, In Progress, Resolved], default: Open, in-list: true, projected: true } - { name: waybill_status, type: Data, read-only: true, in-list: true } # snapshot of the waybill when the ticket is saved - { name: sla_due, type: Datetime, read-only: true, in-list: true } - { name: triage_note, type: Data, read-only: true } permissions: - { role: admin, read: true, create: true, write: true } - { role: care, read: true, create: true, write: true } - { role: sales, read: true, create: true } - { role: ops, read: true } ``` Vì sao chọn như vậy: - `waybill_status`, `sla_due` và `triage_note` là `read-only`: client không gửi được các trường này, nên nhân viên không thể tự đặt hạn cho mình hay làm giả trạng thái bưu kiện. Chỉ hook phía server điền chúng. - `priority` và `status` là `projected`, nên hàng đợi "đang mở, High" là một truy vấn có index (`GET /api/doc/SupportTicket?priority=High&status=Open` ở cảnh 6). - `waybill` là `Link`: kernel từ chối ticket trỏ tới vận đơn không tồn tại trong tenant này. - `ops` đọc được ticket nhưng không ghi được; `care` đọc được vận đơn nhưng không quét được (xem [Video 5](/astra-cms/vi/ops)). Mỗi module giữ quyền ghi đối với chứng từ của riêng mình. - `SupportTicket` không `submittable`: trạng thái của ticket vốn phải thay đổi, nên vòng đời Draft/Submit của [Video 4](/astra-cms/vi/lifecycle) chỉ thêm thủ tục mà không có giá trị kiểm toán. ### Hook phân loại *`astra-apps/express/src/main/java/io/astra/apps/express/TicketTriageHook.java`* ```java /** * Customer care rules: loss or damage complaints are escalated, every ticket gets an SLA deadline from * its priority, and the linked waybill's current status is copied in so the agent sees OPS state * without leaving CARE. Rule-based today; AI triage over MCP is on the roadmap. */ @ApplicationScoped public class TicketTriageHook implements DocLifecycleHook { private static final List ESCALATE = List.of("mất", "thất lạc", "hỏng", "vỡ", "lost", "damaged", "broken"); private static final Set LOW = Set.of("Low", "Normal"); private static final Map SLA = Map.of( "Urgent", Duration.ofHours(4), "High", Duration.ofHours(24), "Normal", Duration.ofHours(72), "Low", Duration.ofDays(5)); @Inject DocumentService docs; @Override public String doctype() { return "SupportTicket"; } @Override public void beforeSave(Document t) { String text = (Objects.toString(t.get("subject"), "") + " " + Objects.toString(t.get("description"), "")).toLowerCase(Locale.ROOT); if (LOW.contains(t.getString("priority"))) { ESCALATE.stream().filter(text::contains).findFirst().ifPresent(word -> { t.set("priority", "High"); t.set("triage_note", "auto-escalated: mentions '" + word + "'"); }); } if (t.get("sla_due") == null) { t.set("sla_due", Instant.now().plus(SLA.getOrDefault(t.getString("priority"), SLA.get("Normal"))).toString()); } String waybill = t.getString("waybill"); if (waybill != null && !waybill.isBlank()) { docs.get("Waybill", waybill).ifPresent(w -> t.set("waybill_status", w.getString("ops_status"))); } } } ``` Ghi chú thiết kế, đọc thẳng từ code: - **Chỉ nâng mức, và chỉ từ Low hoặc Normal.** Nhân viên đã chọn Urgent thì không bao giờ bị quy tắc hạ xuống High. - **Từ khoá phủ cả tiếng Việt và tiếng Anh** ("mất", "thất lạc", "hỏng", "vỡ", "lost", "damaged", "broken"), được tìm trong tiêu đề và mô tả, không phân biệt hoa thường. Từ khớp đầu tiên được ghi vào `triage_note`, nên việc nâng mức giải thích được cho cả nhân viên lẫn kiểm toán viên. - **SLA chỉ đặt một lần.** `sla_due` chỉ được điền khi đang trống, nên sửa ticket về sau không dời hạn. Lưu ý hệ quả: nếu nhân viên nâng ticket lên Urgent sau khi tạo, hạn vẫn giữ giá trị tính lúc tạo. - **Trạng thái vận đơn được làm mới mỗi lần lưu.** `beforeSave` chạy khi insert và ở mọi lần update, nên `waybill_status` là ảnh chụp tại lần lưu gần nhất của ticket, không phải giá trị trực tiếp. Góc nhìn trực tiếp là thẻ bên dưới. - **Đọc chéo module qua SPI.** Hook đọc vận đơn qua `DocumentService`, chính API mà mọi app dùng. Không có lời gọi REST giữa các module, không có DAO dùng chung, và lần đọc diễn ra bên trong transaction của ticket, dưới cùng tenant. ![Hình 1: Một lần lưu, ba trường được điền](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-06-figure-1.png) *Một lần lưu, ba trường được điền.* ### Kernel gọi hook ở đâu *`astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`* ```java private Document persistNew(Connection conn, DocTypeDefinition def, Document doc) throws SQLException { DocumentValidator.applyDefaultsAndValidate(def, doc); hooks.beforeSave(doc); validateLinks(conn, def, doc); if (doc.name() == null) doc.name(assignName(conn, def, doc)); try (PreparedStatement ps = conn.prepareStatement( "INSERT INTO astra_doc (id, tenant_id, doctype, name, owner, data, amended_from) " + "VALUES (?, ?, ?, ?, ?, ?::jsonb, ?) RETURNING *")) { // … ``` Giá trị mặc định được áp trước (nên `priority` đã là `Normal` khi hook xem tới), rồi hook sửa chứng từ, rồi các liên kết được kiểm tra và dòng được insert. Vì vậy đầu ra của hook là một phần của phiên bản đầu tiên của dòng, và là một phần của bản ghi WAL đầu tiên mà worker CDC nhìn thấy. ### Thẻ replica Thẻ này là code Desk dùng chung. Với một ticket, nó tra vận đơn được liên kết trong `replica_waybill`: *`astra-server/src/main/resources/META-INF/resources/desk/desk.js`* ```js const REPLICA_OF = { Waybill: ['replica_waybill', (d) => d.name], CustomerOrder: ['replica_order', (d) => d.name], SupportTicket: ['replica_waybill', (d) => d.data.waybill] }; // … const required = state.lastLsn; const res = await api('GET', '/api/replica/' + spec[0] + '?name=' + enc(key), undefined, required ? { 'X-Required-LSN': required } : {}); if (res.status === 202) { // … 'sync pending' badge, retry after 100 ms ``` Bảng replica do worker Rust CDC ghi từ WAL của PostgreSQL, với các cột khai báo trong một file routes: *`astra-cdc-rust/cdc-routes.yaml`* ```yaml routes: - doctype: Waybill target: replica_waybill columns: - { name: order_name, from: order } - { name: ops_status, from: ops_status } - { name: current_hub, from: current_hub } - { name: receiver_name, from: receiver_name } - { name: scans, from: scan_log, transform: length, type: integer } ``` `ReplicaResource` phục vụ `GET /api/replica/{table}` cho bất kỳ ai được đọc DocType nguồn (ở đây là `Waybill`, mà `care` được đọc), dưới cùng row-level security như mọi thứ khác. Khi request mang `X-Required-LSN`, `SyncAwareLsnFilter` trả `202 Sync Pending` cho tới khi watermark của worker đạt tới LSN đó; cơ chế read-your-writes này là chủ đề của [Video 16](/astra-cms/vi/cdc). ```mermaid flowchart LR A["ha tạo SupportTicket
(Desk hoặc REST)"] --> B["DocumentStore.persistNew"] B --> C["TicketTriageHook.beforeSave
priority · sla_due · triage_note"] C -->|"docs.get(Waybill)"| D[("astra_doc
dòng Waybill")] C --> E["INSERT astra_doc
SupportTicket"] D -. "WAL (pgoutput)" .-> F["worker astra-cdc-rust"] F --> G[("replica_waybill")] H["Thẻ replica trên Desk"] -->|"GET /api/replica/replica_waybill
X-Required-LSN"| G ``` Vì sao dùng replica cho góc nhìn CSKH: màn hình CSKH đọc nhiều, mở cả ngày và chịu được độ trễ vài mili giây; các bảng vận hành thì gánh các lần ghi từ máy quét. Tách luồng đọc ra là mẫu được mô tả cho CARE trong [use-case-01.md](/astra-cms/vi/use-case). Hook vẫn đọc một điểm trên Waybill lúc lưu, việc này rẻ và cho ticket một trạng thái nhất quán với thời điểm nó được lưu. ![Hình 2: Hai cách CSKH nhìn một bưu kiện](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-06-figure-2.png) *Hai cách CSKH nhìn một bưu kiện.* ## Ví dụ thực tế Một nhà cung cấp dịch vụ fulfilment thương mại điện tử ở Việt Nam vận hành một hotline và một kênh Zalo. Ba ticket đến vào sáng thứ Hai; mọi giá trị dưới đây đều suy ra từ các quy tắc của hook. | Đầu vào | Mức ưu tiên vào → ra | `triage_note` | Hạn SLA | `waybill_status` | | --- | --- | --- | --- | --- | | 09:00, Hotline, "Hàng bị vỡ, khách yêu cầu đền bù", vận đơn Delivered | Normal → High | `auto-escalated: mentions 'vỡ'` | 09:00 thứ Ba (+24 h) | Delivered | | 09:05, Zalo, "Hỏi giờ giao hàng", mức ưu tiên Low, vận đơn Out for Delivery | Low → Low | trống | 09:05 thứ Bảy (+5 ngày) | Out for Delivery | | 09:10, Email, "Parcel lost, no update for 3 days", mức ưu tiên Urgent, vận đơn At Hub | Urgent → Urgent | trống (đã cao hơn Normal) | 13:10 thứ Hai (+4 h) | At Hub | Những gì trưởng nhóm nhận được mà không ai phải gõ: một hàng đợi High (`GET /api/doc/SupportTicket?priority=High&status=Open`) với khiếu nại hư hỏng nằm trên cùng, một hạn chót trên mọi ticket mà báo cáo SLA tin được, và với mỗi ticket là trạng thái bưu kiện tại thời điểm ghi nhận, điều quan trọng khi khách sau này phản bác "chưa bao giờ nhận được hàng". Nhân viên thấy trạng thái hiện tại, hub và số lần quét của bưu kiện trong thẻ replica trên cùng màn hình. Với bộ phận khiếu nại của ngân hàng, mẫu này vẫn đúng: quy tắc theo từ khoá hoặc danh mục nâng mức ưu tiên, thời hạn phản hồi của cơ quan quản lý trở thành `sla_due`, và trạng thái của giao dịch được liên kết được ghi lại lúc tiếp nhận. ![Hình 3: Hàng đợi phân loại](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-06-figure-3.png) *Hàng đợi phân loại.* ## Dành cho tech lead - **Dựa trên quy tắc, có chủ đích.** Bảy từ khoá thì minh bạch, kiểm thử được (`ExpressFlowTest.ticketsAreTriagedAndSeeTheWaybill`) và chạy không tốn gì. Chúng sẽ bỏ sót cách diễn đạt khác ("cracked", "không còn nguyên vẹn") và có thể nâng mức nhầm ("không bị vỡ"). Hãy đo tỷ lệ bỏ sót bằng cách lấy mẫu các ticket Normal trước khi đầu tư vào phân loại bằng AI. - **Phân loại bằng AI là lộ trình.** Chính Javadoc của hook nói vậy. Khi tới lúc, cách rẻ là giữ hook này làm mức sàn tất định và chỉ để mô hình nâng mức ưu tiên, không bao giờ hạ. - **Chính sách SLA nằm trong code.** Bốn khoảng thời gian trong một map thì dễ review nhưng muốn đổi phải deploy. Giờ làm việc, ngày lễ và hợp đồng theo từng khách chưa được mô hình hoá. Nếu điều khoản SLA khác nhau theo tenant hay theo khách, đó là ứng viên cho overlay ([Video 13](/astra-cms/vi/overlay)) hoặc một quy tắc Wasm ([Video 14](/astra-cms/vi/wasm)). - **Hai kiểu độ tươi dữ liệu.** `waybill_status` là ảnh chụp tại một thời điểm (nhất quán với lần ghi ticket); thẻ replica gần thời gian thực (nhất quán sau cùng, có read-your-writes khi yêu cầu). Hãy chắc rằng nhân viên và báo cáo biết mình đang nhìn vào loại nào. - **Chi phí vận hành.** Replica cần worker CDC, một logical replication slot và giám sát độ trễ. Hãy đo độ trễ worker (thẻ hiện 47 ms trong bản ghi hình), lượng WAL mà replication slot giữ lại, và tỷ lệ phản hồi 202 Sync Pending. - **Khi nào không nên dùng replica.** Với một đội nhỏ, lượng đọc thấp, đọc thẳng Waybill đơn giản hơn và hook vốn đã làm vậy. Replica có lợi khi lưu lượng đọc của CSKH đủ lớn để tranh tài nguyên với các lần ghi vận hành, hoặc khi dữ liệu CSKH phải nằm ở một kho riêng. - **So với Frappe/ERPNext.** DocType Service Level Agreement của ERPNext cho cấu hình mức ưu tiên và giờ làm việc trên giao diện; demo của Astra đi theo hướng code trước và mỏng hơn về chính sách SLA, nhưng chạy quy tắc trong cùng transaction với lần ghi ticket và tách luồng đọc của CSKH khỏi vận hành. ## Tự chạy thử Yêu cầu: JDK 25, Docker Desktop (khoảng 8 GB RAM), `kubectl`, `k3d`, `jq`, Bash 5. Thẻ replica cần stack k3d, trong đó có worker Rust CDC. ```bash make k3d-up make demo-reset && make demo-seed make demo-express SCENES=6 PAUSE=0 open http://localhost:8080/desk/ # sign in as ha (password Astra@123), New Support Ticket, link a waybill, Create ``` Output dự kiến, rút gọn từ một lần chạy đã ghi lại (`docs/video/v06-care/assets/scene-06.log`): ```text ━━━ Scene 6 · Customer care sees operations ━━━ HTTP 200 [ "WB-2610-000002" ] ▸ A complaint mentioning damage is escalated, gets an SLA deadline and a snapshot of the waybill status HTTP 201 X-Tx-LSN: 0/… { "name": "TCK-00003", "priority": "High", "triage_note": "auto-escalated: mentions 'vỡ'", "sla_due": "", "waybill_status": "Out for Delivery" } ▸ Open high-priority tickets HTTP 200 [ {"name":"TCK-00003",…}, {"name":"TCK-00001","subject":"Khách báo hàng gốm bị vỡ"} ] ``` `TCK-00001` được seed bởi `scripts/demo-seed.sh`; nó được chính hook này nâng mức vì tiêu đề chứa "vỡ". Số ticket phụ thuộc vào số ticket đang có khi bạn chạy cảnh này. ## Nói đúng - **Chỉ phân loại theo quy tắc.** Không có mô hình nào tham gia. Phân loại bằng AI qua MCP, tự động phân công ticket và các luồng phân loại Wasm Tier 2 mô tả cho CARE trong [use-case-01.md](/astra-cms/vi/use-case) chưa được xây dựng. - **`waybill_status` không đọc từ replica.** Hook đọc trực tiếp chứng từ Waybill lúc lưu. Chỉ thẻ "CARE replica (CDC)" mới đọc `replica_waybill`. - **Không có schema CARE riêng.** Các bảng replica nằm cạnh `astra_doc` (`V5__cdc_replicas.sql`), được bảo vệ bằng row-level security; `schema_care` theo module của kiến trúc mục tiêu chưa được xây dựng. - **SLA tính theo giờ đồng hồ.** Không có giờ làm việc, ngày lễ, tạm dừng khi "chờ khách phản hồi", hay tính lại khi mức ưu tiên đổi sau lúc tạo. - **Khiếu nại và bồi thường** (một `ClaimCase` ghi sổ cái khi được duyệt) là phạm vi mục tiêu, chưa được xây dựng. - **Độ trễ worker 47 ms** là giá trị hiện trong một lần ghi hình; nó thay đổi theo tải. ## Hình minh hoạ - [Hình 1 · Một lần lưu, ba trường được điền](#hook-phân-loại) - [Hình 2 · Hai cách CSKH nhìn một bưu kiện](#thẻ-replica) - [Hình 3 · Hàng đợi phân loại](#ví-dụ-thực-tế) ## Liên quan - Trước: [Video 5 · Vận hành: máy trạng thái bưu kiện](/astra-cms/vi/ops). Tiếp: [Video 7 · Đa thuê bao](/astra-cms/vi/tenants). - Đào sâu: [Video 16 · Rust CDC + Sync-Aware LSN](/astra-cms/vi/cdc) (cách thẻ replica luôn cập nhật), [Video 3 · Quy tắc nghiệp vụ: hook & validation](/astra-cms/vi/hooks) (cùng mẫu hook), [Video 15 · MCP: agent AI dùng Astra bằng quyền của người dùng](/astra-cms/vi/mcp) (agent AI trên cùng bộ quyền), [Video 11 · Keycloak](/astra-cms/vi/keycloak) (role `care`). - Trích đoạn code: [demo-code-snippets.md, S3](/astra-cms/vi/code-snippets) và [S10](/astra-cms/vi/code-snippets). - Mã nguồn: `astra-apps/express/src/main/java/io/astra/apps/express/TicketTriageHook.java`, `astra-apps/express/src/main/resources/META-INF/astra/doctypes/support-ticket.doctype.yaml`, `astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`, `astra-doctype/src/main/java/io/astra/doctype/api/ReplicaResource.java`, `astra-doctype/src/main/java/io/astra/doctype/sync/SyncAwareLsnFilter.java`, `astra-cdc-rust/cdc-routes.yaml`, `scripts/demo-express.sh` (`scene6`). --- # Đa thuê bao Tóm tắt một câu: Một máy chủ Astra phục vụ nhiều công ty; kernel quyết định request thuộc tenant nào, mang quyết định đó trong một ScopedValue của Java vào một thiết lập PostgreSQL chỉ sống trong transaction, và… Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/tenants ▶ [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-7-tenants-vi.mp4) · [English](https://media.zerohostcms.com/astra-cms/astra-video-7-tenants-en.mp4) · 2:28 · Kịch bản: [demo-video-series.md](/astra-cms/vi/video-scripts) · [Bản tiếng Anh](/astra-cms/vi/tenants) > **Tóm tắt một câu:** Một máy chủ Astra phục vụ nhiều công ty; kernel quyết định request thuộc tenant nào, mang quyết định đó trong một `ScopedValue` của Java vào một thiết lập PostgreSQL chỉ sống trong transaction, và row-level security trả về không dòng nào mỗi khi thiết lập đó vắng mặt. > > **Nên đọc nếu:** bạn viết app Astra và muốn biết vì sao không API nào nhận tham số `tenant` (góc nhìn developer) / bạn đang cân nhắc giữa SaaS dùng chung schema, mỗi tenant một database và mỗi tenant một instance, và muốn biết ranh giới cô lập thực sự nằm ở đâu (góc nhìn tech lead). ## Vấn đề Astra Express, công ty chuyển phát trong demo, chạy thành hai tenant trên cùng một nền tảng: `saigon-express` và `hanoi-express`. Cả hai đều có một khách hàng mã `C001`. Ở Sài Gòn đó là "Shop Hoa Mai"; ở Hà Nội là "Nhà sách Tràng Tiền". Cả hai tạo đơn hàng, vận đơn và phiếu nộp tiền COD qua cùng các URL và cùng các DocType. Có người làm việc cho cả hai: user `demo` là admin ở mỗi bên. Nỗi đau của SaaS dùng chung schema thì ai cũng biết. Việc cô lập thường nằm trong code ứng dụng: mọi truy vấn đều cần `WHERE tenant_id = ?`, mọi phương thức repository đều nhận tham số tenant, và mỗi developer mới, mỗi background job, mỗi báo cáo hay công cụ AI lại thêm một chỗ có thể quên bộ lọc đó. Kiểu hỏng này diễn ra trong im lặng: thiếu bộ lọc không ném lỗi, mà trả về khách hàng của công ty khác. Các phương án thường gặp đổi rủi ro đó lấy chi phí vận hành: - **Mỗi tenant một database hoặc một instance.** Frappe/ERPNext làm theo cách này: một bench chứa nhiều *site*, mỗi site có database riêng. Cô lập mạnh, nhưng mỗi site phải migrate, backup và giám sát riêng, và công việc xuyên site (một điều phối viên phục vụ hai chi nhánh) nghĩa là hai lần đăng nhập. - **Bảng dùng chung với bộ lọc ở ORM.** Rẻ khi vận hành, nhưng ranh giới chỉ mạnh bằng truy vấn cẩu thả nhất, kể cả SQL thô trong báo cáo và hook. Lát cắt mỏng của Astra chọn hướng bảng dùng chung, nhưng chuyển ranh giới ra khỏi code ứng dụng, đặt vào hai nơi mà ứng dụng không thể bỏ qua: bộ lọc request của kernel và row-level security (RLS) của PostgreSQL. ## Video cho thấy gì | Cảnh | Bạn thấy gì | Điều được chứng minh | | --- | --- | --- | | 1 · Tiêu đề | "Một máy chủ, nhiều công ty." | Đặt vấn đề. | | 2 · Danh sách | `demo` mở `/desk/compare.html`, Screen `#/list/Customer`. Hai khung, cùng URL, cùng DocType. Sài Gòn hiện 3 khách hàng, Hà Nội 2. | Mỗi khung gọi cùng một API với header `X-Astra-Tenant` khác nhau; dữ liệu khác nhau. | | 3 · Form | `#/form/Customer/C001` ở cả hai khung. | Cùng một tên chứng từ là hai bản ghi riêng biệt. Tính duy nhất là `(tenant_id, doctype, name)`. | | 4 · Nhận diện | Thứ tự nhận diện: header, subdomain, tenant duy nhất của user. Không nhận diện được: 400. Không phải thành viên: 403. | Không API nào nhận tenant làm tham số; kernel nhận diện một lần cho mỗi request. | | 5 · Link | `GET Customer/C003` với tư cách Hà Nội trả về 404; một đơn của Hà Nội trỏ tới `C003` thất bại với `field 'customer': Customer 'C003' not found`. | Kiểm tra Link chỉ thấy các dòng của tenant hiện tại. | | 6 · Thành viên | `nam` (admin Hà Nội) gọi Sài Gòn nhận `403 not a member of tenant saigon-express`; `demo` không gửi header nhận `400`. | Fail-closed: khi tenant không rõ ràng, kernel từ chối thay vì đoán. | | 7 · RLS | Trong `psql` với role ứng dụng: `visible_rows = 0` khi không có tenant; sau `set_config('astra.tenant','saigon-express',true)`, số dòng theo từng DocType (Customer 3, CustomerOrder 7, Waybill 5, …). | Chính database thực thi ranh giới, kể cả với SQL thô. | | 8 · Code | `TenantContext`, `TenantTransactions`, `RowLevelSecurityGuard`. | Tenant đi theo luồng gọi, không theo danh sách tham số. | | 9 · Tóm tắt | Bốn lớp: một URL, Link, Keycloak theo tenant, không dòng nào khi thiếu tenant. | | Nửa terminal của video là `make demo-express SCENES=7`. Lần chạy được ghi lại (`docs/video/v07-tenants/assets/scene-07.log`) cho thấy đúng các phản hồi này: ```text $ curl -X GET http://localhost:8080/api/doc/Customer -H "Authorization: Bearer " -H "X-Astra-Tenant: saigon-express" HTTP 403 { "error": "not a member of tenant saigon-express" } $ curl -X GET http://localhost:8080/api/doc/Customer -H "Authorization: Bearer " HTTP 400 { "error": "tenant could not be resolved; send X-Astra-Tenant or use a tenant subdomain" } ``` ![Hình 1: Một URL, hai công ty](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-07-figure-1.png) *Một URL, hai công ty.* ## Cách hoạt động Đường đi từ HTTP request đến dòng SQL gồm bốn bước. Mỗi bước là nơi tenant được xác lập hoặc được thực thi, không bao giờ do bên gọi truyền vào. ```mermaid sequenceDiagram participant C as Client (Desk / curl / MCP) participant F as TenantResolverFilter participant R as DocResource participant T as TenantTransactions participant P as PostgreSQL (RLS) C->>F: Bearer JWT (groups /tenants//) + X-Astra-Tenant tuỳ chọn F->>F: header → subdomain → membership duy nhất alt không nhận diện được F-->>C: 400 else không phải thành viên F-->>C: 403 end F->>R: tenantRequest.tenantId(t), roles R->>R: TenantContext.call(t, work) (ScopedValue) R->>T: inTenantTx(work) T->>P: BEGIN; set_config('astra.tenant', t, true) T->>P: SELECT/INSERT … (SQL không có bộ lọc tenant) P-->>T: chỉ các dòng có tenant_id = current_setting('astra.tenant') T->>P: COMMIT (thiết lập biến mất) ``` ### Bước 1: nhận diện tenant, một lần, từ đầu vào đáng tin *`astra-kernel/src/main/java/io/astra/kernel/tenant/TenantResolverFilter.java`* ```java Memberships memberships = Memberships.of(identity); String tenant = fromHeader(ctx).or(() -> fromHost(ctx)).or(() -> onlyMembership(memberships)).orElse(null); if (tenant == null && "single".equals(mode)) { tenant = defaultTenant; } if (tenant == null || !tenant.matches("[a-z0-9][a-z0-9-]{0,62}")) { ctx.abortWith(error(Response.Status.BAD_REQUEST, "tenant could not be resolved; send " + TENANT_HEADER + " or use a tenant subdomain")); return; } Set roles = memberships.roles(tenant); if (roles.isEmpty()) { ctx.abortWith(error(Response.Status.FORBIDDEN, "not a member of tenant " + tenant)); return; } ``` Vì sao có hình dạng này: - **Header là gợi ý, không phải thẩm quyền.** Client có thể *nêu tên* một tenant, nhưng tư cách thành viên được kiểm tra dựa trên bearer token đã xác minh. Membership lấy từ đường dẫn group Keycloak dạng `/tenants//` (`astra-kernel/src/main/java/io/astra/kernel/security/Memberships.java`), nên role luôn gắn với từng tenant: `nam` là admin, nhưng chỉ trong `hanoi-express`. - **User chỉ thuộc một tenant không cần nói gì.** `onlyMembership` chọn tenant duy nhất mà user thuộc về, nên một user như `nam`, chỉ thuộc `hanoi-express`, không cần gửi header. - **Mơ hồ là lỗi.** User thuộc hai tenant mà không gửi gì sẽ nhận 400 thay vì "tenant đầu tiên", vì đoán chính là cách dữ liệu rơi vào nhầm công ty. - **Nhận diện theo subdomain có sẵn nhưng mặc định tắt.** `fromHost` chỉ chạy khi `astra.tenancy.base-domain` được đặt; trong `astra-server/src/main/resources/application.yml` dòng này đang bị comment. ![Hình 2: Ai quyết định tenant](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-07-figure-2.png) *Ai quyết định tenant.* ### Bước 2: mang tenant trong một `ScopedValue`, không phải trong tham số *`astra-spi/src/main/java/io/astra/spi/tenant/TenantContext.java`* ```java /** * The current tenant, carried in a Java 25 {@link ScopedValue}. The kernel binds it once per request; * every kernel service reads it from here. No kernel API accepts a tenant as a caller-supplied * parameter, which is what makes cross-tenant access impossible from app code. */ public final class TenantContext { private static final ScopedValue TENANT = ScopedValue.newInstance(); // … /** The bound tenant; throws when no tenant is in scope, which is always a programming error. */ public static String require() { if (!TENANT.isBound()) { throw new IllegalStateException("No tenant bound in the current scope"); } return TENANT.get(); } ``` Các resource bind nó quanh mỗi lời gọi kernel: *`astra-doctype/src/main/java/io/astra/doctype/api/DocResource.java`* ```java /** Binds the request's tenant into the ScopedValue scope for the duration of the kernel call. */ private T scoped(ScopedValue.CallableOp work) { return TenantContext.call(tenant.tenantId(), work); } ``` Vì sao dùng `ScopedValue` thay vì `ThreadLocal` hay tham số: - **Tham số** có thể do code app cung cấp, nên một hook của app có thể đòi dữ liệu của tenant khác. SPI `DocumentService` mà hook dùng hoàn toàn không có tham số tenant. - **`ThreadLocal`** có thể được gán rồi bị quên, và rò rỉ giữa các thread trong pool. `ScopedValue` bất biến bên trong scope và không được bind bên ngoài, rất hợp với virtual thread: không có gì phải dọn dẹp. - `TenantContext` nằm trong `astra-spi`, nên app có thể *đọc* tenant (ví dụ để ghi log) nhưng cách duy nhất để *bind* một tenant là lời gọi bao quanh code của chúng. ### Bước 3: biến scope thành một thiết lập database chỉ sống trong transaction *`astra-doctype/src/main/java/io/astra/doctype/store/TenantTransactions.java`* ```java String tenant = TenantContext.require(); try (Connection conn = dataSource.getConnection()) { conn.setAutoCommit(false); try { try (PreparedStatement ps = conn.prepareStatement("SELECT set_config('astra.tenant', ?, true)")) { ps.setString(1, tenant); ps.execute(); } T result = ScopedValue.where(TX, conn).call(() -> work.run(conn)); conn.commit(); return result; } catch (Exception e) { conn.rollback(); // … ``` Tham số thứ ba của `set_config` là `true`: thiết lập này **chỉ sống trong transaction**. Khi connection trở về pool, tenant cũng đi theo commit hoặc rollback. Request kế tiếp mượn connection đó bắt đầu mà không có tenant, nghĩa là (bước 4) nó không thấy gì cho đến khi tự bind tenant của mình. Connection đang mở cũng được mang trong một `ScopedValue`, nên một hook ghi Waybill trong lúc một đơn hàng đang được submit sẽ tham gia cùng transaction và cùng tenant. ### Bước 4: để PostgreSQL thực thi *`astra-doctype/src/main/resources/db/migration/V1__astra_doc.sql`* ```sql ALTER TABLE astra_doc ENABLE ROW LEVEL SECURITY; ALTER TABLE astra_doc FORCE ROW LEVEL SECURITY; -- applies to the table owner too CREATE POLICY astra_doc_tenant_isolation ON astra_doc USING (tenant_id = current_setting('astra.tenant', true)) WITH CHECK (tenant_id = current_setting('astra.tenant', true)); ``` - `current_setting('astra.tenant', true)` trả về NULL khi chưa có gì được bind; `tenant_id = NULL` không bao giờ đúng, nên kết quả là **không dòng nào**, không phải lỗi và cũng không phải toàn bộ các dòng. Đó chính là `visible_rows = 0` ở cảnh 7. - `WITH CHECK` chặn một thao tác ghi rơi vào tenant khác, kể cả khi `tenant_id` được insert bị sai. - `FORCE` quan trọng vì role ứng dụng sở hữu các bảng do Flyway tạo; không có `FORCE`, PostgreSQL miễn cho chủ sở hữu khỏi chính các policy của nó. - Cùng khuôn mẫu này được áp dụng trong các migration sau: `astra_series` (V1), sổ cái (V3), overlay (V4) và các bảng bản sao CDC (V5) đều dùng `FORCE ROW LEVEL SECURITY`. Đây là lý do kiểm tra Link không cần bộ lọc tenant. Truy vấn từ chối đơn hàng `C003` của Hà Nội hoàn toàn không nhắc đến tenant: *`astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`* ```java try (PreparedStatement ps = conn.prepareStatement( "SELECT 1 FROM astra_doc WHERE doctype = ? AND name = ?")) { ps.setString(1, f.options().getFirst()); ps.setString(2, target); try (ResultSet rs = ps.executeQuery()) { if (!rs.next()) errors.add("field '" + f.name() + "': " + f.options().getFirst() + " '" + target + "' not found"); } } ``` `C003` có tồn tại, nhưng chỉ dưới `saigon-express`. Bên trong transaction của Hà Nội, RLS ẩn nó đi, nên kết quả là "not found". Người viết SQL không phải nhớ gì cả. ![Hình 3: Không dòng nào khi thiếu tenant](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-07-figure-3.png) *Không dòng nào khi thiếu tenant.* ### Lưới an toàn Tất cả những điều trên đều giả định role database thực sự chịu RLS. Superuser của PostgreSQL và các role có `BYPASSRLS` bỏ qua mọi policy. `RowLevelSecurityGuard` kiểm tra điều này lúc khởi động và dừng kernel; đó là chủ đề của [Video 8](/astra-cms/vi/failclosed). ## Ví dụ thực tế *Tình huống minh hoạ: công ty, con người và các giá trị là hư cấu; cơ chế và các quy tắc được áp dụng là thật, như đã trình bày ở trên.* **Một nền tảng 3PL tại Việt Nam phục vụ nhiều thương hiệu chuyển phát.** Một nhà cung cấp phần mềm logistics chạy một cụm Astra cho ba công ty chuyển phát theo vùng. Mỗi công ty là một tenant. Một điều phối viên tự do, Linh, nhận hợp đồng với hai trong số đó. Đầu vào: - Group Keycloak của Linh: `/tenants/hanoi-express/dispatcher` và `/tenants/saigon-express/dispatcher`. - Cả hai tenant đều có khách hàng `C001` và dãy số đơn hàng `ORD-.YYYYMMDD.-.#####`. - Một báo cáo viết cẩu thả của chuyên viên phân tích bên nhà cung cấp: `SELECT count(*) FROM astra_doc WHERE doctype = 'CustomerOrder'`. Astra xử lý thế nào: 1. Linh mở Desk mà không chọn công ty và gọi `GET /api/doc/CustomerOrder`. Cô thuộc hai tenant và không gửi header, nên kernel trả lời `400 tenant could not be resolved`. Client buộc phải để cô chọn. 2. Cô chọn Hà Nội (`X-Astra-Tenant: hanoi-express`). Bộ lọc kiểm tra token, tìm thấy role dispatcher ở đó và bind `hanoi-express` cho lời gọi. 3. Cô tạo một đơn cho `C001`. Kiểm tra Link tìm thấy `C001` của Hà Nội. Số đơn lấy từ dòng bộ đếm riêng của Hà Nội trong `astra_series`, nên việc đánh số của Sài Gòn không bị ảnh hưởng. 4. Báo cáo của chuyên viên phân tích, khi chạy qua kernel, chỉ đếm đơn của tenant đang được bind. Khi chạy trực tiếp bằng role ứng dụng mà không bind tenant nào, nó trả về `0`. Đầu ra: một đơn ở Hà Nội, Sài Gòn không thấy thay đổi gì, một báo cáo không thể đếm thừa, và không có đoạn code nào phải nhớ `WHERE tenant_id = ?`. So với mô hình mỗi site một database: Linh sẽ phải giữ hai tài khoản đăng nhập, còn nhà cung cấp phải chạy ba database, ba lần migrate và ba lịch backup cho cùng một schema. ## Dành cho tech lead - **Ranh giới nằm ở đâu.** Hai lớp độc lập: bộ lọc của kernel (ai được thao tác trong tenant nào) và RLS của PostgreSQL (transaction được chạm vào những dòng nào). Một bug trong code app không thể vượt tenant vì code app không bao giờ chọn tenant. Một bug trong bộ lọc của kernel vẫn bị RLS chặn đối với mọi thứ chạy bên trong `inTenantTx`. - **Chi phí.** Thêm một câu lệnh `set_config` cho mỗi transaction, và mọi truy vấn đều được đánh giá kèm predicate RLS. Khoá duy nhất và index danh sách trong `V1__astra_doc.sql` đều bắt đầu bằng `tenant_id`, giúp predicate đó rẻ khi tra theo tên và khi liệt kê. Repo chưa công bố benchmark nào về chi phí của RLS; hãy đo trên workload của bạn. - **Lợi ích vận hành.** Một schema, một lần migrate, một bản backup, một connection pool cho mọi tenant. Onboard một tenant trong demo là tạo group Keycloak và dữ liệu, không phải cấp phát database. - **Nên đo gì.** Tỉ lệ 400 và 403 theo từng client (tăng đột biến nghĩa là một client đã mất lựa chọn tenant), các truy vấn bất ngờ trả về không dòng nào (thiếu scope tenant), và số dòng theo tenant trên những bảng lớn nhất (hàng xóm ồn ào). - **Rủi ro.** - Code đi vòng qua `TenantTransactions` (gọi `DataSource` trực tiếp) nhận về không dòng nào thay vì dữ liệu bị rò rỉ. Đó là hướng an toàn, nhưng có thể trông giống bug. - Hàng xóm ồn ào dùng chung CPU, I/O và connection pool. Bulkhead và quota theo tenant là mục tiêu thiết kế, chưa được xây (xem "Nói đúng"). - RLS bảo vệ các dòng, không bảo vệ mọi thứ: cache, log, file và index tìm kiếm cần khoá tenant riêng. - **Khi nào không nên dùng RLS trên schema chung.** Khách hàng có yêu cầu hợp đồng về nơi lưu trữ dữ liệu, khoá riêng hoặc "database của riêng mình". Thiết kế đặt tên các profile `database-per-tenant` và `cluster-per-tenant` cho họ; chưa profile nào được xây. - **So với Frappe.** Frappe cô lập theo site và database; Astra cô lập theo dòng trong một database và coi user thuộc nhiều công ty là trường hợp chính danh. Mô hình của Frappe dễ suy luận hơn với từng khách hàng; mô hình của Astra rẻ hơn khi vận hành với nhiều tenant. ## Tự chạy thử Yêu cầu: JDK 25, Docker (8 GB cho toàn bộ stack), `kubectl`, `k3d`, `jq`, Bash 5. ```bash make k3d-up # 2 kernel pods, PostgreSQL, Redis, Keycloak, Seata, CDC make demo-reset && make demo-seed # tenants saigon-express and hanoi-express open http://localhost:8080/desk/compare.html # log in as demo / Astra@123 first make demo-express SCENES=7 PAUSE=0 # the terminal half of the video ``` Trong `compare.html`, đặt Screen là `#/list/Customer` rồi `#/form/Customer/C001`. Kết quả mong đợi: Sài Gòn liệt kê 3 khách hàng và Hà Nội 2; `C001` là "Shop Hoa Mai" ở Sài Gòn và "Nhà sách Tràng Tiền" ở Hà Nội. Kết quả mong đợi của cảnh 7, theo thứ tự: hai lần đọc `C001` trả `HTTP 200` với hai tên khác nhau; `HTTP 404` cho `C003` ở Hà Nội; `HTTP 422` với `field 'customer': Customer 'C003' not found`; `HTTP 403` cho `nam` trên Sài Gòn; `HTTP 400` cho `demo` không gửi header; rồi trong SQL là `visible_rows = 0`, tiếp theo là số dòng theo từng DocType của Sài Gòn. Phần SQL chỉ chạy khi script kết nối được tới database (`DB=k3d` hoặc `DB=compose`, được tự phát hiện trong `scripts/demo-express.sh`). Integration test `astra-server/src/test/java/io/astra/server/TenantIsolationTest.java` kiểm tra cùng các quy tắc này mà không cần stack demo: `make test`. ## Nói đúng - Đã xây: nhận diện qua header, subdomain (chỉ khi `astra.tenancy.base-domain` được đặt) và membership duy nhất; chế độ `single` với tenant mặc định; RLS trên các bảng chứng từ, dãy số, sổ cái, overlay và bản sao CDC; cơ chế chặn lúc khởi động. - Thiết kế trong [explain.md](/astra-cms/vi/why-astra) mô tả nhiều hơn lát cắt mỏng: một DocType `Tenant` có vòng đời, nhận diện qua claim OIDC và tiền tố đường dẫn, các profile cô lập (`database-per-tenant`, `cluster-per-tenant`), bulkhead theo tenant, quota, đo đếm, tổ chức và chia sẻ, và `astra tenant migrate`. Chưa mục nào được xây. Tenant trong demo là group Keycloak cộng với dữ liệu; quản trị tenant là resource `/api/iam/tenants` của `astra-iam`. - Thứ tự nhận diện đã xây là header → subdomain → membership duy nhất → mặc định (chế độ single). Tài liệu thiết kế liệt kê một thứ tự khác (domain, claim trong token, header, path); code là căn cứ cho những gì đang chạy hôm nay. - RLS chỉ phủ các bảng PostgreSQL. Bộ đếm Redis được scope theo tenant vì `RedisCounters` (`astra-tx-seata`) gắn tiền tố `astra::` cho mọi key, chứ không nhờ một policy của database. - Trong kế hoạch quay ban đầu, đơn hàng `C003` của Hà Nội thất bại vì overlay của Hà Nội bắt buộc trường `note`, chứ không phải vì Link. `scripts/demo-express.sh` giờ gửi kèm `note`, nên lỗi hiển thị là lỗi Link. ## Hình minh hoạ - [Hình 1 · Một URL, hai công ty](#video-cho-thấy-gì) - [Hình 2 · Ai quyết định tenant](#bước-1-nhận-diện-tenant-một-lần-từ-đầu-vào-đáng-tin) - [Hình 3 · Không dòng nào khi thiếu tenant](#bước-4-để-postgresql-thực-thi) ## Liên quan - Trước: [Video 6 · CSKH nhìn thấy vận hành](/astra-cms/vi/care) · Tiếp: [Video 8 · Bảo mật fail-closed](/astra-cms/vi/failclosed) - [Video 11 · Keycloak](/astra-cms/vi/keycloak) (role theo tenant đến từ đâu) · [Video 13 · Overlay theo tenant](/astra-cms/vi/overlay) (metadata theo tenant trên cùng các bảng) - Đi qua code: [demo-code-snippets.md, S4](/astra-cms/vi/code-snippets) - Thiết kế: [explain.md, đa tenant](/astra-cms/vi/why-astra) - Mã nguồn: `astra-kernel/src/main/java/io/astra/kernel/tenant/TenantResolverFilter.java`, `astra-spi/src/main/java/io/astra/spi/tenant/TenantContext.java`, `astra-doctype/src/main/java/io/astra/doctype/store/TenantTransactions.java`, `astra-doctype/src/main/resources/db/migration/V1__astra_doc.sql`, `astra-kernel/src/main/java/io/astra/kernel/security/Memberships.java` --- # Bảo mật fail-closed Tóm tắt một câu: Nếu kernel được trỏ tới một role PostgreSQL sẽ bỏ qua row-level security (một superuser hoặc một role có BYPASSRLS), nó từ chối khởi động, trước khi bất kỳ migration nào chạy, thay vì lặng lẽ phục vụ dữ… Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/failclosed ▶ [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-8-failclosed-vi.mp4) · [English](https://media.zerohostcms.com/astra-cms/astra-video-8-failclosed-en.mp4) · 2:02 · Kịch bản: [demo-video-series.md](/astra-cms/vi/video-scripts) · [Bản tiếng Anh](/astra-cms/vi/failclosed) > **Tóm tắt một câu:** Nếu kernel được trỏ tới một role PostgreSQL sẽ bỏ qua row-level security (một superuser hoặc một role có `BYPASSRLS`), nó từ chối khởi động, trước khi bất kỳ migration nào chạy, thay vì lặng lẽ phục vụ dữ liệu của mọi tenant cho mọi tenant. > > **Nên đọc nếu:** bạn triển khai hoặc cấu hình Astra và muốn biết nó phải dùng role database nào, cũng như chuyện gì xảy ra nếu cấu hình sai (góc nhìn developer) / bạn cần giải thích với kiểm toán viên vì sao một lỗi cấu hình không thể trở thành rò rỉ dữ liệu giữa các tenant (góc nhìn tech lead). ## Vấn đề [Video 7](/astra-cms/vi/tenants) cho thấy hai tenant của Astra Express, `saigon-express` và `hanoi-express`, dùng chung một bảng `astra_doc` và được tách biệt bằng row-level security (RLS) của PostgreSQL. RLS có một ngoại lệ được ghi rõ trong tài liệu: **superuser và các role có thuộc tính `BYPASSRLS` bỏ qua mọi policy.** Điều đó biến việc cô lập tenant thành một câu hỏi cấu hình. Trong stack Docker Compose, role khởi tạo `astra` là superuser (bắt buộc, để tạo các role khác) còn role ứng dụng `astra_app` thì không. Hai chuỗi kết nối chỉ khác nhau bốn ký tự. Nếu ai đó đặt `ASTRA_DB_USER=astra`, có thể để vượt qua một lỗi quyền trong lúc xử lý sự cố, kernel vẫn chạy hoàn hảo: mọi trang đều tải được, mọi test kiểu "tôi có đọc được dữ liệu của mình không" đều qua. Triệu chứng duy nhất là danh sách Customer của Sài Gòn giờ chứa cả khách hàng của Hà Nội. Không có gì ghi log lỗi, vì từ góc nhìn của PostgreSQL thì chẳng có gì sai. Hầu hết hệ thống coi đây là một mục trong checklist triển khai. Frappe/ERPNext né vấn đề bằng cách mỗi site một database, nên không có bảng chung nào để rò rỉ; lỗi tương đương ở đó là trỏ một site vào nhầm database. Thiết kế bảng dùng chung của Astra cần một câu trả lời mạnh hơn checklist: kernel tự kiểm tra role và coi một role không an toàn là lỗi nghiêm trọng. ![Hình 1: Hai chuỗi kết nối, hai kết cục](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-08-figure-1.png) *Hai chuỗi kết nối, hai kết cục.* ## Video cho thấy gì | Cảnh | Bạn thấy gì | Điều được chứng minh | | --- | --- | --- | | 1 · Tiêu đề | "Sai cấu hình, và kernel không chạy." | Đặt vấn đề. | | 2 · Rủi ro | Thuộc tính role trong database Compose: `astra` có `rolsuper = t`, `rolbypassrls = t`; `astra_app` có `f`, `f`. | Chỉ một chuỗi kết nối sai là đủ để vô hiệu hoá việc cô lập trong im lặng. | | 3 · Thử | Một kernel thứ hai khởi động với `ASTRA_DB_USER=astra`. Nó in ra `Database role 'astra' is a SUPERUSER: row-level security would be ignored and tenants would see each other's data.` và thoát với mã 1. | Cơ chế chặn chạy trước Flyway: không migration nào chạy và không DocType nào được nạp. | | 4 · Cơ chế chặn | Code của `RowLevelSecurityGuard`. | Nó đọc `rolsuper` và `rolbypassrls` của `current_user` rồi ném lỗi. Nó được gọi hai lần: trước khi Flyway migrate và lúc khởi động. | | 5 · Role | `deploy/docker/init/01-app-role.sql`. | Role của kernel là `NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE`. `FORCE ROW LEVEL SECURITY` phủ cả chủ sở hữu bảng. | | 6 · Thực tế | Cụm k3d đang chạy: dòng log của kernel `Tenant isolation guard: role 'astra_app' is subject to row-level security`; các role đang kết nối `astra_app` (4 kết nối), `astra_bms` (2), `astra_cdc` (1), tất cả đều `rolsuper = f` và `rolbypassrls = f`. | Bản triển khai có hình dạng production chỉ dùng các role thường. | | 7 · Nói đúng | `astra.tenancy.allow-rls-bypass`, mặc định `false`. | Khi bật, kernel khởi động kèm một cảnh báo. Chỉ dành cho máy dev. | Log đầy đủ của lần khởi động bị từ chối nằm ở `docs/video/v08-failclosed/assets/unsafe-kernel.log`. Phần quan trọng của stack trace là nơi exception phát sinh: ```text Caused by: java.lang.IllegalStateException: Database role 'astra' is a SUPERUSER: row-level security would be ignored and tenants would see each other's data. Connect as a non-superuser application role (see deploy/docker/init/01-app-role.sql). at io.astra.doctype.store.RowLevelSecurityGuard.check(RowLevelSecurityGuard.java:58) at io.astra.doctype.store.RowLevelSecurityGuard_ClientProxy.check(Unknown Source) at io.astra.doctype.store.RowLevelSecurityFlywayCustomizer.customize(RowLevelSecurityFlywayCustomizer.java:20) at io.quarkus.flyway.runtime.FlywayCreator.createFlyway(FlywayCreator.java:155) ``` Cơ chế chặn kích hoạt khi Quarkus vẫn đang *tạo* instance Flyway, tức là trước khi Flyway có thể migrate. ## Cách hoạt động ```mermaid flowchart TD A[Tiến trình kernel khởi động] --> B["Quarkus tạo Flyway
(migrate-at-start: true)"] B --> C["RowLevelSecurityFlywayCustomizer.customize()"] C --> D{"RowLevelSecurityGuard.check()
current_user có rolsuper
hoặc rolbypassrls?"} D -- "không" --> E["Flyway migrate với astra_app
(astra_app sở hữu các bảng)"] E --> F["StartupEvent: check() lần nữa"] F --> G["Log: role 'astra_app' is subject to
row-level security; phục vụ traffic"] D -- "có, allow-rls-bypass=false (mặc định)" --> X["IllegalStateException
mã thoát 1, không gì được migrate"] D -- "có, allow-rls-bypass=true" --> W["WARN … NEVER use this in production
tiếp tục"] ``` ### Phép kiểm tra *`astra-doctype/src/main/java/io/astra/doctype/store/RowLevelSecurityGuard.java`* ```java @ConfigProperty(name = "astra.tenancy.allow-rls-bypass", defaultValue = "false") boolean allowBypass; void onStart(@Observes StartupEvent ev) { check(dataSource); } // … void check(DataSource dataSource) { try (Connection conn = dataSource.getConnection(); Statement st = conn.createStatement(); ResultSet rs = st.executeQuery( "SELECT current_user, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user")) { if (!rs.next()) throw new IllegalStateException("cannot read role attributes for current_user"); String role = rs.getString(1); boolean superuser = rs.getBoolean(2); boolean bypass = rs.getBoolean(3); if (superuser || bypass) { String msg = "Database role '" + role + "' is " + (superuser ? "a SUPERUSER" : "BYPASSRLS") + ": row-level security would be ignored and tenants would see each other's data. " + "Connect as a non-superuser application role (see deploy/docker/init/01-app-role.sql)."; if (allowBypass) { LOG.warn(msg + " Continuing because astra.tenancy.allow-rls-bypass=true. NEVER use this in production."); } else { throw new IllegalStateException(msg); } } else { LOG.infof("Tenant isolation guard: role '%s' is subject to row-level security", role); } } catch (SQLException e) { throw new IllegalStateException("cannot verify row-level security role attributes", e); } } ``` Những lựa chọn thiết kế đáng chú ý: - **Hỏi database, không hỏi cấu hình.** Cơ chế chặn không so `ASTRA_DB_USER` với một danh sách cấm. Nó hỏi PostgreSQL xem role đang kết nối *thực chất là gì*. Một role được đổi sang tên vô hại, hay một role thường về sau được cấp `BYPASSRLS`, vẫn bị bắt. - **Mọi nhánh lỗi đều đóng.** Nếu không đọc được dòng role, hoặc chính truy vấn thất bại, cơ chế chặn cũng ném lỗi. "Không xác minh được" được đối xử như "không an toàn". - **Lối thoát hiểm phải tường minh và ồn ào.** `astra.tenancy.allow-rls-bypass` mặc định là `false`. Bật nó lên không che giấu vấn đề; nó chỉ đổi exception thành một cảnh báo ghi "NEVER use this in production" ở mỗi lần khởi động. - **Nhánh thành công cũng ghi log.** Dòng `INFO` ở một lần khởi động lành mạnh là bằng chứng mà người vận hành hay kiểm toán viên có thể grep, đó là điều cảnh 6 cho thấy từ cụm k3d. ### Vì sao nó chạy trước Flyway *`astra-doctype/src/main/java/io/astra/doctype/store/RowLevelSecurityFlywayCustomizer.java`* ```java /** * Runs the tenant isolation guard before Flyway migrates the default datasource at start. Quarkus migrates * before {@code StartupEvent} observers run, so without this a kernel started as a superuser would apply the * migrations (and own the tables it creates) before refusing to start. */ @Singleton public class RowLevelSecurityFlywayCustomizer implements FlywayConfigurationCustomizer { @Inject RowLevelSecurityGuard guard; @Override public void customize(FluentConfiguration configuration) { guard.check(configuration.getDataSource()); } } ``` Thứ tự quan trọng vì quyền sở hữu. Role ứng dụng sở hữu các bảng do Flyway tạo, và các migration phụ thuộc vào điều đó (xem `FORCE` bên dưới). Nếu một superuser chạy migration đầu tiên, superuser sẽ sở hữu `astra_doc`, và kernel chỉ từ chối khởi động *sau khi* đã để schema rơi vào tay sai người. Một phiên bản demo trước đây đã làm đúng như vậy: brief của video ghi rằng superuser đã tạo các bảng V3 đến V5 trước khi cơ chế chặn dừng nó lại. Móc vào bước cấu hình của Flyway đóng cửa sổ đó. Lần gọi thứ hai ở `StartupEvent` phủ các bản triển khai chạy migration riêng và chạy kernel với migration tắt. ![Hình 2: Cơ chế chặn đứng trước Flyway](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-08-figure-2.png) *Cơ chế chặn đứng trước Flyway.* ### Role mà kernel phải dùng *`deploy/docker/init/01-app-role.sql`* ```sql -- Runs once at database initialisation (docker-entrypoint-initdb.d), as the bootstrap superuser. -- The kernel never connects as a superuser: superusers and BYPASSRLS roles ignore row-level -- security, which would silently disable tenant isolation. The application role below owns the -- schema objects Flyway creates; FORCE ROW LEVEL SECURITY keeps the owner subject to policies. CREATE ROLE astra_app LOGIN PASSWORD 'astra_app' NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE NOINHERIT; GRANT CONNECT ON DATABASE astra TO astra_app; GRANT USAGE, CREATE ON SCHEMA public TO astra_app; ``` Câu lệnh tương tự có trong manifest k3d (`deploy/k8s/base/postgres.yaml`, ConfigMap `postgres-init`), và username mặc định của kernel là `astra_app` (`astra-server/src/main/resources/application.yml`). Các role khác trong cụm cũng đều là role thường: `astra_bms` (các nhánh Seata AT) là `NOSUPERUSER NOBYPASSRLS`, và `astra_cdc` (`deploy/db/20-cdc.sql`) có `REPLICATION` để đọc WAL nhưng vẫn `NOSUPERUSER NOBYPASSRLS`. ### Hai lớp, không phải một *`astra-doctype/src/main/resources/db/migration/V1__astra_doc.sql`* ```sql ALTER TABLE astra_doc ENABLE ROW LEVEL SECURITY; ALTER TABLE astra_doc FORCE ROW LEVEL SECURITY; -- applies to the table owner too ``` Mặc định PostgreSQL không áp RLS lên chủ sở hữu bảng. Vì `astra_app` sở hữu các bảng, chỉ `ENABLE` thôi sẽ để kernel được miễn khỏi chính các policy của nó. `FORCE` xoá bỏ sự miễn trừ đó. Cơ chế chặn và `FORCE` bịt những lỗ hổng khác nhau: | Lỗ hổng | Được bịt bởi | | --- | --- | | Role đang kết nối là superuser hoặc có `BYPASSRLS` | `RowLevelSecurityGuard` (từ chối khởi động) | | Role đang kết nối sở hữu bảng | `FORCE ROW LEVEL SECURITY` trong mọi migration có scope theo tenant | | Không có tenant nào được bind trong transaction | Policy so sánh với `current_setting('astra.tenant', true)`, giá trị này là NULL, nên không dòng nào ([Video 7](/astra-cms/vi/tenants)) | ![Hình 3: Hai lớp, ba lỗ hổng](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-08-figure-3.png) *Hai lớp, ba lỗ hổng.* ## Ví dụ thực tế *Tình huống minh hoạ: công ty, con người và các giá trị là hư cấu; cơ chế và các quy tắc được áp dụng là thật, như đã trình bày ở trên.* **Một SaaS kế toán giữa sự cố lúc 2 giờ sáng.** Một nhà cung cấp chạy Astra cho sổ cái của 40 công ty nhỏ trên một cụm Kubernetes. Sau khi khôi phục database, kernel báo lỗi quyền trên một bảng. Kỹ sư trực, dưới áp lực, sửa Secret `astra-db` để `username` thành superuser khởi tạo, rồi restart deployment. Đầu vào: - Secret `astra-db`: `username` đổi từ `astra_app` sang superuser. - Deployment `astra-server`: 2 replica, `RollingUpdate` với `maxUnavailable: 0` và `maxSurge: 1`, `readinessProbe` trên `/q/health/ready` (`deploy/k8s/base/astra-server.yaml`). Astra xử lý thế nào: 1. Pod mới đầu tiên khởi động, Quarkus chuẩn bị Flyway, customizer gọi cơ chế chặn, và cơ chế chặn đọc được `rolsuper = t` cho role đang kết nối. 2. Cơ chế chặn ném `Database role '…' is a SUPERUSER: …`; tiến trình thoát với mã 1 trước mọi migration và trước khi phục vụ bất kỳ HTTP request nào. 3. Pod không bao giờ báo ready. Vì `maxUnavailable` là 0, Kubernetes giữ hai pod cũ, vẫn đang kết nối bằng `astra_app`, tiếp tục phục vụ traffic. Rollout bị treo thay vì hoàn tất. Đầu ra: không tenant nào nhìn thấy sổ cái của tenant khác, người dùng tiếp tục làm việc trên các pod cũ, và log của pod nêu nguyên nhân cùng cách sửa trong một dòng. Kỹ sư quay sang sửa vấn đề quyền thật sự (ví dụ một quyền grant bị mất trong lúc khôi phục). Việc rollout bị treo ở bước 3 là hành vi chuẩn của Kubernetes với các thiết lập trong manifest; video minh hoạ bước 2 bằng một tiến trình cục bộ, không phải đúng lần rollout này. Nếu không có cơ chế chặn, cùng sự cố đó sẽ kết thúc bằng một hệ thống chạy bình thường và một vụ rò rỉ dữ liệu phải báo cáo, được phát hiện vào lúc nào đó khi một khách hàng tình cờ thấy hoá đơn của người khác. ## Dành cho tech lead - **Nó mang lại gì.** Nó biến cách dễ xảy ra nhất để vô hiệu hoá việc cô lập tenant, một chuỗi kết nối sai, từ một vụ rò rỉ âm thầm thành một lần triển khai thất bại. Với kiểm toán viên, control này là code (`RowLevelSecurityGuard`), cộng một dòng log ở mỗi lần khởi động lành mạnh, cộng DDL của role. - **Chi phí.** Một truy vấn vào `pg_roles` mỗi lần khởi động, hai lần. Về vận hành, cái giá là đội ngũ phải giữ một role khởi tạo riêng cho việc quản trị và không bao giờ dùng lại nó cho ứng dụng. - **Những gì nó không phủ.** - Một role không phải superuser nhưng sở hữu bảng, về nguyên tắc, có thể chạy `ALTER TABLE … NO FORCE ROW LEVEL SECURITY` hoặc xoá một policy. Không có gì trong kernel làm vậy, và cơ chế chặn không phát hiện việc đó. Hãy coi migration là code phải được review. - Cơ chế chặn chỉ kiểm tra datasource mặc định. Datasource BMS dùng cho Seata AT (`astra-tx-seata/src/main/resources/db/bms`) không có policy RLS; các dòng của nó mang tenant trong khoá. - Truy cập database trực tiếp bởi con người hay công cụ khác nằm ngoài tầm kiểm soát của kernel. Hãy cấp cho người dùng cùng các role thường đó và kiểm toán các phiên superuser. - **Nên đo gì.** Cảnh báo nếu dòng `Tenant isolation guard: role '…' is subject to row-level security` vắng mặt ở một lần khởi động kernel, và cảnh báo với mọi warning `allow-rls-bypass=true` ngoài môi trường dev. Định kỳ liệt kê các role đang kết nối có `rolsuper` hoặc `rolbypassrls`; cảnh 6 cho thấy kết quả mong đợi của cụm. - **Khi nào được bật công tắc.** `astra.tenancy.allow-rls-bypass=true` tồn tại cho laptop của developer, nơi user database duy nhất là superuser. Nó không bao giờ nên xuất hiện trong cấu hình của một môi trường dùng chung; hãy cân nhắc cho CI thất bại khi gặp nó. - **So với các phương án khác.** Thiết kế mỗi tenant một database (site của Frappe) không gặp rủi ro cụ thể này nhưng trả giá bằng chi phí vận hành. Bộ lọc `WHERE tenant_id` ở tầng ứng dụng hoàn toàn không có phép kiểm tra fail-closed tương đương: thiếu bộ lọc thì hỏng theo hướng mở. ## Tự chạy thử Yêu cầu: JDK 25, Docker, `jq`, Bash 5, và một kernel Astra truy cập được tại `BASE` (mặc định `http://localhost:8080`), vì `scripts/lib/demo-common.sh` kiểm tra `/api/iam/config` trước khi chạy bất kỳ cảnh nào. Stack k3d (`make k3d-up`) đáp ứng điều này; bản ghi hình đã dùng nó. ```bash make infra-up && make build # Compose PostgreSQL (superuser astra, app role astra_app) + the kernel jar make demo-express SCENES=8 PAUSE=0 # starts a throwaway kernel on :8099 as the superuser ``` Kết quả mong đợi (`docs/video/v08-failclosed/assets/scene-08.log`): ```text ━━━ Scene 8 · The kernel refuses to run unsafely ━━━ ▸ Start a second kernel connected as the PostgreSQL superuser (RLS would be ignored)… $ ASTRA_DB_USER=astra java -jar astra-server/target/quarkus-app/quarkus-run.jar Database role 'astra' is a SUPERUSER: row-level security would be ignored and tenants would see each other's data. ▸ …it refuses to start. Tenant isolation cannot be switched off by a misconfigured connection. ``` Nếu stack Compose chưa chạy hoặc thiếu file jar, cảnh này in ra `Needs the Docker Compose stack (make infra-up) and a build (make build)` thay thế. Để xem nhánh lành mạnh trên cụm k3d, tìm dòng `INFO` của cơ chế chặn trong log của một pod kernel: ```bash kubectl -n astra logs deployment/astra-server | grep "Tenant isolation guard" ``` Kết quả mong đợi: `Tenant isolation guard: role 'astra_app' is subject to row-level security`. ## Nói đúng - Công tắc `astra.tenancy.allow-rls-bypass` có tồn tại và mặc định là `false`. Khi là `true`, một role không an toàn chỉ sinh ra cảnh báo và kernel vẫn khởi động. Chỉ dành cho máy dev. - Đừng hiểu điều này thành "role ứng dụng không thể tắt RLS". Với tư cách chủ sở hữu bảng, `astra_app` về lý thuyết có thể thay đổi thiết lập RLS trên chính các bảng của nó. `FORCE ROW LEVEL SECURITY` và cơ chế chặn là hai lớp riêng biệt; không lớp nào ngăn được một thay đổi DDL cố ý của chủ sở hữu. - Cơ chế chặn kiểm tra thuộc tính role lúc khởi động. Một role bị đổi sang `BYPASSRLS` trong khi kernel đang chạy sẽ không bị phát hiện cho đến lần khởi động kế tiếp. - Chỉ datasource mặc định được kiểm tra. Schema BMS không chịu RLS. - Không có test tự động nào trong repository kiểm tra cơ chế chặn; bằng chứng là lần chạy cảnh 8 và các log được ghi lại trong `docs/video/v08-failclosed/assets/`. ## Hình minh hoạ - [Hình 1 · Hai chuỗi kết nối, hai kết cục](#vấn-đề) - [Hình 2 · Cơ chế chặn đứng trước Flyway](#vì-sao-nó-chạy-trước-flyway) - [Hình 3 · Hai lớp, ba lỗ hổng](#hai-lớp-không-phải-một) ## Liên quan - Trước: [Video 7 · Đa thuê bao](/astra-cms/vi/tenants) · Tiếp: [Video 9 · Dành cho developer: viết một app Astra](/astra-cms/vi/developer) - [Video 10 · Chạy trên Kubernetes](/astra-cms/vi/k8s) (bản triển khai mà cơ chế chặn bảo vệ) · [Video 16 · Rust CDC](/astra-cms/vi/cdc) (role replication `astra_cdc`) - Đi qua code: [demo-code-snippets.md, S4](/astra-cms/vi/code-snippets) - Mã nguồn: `astra-doctype/src/main/java/io/astra/doctype/store/RowLevelSecurityGuard.java`, `astra-doctype/src/main/java/io/astra/doctype/store/RowLevelSecurityFlywayCustomizer.java`, `deploy/docker/init/01-app-role.sql`, `deploy/k8s/base/postgres.yaml`, `astra-doctype/src/main/resources/db/migration/V1__astra_doc.sql` --- # Chạy trên Kubernetes Tóm tắt một câu: Astra chạy thành hai replica kernel không trạng thái ngay từ ngày đầu; số chứng từ luôn liên tục và duy nhất giữa các pod vì bộ đếm là một lệnh upsert PostgreSQL dưới khoá dòng, nằm bên trong chính… Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/k8s ▶ [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-10-k8s-vi.mp4) · [English](https://media.zerohostcms.com/astra-cms/astra-video-10-k8s-en.mp4) · 2:06 · Kịch bản: [demo-video-series.md](/astra-cms/vi/video-scripts) · [Bản tiếng Anh](/astra-cms/vi/k8s) > **Tóm tắt một câu:** Astra chạy thành hai replica kernel không trạng thái ngay từ ngày đầu; số chứng từ luôn liên tục và duy nhất giữa các pod vì bộ đếm là một lệnh upsert PostgreSQL dưới khoá dòng, nằm bên trong chính transaction của chứng từ, và một lần rolling restart giữ cho dịch vụ gần như, nhưng chưa hoàn toàn, không gián đoạn. > > **Nên đọc nếu:** bạn muốn biết vì sao `ORD-20261021-00007` không bao giờ bị cấp hai lần kể cả khi hai pod ghi cùng lúc, và cần thay đổi gì trước khi dựa vào rolling restart (góc nhìn developer) / bạn đang định hình mô hình vận hành: phần nào không trạng thái, phần nào có, và một lần deploy hôm nay khiến người dùng chịu gì (góc nhìn tech lead). ## Vấn đề Astra Express tạo đơn hàng từ Desk, từ API và từ một agent AI. Mỗi đơn có một số dễ đọc như `ORD-20261021-00007`: tiền tố, ngày, rồi một bộ đếm năm chữ số theo tenant theo ngày. Nhân viên đọc các số này qua điện thoại, in chúng lên vận đơn và đối soát COD theo chúng. Có hai tính chất quan trọng: **không trùng** (hai bưu kiện cùng số đơn là một sự cố vận hành) và **không hổng số** (một số bị thiếu khiến kiểm toán viên hỏi cái gì đã bị xoá). Hệ thống một máy chủ có cả hai mà không tốn gì: một tiến trình, một bộ đếm. Ngay khi chạy hai replica, mỗi cách làm thông thường lại phá vỡ một tính chất: - **Bộ đếm trong bộ nhớ của từng tiến trình** sinh ra số trùng. - **`SEQUENCE` của database** duy nhất và nhanh, nhưng không mang tính giao dịch: một insert bị rollback vẫn tiêu một giá trị, nên xuất hiện lỗ hổng. Nó cũng là toàn cục, không theo tenant và theo ngày. - **`INCR` của Redis** duy nhất giữa các pod nhưng nằm ngoài transaction của chứng từ, nên một lần lưu thất bại để lại lỗ hổng, và Redis trở thành một phần của tính đúng đắn của việc đánh số. - **Frappe/ERPNext** giữ bộ đếm naming series trong một bảng database, cập nhật bên trong transaction của request, cùng họ giải pháp với Astra. Khác biệt là kernel của Astra được thiết kế để chạy nhiều replica ngay từ đầu, nên cơ chế phải được chứng minh khi có nhiều bên ghi đồng thời. Nửa sau của vấn đề là triển khai. Một lần rolling restart khi người dùng đang online không được đăng xuất ai hay làm mất một cú click nào. Video đo xem manifest hiện tại tiến gần đến mức nào. ## Video cho thấy gì | Cảnh | Bạn thấy gì | Điều được chứng minh | | --- | --- | --- | | 1 · Tiêu đề | "Hai pod, một dãy số liên tục." | Đặt vấn đề. | | 2 · Triển khai | `kubectl` cho thấy `astra-server` `2/2` ready; manifest có `replicas: 2`, `RollingUpdate` với `maxUnavailable: 0`, readiness probe và liveness probe. Các pod khác: `astra-cdc`, `keycloak`, `postgres-0`, `redis`, `seata-server`. | Hai replica ngay từ đầu, vì đa thuê bao và đánh số phải an toàn trên cụm. | | 3 · Song song | 12 đơn được tạo cùng lúc, 6 đơn gửi thẳng vào mỗi pod. Tất cả trả về `201`. Các số chạy từ `ORD-20261021-00007` đến `ORD-20261021-00018`, không trùng, không bỏ số; pod nào nhận số nào thì xen kẽ nhau. | Bộ đếm an toàn khi có nhiều bên ghi đồng thời trên các pod khác nhau. | | 4 · Desk | Danh sách Customer Order trong Desk hiện cùng dãy số liên tục đó. | Khuôn mẫu đếm theo tenant và theo ngày. | | 5 · Code | `SeriesService`: một upsert, một khoá dòng. | Pod thứ hai chờ khoá của pod thứ nhất; rollback cũng rollback luôn con số. Không có sequence riêng, không có Redis. | | 6 · Rollout | `kubectl -n astra rollout restart deployment/astra-server` trong khi một client đọc đơn hàng mỗi 0,25 s qua ingress. 47 request: 45 trả về `200`, 2 không nhận được phản hồi khi một pod cũ dừng. | Gần như không gián đoạn. | | 7 · Nói đúng | Không có preStop hook, không có thời gian drain. | Chưa phải zero downtime: một thao tác trên Desk có thể phải thử lại. | Ánh xạ pod với số đơn được ghi lại (`docs/video/v10-k8s/assets/parallel-sorted.txt`): ```text astra-server-c6966f6f6-p56xb 201 ORD-20261021-00007 astra-server-c6966f6f6-th87l 201 ORD-20261021-00008 astra-server-c6966f6f6-th87l 201 ORD-20261021-00009 astra-server-c6966f6f6-p56xb 201 ORD-20261021-00010 astra-server-c6966f6f6-p56xb 201 ORD-20261021-00011 astra-server-c6966f6f6-th87l 201 ORD-20261021-00012 astra-server-c6966f6f6-p56xb 201 ORD-20261021-00013 astra-server-c6966f6f6-p56xb 201 ORD-20261021-00014 astra-server-c6966f6f6-th87l 201 ORD-20261021-00015 astra-server-c6966f6f6-p56xb 201 ORD-20261021-00016 astra-server-c6966f6f6-th87l 201 ORD-20261021-00017 astra-server-c6966f6f6-th87l 201 ORD-20261021-00018 ``` Dãy số bắt đầu từ 7 vì `make demo-seed` đã tạo sáu đơn trong ngày hôm đó. ## Cách hoạt động ### Bản triển khai *`deploy/k8s/base/astra-server.yaml`* ```yaml spec: replicas: 2 # two pods from the start: tenancy and naming series must be cluster-safe selector: matchLabels: app: astra-server strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 0 maxSurge: 1 # … readinessProbe: httpGet: { path: /q/health/ready, port: 8080 } periodSeconds: 5 livenessProbe: httpGet: { path: /q/health/live, port: 8080 } periodSeconds: 10 resources: requests: { cpu: 250m, memory: 512Mi } limits: { memory: 1536Mi } # ZGC heap is 75 % of this; the Timefold solver needs headroom ``` Cùng file này còn định nghĩa một `Service`, một `Ingress` (đường dẫn `/auth` tới Keycloak, `/` tới kernel) và một `PodDisruptionBudget` với `minAvailable: 1`. Vì sao hai replica ngay từ đầu thay vì "để sau rồi scale": bất cứ thứ gì chỉ chạy được trên một pod (một bộ đếm trong bộ nhớ, một cache cục bộ bị coi là sự thật, một sticky session) sẽ ẩn mình cho đến lần scale-out đầu tiên, thường vào lúc tệ nhất. Chạy hai pod trong demo buộc mọi tính năng phải an toàn trên cụm ngay bây giờ. Kernel không giữ trạng thái phiên: danh tính đến từ bearer token trên mỗi request, tenant từ request (xem [Video 7](/astra-cms/vi/tenants)), và chứng từ từ PostgreSQL. `maxUnavailable: 0` với `maxSurge: 1` nghĩa là Kubernetes khởi động một pod mới, chờ nó qua readiness, rồi mới dừng một pod cũ. Theo thiết kế, năng lực phục vụ không bao giờ xuống dưới hai pod ready. Khoảng hở còn lại được nói ở phần "Nói đúng". ### Bộ đếm *`astra-doctype/src/main/java/io/astra/doctype/store/SeriesService.java`* ```java /** * Naming series such as {@code ART-.#####} or {@code ORD-.YYYYMMDD.-.#####}: literal parts and date * tokens ({@code YYYY}, {@code YY}, {@code MM}, {@code DD}) separated by dots, then a zero-padded * counter whose width is the number of {@code #}. Counters live per tenant in {@code astra_series}, * keyed by the pattern with its date tokens resolved, so dated series restart every period. The row * lock taken by the upsert makes the sequence gap-free within the caller's transaction and safe across pods. */ // … try (PreparedStatement ps = conn.prepareStatement( "INSERT INTO astra_series (tenant_id, series_key, current) VALUES (?, ?, 1) " + "ON CONFLICT (tenant_id, series_key) DO UPDATE SET current = astra_series.current + 1 " + "RETURNING current")) { ps.setString(1, TenantContext.require()); ps.setString(2, key); try (ResultSet rs = ps.executeQuery()) { rs.next(); return prefix + String.format("%0" + width + "d", rs.getLong(1)); } } ``` Khuôn mẫu đến từ DocType: *`astra-apps/express/src/main/resources/META-INF/astra/doctypes/customer-order.doctype.yaml`* ```yaml naming: { series: "ORD-.YYYYMMDD.-.#####" } ``` Và bảng bộ đếm, được tạo trong migration đầu tiên với cùng policy tenant như chứng từ: *`astra-doctype/src/main/resources/db/migration/V1__astra_doc.sql`* ```sql -- Cluster-safe naming series, gap-free inside the enclosing transaction. CREATE TABLE astra_series ( tenant_id text NOT NULL, series_key text NOT NULL, current bigint NOT NULL DEFAULT 0, PRIMARY KEY (tenant_id, series_key) ); ``` Chi tiết then chốt là `next` chạy trên *connection nào*. `DocumentStore` gọi nó bằng chính connection của chứng từ, bên trong cùng transaction insert chứng từ: *`astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`* ```java private Document persistNew(Connection conn, DocTypeDefinition def, Document doc) throws SQLException { DocumentValidator.applyDefaultsAndValidate(def, doc); hooks.beforeSave(doc); validateLinks(conn, def, doc); if (doc.name() == null) doc.name(assignName(conn, def, doc)); try (PreparedStatement ps = conn.prepareStatement( "INSERT INTO astra_doc (id, tenant_id, doctype, name, owner, data, amended_from) " + "VALUES (?, ?, ?, ?, ?, ?::jsonb, ?) RETURNING *")) { // … ``` ```mermaid sequenceDiagram participant A as Pod p56xb participant B as Pod th87l participant P as PostgreSQL astra_series A->>P: BEGIN; set_config('astra.tenant', 'saigon-express', true) B->>P: BEGIN; set_config('astra.tenant', 'saigon-express', true) A->>P: INSERT … ON CONFLICT DO UPDATE current+1 RETURNING current P-->>A: 7 (dòng ORD-20261021-##### bị A khoá) B->>P: INSERT … ON CONFLICT DO UPDATE current+1 Note over B,P: B chờ khoá dòng của A A->>P: INSERT astra_doc ORD-20261021-00007; COMMIT P-->>B: 8 (khoá được nhả, B thấy 7) B->>P: INSERT astra_doc ORD-20261021-00008; COMMIT ``` Vì sao thiết kế như vậy: - **Tính duy nhất** đến từ khoá dòng. `ON CONFLICT … DO UPDATE` khoá dòng bộ đếm; một transaction thứ hai tăng cùng key phải chờ đến khi transaction thứ nhất commit hoặc rollback, rồi mới thấy giá trị mới. - **Không hổng số** đến từ việc dùng chung transaction. Nếu validation, một hook hoặc lệnh insert thất bại sau khi số đã được cấp, toàn bộ transaction rollback, kể cả lần tăng bộ đếm, nên đơn tiếp theo nhận đúng số đó. `SEQUENCE` của database không làm được điều này. - **Theo tenant, theo kỳ** đến từ key. Khoá chính là `(tenant_id, series_key)` và key đã được thay ngày vào (`ORD-.20261021.-.#####`), nên mỗi tenant có dòng riêng cho mỗi ngày và việc đánh số bắt đầu lại hằng ngày mà không cần job reset. Bảng cũng chịu RLS, nên một tenant thậm chí không đọc được bộ đếm của tenant khác. - **Không cần thêm hạ tầng.** Tính đúng đắn chỉ phụ thuộc vào PostgreSQL. Redis chậm hay sập cũng không ảnh hưởng tới việc đánh số. Cái giá là tuần tự hoá: mọi lần tạo trên dãy số của một tenant trong một ngày đều xếp hàng trên một dòng, và khoá được giữ từ `assignName` đến khi transaction commit, bao gồm lệnh insert và mọi hook `afterSave`. Với một tenant tạo vài đơn mỗi giây thì điều này không thể nhận ra; xem "Dành cho tech lead" để biết khi nào thì có. ![Hình 1: Hai pod, một dòng bộ đếm](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-10-figure-1.png) *Hai pod, một dòng bộ đếm.* ![Hình 2: Rollback trả lại con số](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-10-figure-2.png) *Rollback trả lại con số.* ### Rolling restart Probe được ghi lại (`docs/video/v10-k8s/assets/rollout-summary.txt`) đếm mã phản hồi của 47 lần đọc gửi mỗi 0,25 s qua ingress trong lúc `rollout restart`: ```text 2 000 45 200 ``` `000` nghĩa là curl hoàn toàn không nhận được phản hồi HTTP. Theo brief của video, một trong hai lần đó chạm timeout 5 s của probe, đúng lúc một pod cũ dừng. Nguyên nhân nằm ở những gì manifest *không* có: không có hook `preStop` và không có độ trễ drain. Khi Kubernetes đánh dấu một pod để kết thúc, pod nhận `SIGTERM` gần như cùng lúc ingress controller biết để ngừng định tuyến tới nó. Các request mà ingress gửi đi trong khoảng đó tới một tiến trình đã bắt đầu tắt. Chính các script demo cho thấy cách giảm thiểu phía client. `scripts/lib/demo-common.sh` thử lại một request tối đa 15 lần khi ingress trả `502` hoặc `503`, vì "the ingress found no ready pod (a rollout is draining), so nothing ran yet". Cách đó phủ 502/503, không phủ một kết nối bị rớt, mà đó lại là thứ probe gặp phải. ![Hình 3: Rolling restart, khoảng hở còn lại](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-10-figure-3.png) *Rolling restart, khoảng hở còn lại.* ## Ví dụ thực tế **Đỉnh 11.11 của một 3PL với phiếu nộp tiền COD đánh số theo tháng.** Một công ty chuyển phát chạy Astra với hai pod kernel. Trong đợt cao điểm của lễ hội mua sắm, nhân viên bán hàng của tenant Sài Gòn và một tích hợp với sàn thương mại điện tử cùng tạo đơn, còn kế toán lập phiếu nộp tiền COD theo lô, dùng dãy số `CODR-.YYMM.-.####` (`astra-apps/express/src/main/resources/META-INF/astra/doctypes/cod-remittance.doctype.yaml`). Đầu vào: - 08:00:00.100: tích hợp với sàn gửi một đơn, được định tuyến tới pod A. - 08:00:00.105: một nhân viên bán hàng lưu một đơn trong Desk, được định tuyến tới pod B. - 08:00:00.110: một đơn thứ ba từ pod A qua được validation, nhưng một hook `afterSave` ném lỗi sau lệnh insert. (Validation và `beforeSave` chạy trước `assignName`, nên lỗi ở đó không bao giờ chạm tới bộ đếm.) - 23:30: trưởng nhóm vận hành chạy rolling restart để nhận một thay đổi cấu hình. Astra xử lý thế nào: 1. Transaction của pod A lấy dòng bộ đếm của ngày và nhận `ORD--01234`. Upsert của pod B chờ khoá dòng đó, rồi nhận `01235` sau khi A commit. 2. Đơn thứ ba nhận `01236` bên trong transaction của nó; hook `afterSave` ném lỗi; transaction rollback, và bộ đếm quay về `01235`. Đơn thành công tiếp theo là `01236`. 3. Lúc 23:30 một pod mới khởi động và ready trước khi một pod cũ bị dừng (`maxSurge: 1`, `maxUnavailable: 0`). Một request rơi vào pod đang dừng đúng khoảnh khắc cuối có thể thất bại; người dùng bấm Save lại. Đầu ra: một dãy số đơn liên tục trong ngày cho mỗi tenant, không có nhãn vận đơn trùng, một dãy số phiếu nộp tiền COD bắt đầu lại mỗi tháng mà không cần cron job, và nhiều nhất là một lần thử lại cho người dùng gặp đúng lúc restart. Các số kiểu 01234 chỉ để minh hoạ; lần chạy được đo là dãy 12 đơn ở trên. ## Dành cho tech lead - **Phần nào không trạng thái.** Các pod kernel: danh tính theo từng request từ token Keycloak, tenant theo từng request, dữ liệu trong PostgreSQL. Bạn có thể thêm replica mà không cần session affinity. - **Phần nào có trạng thái.** PostgreSQL (`postgres-0`, một StatefulSet trong demo), database của Keycloak, Redis (giữ chỗ TCC) và Seata server. `deploy/k8s/base/postgres.yaml` nói thẳng: "Demo-grade PostgreSQL. Production uses an operator (CloudNativePG, Patroni) or a managed service." - **Trần thông lượng của việc đánh số.** Một dòng bộ đếm cho mỗi tenant, dãy số và kỳ. Theo kinh nghiệm, nếu một transaction tạo chứng từ giữ khoá trong *t* ms, dãy số đó đạt tối đa khoảng 1000/*t* lần tạo mỗi giây, bất kể số pod. Hãy để việc nặng ra khỏi hook `afterSave` trên các DocType khối lượng lớn, hoặc chọn key thô hơn chỉ khi nghiệp vụ chấp nhận. Repo chưa công bố load test nào cho mức trần này. - **"Không hổng số" có phạm vi.** Các số liên tục đối với những lần tạo đã commit. Chứng từ bị Cancel giữ nguyên số của nó; một bản Amend nhận tên của chứng từ bị Cancel kèm hậu tố `-1` thay vì một số mới. `DocResource` không có endpoint xoá chứng từ, nên các số đã commit không biến mất. - **Múi giờ.** Phần ngày lấy từ `LocalDate.now()` theo múi giờ mặc định của JVM, và `astra-server/src/main/docker/Dockerfile.jvm` không đặt múi giờ. Nếu ranh giới ngày phải theo giờ Việt Nam, hãy đặt múi giờ cho pod một cách tường minh. - **Chi phí deploy hôm nay.** Đã đo: 2 trên 47 lần đọc thất bại trong một lần rolling restart. Trước khi hứa deploy zero downtime, hãy thêm độ trễ `preStop`, một termination grace period đủ phủ các request đang xử lý, và cơ chế thử lại phía client cho các lời gọi idempotent. - **Nên đo gì.** Thời gian chờ khoá trên `astra_series` (wait event trong `pg_stat_activity` của PostgreSQL), tỉ lệ lỗi ở ingress trong mỗi lần rollout, thời gian để một pod mới ready, và bộ nhớ so với giới hạn 1536 Mi. - **Khi nào không nên dùng bộ đếm này.** Các định danh khối lượng rất lớn, không mang tính tài chính (event ID, lượt quét theo dõi) không cần đánh số liên tục; đặt tên bằng UUID (mặc định khi không đặt series) tránh hẳn khoá này. ## Tự chạy thử Yêu cầu: JDK 25, Docker với 8 GB RAM, `kubectl`, `k3d`, `jq`, Bash 5. ```bash make k3d-up # builds images, deploys deploy/k8s/base, waits for rollouts make demo-reset && make demo-seed # clean demo data, including the astra_series counters kubectl -n astra get pods # 2 astra-server pods + astra-cdc, keycloak, postgres-0, redis, seata-server kubectl -n astra get deployment astra-server -o wide ``` Kết quả mong đợi của `get deployment` (từ `docs/video/v10-k8s/assets/deploy.txt`; cột age sẽ khác): ```text NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR astra-server 2/2 2 2 7h45m astra-server astra/server:dev app=astra-server ``` Mở `http://localhost:8080/desk/`, đăng nhập bằng `tuan` (mật khẩu `Astra@123`), và tạo vài Customer Order: số tiếp nối từ các đơn đã seed. Để tái hiện lần chạy song song, gửi các request `POST /api/doc/CustomerOrder` đồng thời tới từng pod riêng (ví dụ qua một `kubectl port-forward` cho mỗi pod), dùng body đơn hàng được định nghĩa là `ORDER` trong `scripts/demo-express.sh`. Script ghi lại dùng cho video không được đưa vào repository. Rolling restart khi Desk đang mở: ```bash kubectl -n astra rollout restart deployment/astra-server kubectl -n astra rollout status deployment/astra-server ``` Kết quả mong đợi: `deployment "astra-server" successfully rolled out`, hai tên pod mới trong `kubectl -n astra get pods`, và Desk vẫn dùng được, có khả năng một thao tác trong lúc chuyển đổi cần thử lại. Dọn dẹp bằng `make k3d-down`. ## Nói đúng - Gần như không gián đoạn, chưa phải zero downtime. Không có hook `preStop` hay thời gian drain trong `deploy/k8s/base/astra-server.yaml`, nên các request tới một pod đúng lúc nó dừng có thể thất bại. Lần chạy đã đo: 2 trên 47 request không nhận được phản hồi. - PostgreSQL của demo là một pod StatefulSet duy nhất, không phải cấu hình high availability. - Tài liệu thiết kế ([explain.md](/astra-cms/vi/why-astra)) mô tả naming series là "Redis counters, gap-free mode for fiscal documents" và rolling upgrade của kernel có drain transaction. Những gì đã xây là bộ đếm khoá dòng PostgreSQL trình bày ở đây, dùng cho mọi dãy số, và một rolling update thường không có drain. - Không có test tự động nào trong repository kiểm tra `SeriesService` với nhiều bên ghi đồng thời; bằng chứng là bản ghi 12 đơn trong `docs/video/v10-k8s/assets/`. - Các đơn `ORD-20261021-00007` đến `00018` được tạo trong dữ liệu demo dùng chung; hãy reset và seed trước khi quay các video sau. ## Hình minh hoạ - [Hình 1 · Hai pod, một dòng bộ đếm](#bộ-đếm) - [Hình 2 · Rollback trả lại con số](#bộ-đếm) - [Hình 3 · Rolling restart, khoảng hở còn lại](#rolling-restart) ## Liên quan - Trước: [Video 9 · Dành cho developer: viết một app Astra](/astra-cms/vi/developer) · Tiếp: [Video 11 · Keycloak](/astra-cms/vi/keycloak) - [Video 4 · Vòng đời chứng từ](/astra-cms/vi/lifecycle) (Cancel và Amend giữ số gốc) · [Video 7 · Đa thuê bao](/astra-cms/vi/tenants) (vì sao bộ đếm theo tenant) · [Video 8 · Bảo mật fail-closed](/astra-cms/vi/failclosed) (các role mà những pod này dùng để kết nối) - Thiết lập: [demo-video-series.md, chuẩn bị môi trường quay](/astra-cms/vi/video-scripts) - Mã nguồn: `deploy/k8s/base/astra-server.yaml`, `deploy/k8s/base/postgres.yaml`, `scripts/k3d-up.sh`, `astra-doctype/src/main/java/io/astra/doctype/store/SeriesService.java`, `astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java` --- # Dành cho developer: viết một app Astra Tóm tắt một câu: một app Astra chỉ là Java và YAML bình thường, biên dịch dựa trên một module SPI nhỏ (astra-spi); kernel biến YAML thành lưu trữ, REST, Desk và validation, đồng thời chạy hook của bạn ngay trong… Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/developer ▶ [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-9-developer-vi.mp4) · [English](https://media.zerohostcms.com/astra-cms/astra-video-9-developer-en.mp4) · 2:22 · Kịch bản: [demo-video-series.md](/astra-cms/vi/video-scripts) · [Bản tiếng Anh](/astra-cms/vi/developer) > **Tóm tắt một câu:** một app Astra chỉ là Java và YAML bình thường, biên dịch dựa trên một module SPI nhỏ (`astra-spi`); kernel biến YAML thành lưu trữ, REST, Desk và validation, đồng thời chạy hook của bạn ngay trong transaction của chính chứng từ. > > **Nên đọc nếu:** bạn sắp viết logic nghiệp vụ trên Astra và muốn biết mình viết gì, không phải viết gì, và test thế nào / bạn đang cân nhắc liệu một team có thể xây module trên Astra mà không đụng vào kernel hay không, và vòng lặp phát triển tốn bao nhiêu. ## Vấn đề Astra Express là một công ty chuyển phát. Developer của họ phải giao tính năng nghiệp vụ mỗi tuần: một trường mới mà tổng đài muốn có trên hồ sơ khách hàng, một quy tắc tạo vận đơn khi đơn hàng được submit, một bút toán cho phí vận chuyển. Ở phần lớn các stack, mỗi việc như vậy chạm tới bốn tầng: migration database, lớp entity, controller và form. Vận đơn và bút toán còn đặt ra câu hỏi về tính đúng đắn: nếu đơn hàng đã lưu nhưng việc insert vận đơn thất bại, hai chứng từ lệch nhau, và cuối tháng sẽ có người phải đối chiếu bằng tay. Từ đó sinh ra ba nỗi đau: - **Schema thay đổi liên tục.** Mỗi trường mới là một lệnh `ALTER TABLE` cộng một lần deploy. Trên bảng lớn, đó là lock và một khung giờ bảo trì. - **Nhất quán giữa các chứng từ.** Tác dụng phụ (vận đơn, sổ cái) thường được ghi bằng các lời gọi riêng hoặc trong một event handler sau khi commit, nên lỗi giữa chừng để lại một nghiệp vụ dở dang. - **App dính chặt vào nền tảng.** Nếu code của app chạm được vào mọi thứ trong nền tảng, mỗi lần nâng cấp nền tảng đều có nguy cơ làm hỏng mọi app. Frappe/ERPNext giải quyết vấn đề thứ nhất bằng metadata (một DocType sinh ra form và API) và vấn đề thứ ba bằng app cài đặt được cùng hook. Nhưng Frappe vẫn dùng một bảng cho mỗi DocType và chạy DDL khi thêm trường ([explain.md](/astra-cms/vi/why-astra)). Astra giữ ý tưởng DocType, lưu mọi DocType trong một bảng JSONB duy nhất, và chỉ cho app đúng một dependency: `astra-spi`. ## Video cho thấy gì Video dài 2:22, là phần hướng dẫn quay trên stack Docker Compose ở dev mode. 1. **Hai lệnh khởi động kernel có live reload.** `make infra-up`, rồi `make dev`. k3d đã chiếm cổng 8080, nên dev mode chạy ở 8081 (`DEV_ARGS=-Dquarkus.http.port=8081`). 2. **Cây thư mục của app.** `astra-apps/express` chứa lớp `ExpressApp`, các hook, tool MCP, bảy file YAML DocType và hai module Wasm. App chỉ thấy `astra-spi`; phần còn lại do kernel cung cấp. 3. **Hook tham gia transaction.** Khi submit, `OrderWaybillHook` tạo vận đơn và `OrderPostingHook` ghi bút toán. Cả hai chạy trong transaction của đơn hàng, nên các chứng từ không bao giờ lệch nhau. 4. **Sửa trực tiếp.** Một dòng YAML, `- { name: zalo_id, type: Data, in-list: true }`, được thêm vào `customer.doctype.yaml`. Dev mode khởi động lại (log quay được ghi `started in 0.731s`) và Desk hiện cột Zalo id. Không migration, không `ALTER TABLE`: trường này nằm trong JSONB. 5. **Một định nghĩa dùng ở mọi nơi.** `GET /api/doctype/Customer` đã liệt kê `zalo_id` (response quay được: HTTP 200, các trường `code, customer_name, segment, phone, address, credit_limit, zalo_id`). 6. **Test tích hợp trên hạ tầng thật.** Lần chạy `./mvnw test` được quay: 44 test, 0 lỗi, build thành công, với container PostgreSQL, Seata Server 2.6 và Redis. 7. **Nói đúng.** Thêm trường thì dễ; xoá trường sẽ làm hỏng việc sửa các chứng từ cũ. Tiến hoá schema an toàn còn trên roadmap. Điều video chứng minh: app không cần framework riêng, đổi trường không cần migration, và tác dụng phụ giữa các chứng từ là nguyên tử. ## Cách hoạt động ### Một app là danh sách DocType cộng các CDI bean Điểm vào của app khai báo id, phiên bản và các resource DocType. Không có gì khác phải đăng ký bằng tay. *`astra-apps/express/src/main/java/io/astra/apps/express/ExpressApp.java`* ```java @ApplicationScoped public class ExpressApp implements AstraApp { @Override public String id() { return "io.astra.express"; } @Override public String version() { return "0.1.0"; } @Override public List doctypeResources() { return List.of( "META-INF/astra/doctypes/customer.doctype.yaml", "META-INF/astra/doctypes/customer-order.doctype.yaml", // … "META-INF/astra/doctypes/pricing-rule.doctype.yaml"); } } ``` SPI giải thích vì sao danh sách được liệt kê tường minh thay vì quét tự động: *`astra-spi/src/main/java/io/astra/spi/app/AstraApp.java`* ```java /** * Classpath resource paths of DocType definitions (YAML) this app contributes, * e.g. {@code META-INF/astra/doctypes/article.doctype.yaml}. Explicit listing keeps * loading deterministic inside jars and native images. */ List doctypeResources(); ``` Ranh giới dependency được build ép buộc, không dựa vào quy ước. Ngoài CDI API, dependency biên dịch duy nhất của module Express là `astra-spi`: *`astra-apps/express/pom.xml`* ```xml io.astra astra-spi jakarta.enterprise jakarta.enterprise.cdi-api provided ``` Vì sao: nếu app không import được lớp của kernel, kernel có thể đổi phần bên trong (lưu trữ, cache, transaction) mà không làm hỏng app. Các package SPI trong repository này là `app`, `doc`, `doctype`, `hook`, `ledger`, `script`, `tenant`, `tx`, `kv` và `ai`, nằm dưới `astra-spi/src/main/java/io/astra/spi/`. ![Hình 1: Một app Astra chứa gì và được phép phụ thuộc vào đâu](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-09-figure-1.png) *Một app Astra chứa gì và được phép phụ thuộc vào đâu.* ### Một hook là một interface với bốn phương thức mặc định *`astra-spi/src/main/java/io/astra/spi/hook/DocLifecycleHook.java`* ```java public interface DocLifecycleHook { /** The DocType this hook applies to, e.g. {@code "Article"}. */ String doctype(); /** Runs before a document is inserted or updated, after kernel validation of declared fields. */ default void beforeSave(Document doc) { } /** Runs after the document has been persisted in the same transaction. */ default void afterSave(Document doc) { } /** * Runs when a draft of a submittable DocType is submitted, inside the transaction that writes * {@code docstatus = 1}. The hook may mutate the document and write other documents through * {@link io.astra.spi.doc.DocumentService}; throwing rolls all of it back. */ default void onSubmit(Document doc) { } /** Runs when a submitted document is cancelled, inside the transaction that writes {@code docstatus = 2}. */ default void onCancel(Document doc) { } } ``` Kernel gom mọi hook bean một lần và tính sẵn một chuỗi cho từng DocType (`astra-kernel/src/main/java/io/astra/kernel/extension/HookRegistry.java`), nên việc gọi hook trên đường nóng chỉ là duyệt một danh sách, không phải tra cứu theo tên. ### Tác dụng phụ tham gia transaction của chứng từ gốc Hai hook trên `CustomerOrder` làm phần việc liên module mà video cho thấy. *`astra-apps/express/src/main/java/io/astra/apps/express/OrderWaybillHook.java`* ```java @Inject DocumentService docs; @Override public void onSubmit(Document order) { Document waybill = docs.insert("Waybill", Map.of( "order", order.name(), "receiver_name", order.getString("receiver_name")), order.owner()); order.set("waybill", waybill.name()); } @Override public void onCancel(Document order) { // … if (!CANCELLABLE.contains(status)) { throw new DocValidationException("cannot cancel " + order.name() + ": waybill " + name + " is already '" + status + "'"); } docs.update("Waybill", name, Map.of("ops_status", "Cancelled")); } ``` *`astra-apps/express/src/main/java/io/astra/apps/express/OrderPostingHook.java`* ```java @Inject GeneralLedger ledger; @Override public void onSubmit(Document order) { BigDecimal fee = BigDecimal.valueOf(Numbers.toDouble(order.get("shipping_fee"))); String customer = order.getString("customer"); ledger.post("CustomerOrder", order.name(), LocalDate.now(), List.of( Entry.debit(Accounts.RECEIVABLE, customer, fee), Entry.credit(Accounts.REVENUE, customer, fee)), "Shipping fee " + order.name()); } @Override public void onCancel(Document order) { ledger.reverse("CustomerOrder", order.name()); } ``` Không hook nào mở transaction. Lệnh submit của kernel mở transaction, và mọi thứ bên trong dùng chung một connection: *`astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`* ```java public Optional submit(String doctype, String name) { DocTypeDefinition def = requireSubmittable(doctype); return global(def, "submit " + doctype + " " + name, () -> inTenantTx(conn -> { // … requireDraft(doc); requireCaller(doc, Action.SUBMIT); hooks.onSubmit(doc); DocumentValidator.applyDefaultsAndValidate(def, doc); validateLinks(conn, def, doc); return Optional.of(write(conn, doc, 1)); })); } ``` `inTenantTx` dùng lại connection đã gắn khi đang có một connection mở; nhờ vậy `docs.insert(...)` bên trong hook tham gia transaction của bên gọi thay vì tự mở transaction riêng: *`astra-doctype/src/main/java/io/astra/doctype/store/TenantTransactions.java`* ```java public T inTenantTx(SqlWork work) { if (TX.isBound()) { try { return work.run(TX.get()); // … } String tenant = TenantContext.require(); try (Connection conn = dataSource.getConnection()) { conn.setAutoCommit(false); // … SELECT set_config('astra.tenant', ?, true) ``` ```mermaid sequenceDiagram autonumber actor U as tuan (sales) participant K as DocumentStore.submit participant W as OrderWaybillHook participant L as OrderPostingHook participant PG as PostgreSQL (một tx, RLS) U->>K: POST /api/doc/CustomerOrder/ORD-…/submit K->>PG: BEGIN · set_config('astra.tenant', …) Note over K,L: HookRegistry lần lượt chạy mọi hook của CustomerOrder K->>W: onSubmit(order) W->>PG: INSERT Waybill (cùng connection) K->>L: onSubmit(order) L->>PG: INSERT astra_ledger Nợ 131 / Có 511 K->>PG: UPDATE order docstatus = 1 K->>PG: COMMIT (hoặc ROLLBACK nếu bước nào ném lỗi) K-->>U: 200 ``` Vì sao thiết kế như vậy: tính nguyên tử là mô hình nhất quán rẻ nhất có thể có. Giữa đơn hàng, vận đơn và sổ cái không có outbox, không retry, không job đối soát, vì chúng là các dòng trong một transaction PostgreSQL. Khi tác dụng phụ đi qua nhiều database (hạn mức tín dụng BMS ở schema khác, một khung giờ lấy hàng trong Redis), `transaction: global` trên DocType bọc cùng lời gọi đó trong một global transaction của Seata; đó là Video 17. ![Hình 2: Một lần submit, một transaction](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-09-figure-2.png) *Một lần submit, một transaction.* ### Một trường là một dòng YAML, lưu trong JSONB Mọi DocType dùng chung bảng `astra_doc`, và trường nghiệp vụ nằm trong cột `data jsonb` của bảng này, nên thêm trường là thay đổi metadata chứ không phải thay đổi schema. DocType Customer trước khi sửa trực tiếp: *`astra-apps/express/src/main/resources/META-INF/astra/doctypes/customer.doctype.yaml`* ```yaml fields: - { name: code, type: Data, required: true, in-list: true, label: Customer code } - { name: customer_name, type: Data, required: true, in-list: true, label: Customer name } - { name: segment, type: Select, options: [Retail, B2B], default: Retail, in-list: true, projected: true } - { name: phone, type: Data, pii: true } - { name: address, type: Text, pii: true } - { name: credit_limit, type: Currency, label: Credit limit (BMS) } # mirrored to bms.customer_credit ``` Video thêm dòng `- { name: zalo_id, type: Data, in-list: true }`. Dev mode chỉ khởi động lại khi sửa YAML vì các file DocType được khai báo là resource được theo dõi: *`astra-server/src/main/resources/application.yml`* ```yaml "%dev": quarkus: # … live-reload: # DocType YAML is read once at startup; without this, dev mode sees the edit but does not restart watched-resources: - META-INF/astra/doctypes/account.doctype.yaml # … - META-INF/astra/doctypes/customer.doctype.yaml ``` Desk không có dòng code nào gọi tên một DocType cụ thể; nó render bất cứ thứ gì `GET /api/doctype` trả về (`astra-server/src/main/resources/META-INF/resources/desk/desk.js`, xem [S12](/astra-cms/vi/code-snippets)). Vì vậy cột mới xuất hiện mà không phải sửa frontend. ![Hình 3: Thêm trường mà không cần migration](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-09-figure-3.png) *Thêm trường mà không cần migration.* ### Vì sao xoá trường khó hơn thêm trường Validator từ chối mọi key trong chứng từ mà DocType không khai báo: *`astra-doctype/src/main/java/io/astra/doctype/store/DocumentValidator.java`* ```java if (def.field(key).isEmpty()) errors.add("unknown field '" + key + "' for DocType " + def.name()); ``` Chính sự chặt chẽ này bắt được lỗi gõ nhầm và client cũ. Cái giá của nó: nếu bạn xoá `zalo_id` khỏi YAML, mọi chứng từ hiện có vẫn còn `zalo_id` trong JSONB sẽ trượt validation ở lần lưu kế tiếp. Đây là giới hạn mà video nêu ở cuối. ## Ví dụ thực tế *Tình huống minh hoạ: công ty, con người và số liệu là giả định; cơ chế và các quy tắc được áp dụng là thật, như đã trình bày ở trên.* Một công ty 3PL ở Việt Nam phục vụ các shop thương mại điện tử, chạy cả nhận đơn lẫn kế toán trên một hệ thống. Kỳ khoá sổ cuối tháng nào cũng lòi ra những đơn đã tính tiền nhưng chưa bao giờ được giao cho kho, vì hệ thống cũ tạo vận đơn trong một background job sau khi đơn hàng commit. Trên Astra, cùng luồng đó chính là hai hook ở trên. Lấy một đơn của khách B2B C001 trong demo: Domestic, Express, 2,2 kg, COD 500.000 ₫, biểu giá chuẩn. | Bước | Đầu vào | Astra làm gì | Kết quả | | --- | --- | --- | --- | | Lưu nháp | nội dung đơn | `ShippingFeeHook.beforeSave` kiểm tra số điện thoại và cân nặng, tính cước bưu kiện | `shipping_fee = 73,000`, `priced_by = Standard tariff` | | Submit | `POST …/submit` | một transaction: insert vận đơn, ghi sổ cái, `docstatus = 1` | vận đơn `WB-…`, bút toán Nợ 131 73.000 / Có 511 73.000 | | Submit lỗi giữa chừng | ví dụ insert vận đơn ném lỗi | rollback toàn bộ transaction | đơn vẫn là Draft, không có vận đơn, không có dòng sổ cái | | Hủy sớm | vận đơn vẫn ở `Created` hoặc `Picked Up` | vận đơn chuyển sang `Cancelled`, sổ cái ghi bút toán đối xứng | bút toán đảo Nợ 511 / Có 131; các dòng cũ giữ nguyên | | Hủy muộn | vận đơn đã `At Hub` | `onCancel` ném `DocValidationException` | 422 `cannot cancel ORD-…: waybill WB-… is already 'At Hub'`; không có gì thay đổi | Cách tính cước (từ Video 3): 30.000 + 3 × 5.000 = 45.000; × 1,5 cho Express = 67.500; + phí COD max(5.000; 1 %) = 72.500; làm tròn lên hàng nghìn = 73.000. Khi tổng đài sau đó muốn có Zalo id cho từng khách hàng (Zalo là ứng dụng nhắn tin mà phần lớn khách hàng Việt Nam dùng), việc đó chỉ là một dòng YAML và một lần rolling deploy image của app; không cần khung giờ migration. ## Dành cho tech lead - **Developer viết gì.** Một file YAML DocType cho mỗi thực thể, các CDI bean hiện thực interface SPI (hook, tool MCP, participant TCC), và test tích hợp. Không controller, không entity, không migration, không code form. - **Giữ ranh giới.** App chỉ biên dịch dựa trên `astra-spi`. Đây là đòn bẩy chính để việc nâng cấp nền tảng luôn rẻ; hãy xem xét kỹ mọi đề xuất nới rộng ranh giới này. - **Mô hình nhất quán.** Tác dụng phụ trong cùng database thì nguyên tử ngay từ thiết kế. Tác dụng phụ đi qua nhiều kho dữ liệu cần `transaction: global` (Seata), kéo theo một coordinator và chi phí vận hành của nó. Đừng dùng tới khi một transaction PostgreSQL là đủ. - **Thứ tự hook.** `HookRegistry` nhóm hook theo DocType từ CDI discovery và không khai báo thứ tự nào, nên các hook trên cùng một DocType không nên phụ thuộc vào tác dụng phụ của nhau. - **Vòng lặp phát triển.** Sửa YAML hoặc Java, dev mode khởi động lại (0,73 s trong bản quay). Bộ test đầy đủ cần Docker vì nó khởi động container PostgreSQL, Seata và Redis thật; hãy tính trước số phút CI. - **Xoá trường là khoảng trống đã biết.** Thêm thì miễn phí; xoá hoặc đổi tên sẽ làm hỏng việc sửa chứng từ cũ cho tới khi có tiến hoá schema an toàn. Hãy coi việc xoá trường là một đợt migration dữ liệu do bạn chịu trách nhiệm. - **Deploy thay đổi của app.** Thay đổi trong app Express là thay đổi Tier 0: nó được biên dịch vào image của server và rollout như mọi image khác (Video 10). Thay đổi theo từng tenant mà không cần deploy thì dùng overlay (Video 13) và Wasm (Video 14). - **Khi nào không nên dùng.** Nếu team cần tuỳ biến chính hành vi của kernel (lưu trữ, transaction) chứ không phải quy tắc nghiệp vụ, SPI sẽ có cảm giác chật hẹp. Nếu mô hình dữ liệu chủ yếu là phân tích quan hệ thay vì chứng từ có vòng đời, một schema truyền thống có thể phù hợp hơn. - **Cần đo gì.** Thời gian khởi động lại của dev mode, thời gian chạy bộ test, số hook trên mỗi DocType (chuỗi này chạy ở mỗi lần lưu), và tỉ lệ 422 sau khi đổi DocType. ## Tự chạy thử Cần có: JDK 25, Docker Desktop, `jq`, Bash 5. Xem [môi trường quay](/astra-cms/vi/video-scripts). ```bash make infra-up && make build # PostgreSQL :5432, Redis, Keycloak :8180 make dev DEV_ARGS=-Dquarkus.http.port=8081 # dev mode with live reload on 8081 BASE=http://localhost:8081 make demo-seed # demo tenants and data, through the REST API open http://localhost:8081/desk/ # log in as mai / Astra@123 ``` Sau đó thêm dòng này dưới `fields:` trong `astra-apps/express/src/main/resources/META-INF/astra/doctypes/customer.doctype.yaml`: ```yaml - { name: zalo_id, type: Data, in-list: true } ``` Kết quả mong đợi: log dev mode in ra một dòng khởi động lại kiểu `started in 0.731s. Listening on: http://localhost:8081`, và danh sách Customer trong Desk hiện cột `zalo_id` sau khi tải lại trang. Sau đó hãy hoàn tác thay đổi và reset dữ liệu demo; lệnh reset cho stack Compose là `scripts/demo-reset.sh compose`, rồi seed lại. Chạy bộ test (cần Docker; Dev Services khởi động PostgreSQL): ```bash make test ``` Lần chạy được quay báo `Tests run: 44, Failures: 0, Errors: 0, Skipped: 0` và `BUILD SUCCESS`. Từ đó repository đã có thêm test (HRM, STMS, SCIM), nên con số bạn thấy sẽ cao hơn. Các test mà video nhắc tới là `astra-server/src/test/java/io/astra/server/ExpressFlowTest.java`, `LedgerTest.java` và `SeataTest.java`. ## Nói đúng - Xoá một trường khỏi DocType khiến các chứng từ cũ còn trường đó không sửa được nữa (validator từ chối trường không khai báo). Tiến hoá schema an toàn còn trên roadmap. Sau khi sửa trực tiếp, hãy chạy `make demo-reset && make demo-seed` (hoặc lệnh reset Compose ở trên). - Live reload chỉ theo dõi các file DocType được liệt kê trong `application.yml`; file DocType mới phải được thêm vào đó thì dev mode mới khởi động lại. - Số test trong video (44) là số tại thời điểm quay. - Tier 1 (app JVM cô lập, hot-swap lúc chạy) được mô tả trong [explain.md](/astra-cms/vi/why-astra) nhưng chưa được xây. Mọi app trong repository này đều là Tier 0, biên dịch vào image của server. ## Hình minh hoạ - [Hình 1 · Một app Astra chứa gì và được phép phụ thuộc vào đâu](#một-app-là-danh-sách-doctype-cộng-các-cdi-bean) - [Hình 2 · Một lần submit, một transaction](#tác-dụng-phụ-tham-gia-transaction-của-chứng-từ-gốc) - [Hình 3 · Thêm trường mà không cần migration](#một-trường-là-một-dòng-yaml-lưu-trong-jsonb) ## Liên quan - Trước: [Video 8 · Bảo mật fail-closed](/astra-cms/vi/failclosed) · Tiếp: [Video 10 · Chạy trên Kubernetes](/astra-cms/vi/k8s) - [Video 2 · DocType](/astra-cms/vi/doctype), [Video 3 · Hook](/astra-cms/vi/hooks), [Video 4 · Vòng đời chứng từ](/astra-cms/vi/lifecycle), [Video 12 · Sổ cái](/astra-cms/vi/ledger), [Video 17 · Seata](/astra-cms/vi/seata) - Trích đoạn code: [S2 Một app và một hook](/astra-cms/vi/code-snippets), [S12 Desk sinh ra từ metadata](/astra-cms/vi/code-snippets) - Mã nguồn: `astra-apps/express/`, `astra-spi/src/main/java/io/astra/spi/`, `astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`, `astra-kernel/src/main/java/io/astra/kernel/extension/HookRegistry.java`, `astra-server/src/test/java/io/astra/server/ExpressFlowTest.java` --- # Overlay theo tenant Tóm tắt một câu: overlay theo tenant là một file YAML nhỏ, lưu riêng cho từng tenant dưới row-level security, dùng để thêm trường vào DocType của một app hoặc chỉnh một nhóm thuộc tính trường an toàn; nó có hiệu lực… Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/overlay ▶ [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-13-overlay-vi.mp4) · [English](https://media.zerohostcms.com/astra-cms/astra-video-13-overlay-en.mp4) · 2:24 · Kịch bản: [demo-video-series.md](/astra-cms/vi/video-scripts) · [Bản tiếng Anh](/astra-cms/vi/overlay) > **Tóm tắt một câu:** overlay theo tenant là một file YAML nhỏ, lưu riêng cho từng tenant dưới row-level security, dùng để thêm trường vào DocType của một app hoặc chỉnh một nhóm thuộc tính trường an toàn; nó có hiệu lực ngay, chỉ cho tenant đó, mà không phải fork hay deploy lại app. > > **Nên đọc nếu:** bạn cần biết chính xác overlay thay đổi được gì, kernel trộn nó ra sao, và vì sao một số thay đổi bị từ chối / bạn vận hành một sản phẩm cho nhiều công ty và muốn cấu hình riêng cho từng khách hàng mà không phải tách nhánh code cho từng khách. ## Vấn đề Astra Express chạy hai tenant trên cùng một app Express: `saigon-express` và `hanoi-express`. Hà Nội có những yêu cầu mà Sài Gòn không có. Các shop B2B của họ đòi hoá đơn VAT cho từng đơn, họ giao hàng ra đảo (Cát Bà, Cô Tô), và bộ phận điều phối muốn có ghi chú giao hàng trên mọi đơn. Các yêu cầu này lấy nguyên từ comment ở đầu file overlay thật, `tenants/hanoi-express/overlays/customer-order.overlay.yaml`. Các lựa chọn thông thường đều đắt: - **Fork app** cho Hà Nội. Mọi bản sửa lỗi phía upstream giờ phải merge hai lần, và việc nâng cấp bị ách lại. - **Thêm trường của mọi tenant vào app dùng chung**, chặn bằng feature flag. DocType dùng chung phình ra với những trường mà đa số tenant không bao giờ dùng, và người dùng Sài Gòn nhìn thấy hoặc có thể gửi lên những trường vô nghĩa với họ. - **Một bảng key-value "thuộc tính mở rộng" chung chung.** Linh hoạt, nhưng các trường này vô hình với validation, form và API, trừ khi tầng nào cũng xử lý riêng cho chúng. Frappe/ERPNext trả lời bài toán này bằng Custom Field và Property Setter: các tuỳ biến theo site, sống sót qua nâng cấp ([explain.md](/astra-cms/vi/why-astra)). Astra mượn ý tưởng đó và đổi hai điểm. Overlay là một dòng theo tenant trong database dùng chung thay vì thay đổi schema theo site, và vì trường nghiệp vụ nằm trong JSONB, thêm trường không chạy DDL nào. ## Video cho thấy gì Video dài 2:24. 1. **File overlay.** YAML của Hà Nội thêm các trường hoá đơn VAT, mã số thuế và email nhận hoá đơn, thêm vùng `Island`, và bắt buộc ghi chú giao hàng. Admin của tenant áp dụng nó qua API overlay; file nằm trong Git. App Express không đổi một dòng nào. 2. **Hai tenant, một form.** `compare.html` hiện cùng màn hình New Customer Order cho cả hai tenant. Các trường được thêm và được sửa của Hà Nội mang nhãn `✚ tenant overlay` hoặc `✎ tenant overlay`; Sài Gòn không đổi. 3. **Validation theo từng tenant.** Hà Nội chấp nhận một đơn Island có hoá đơn VAT. Sài Gòn từ chối cùng nội dung đó: trường không xác định, vùng không hợp lệ. Hà Nội từ chối đơn không có ghi chú. Form, API và validation cùng đọc một định nghĩa hiệu lực. 4. **Sửa trực tiếp trong Desk.** `mai`, admin của Sài Gòn, thêm trường `gift_wrap` trong trình sửa overlay và lưu. Thay đổi có hiệu lực ngay: không restart, không migration. Các pod khác nhận thay đổi trong vòng hai giây. (Overlay được xoá sau đó để các bản quay sau vẫn thấy Sài Gòn không có overlay.) 5. **Hàng rào bảo vệ.** Đổi kiểu trường trả về 422. Một user sales (`tuan`) sửa overlay nhận 403. 6. **Cho developer.** Overlay là dữ liệu của tenant trong bảng riêng, dưới cùng row-level security như chứng từ. 7. **Nói đúng.** Overlay thêm trường và chỉnh một số thuộc tính. Chưa có hook hay quyền, không xoá được trường. ## Cách hoạt động ### Định dạng overlay *`tenants/hanoi-express/overlays/customer-order.overlay.yaml`* ```yaml # Hanoi Express only: B2B shippers ask for a VAT invoice on each order, deliveries reach islands # (Cát Bà, Cô Tô), and dispatchers want delivery notes on every order. The Express app is untouched; # applied with: PUT /api/overlay/CustomerOrder (tenant admin), see scripts/demo-seed.sh. doctype: CustomerOrder fields: add: - { name: vat_invoice, type: Check, label: "Xuất hóa đơn VAT", in-list: true } - { name: tax_code, type: Data, label: "Mã số thuế" } - { name: invoice_email, type: Data, label: "Email nhận hóa đơn" } modify: - { name: zone, options: [Same city, Domestic, Remote, Island] } - { name: note, label: "Ghi chú giao hàng", required: true } ``` Hai động từ, `add` và `modify`. Các trường gốc bị sửa là do app khai báo: *`astra-apps/express/src/main/resources/META-INF/astra/doctypes/customer-order.doctype.yaml`* ```yaml - { name: zone, type: Select, options: [Same city, Domestic, Remote], default: Domestic, in-list: true, projected: true } # … - { name: note, type: Text } ``` ### Quy tắc trộn: overlay được và không được làm gì Mọi quy tắc nằm trong một hàm thuần duy nhất. *`astra-doctype/src/main/java/io/astra/doctype/registry/Overlays.java`* ```java public final class Overlays { private static final Set MODIFIABLE = Set.of("name", "label", "required", "options", "default", "in-list"); /** Returns the effective DocType; throws {@link IllegalArgumentException} with a readable reason for a bad overlay. */ public static DocTypeDefinition apply(DocTypeDefinition base, String yaml) { // … parse YAML, check the doctype name matches List fields = new ArrayList<>(base.fields()); for (JsonNode m : root.path("fields").path("modify")) { String name = m.path("name").asText(""); int i = indexOf(fields, name); if (i < 0) throw new IllegalArgumentException("cannot modify unknown field '" + name + "' of " + base.name()); m.fieldNames().forEachRemaining(k -> { if (!MODIFIABLE.contains(k)) throw new IllegalArgumentException("field property '" + k + "' cannot be changed by an overlay"); }); FieldDefinition f = fields.get(i); fields.set(i, new FieldDefinition(f.name(), f.type(), m.hasNonNull("label") ? m.get("label").asText() : f.label(), m.has("required") ? m.get("required").asBoolean() : f.required(), f.readOnly(), // … in-list, projected, options, default, pii } for (JsonNode a : root.path("fields").path("add")) { // … parse the field definition if (indexOf(fields, f.name()) >= 0) throw new IllegalArgumentException("field '" + f.name() + "' already exists in " + base.name()); if (f.projected() || f.readOnly()) { throw new IllegalArgumentException("overlay field '" + f.name() + "' cannot be projected or read-only"); } fields.add(f); } return new DocTypeDefinition(base.name(), base.module(), base.kind(), base.submittable(), base.naming(), fields, base.permissions(), base.segregationOfDuties(), base.globalTransaction()); } ``` Vì sao có từng quy tắc: | Quy tắc | Lý do | | --- | --- | | Chỉ sửa được `label`, `required`, `options`, `default`, `in-list` | Các thuộc tính này đổi thứ người dùng nhìn thấy và phải nhập. Chúng không đổi thứ mà code Java của app đọc. | | Không đổi được `type` | Hook của app đọc trường bằng accessor có kiểu (`ShippingFeeHook` đọc `weight_kg` như một số). Đổi kiểu sẽ làm hỏng app, và cả các chứng từ đã lưu với kiểu cũ. | | Không đặt được `read-only`, `projected` | Trường `read-only` dành riêng cho hook (ví dụ `shipping_fee`). `projected` tạo một expression index trên bảng dùng chung `astra_doc`, việc mà một tenant không nên kích hoạt. | | Không xoá trường | Chứng từ ghi trước khi có overlay phải luôn hợp lệ, và app có thể phụ thuộc vào mọi trường nó đã khai báo. | | `permissions`, `submittable`, cách đặt tên, `transaction: global` chép từ bản gốc | Bảo mật và vòng đời vẫn là quyết định của app. | Comment của lớp tóm tắt điều này: overlay "may not remove or retype fields, so documents written before the overlay stay valid and the app's own code keeps working" (không được xoá hay đổi kiểu trường, để chứng từ ghi trước overlay vẫn hợp lệ và code của chính app vẫn chạy). ![Hình 1: DocType gốc cộng overlay bằng DocType hiệu lực của tenant](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-13-figure-1.png) *DocType gốc cộng overlay bằng DocType hiệu lực của tenant.* ### Lưu trữ: dữ liệu tenant dưới row-level security *`astra-doctype/src/main/resources/db/migration/V4__overlays.sql`* ```sql CREATE TABLE astra_overlay ( tenant_id text NOT NULL, doctype text NOT NULL, yaml text NOT NULL, version integer NOT NULL DEFAULT 1, updated_by text NOT NULL, updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (tenant_id, doctype) ); ALTER TABLE astra_overlay ENABLE ROW LEVEL SECURITY; ALTER TABLE astra_overlay FORCE ROW LEVEL SECURITY; CREATE POLICY astra_overlay_tenant_isolation ON astra_overlay USING (tenant_id = current_setting('astra.tenant', true)) WITH CHECK (tenant_id = current_setting('astra.tenant', true)); ``` Vì sao là bảng mà không phải file trên đĩa: overlay là cấu hình của tenant, nên nó được cô lập giống như chứng từ của tenant (Video 7). Một truy vấn trong phạm vi Hà Nội không thấy được overlay của Sài Gòn, kể cả khi code quên mệnh đề `WHERE`. Khoá chính cho phép mỗi tenant và DocType có một overlay; mỗi lần lưu tăng `version` và ghi lại `updated_by`. File YAML dưới `tenants/` là bản trong Git; script seed áp dụng nó qua API (`scripts/demo-seed.sh`). ### DocType hiệu lực, cache trong hai giây Mọi chỗ trong kernel cần một DocType đều hỏi "DocType mà tenant này nhìn thấy": *`astra-doctype/src/main/java/io/astra/doctype/registry/DocTypeOverlays.java`* ```java static final long TTL_MILLIS = 2_000; /** The effective DocType for the tenant in scope, or the app's definition outside a tenant scope. */ public DocTypeDefinition effective(String doctype) { DocTypeDefinition base = registry.require(doctype); Optional tenant = TenantContext.current(); if (tenant.isEmpty()) return base; String key = tenant.get() + "|" + doctype; Cached c = cache.get(key); if (c != null && System.currentTimeMillis() - c.loadedAt() < TTL_MILLIS) return c.def(); DocTypeDefinition def = base; Optional> overlay = get(doctype); if (overlay.isPresent()) { try { def = Overlays.apply(base, (String) overlay.get().get("yaml")); } catch (IllegalArgumentException e) { // Stored overlays are validated on write; one that no longer fits a newer app version is skipped. LOG.warnf("Overlay of %s for tenant %s ignored: %s", doctype, tenant.get(), e.getMessage()); } } cache.put(key, new Cached(def, System.currentTimeMillis())); return def; } ``` `DocumentStore` gọi `overlays.effective(doctype)` cho list, insert, update và validation (`astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`), và `GET /api/doctype` trả cùng kết quả đó cho Desk và client API. Chính điểm gọi duy nhất này là lý do form, danh sách, REST và validation không thể lệch nhau. Vì sao TTL 2 giây: pod xử lý lệnh `PUT` xoá ngay entry cache của chính nó, nên người sửa thấy thay đổi tức thì. Các pod khác không có kênh vô hiệu hoá cache; chúng đọc lại trong vòng `TTL_MILLIS`. Đó là đánh đổi một độ trễ có giới hạn để kernel không cần message bus. Vì sao "bỏ qua kèm cảnh báo" khi đọc: overlay hợp lệ lúc lưu, nhưng một phiên bản app sau này có thể xoá hoặc đổi tên một trường mà overlay sửa. Khi đó kernel phục vụ định nghĩa gốc của app và ghi log cảnh báo, thay vì làm hỏng mọi request của tenant đó. ```mermaid sequenceDiagram autonumber actor A as mai (admin, saigon-express) participant R as OverlayResource participant O as DocTypeOverlays participant PG as astra_overlay (RLS) participant P2 as pod kernel khác A->>R: PUT /api/overlay/CustomerOrder (application/yaml) R->>R: requireAdmin → 403 nếu không phải admin R->>O: put(doctype, yaml, user) O->>O: Overlays.apply(base, yaml) → 422 nếu không an toàn O->>PG: INSERT … ON CONFLICT DO UPDATE version + 1 O->>O: xoá cache cục bộ (tenant|doctype) R-->>A: 200 {version, added, modified} Note over P2: entry cache hết hạn trong 2 s,
request kế tiếp đọc lại và áp dụng lại ``` ![Hình 3: Lưu overlay khi có hai pod](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-13-figure-3.png) *Lưu overlay khi có hai pod.* ### Ai được đổi overlay *`astra-doctype/src/main/java/io/astra/doctype/api/OverlayResource.java`* ```java /** * Per-tenant DocType overlays. Every member may read them (they shape the forms they use); only the * tenant's {@code admin} role may change them. Bodies are YAML ({@code application/yaml} or text). */ @Path("/api/overlay") // … @PUT @Path("/{doctype}") @Consumes({"application/yaml", "text/yaml", MediaType.TEXT_PLAIN}) public Map put(@PathParam("doctype") String doctype, String yaml) { requireAdmin("change the overlay of " + doctype); return scoped(() -> overlays.put(doctype, yaml, tenant.userId())); } ``` Kiểm tra role đọc role từ token Keycloak cho tenant hiện tại (Video 11), nên `admin` ở Hà Nội không cho quyền gì ở Sài Gòn. ## Ví dụ thực tế *Tình huống minh hoạ: công ty, con người và số liệu là giả định; cơ chế và các quy tắc được áp dụng là thật, như đã trình bày ở trên.* Một hãng chuyển phát khu vực ở miền Bắc phục vụ các shop B2B tại Hà Nội và giao hàng ra các đảo trên vịnh Hạ Long. Kế toán cần mã số thuế trên mọi đơn yêu cầu hoá đơn VAT; điều phối cần ghi chú trên mọi đơn. Hãng này dùng cùng app Express với một nhà vận hành ở miền Nam không có quy tắc nào trong số đó. Hành vi dưới đây là những gì `astra-server/src/test/java/io/astra/server/OverlayTest.java` và cảnh 11 của `scripts/demo-express.sh` kiểm chứng. | Request | Tenant | Kết quả | | --- | --- | --- | | `PUT /api/overlay/CustomerOrder` với YAML của Hà Nội, bằng admin | hanoi | 200, `version: 1` | | `GET /api/doctype/CustomerOrder` | hanoi | các trường có `tax_code`; tuỳ chọn của `zone` có `Island`; `note.required = true` | | như trên | saigon | không có `tax_code`; tuỳ chọn gốc | | đơn có `zone: Island`, `vat_invoice: true`, `tax_code: 0100109106`, `note: Giao trước 10h` | hanoi | 201 | | cùng nội dung | saigon | 422: `unknown field 'tax_code' for DocType CustomerOrder`, và vùng không nằm trong các tuỳ chọn cho phép | | đơn không có `note` | hanoi | 422: `field 'note' is required` | | overlay sửa `weight_kg` thành `type: Data` | hanoi | 422: `field property 'type' cannot be changed by an overlay` | | overlay thêm `gift_wrap`, bằng `tuan` (sales) | saigon | 403 | Hãng chuyển phát khu vực có được: không fork, không đổi schema, và quy tắc của họ được áp dụng trên mọi kênh (Desk, REST, MCP), vì cả ba đều validate theo định nghĩa hiệu lực. Một việc riêng overlay không làm được: tính cước khác cho hàng ra đảo. Biểu giá chuẩn trong `ShippingFeeHook` không có mục `Island` và rơi về giá Domestic (`TARIFF.getOrDefault(zone, TARIFF.get("Domestic"))`). Tenant cần giá riêng cho đảo dùng một pricing rule Tier 2, mà ABI của nó đã ánh xạ `Island` thành zone 3; xem [Video 14](/astra-cms/vi/wasm). ![Hình 2: Cùng một request, hai tenant, hai câu trả lời](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-13-figure-2.png) *Cùng một request, hai tenant, hai câu trả lời.* ## Dành cho tech lead - **Tuỳ biến mà không phân nhánh.** Khác biệt của mỗi tenant là vài dòng YAML, có phiên bản trong bảng và trong Git, không phải một nhánh code. Nâng cấp app là nâng cấp cho mọi tenant. - **Quyền hạn có giới hạn ngay từ thiết kế.** Overlay thêm được trường và đổi cách hiển thị cũng như tính bắt buộc. Overlay không đổi được kiểu, bảo mật, vòng đời hay index. Nhờ vậy sai sót của admin tenant chỉ ảnh hưởng tới form và validation của chính tenant đó. - **Cửa sổ nhất quán.** Thay đổi có hiệu lực ngay trên pod đã lưu nó và hiện trên các pod khác trong vòng 2 giây. Với cấu hình, như vậy là chấp nhận được; đừng dùng overlay cho thứ cần chuyển đổi nguyên tử trên toàn cluster. - **Rủi ro khi nâng cấp.** Nếu một bản phát hành app xoá hoặc đổi tên một trường mà overlay sửa, kernel lặng lẽ rơi về định nghĩa gốc cho tenant đó và ghi log `Overlay of … ignored`. Hãy theo dõi dòng log này sau mỗi lần deploy app; hiện chưa có bước kiểm tra tương thích tự động trước khi rollout. - **Chi phí vận hành.** Thêm một lần tra cứu có cache cho mỗi DocType, mỗi tenant, mỗi 2 giây, mỗi pod. Không DDL, không deploy. - **Khi nào không nên dùng overlay.** Khi tenant cần hành vi khác (hook, workflow, quyền), không chỉ trường khác; việc này chưa được hỗ trợ. Khi một trường cần được index để lọc ở quy mô lớn; trường của overlay không thể là projected. - **Cần đo gì.** Số tenant có overlay, số trường được thêm mỗi overlay, tỉ lệ 422 theo tenant sau khi đổi overlay, và số lần xuất hiện cảnh báo "ignored". ## Tự chạy thử Cần có: stack k3d theo [môi trường quay](/astra-cms/vi/video-scripts) (`make k3d-up`), rồi seed lại từ đầu. Seed áp dụng overlay của Hà Nội qua API. ```bash make demo-reset && make demo-seed make demo-express SCENES=11 # add PAUSE=0 to run without pressing Enter ``` Kết quả mong đợi: cảnh 11 in ra file overlay, rồi `GET /api/doctype/CustomerOrder` cho cả hai tenant (Hà Nội có `vat_invoice`, `tax_code`, `invoice_email`, `Island` và `note` bắt buộc; Sài Gòn thì không), 201 cho đơn Island có VAT ở hanoi-express, 422 cho cùng nội dung ở saigon-express, 422 cho đơn không có ghi chú, 422 cho việc đổi kiểu và 403 cho `tuan`. Trong trình duyệt: 1. Đăng nhập bằng `demo` / `Astra@123` tại `http://localhost:8080/desk/`, rồi mở `http://localhost:8080/desk/compare.html` và vào `#/new/CustomerOrder`. 2. Đăng nhập bằng `mai`, mở DocType metadata → CustomerOrder → Tenant overlay, thêm `- { name: gift_wrap, type: Check }` dưới `fields: add:`, rồi bấm Save overlay. Xong việc thì bấm Remove overlay. Chạy riêng test overlay (cần Docker) bằng bộ lọc test chuẩn của Maven, ví dụ `./mvnw -pl astra-server -am test -Dtest=OverlayTest -Dsurefire.failIfNoSpecifiedTests=false`. ## Nói đúng - Overlay thêm trường và sửa `label`, `required`, `options`, `default` và `in-list`. Overlay chưa thêm được hook hay quyền, và không xoá được trường của app. - Trường của overlay không thể là `projected` hay `read-only`. - Các pod khác thấy thay đổi trong vòng 2 giây, không phải tức thì. - Overlay không còn khớp với phiên bản app mới hơn sẽ bị bỏ qua kèm cảnh báo trong log; hiện chưa có bước kiểm tra tương thích trước khi rollout. - [overlay-wasm.md](/astra-cms/vi/overlay-wasm) mô tả một thiết kế mục tiêu rộng hơn (`add-permissions`, `scripts` Wasm gắn vào overlay, kiểm tra tương thích khi nâng cấp). Các phần đó chưa được xây. ## Hình minh hoạ - [Hình 1 · DocType gốc cộng overlay bằng DocType hiệu lực của tenant](#quy-tắc-trộn-overlay-được-và-không-được-làm-gì) - [Hình 2 · Cùng một request, hai tenant, hai câu trả lời](#ví-dụ-thực-tế) - [Hình 3 · Lưu overlay khi có hai pod](#doctype-hiệu-lực-cache-trong-hai-giây) ## Liên quan - Trước: [Video 12 · Sổ cái append-only](/astra-cms/vi/ledger) · Tiếp: [Video 14 · Tier 2 Wasm](/astra-cms/vi/wasm) - [Video 2 · DocType](/astra-cms/vi/doctype), [Video 7 · Đa thuê bao](/astra-cms/vi/tenants), [Video 9 · Viết một app Astra](/astra-cms/vi/developer), [Video 11 · Keycloak](/astra-cms/vi/keycloak) - Trích đoạn code: [S7 Overlay theo tenant](/astra-cms/vi/code-snippets) - Bối cảnh (thiết kế mục tiêu, mới xây một phần): [overlay-wasm.md](/astra-cms/vi/overlay-wasm), [explain.md](/astra-cms/vi/why-astra) - Mã nguồn: `astra-doctype/src/main/java/io/astra/doctype/registry/Overlays.java`, `astra-doctype/src/main/java/io/astra/doctype/registry/DocTypeOverlays.java`, `astra-doctype/src/main/java/io/astra/doctype/api/OverlayResource.java`, `astra-doctype/src/main/resources/db/migration/V4__overlays.sql`, `tenants/hanoi-express/overlays/customer-order.overlay.yaml`, `astra-server/src/test/java/io/astra/server/OverlayTest.java` --- # Tier 2 Wasm: công thức cước của riêng tenant Tóm tắt một câu: tenant tải công thức tính phí vận chuyển của riêng mình lên dưới dạng một module WebAssembly nằm trong một chứng từ PricingRule bình thường; kernel chạy thử nó trong sandbox thuần Java khi lưu, và khi… Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/wasm ▶ [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-14-wasm-vi.mp4) · [English](https://media.zerohostcms.com/astra-cms/astra-video-14-wasm-en.mp4) · 2:20 · Kịch bản: [demo-video-series.md](/astra-cms/vi/video-scripts) · [Bản tiếng Anh](/astra-cms/vi/wasm) > **Tóm tắt một câu:** tenant tải công thức tính phí vận chuyển của riêng mình lên dưới dạng một module WebAssembly nằm trong một chứng từ `PricingRule` bình thường; kernel chạy thử nó trong sandbox thuần Java khi lưu, và khi được bật, module tính cước cho đơn với giới hạn bộ nhớ 1 MiB, không truy cập host và có deadline cứng, mà không cần deploy. > > **Nên đọc nếu:** bạn muốn biết chính xác ABI, giới hạn sandbox và cách xử lý lỗi trước khi viết script cho tenant / bạn cần cho khách hàng (hoặc AI) cung cấp logic nghiệp vụ và phải biết đoạn code đó làm được gì và không làm được gì với nền tảng. ## Vấn đề Giá cước vận chuyển thay đổi liên tục. Tenant Sài Gòn của Astra Express muốn chạy khuyến mãi Tết cho các shop B2B: giảm 20 % cho hàng ngoài thành phố, miễn phí giao nội thành tới 2 kg, Express chỉ +30 % thay vì +50 %, và phí COD 0,8 % (comment đầu file `astra-apps/express/wasm/tet-promo/src/lib.rs`). Tháng sau Hà Nội sẽ muốn một chương trình khác. Các lựa chọn thông thường: - **Hard-code công thức của từng tenant trong app.** Mỗi chương trình khuyến mãi là một lần phát hành app dùng chung, và app đầy những nhánh `if tenant == …`. - **Một bảng quy tắc hoặc ngôn ngữ biểu thức.** An toàn, nhưng mỗi loại quy tắc mới cần thêm cột hoặc toán tử mới, và các công thức thật (bậc cân nặng, làm tròn, mức trần) nhanh chóng vượt quá khả năng của nó. - **Chạy script của tenant bằng chính ngôn ngữ của server.** Linh hoạt, nhưng script có thể lặp vô hạn, chiếm hết heap hoặc gọi bất cứ thứ gì trên classpath. Một script tồi đánh sập pod cho mọi tenant. Frappe/ERPNext có Server Script: Python chạy trong một interpreter bị giới hạn. Ghi chú thiết kế của Astra mô tả cùng ý tưởng đó "with a real sandbox instead of a restricted interpreter" (với một sandbox thật thay cho interpreter bị giới hạn) ([explain.md](/astra-cms/vi/why-astra)); Shopify Functions, vốn chạy logic của merchant dưới dạng Wasm, được nhắc ở đó như một điểm tham chiếu. Cách tiếp cận: biên dịch công thức ra WebAssembly và chạy nó trong một sandbox không với tới được thứ gì ngoài bộ nhớ của chính nó. ## Video cho thấy gì Video dài 2:20. 1. **Công thức là một hàm Rust thuần.** Hàm nhận cân nặng, vùng, dịch vụ và COD rồi trả về phí. Nó biên dịch ra 421 byte: không thư viện chuẩn, không I/O, chỉ có tính toán. Tenant tải file lên như mọi chứng từ khác. 2. **Lưu là chạy thử.** Trong một Pricing Rule, việc lưu sẽ chạy module trong sandbox. Hook tính cước cho một đơn mẫu; module không chạy được thì không bao giờ được lưu. 3. **Tắt rule, bật rule.** Cùng một đơn B2B (C001, Domestic, Express, 2,2 kg, COD 500.000): tắt rule, 73.000 ₫. Bật rule, không deploy hay restart: 51.000 ₫. Tính tay: 45.000, trừ 20 %, cộng 30 % cho Express, cộng phí COD, làm tròn lên 51.000. 4. **Đơn hàng ghi rõ ai tính giá.** Desk hiện 51.000 ₫ "theo rule một, Wasm" (`priced_by` là `PRL-001 … (Wasm)`). Hook đặt phí và nguồn của nó; người dùng không sửa được. 5. **Một script độc hại.** Một vòng lặp vô hạn, tải lên dưới dạng rule mới, bị từ chối sau 50 ms và không bao giờ được bật: `module rejected (timeout): script exceeded its 50 ms deadline and was stopped`. Đơn kế tiếp vẫn được tính cước bình thường. 6. **Các giới hạn.** Chicory chạy WebAssembly bằng Java thuần. Trần bộ nhớ 1 MiB, không host import, không I/O hay mạng, mỗi lần gọi một instance mới. Mỗi rule tự đặt deadline, tối đa 200 ms. 7. **Nói đúng.** Chicory chưa đếm được số lệnh, nên CPU bị giới hạn theo thời gian. Tier 1 còn trên roadmap. ![Hình 3: Script chạy vô hạn bị chặn và không bao giờ được bật](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-14-figure-3.png) *Script chạy vô hạn bị chặn và không bao giờ được bật.* ## Cách hoạt động ```mermaid flowchart LR R["tet-promo/src/lib.rs"] -->|scripts/build-wasm.sh| W["tet-promo.wasm · 421 byte"] W -->|"POST /api/doc/PricingRule (module_wasm, base64)"| P["Chứng từ PricingRule"] P -->|"PricingRuleHook.beforeSave: tự kiểm tra"| S["WasmSandbox (Chicory 1.7.5)"] S -->|"422 nếu invalid / trap / timeout"| X["không được lưu"] S -->|"ok: self_test, module_sha256, module_bytes"| P O["Lưu CustomerOrder"] -->|"ShippingFeeHook: có rule đang bật cho segment?"| S S -->|"fee(weight_g, zone, express, cod)"| F["shipping_fee + priced_by"] ``` ![Hình 1: Từ mã nguồn Rust tới một đơn đã tính cước](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-14-figure-1.png) *Từ mã nguồn Rust tới một đơn đã tính cước.* ### Code của tenant: một hàm export, không import gì *`astra-apps/express/wasm/tet-promo/src/lib.rs`* ```rust #![no_std] const ZONE_BASE: [i64; 4] = [18_000, 30_000, 45_000, 60_000]; // first kg, per zone const ZONE_STEP: [i64; 4] = [2_500, 5_000, 7_000, 9_000]; // each further 500 g #[unsafe(no_mangle)] pub extern "C" fn fee(weight_g: i64, zone: i64, express: i64, cod: i64) -> i64 { let z = zone.clamp(0, 3) as usize; if z == 0 && weight_g <= 2_000 && express == 0 { return cod_fee(cod); } let extra_steps = ((weight_g - 1_000).max(0) + 499) / 500; let mut fee = ZONE_BASE[z] + extra_steps * ZONE_STEP[z]; if z > 0 { fee = fee * 80 / 100; } if express != 0 { fee = fee * 130 / 100; } round_up_thousand(fee + cod_fee(cod)) } fn cod_fee(cod: i64) -> i64 { if cod <= 0 { 0 } else { (cod * 8 / 1_000).max(4_000) } } ``` ABI được ghi trong manifest của workspace: *`astra-apps/express/wasm/Cargo.toml`* ```toml # Tenant pricing scripts for the Express demo, compiled to WebAssembly and run by Astra's Tier 2 sandbox. # ABI: export `fee(weight_g: i64, zone: i64, express: i64, cod: i64) -> i64` returning VND; # zone is 0 = Same city, 1 = Domestic, 2 = Remote, 3 = Island. Build with scripts/build-wasm.sh. ``` Vì sao chỉ dùng số nguyên: `i64` vào, `i64` ra nghĩa là không cần bộ nhớ chia sẻ, không serialise, không host function để truyền tham số. Cân nặng tính bằng gam và tiền tính bằng đồng chẵn, nên số học số nguyên là chính xác. `#![no_std]` giữ module ở 421 byte và bảo đảm nó không thể liên kết tới bất kỳ tiện ích nào của hệ điều hành. Có một chi tiết build quan trọng vì trần bộ nhớ: *`astra-apps/express/wasm/.cargo/config.toml`* ```toml # Tier 2 caps a script's linear memory (16 pages = 1 MiB by default). Rust reserves a 1 MiB stack # unless told otherwise, which alone would exceed the cap; pricing scripts need only a few KiB. [target.wasm32-unknown-unknown] rustflags = ["-C", "link-arg=-zstack-size=32768"] ``` Phiên bản độc hại chỉ có mười dòng: *`astra-apps/express/wasm/runaway/src/lib.rs`* ```rust #[unsafe(no_mangle)] pub extern "C" fn fee(weight_g: i64, _zone: i64, _express: i64, _cod: i64) -> i64 { let mut i = weight_g; loop { i = core::hint::black_box(i.wrapping_add(1)); } } ``` ### Sandbox: instance mới, bộ nhớ có trần, không import, có deadline Kernel mở Tier 2 cho app qua một phương thức SPI duy nhất: *`astra-spi/src/main/java/io/astra/spi/script/ScriptRuntime.java`* ```java public interface ScriptRuntime { /** * Calls an exported function that takes and returns {@code i64} values. * * @throws ScriptException when the module is invalid, traps, exceeds its memory limit or misses its deadline */ long callLong(byte[] module, String function, long timeoutMillis, long... args); } ``` Phần hiện thực: *`astra-plugin-host/src/main/java/io/astra/pluginhost/WasmSandbox.java`* ```java @ConfigProperty(name = "astra.scripts.max-memory-pages", defaultValue = "16") int maxPages; @ConfigProperty(name = "astra.scripts.max-timeout-ms", defaultValue = "200") long maxTimeoutMillis; private final ExecutorService threads = Executors.newVirtualThreadPerTaskExecutor(); @Override public long callLong(byte[] module, String function, long timeoutMillis, long... args) { WasmModule wasm = parse(module); long deadline = Math.min(Math.max(timeoutMillis, 1), maxTimeoutMillis); // … Future call = threads.submit(() -> instantiate(wasm).export(function).apply(args)); try { long[] result = call.get(deadline, TimeUnit.MILLISECONDS); // … exactly one i64 or "invalid" return result[0]; } catch (TimeoutException e) { call.cancel(true); // interrupts the interpreter loop, which stops at its next check // … throw new ScriptException("timeout", "script exceeded its " + deadline + " ms deadline and was stopped", e); } catch (ExecutionException e) { // … ScriptException("trap", "script failed: " + cause.getMessage(), cause) } } private Instance instantiate(WasmModule wasm) { // … declared initial pages return Instance.builder(wasm) .withMemoryLimits(new MemoryLimits(declaredInitial, maxPages)) .build(); } /** Parses and vets a module once per content hash. */ private WasmModule parse(byte[] module) { // … return parsed.computeIfAbsent(sha256(module), k -> { // … Parser.parse(module), else "not a WebAssembly module" if (wasm.importSection().importCount() > 0) { throw new ScriptException("invalid", "module imports host functions; Tier 2 scripts must be self-contained", null); } // … initial pages > maxPages → "invalid" return wasm; }); } ``` Vì sao có từng lớp kiểm soát: | Kiểm soát | Ngăn được gì | | --- | --- | | Chicory, Java thuần, không JNI | Code native làm sập JVM. Module không thể trỏ tới bất cứ thứ gì ngoài linear memory của nó. | | Từ chối import | Mọi I/O, mạng, đồng hồ hay callback về host. Module chỉ tính toán được, ngoài ra không gì cả. | | `MemoryLimits(declaredInitial, 16)` | Cạn bộ nhớ. 16 trang × 64 KiB = 1 MiB mỗi instance. | | `Instance` mới cho mỗi lần gọi | Trạng thái rò rỉ giữa các lần gọi hoặc giữa các tenant. | | Virtual thread riêng, `call.get(deadline)`, `cancel(true)` | Vòng lặp giữ chặt thread của request. Deadline là `timeout_ms` của rule, kẹp trong khoảng 1…200 ms. | | Module đã parse được cache theo SHA-256 | Parse lại ở mỗi đơn hàng; đồng thời kết quả kiểm duyệt gắn với nội dung module. | | Ba lý do thất bại: `invalid`, `trap`, `timeout` | Bên gọi phản ứng được chính xác (`ScriptException.reason()`). | Vì sao dùng deadline thời gian thay vì ngân sách lệnh: Chicory chưa có cơ chế đếm lệnh ("fuel"), nên thời gian thực là cách kiểm soát CPU duy nhất hiện có. Đó là giới hạn được nêu trong video. ![Hình 2: Sandbox ngăn những gì](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-14-figure-2.png) *Sandbox ngăn những gì.* ### Lưu rule là chạy thử Rule là một DocType bình thường, nên nó có sẵn REST, Desk, phân quyền và cô lập tenant mà không tốn công: *`astra-apps/express/src/main/resources/META-INF/astra/doctypes/pricing-rule.doctype.yaml`* ```yaml fields: - { name: rule_name, type: Data, required: true, in-list: true } - { name: segment, type: Select, options: [All, Retail, B2B], default: All, in-list: true, projected: true } - { name: active, type: Check, default: false, in-list: true, projected: true } - { name: timeout_ms, type: Int, default: 50, label: Deadline (ms) } - { name: module_wasm, type: Attach, required: true, label: WebAssembly module } - { name: module_sha256, type: Data, read-only: true, label: Module SHA-256 } - { name: module_bytes, type: Int, read-only: true, in-list: true, label: Module size (bytes) } - { name: self_test, type: Data, read-only: true, in-list: true } # set by PricingRuleHook on every save permissions: - { role: admin, read: true, create: true, write: true } - { role: accountant, read: true, create: true, write: true } - { role: sales, read: true } ``` *`astra-apps/express/src/main/java/io/astra/apps/express/PricingRuleHook.java`* ```java @Override public void beforeSave(Document rule) { byte[] module = Base64.getDecoder().decode(rule.getString("module_wasm")); rule.set("module_sha256", sha256(module)); rule.set("module_bytes", module.length); long started = System.nanoTime(); long fee; try { fee = PricingRules.fee(scripts, rule, 1.0, "Domestic", false, 0); } catch (ScriptException e) { throw new DocValidationException("module rejected (" + e.reason() + "): " + e.getMessage()); } if (fee < 0 || fee > 10_000_000) throw new DocValidationException("module rejected: fee(1 kg, Domestic) returned " + fee); long micros = (System.nanoTime() - started) / 1_000; rule.set("self_test", "fee(1 kg, Domestic) = " + fee + " VND in " + micros + " µs"); } ``` Vì sao chạy thử khi lưu: module hỏng bị phát hiện ngay lúc admin tải lên, chứ không phải lúc khách hàng đặt đơn. Script chạy vô hạn không bao giờ tới được trạng thái `active`. ### Tính cước cho đơn, và thất bại an toàn *`astra-apps/express/src/main/java/io/astra/apps/express/ShippingFeeHook.java`* ```java Optional rule = activeRule(doc.getString("customer")); if (rule.isPresent()) { try { doc.set("shipping_fee", PricingRules.fee(scripts, rule.get(), weight, zone, express, cod)); doc.set("priced_by", rule.get().name() + " " + rule.get().getString("rule_name") + " (Wasm)"); return; } catch (ScriptException e) { // … log a warning doc.set("priced_by", "Standard tariff (" + rule.get().name() + " failed: " + e.reason() + ")"); doc.set("shipping_fee", fee(zone, doc.getString("service"), weight, cod)); return; } } doc.set("shipping_fee", fee(zone, doc.getString("service"), weight, cod)); doc.set("priced_by", "Standard tariff"); ``` `activeRule` chọn rule đang bật mới nhất có segment là `All` hoặc khớp segment của khách hàng. Vì sao rơi về biểu giá chuẩn thay vì từ chối đơn: script của tenant là trách nhiệm của tenant; script lỗi chỉ nên khiến tenant mất khoản giảm giá trên đơn đó, không được làm ngừng việc nhận đơn. `priced_by` ghi lại nhánh đã đi, nên việc rơi về giá chuẩn luôn nhìn thấy được. Phần ánh xạ tham số nằm ở một chỗ duy nhất, dùng chung cho bước tự kiểm tra và bước tính cước: *`astra-apps/express/src/main/java/io/astra/apps/express/PricingRules.java`* ```java static long fee(ScriptRuntime scripts, Document rule, double weightKg, String zone, boolean express, long cod) { byte[] module = Base64.getDecoder().decode(rule.getString("module_wasm")); long timeout = Math.round(Numbers.toDouble(rule.get("timeout_ms"))); return scripts.callLong(module, FUNCTION, timeout <= 0 ? 50 : timeout, Math.round(weightKg * 1000), Math.max(0, ZONES.indexOf(zone)), express ? 1 : 0, cod); } ``` ## Ví dụ thực tế *Tình huống minh hoạ: công ty, con người và số liệu là giả định; cơ chế và các quy tắc được áp dụng là thật, như đã trình bày ở trên.* Một hãng chuyển phát ở Sài Gòn chạy chiến dịch Tết cho các shop B2B từ cuối tháng Một tới giữa tháng Hai. Bộ phận marketing muốn chiến dịch lên sóng vào một sáng thứ Hai và tắt hai tuần sau đó, không cần một lần phát hành nào từ phía kỹ thuật. 1. Một developer (hoặc chính bộ phận IT của shop) viết công thức bằng Rust và build bằng `scripts/build-wasm.sh`: 421 byte. 2. Một `admin` hoặc `accountant` của tenant tạo Pricing Rule, segment `B2B`, `active: false`, và đính kèm file. Bước tự kiểm tra trả lời `fee(1 kg, Domestic) = 24000 VND in … µs`. 3. Ngày ra mắt họ đặt `active: true`. Ngày cuối, `active: false`. Không deploy ở cả hai đầu. Phí khi bật rule, so với biểu giá chuẩn. Ba dòng đầu được kiểm chứng trong `astra-server/src/test/java/io/astra/server/WasmTest.java`; dòng 2,2 kg là đơn trong video. | Bưu kiện (khách B2B) | Biểu giá chuẩn | Rule Tết (Wasm) | | --- | --- | --- | | 1 kg, Domestic, Standard, không COD | 30.000 | 24.000 (30.000 − 20 %) | | 1,5 kg, Same city, Standard, không COD | 21.000 | 0 (miễn phí nội thành ≤ 2 kg) | | 3 kg, Domestic, Express, COD 500.000 | 80.000 | 56.000 (50.000 − 20 % × 1,3 + 4.000) | | 2,2 kg, Domestic, Express, COD 500.000 | 73.000 | 51.000 (45.000 − 20 % = 36.000; × 1,3 = 46.800; + 4.000; làm tròn lên) | | bưu kiện bất kỳ của khách Retail C003 | chuẩn | chuẩn (`priced_by: Standard tariff`, rule chỉ áp dụng cho B2B) | Nếu module có lúc lỗi khi chạy, ví dụ một lần tải lên sau đó bị trap, đơn vẫn được tạo với giá chuẩn và `priced_by` ghi `Standard tariff (PRL-… failed: trap)`. ABI còn bao gồm zone 3 (`Island`), vốn chỉ tồn tại ở những tenant có overlay thêm nó (Hà Nội, [Video 13](/astra-cms/vi/overlay)). Tính từ `lib.rs`, không có trong video: 2,2 kg Island Express với COD 500.000 cho ra 60.000 + 3 × 9.000 = 87.000; − 20 % = 69.600; × 1,3 = 90.480; + 4.000 = 94.480; làm tròn lên 95.000. Biểu giá chuẩn không có giá cho Island và sẽ tính giá Domestic 73.000. Overlay cộng Wasm là cách để một tenant có giá riêng cho hàng ra đảo mà không đụng tới app. ## Dành cho tech lead - **Ranh giới tin cậy.** Tier 2 dành cho code bạn không tin: script của tenant, logic từ marketplace, logic do AI sinh ra. Sandbox hoàn toàn không có import, nên không có năng lực nào để lỡ tay cấp nhầm. - **Phạm vi ảnh hưởng.** Mỗi lần gọi bị giới hạn ở 1 MiB và tối đa 200 ms thời gian thực trên một virtual thread. Script lỗi chỉ khiến đơn đó rơi về biểu giá chuẩn; pod và các tenant khác không bị ảnh hưởng (test runaway kiểm chứng rằng lần gọi kế tiếp vẫn trả về 24.000). - **Đo CPU chỉ là gần đúng.** Deadline không phải ngân sách lệnh. Khi tải nặng, một script hợp lệ có thể bị timeout; một script độc hại vẫn đốt được tới hết deadline mỗi lần gọi. Hãy giữ deadline chặt (deadline mặc định của rule là 50 ms) và theo dõi tỉ lệ timeout. - **Thiết kế ABI mới là phần việc thật.** `i64` vào, `i64` ra thì an toàn và nhanh một cách hiển nhiên, nhưng không truyền được chuỗi hay cấu trúc. Các điểm mở rộng phong phú hơn cần một ABI truyền qua bộ nhớ, thứ mà sandbox hiện tại chưa hiện thực. - **Quản trị.** Rule là chứng từ: mỗi rule ghi lại chính xác module nó chạy (`module_sha256`, `module_bytes`), và chỉ `admin` và `accountant` được tạo hoặc sửa rule. - **Khi nào không nên dùng Tier 2.** Logic cần đọc chứng từ khác, gọi dịch vụ hoặc ghi dữ liệu. Sandbox không làm được những việc đó; logic như vậy thuộc về một hook Tier 0 trong app. - **Cần đo gì.** Thời gian tự kiểm tra (`self_test` ghi µs), tỉ lệ giá trị `priced_by` chứa `failed`, cảnh báo timeout từ `WasmSandbox` trong log, và kích thước module so với giới hạn đính kèm 1 MiB. ## Tự chạy thử Cần có: stack k3d (`make k3d-up`) theo [môi trường quay](/astra-cms/vi/video-scripts), và seed lại từ đầu. Seed tải rule Tết lên ở trạng thái tắt. Chỉ cần Rust nếu bạn sửa mã nguồn Rust; các file `.wasm` đã được commit. ```bash make demo-reset && make demo-seed make demo-express SCENES=12 # add PAUSE=0 to run without pressing Enter ``` Kết quả mong đợi: cảnh này in ra `lib.rs`, liệt kê `runaway.wasm` (145 byte) và `tet-promo.wasm` (421 byte), hiện Pricing Rule cùng `self_test` của nó, tính đơn B2B ở 73.000 với `Standard tariff`, bật rule và tính lại ở 51.000 với `PRL-… (Wasm)`, từ chối bản tải lên runaway với `module rejected (timeout): script exceeded its 50 ms deadline and was stopped`, tính đơn kế tiếp bình thường, rồi tắt rule trở lại. Để build lại module sau khi sửa Rust: ```bash scripts/build-wasm.sh # adds the wasm32-unknown-unknown target, builds, copies to wasm/modules ``` Trong Desk, bằng `mai`: Pricing Rule → mở rule → xem `self_test` → bật `active` → tạo đơn B2B cho C001 (Domestic, Express, 2,2 kg, COD 500.000). Sau đó tắt `active`, nếu không đơn B2B trong các demo khác sẽ không còn là 73.000. ## Nói đúng - Chicory chưa có đếm lệnh (fuel), nên giới hạn CPU là deadline thời gian thực (mặc định của rule 50 ms, tối đa của kernel 200 ms). - ABI là các tham số `i64` và một kết quả `i64`. Trong code chưa có Extism hay ABI truyền qua bộ nhớ, dù [explain.md](/astra-cms/vi/why-astra) liệt kê một ABI tương thích Extism trong thiết kế mục tiêu. - Tier 2 được hai app trong repository này dùng: pricing rule của Express và thưởng KPI của HRM (Video 19). Script Wasm gắn vào overlay, như phác thảo trong [overlay-wasm.md](/astra-cms/vi/overlay-wasm), chưa được xây. - Tier 1 (app JVM cô lập có hot-swap) còn trên roadmap, chưa được xây. ## Hình minh hoạ - [Hình 1 · Từ mã nguồn Rust tới một đơn đã tính cước](#cách-hoạt-động) - [Hình 2 · Sandbox ngăn những gì](#sandbox-instance-mới-bộ-nhớ-có-trần-không-import-có-deadline) - [Hình 3 · Script chạy vô hạn bị chặn và không bao giờ được bật](#video-cho-thấy-gì) ## Liên quan - Trước: [Video 13 · Overlay theo tenant](/astra-cms/vi/overlay) · Tiếp: [Video 15 · MCP](/astra-cms/vi/mcp) - [Video 3 · Hook](/astra-cms/vi/hooks) (biểu giá chuẩn), [Video 9 · Viết một app Astra](/astra-cms/vi/developer), [Video 19 · HRM](/astra-cms/vi/hrm) (thưởng KPI bằng Wasm) - Trích đoạn code: [S8 Wasm Tier 2](/astra-cms/vi/code-snippets) - Bối cảnh (thiết kế mục tiêu, mới xây một phần): [explain.md](/astra-cms/vi/why-astra), [overlay-wasm.md](/astra-cms/vi/overlay-wasm) - Mã nguồn: `astra-plugin-host/src/main/java/io/astra/pluginhost/WasmSandbox.java`, `astra-spi/src/main/java/io/astra/spi/script/ScriptRuntime.java`, `astra-apps/express/src/main/java/io/astra/apps/express/PricingRuleHook.java`, `astra-apps/express/src/main/java/io/astra/apps/express/ShippingFeeHook.java`, `astra-apps/express/wasm/`, `scripts/build-wasm.sh`, `astra-server/src/test/java/io/astra/server/WasmTest.java` --- # Keycloak: một danh tính, role theo tenant, quyền theo DocType Tóm tắt một câu: người dùng đăng nhập một lần bằng Keycloak, role của họ đến từ đường dẫn nhóm Keycloak đã ký /tenants//, còn YAML của từng DocType quyết định các role đó được đọc, tạo, sửa, submit và… Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/keycloak ▶ [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-11-keycloak-vi.mp4) · [English](https://media.zerohostcms.com/astra-cms/astra-video-11-keycloak-en.mp4) · 2:18 · Kịch bản: [demo-video-series.md](/astra-cms/vi/video-scripts) · [Bản tiếng Anh](/astra-cms/vi/keycloak) > **Tóm tắt một câu:** người dùng đăng nhập một lần bằng Keycloak, role của họ đến từ đường dẫn nhóm Keycloak đã ký `/tenants//`, còn YAML của từng DocType quyết định các role đó được đọc, tạo, sửa, submit và cancel những gì, với nguyên tắc người lập/người duyệt do kernel cưỡng chế. > **Nên đọc nếu:** bạn cần biết một `AccessDeniedException` đến từ đâu và cách khai báo quyền cho DocType của mình (developer) / bạn đang quyết định chia danh tính, tenant và quy tắc phê duyệt giữa IdP và ứng dụng như thế nào (tech lead). ## Vấn đề Astra Express chạy hai tenant, `saigon-express` và `hanoi-express`. Chỉ riêng ở Sài Gòn đã có năm nhóm người cùng động vào một đơn hàng: nhân viên sales tạo và submit đơn, nhân viên vận hành quét vận đơn, chăm sóc khách hàng mở ticket về đơn, còn kế toán hủy đơn và chi trả tiền COD (thu hộ) đã thu cho shop. Một người có thể làm cho cả hai tenant với công việc khác nhau ở mỗi bên. Các cách xử lý quen thuộc đều để hở một chỗ: - **Role nằm trong database của ứng dụng.** Mỗi app giữ bảng user và bảng role riêng. Khi nhân viên nghỉ việc, phải nhớ đủ mọi app, và việc xử lý mật khẩu nằm trong code lẽ ra không được thấy mật khẩu. - **Role toàn cục trong IdP.** Chữ "accountant" trong token không cho biết người đó là kế toán của công ty *nào*. App đa thuê bao khi đó đành tin header tenant do client gửi, mà đó chính là thứ kẻ tấn công kiểm soát. - **Kiểm tra quyền trong controller và giao diện.** Giao diện ẩn một nút, API quên kiểm tra, và một script gọi thẳng API. - **Quy tắc phê duyệt nằm trong văn bản quy trình.** "Người lập phiếu chi không được tự duyệt" được viết trong một file PDF chính sách, nhưng không ở đâu cưỡng chế. Frappe/ERPNext đặt quyền theo role trên DocType (có tuỳ chọn "if owner"), và đó là chỗ đúng; Astra giữ ý tưởng này. Khác biệt nằm ở nơi danh tính sống: trong Astra, user, mật khẩu và việc thuộc role nằm ở Keycloak, ứng dụng chỉ đọc một token đã ký. ## Video cho thấy gì | Cảnh | Bạn thấy gì | Điều đó chứng minh | | --- | --- | --- | | Đăng nhập | Desk mở trang đăng nhập Keycloak theo theme Astra; `tuan` đăng nhập | Authorization Code với PKCE ngay trong trình duyệt. Mật khẩu chỉ được gõ vào Keycloak; Astra nhận một authorization code. | | Token | Tuấn thuộc nhóm Keycloak `/tenants/saigon-express/sales` | Role là nhóm trong Keycloak. Header `X-Astra-Tenant` chỉ *chọn* tenant; role đến từ token đã ký. | | `/api/iam/me` | Tuấn được đọc, tạo và submit đơn, không được cancel | Quyền được khai báo theo từng DocType trong YAML; Desk đọc kết quả hiệu lực từ `/api/iam/me`. | | Desk | Đơn nháp của Tuấn có Save và Submit; đơn đã submit không có Cancel; đơn của người khác không lưu được, nhưng sales vẫn submit được | Nút bấm đi theo quyền. `if-owner` giới hạn *write*, không giới hạn *submit*, đúng như YAML ghi. | | API, một đơn, bốn role | Tuấn (sales) không sửa được vận đơn, không cancel được; Quang (ops) sửa vận đơn nhưng không tạo được đơn; Hà (care) mở ticket; Khoa (accountant) cancel | Kernel kiểm tra, nên API từ chối đúng những gì giao diện ẩn đi. | | Phân tách nhiệm vụ | Phiếu chi COD 350.000 VND của Khoa: chính Khoa bấm Submit thì bị từ chối 403; Thu, một kế toán khác, submit được và sổ cái ghi ngay | Người lập ≠ người duyệt là thuộc tính của DocType, cưỡng chế như nhau cho Desk và API. | Video kết thúc bằng ghi chú "Nói đúng": điều kiện theo từng dòng vẫn còn trên roadmap, còn SAML và SCIM nay đã chạy, xem [Video 21](/astra-cms/vi/sso). ![Hình 2: Cùng một đơn, bốn role](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-11-figure-2.png) *Cùng một đơn, bốn role.* ## Cách hoạt động ### Đường đi của một request ```mermaid sequenceDiagram autonumber participant B as Desk (trình duyệt) participant K as Keycloak (realm astra) participant F as TenantResolverFilter participant R as DocResource participant P as DocPermissions B->>K: /auth?client_id=astra-desk&code_challenge=S256(verifier) K-->>B: redirect /desk/?code=… B->>K: POST /token (code + code_verifier) K-->>B: access token, groups: ["/tenants/saigon-express/sales"] B->>F: POST /api/doc/CODRemittance/…/submit
Bearer token + X-Astra-Tenant: saigon-express F->>F: Memberships.of(identity) → role trong saigon-express alt không có role trong tenant đó F-->>B: 403 not a member of tenant … end F->>R: tenantId, userId, roles R->>P: requireSubmit(doctype, roles, userId, existing) P-->>B: 403 khi quy tắc từ chối hoặc người submit là owner ``` ### 1. Đăng nhập: trình duyệt giữ code verifier, không phải Astra Desk là một OIDC client công khai (`astra-desk`). Nó tạo một verifier ngẫu nhiên, chỉ gửi giá trị băm SHA-256 của verifier cho Keycloak, rồi khi đổi code lấy token thì chứng minh mình đang giữ verifier. Server của Astra không bao giờ thấy mật khẩu, và một authorization code bị đánh cắp cũng vô dụng nếu thiếu verifier được giữ trong `sessionStorage`. *`astra-server/src/main/resources/META-INF/resources/desk/desk.js`* ```js async function signIn() { const verifier = b64url(crypto.getRandomValues(new Uint8Array(32))); const challenge = b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))); const st = b64url(crypto.getRandomValues(new Uint8Array(16))); sessionStorage.setItem('astra-desk.pkce', JSON.stringify({ verifier, state: st, back: location.hash, tenant: state.tenant })); const q = new URLSearchParams({ client_id: state.config.deskClientId, redirect_uri: redirectUri(), response_type: 'code', scope: 'openid', code_challenge: challenge, code_challenge_method: 'S256', state: st, ui_locales: 'vi' }); location.assign(oidc('auth') + '?' + q); } ``` Vì sao dùng PKCE thay cho session phía server: Desk là một trang tĩnh do kernel phục vụ, và cùng luồng này phục vụ luôn client di động (`astra.iam.mobile.client-id` trong `IdentityResource`). Một luồng duy nhất, không có client secret nào nằm trong trình duyệt hay trong gói ứng dụng. ### 2. Server là resource server đọc đường dẫn nhóm Kernel được cấu hình như một OIDC *service* (chỉ xác minh bearer token) và ánh xạ cả claim `groups` lẫn realm role thành role của danh tính. *`astra-server/src/main/resources/application.yml`* ```yaml # Resource server: every /api call carries a Keycloak access token (Authorization Code + PKCE on mobile). # Group paths /tenants// and realm roles both become identity roles. oidc: auth-server-url: ${ASTRA_OIDC_URL:http://localhost:8180/auth/realms/astra} # deploy/docker compose Keycloak client-id: astra-server application-type: service token: audience: astra-server principal-claim: sub roles: role-claim-path: groups,realm_access/roles ``` `Memberships` chuyển các đường dẫn đó ngược lại thành một map `tenant → roles`. Role không bao giờ là toàn cục; "accountant" chỉ có nghĩa khi đứng cạnh tên một tenant. *`astra-kernel/src/main/java/io/astra/kernel/security/Memberships.java`* ```java public record Memberships(Map> byTenant, boolean platformAdmin) { public static final String GROUP_PREFIX = "/tenants/"; // … public static Memberships of(SecurityIdentity identity) { if (identity == null || identity.isAnonymous()) return new Memberships(Map.of(), false); Map> out = new TreeMap<>(); for (String role : identity.getRoles()) { if (!role.startsWith(GROUP_PREFIX)) continue; String[] parts = role.substring(GROUP_PREFIX.length()).split("/"); if (parts.length == 2 && !parts[0].isEmpty() && !parts[1].isEmpty()) { out.computeIfAbsent(parts[0], t -> new TreeSet<>()).add(parts[1]); } } // … } ``` Vì sao dùng nhóm thay cho client role: một đường dẫn nhóm Keycloak mang cùng lúc hai thông tin (tenant và role), nó chính là thứ SCIM sau này thao tác dưới tên "Groups" ([Video 21](/astra-cms/vi/sso)), và quản trị viên có thể xem toàn bộ nhân sự của một tenant chỉ bằng cách mở một nhóm trong Keycloak console. ![Hình 1: Một danh tính, role theo tenant](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-11-figure-1.png) *Một danh tính, role theo tenant.* ### 3. Header tenant dùng để chọn, không dùng để cấp quyền `TenantResolverFilter` xác định tenant lần lượt từ header, rồi từ subdomain của host, rồi từ tenant duy nhất mà người dùng là thành viên. Dù chọn được tenant nào, request chỉ đi tiếp nếu token có ít nhất một role trong tenant đó. *`astra-kernel/src/main/java/io/astra/kernel/tenant/TenantResolverFilter.java`* ```java Memberships memberships = Memberships.of(identity); String tenant = fromHeader(ctx).or(() -> fromHost(ctx)).or(() -> onlyMembership(memberships)).orElse(null); // … Set roles = memberships.roles(tenant); if (roles.isEmpty()) { ctx.abortWith(error(Response.Status.FORBIDDEN, "not a member of tenant " + tenant)); return; } tenantRequest.tenantId(tenant); tenantRequest.userId(identity.getPrincipal().getName()); tenantRequest.roles(roles); ``` Một client gửi `X-Astra-Tenant: hanoi-express` kèm token chỉ thuộc Sài Gòn sẽ nhận 403 trước khi bất kỳ code resource nào chạy. Row-level security trong database ([Video 7](/astra-cms/vi/tenants)) là bức tường thứ hai phía sau bức tường này. ### 4. Quyền là dữ liệu trên DocType Mỗi DocType liệt kê quy tắc theo role. Đây là bảng quyền của đơn hàng, giải thích mọi lần bị từ chối trong video: *`astra-apps/express/src/main/resources/META-INF/astra/doctypes/customer-order.doctype.yaml`* ```yaml permissions: - { role: admin, read: true, create: true, write: true, submit: true, cancel: true } - { role: sales, read: true, create: true, write: true, if-owner: true, submit: true } - { role: ops, read: true } - { role: care, read: true } - { role: accountant, read: true, cancel: true } ``` `if-owner` chỉ giới hạn `write`. Vì vậy Tuấn không lưu được đơn của đồng nghiệp nhưng vẫn submit được, một hành vi video cố ý cho thấy chứ không giấu đi. Bộ đánh giá lấy hợp (union) của các quy tắc khớp; không có quy tắc nào khớp nghĩa là không có quyền: *`astra-doctype/src/main/java/io/astra/doctype/permission/DocPermissions.java`* ```java private static boolean allows(DocTypeDefinition def, Set roles, Action action, boolean owner) { for (PermissionRule rule : def.permissions()) { if (!roles.contains(rule.role())) continue; boolean granted = switch (action) { case READ -> rule.read(); case CREATE -> rule.create(); case WRITE -> rule.write() && (!rule.ifOwner() || owner); case SUBMIT -> rule.submit(); case CANCEL -> rule.cancel(); }; if (granted) return true; } return false; } ``` REST resource được sinh ra gọi hàm này trước mọi thao tác (`DocResource`: `require(…READ)` cho list và get, `requireWrite` cho update, `requireSubmit` cho submit, `require(…CANCEL)` cho cancel). Vì app không tự viết controller, app không thể quên bước kiểm tra. ### 5. Desk hỏi, không tự quyết `/api/iam/me` trả về, theo từng tenant, các quyền hiệu lực được tính bằng chính lớp `DocPermissions`: *`astra-iam/src/main/java/io/astra/iam/IdentityResource.java`* ```java private Map tenantView(String tenant, Set roles) { Map m = new LinkedHashMap<>(); m.put("tenant", tenant); m.put("roles", roles); m.put("permissions", permissions.effective(roles)); return m; } ``` Desk vẽ nút từ câu trả lời đó (`desk.js`, `drawForm`: Submit khi `perm.submit`, Cancel khi `perm.cancel`). Ẩn nút chỉ là tiện lợi; mã 403 từ kernel mới là chốt kiểm soát. ### 6. Phân tách nhiệm vụ chỉ là một cờ *`astra-apps/express/src/main/resources/META-INF/astra/doctypes/cod-remittance.doctype.yaml`* ```yaml submittable: true segregation-of-duties: true # maker/checker: whoever prepares a payout cannot approve it # … permissions: - { role: admin, read: true, create: true, write: true, submit: true, cancel: true } - { role: accountant, read: true, create: true, write: true, submit: true, cancel: true } ``` *`astra-doctype/src/main/java/io/astra/doctype/permission/DocPermissions.java`* ```java public void requireSubmit(String doctype, Set roles, String userId, Document existing) { DocTypeDefinition def = registry.require(doctype); if (!allows(def, roles, Action.SUBMIT, false)) { throw new AccessDeniedException("submit " + doctype); } if (def.segregationOfDuties() && userId != null && userId.equals(existing.owner())) { throw new AccessDeniedException("submit " + doctype + "/" + existing.name() + ": segregation of duties, the creator cannot submit their own document"); } } ``` Phép so sánh dùng subject của token (`principal-claim: sub`) đối chiếu với `owner` của chứng từ, nên đổi tên một user trong Keycloak không giúp họ tự duyệt được bản nháp cũ của mình. Map quyền hiệu lực không mô hình hoá phân tách nhiệm vụ, nên Desk vẫn hiện nút Submit cho Khoa; kernel trả 403, đúng như video cho thấy. ![Hình 3: Người lập không phải người duyệt](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-11-figure-3.png) *Người lập không phải người duyệt.* ## Ví dụ thực tế *Tình huống minh hoạ: công ty, con người và số liệu là giả định; cơ chế và các quy tắc được áp dụng là thật, như đã trình bày ở trên.* Một công ty 3PL khu vực chạy hai tenant nhượng quyền trên cùng một cụm Astra. Lan là kế toán trưởng của `hanoi-express`, và cuối tuần còn hỗ trợ bàn chăm sóc khách hàng ở Sài Gòn. **Đầu vào (Keycloak):** Lan là thành viên của `/tenants/hanoi-express/accountant` và `/tenants/saigon-express/care`. **Astra làm gì:** | Request | Role được xác định | Kết quả | | --- | --- | --- | | `GET /api/iam/me` | cả hai tenant | `hanoi-express: [accountant]`, `saigon-express: [care]`, kèm quyền theo từng DocType cho mỗi tenant | | `POST /api/doc/CustomerOrder//cancel`, `X-Astra-Tenant: hanoi-express` | `{accountant}` | được phép (`accountant: cancel: true`) | | Cùng lời gọi đó, `X-Astra-Tenant: saigon-express` | `{care}` | 403, care chỉ có `read` trên đơn hàng | | `POST /api/doc/SupportTicket`, `X-Astra-Tenant: saigon-express` | `{care}` | 201 | | Lan tạo một `CODRemittance` 12.000.000 VND ở Hà Nội, rồi tự submit | `{accountant}` | 403, phân tách nhiệm vụ | | Đồng nghiệp Minh (cũng là `hanoi-express/accountant`) submit phiếu đó | `{accountant}` | 200; khoản chi được ghi vào sổ cái ([Video 12](/astra-cms/vi/ledger)) | **Đầu ra:** một lần đăng nhập, hai công việc khác nhau, không có bảng user riêng cho từng app, và một vết phê duyệt chứng minh được rằng `owner` của chứng từ và người submit là hai người khác nhau. Khi Lan thôi trực cuối tuần, quản trị viên chỉ cần gỡ một thành viên nhóm trong Keycloak; token tiếp theo của chị không còn mang Sài Gòn. ## Dành cho tech lead - **Sự thật nằm ở đâu.** Danh tính và việc thuộc nhóm nằm ở Keycloak; role được làm gì nằm trong Git, cạnh DocType. Review một thay đổi quyền chính là review code. - **Mặc định fail-closed.** Không có quy tắc nghĩa là không có quyền; không có role trong tenant đã xác định nghĩa là 403 trước khi code resource chạy. Một DocType mới không có khối `permissions:` là không truy cập được, chứ không phải mở toang. - **Thời hạn token chính là độ trễ thu hồi.** Astra xác minh JWT offline. Gỡ một nhóm có hiệu lực ở lần refresh token tiếp theo, không phải tức thì. Hãy đặt thời hạn access token theo mức rủi ro; realm dùng 5 phút cho client thường (xem ghi chú ở Video 21). - **Kích thước token.** Mỗi lần thuộc nhóm là một chuỗi trong `groups`. Một chuyên gia tư vấn thuộc hàng trăm tenant sẽ có token rất lớn; nếu tổ chức của bạn có dạng đó, hãy tính trước. - **Nên đo gì.** Tỷ lệ 403 theo DocType và role (tăng vọt sau một lần deploy thường là YAML bị hồi quy), số lần từ chối do phân tách nhiệm vụ mỗi tháng (bằng chứng cho kiểm toán), và thời gian từ lúc HR cho nghỉ việc đến lúc gỡ nhóm. - **Khi nào không dùng nguyên trạng.** Nếu bạn cần "sales chỉ thấy đơn của chi nhánh mình" hay hạn mức số tiền ("submit tối đa 50 triệu"), đó là quyền theo dòng hoặc theo thuộc tính, hiện chưa được xây. Hôm nay nó phải là một hook ném lỗi, tức là code chứ không phải metadata. - **So với các phương án khác.** Keycloak Authorization Services (policy enforcer) có thể tập trung các quyết định nhưng đưa quy tắc theo DocType ra khỏi repository của app; Astra giữ chúng cạnh DocType. Frappe có cùng mô hình quyền ở mức DocType nhưng dùng kho user riêng; Astra giao user cho một IdP. ## Tự chạy thử Yêu cầu: JDK 25, Docker Desktop với 8 GB cho stack k3d, `kubectl`, `k3d`, `jq`, Bash 5. ```bash make k3d-up # cluster with Keycloak, realm synced by scripts/keycloak-sync-realm.sh make demo-reset && make demo-seed # tenants saigon-express and hanoi-express, demo users make demo-express SCENES=9 PAUSE=0 # the scene used in this video open http://localhost:8080/desk/ # sign in as tuan / Astra@123, then khoa, then thu ``` Kết quả mong đợi trên terminal (cảnh 9 của `scripts/demo-express.sh`): `/api/iam/me` của `tuan` với quyền trên `CustomerOrder` và `Waybill`; `tuan` tạo và submit một đơn; lệnh `PUT /api/doc/Waybill/` và lệnh cancel của anh bị từ chối; `quang` cập nhật vận đơn sang `Picked Up` và bị từ chối khi tạo đơn; `ha` tạo một `SupportTicket`; `khoa` cancel đơn. Với cảnh phân tách nhiệm vụ, hãy quay ngay sau `demo-seed`: đăng nhập `khoa`, mở `CODR-…-0002` (350.000, do `khoa` lập khi seed) và bấm Submit (403), rồi đăng nhập `thu` và submit phiếu đó. Các test giữ cố định hành vi này nằm ở `astra-server/src/test/java/io/astra/server/IamAccessTest.java` và `LedgerTest.java`; chạy bằng `make test`. `make demo-iam` chạy kiểm tra IAM đầu-cuối với token Keycloak thật. Các user demo dùng chung mật khẩu `Astra@123` và được định nghĩa trong `deploy/keycloak/realm/astra-realm.json`. Chúng chỉ dành cho môi trường local. ## Nói đúng - Điều kiện quyền theo từng dòng (ví dụ "chỉ đơn của chi nhánh tôi", hoặc hạn mức số tiền) vẫn còn trên roadmap. Quyền hiện nay là theo role và theo DocType, cộng thêm `if-owner` cho thao tác ghi và cờ `segregation-of-duties` cho submit. - SAML SSO và SCIM provisioning nay đã chạy; chúng được trình bày trong [Video 21](/astra-cms/vi/sso). - Màn hình quyền hiệu lực của Desk không mô hình hoá phân tách nhiệm vụ, nên nút Submit có thể hiện ra rồi kernel từ chối bằng 403. - Thay đổi role đến Astra cùng token tiếp theo; một access token đã cấp vẫn giữ role cũ cho tới khi hết hạn. ## Hình minh hoạ - [Hình 1 · Một danh tính, role theo tenant](#2-server-là-resource-server-đọc-đường-dẫn-nhóm) - [Hình 2 · Cùng một đơn, bốn role](#video-cho-thấy-gì) - [Hình 3 · Người lập không phải người duyệt](#6-phân-tách-nhiệm-vụ-chỉ-là-một-cờ) ## Liên quan - Trước: [Video 10 · Chạy trên Kubernetes](/astra-cms/vi/k8s). Tiếp: [Video 12 · Sổ cái append-only](/astra-cms/vi/ledger). - [Video 7 · Đa thuê bao](/astra-cms/vi/tenants) (row-level security phía sau bộ lọc tenant) và [Video 21 · SAML SSO và SCIM](/astra-cms/vi/sso) (IdP doanh nghiệp và provisioning vào chính các nhóm đó). - Tài liệu: [demo-code-snippets.md, S5](/astra-cms/vi/code-snippets), [iam-mobile-integration.md](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/iam-mobile-integration.md). - Mã nguồn: `astra-kernel/src/main/java/io/astra/kernel/security/Memberships.java`, `astra-kernel/src/main/java/io/astra/kernel/tenant/TenantResolverFilter.java`, `astra-doctype/src/main/java/io/astra/doctype/permission/DocPermissions.java`, `astra-doctype/src/main/java/io/astra/doctype/api/DocResource.java`, `astra-iam/src/main/java/io/astra/iam/IdentityResource.java`, `deploy/keycloak/realm/astra-realm.json`, `scripts/demo-express.sh` (cảnh 9). --- # SAML SSO và SCIM Tóm tắt một câu: nhân viên của một doanh nghiệp đăng nhập Astra qua IdP SAML 2.0 của chính công ty mình, do Keycloak làm broker, nhóm bên IdP trở thành role của tenant trong token OIDC, còn hệ thống thư mục của công ty… Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/sso ▶ [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-21-sso-vi.mp4) · [English](https://media.zerohostcms.com/astra-cms/astra-video-21-sso-en.mp4) · 2:32 · Kịch bản: [demo-video-series.md](/astra-cms/vi/video-scripts) · [Bản tiếng Anh](/astra-cms/vi/sso) > **Tóm tắt một câu:** nhân viên của một doanh nghiệp đăng nhập Astra qua IdP SAML 2.0 của chính công ty mình, do Keycloak làm broker, nhóm bên IdP trở thành role của tenant trong token OIDC, còn hệ thống thư mục của công ty tạo, gán role, khoá và xoá các tài khoản đó qua SCIM 2.0, chỉ trong tenant của mình. > **Nên đọc nếu:** bạn cần biết `/scim/v2` chấp nhận những gì, tenant được gắn với một token SCIM ra sao, và vì sao kernel không có code SAML (developer) / bạn đang trả lời bảng câu hỏi bảo mật của một khách hàng doanh nghiệp về SSO, vòng đời vào-chuyển-nghỉ (joiner-mover-leaver) và cô lập tenant (tech lead). ## Vấn đề Khi Astra được bán cho một doanh nghiệp thay vì chỉ một người dùng, đội IT của họ hỏi hai câu trước khi có ai đăng nhập: 1. **"Nhân viên của chúng tôi dùng IdP của công ty được không?"** Họ sẽ không để nhân viên giữ thêm một mật khẩu thứ hai trong hệ thống của nhà cung cấp. IdP của họ (Entra ID, Okta, ADFS…) nói SAML 2.0, đôi khi OIDC. 2. **"Ai tạo và xoá tài khoản?"** Khi có người vào làm, chuyển nhóm hay nghỉ việc, hệ thống thư mục do HR điều khiển phải đẩy thay đổi đó sang. Mời thủ công thì không qua được kiểm toán, và một người đã nghỉ mà bị quên là một sự cố rò rỉ. Câu trả lời thường gặp là một plugin SSO bên trong ứng dụng (đội app tự viết phần phân tích SAML, xoay vòng chứng chỉ và chống phát lại) cộng với import user bằng CSV. Frappe có sẵn đăng nhập mạng xã hội và LDAP; SAML và SCIM thường đến từ app bên thứ ba hoặc một proxy. Astra vốn đã giao danh tính cho Keycloak ([Video 11](/astra-cms/vi/keycloak)). Video này cho thấy hệ quả: SAML nằm trọn trong tính năng identity brokering của Keycloak, còn Astra chỉ thêm một endpoint SCIM để sửa chính các nhóm `/tenants//` mà kernel vẫn đang đọc. ## Video cho thấy gì Tenant demo là `acme`. IdP là một realm Keycloak riêng, `acme-corp-idp`, đóng vai IdP của công ty. Các user `linh` và `bao` của nó không phải `linh` dispatcher trong [Video 20](/astra-cms/vi/stms). | Cảnh | Bạn thấy gì | Điều đó chứng minh | | --- | --- | --- | | Đăng nhập | Một nút duy nhất, "Acme Corp SSO". `linh` đăng nhập ở IdP | Mật khẩu ở lại IdP; Astra nhận một mã OIDC. | | Luồng | SP-initiated: Desk → Keycloak (`kc_idp_hint=acme-corp`) → AuthnRequest tới IdP → SAMLResponse có chữ ký → mã OIDC | Hai realm nói SAML 2.0 thật; Desk chỉ nói OIDC với Keycloak. | | Assertion | NameID `linh`, Audience `urn:astra:sp`, `memberOf astra-editors`, chữ ký RSA-SHA256 trên cả assertion lẫn response. Token: `preferred_username: acme-corp.linh`, `groups: ["/tenants/acme/editor"]` | Keycloak ánh xạ nhóm của IdP thành role của tenant; Astra đọc role từ token. | | Desk | Linh Vo đăng nhập với vai trò editor của acme và tạo một Article (201) | Kernel không có code SSO nào; quyền DocType áp dụng như với user local. | | Giả mạo | SAMLResponse của `bao` bị sửa trên đường đi (`astra-readers` → `astra-admins`); Keycloak trả 400 vì chữ ký không hợp lệ | Không có mã nào được cấp, nên không ai đăng nhập được. | | Discovery | `/scim/v2/ServiceProviderConfig`: `patch: true`, filter tối đa 200, `bulk: false`, `etag: false` | Tenant chỉ lấy từ claim `scim_tenant` trong token của client, không bao giờ từ request. | | Tạo | `POST /scim/v2/Users` → 201; trùng `userName` → 409 `uniqueness`; user mới nhận 403 trên `/api/doc/Article` | User mới chưa có role, nên chưa có quyền gì. | | Role | PATCH thêm user vào nhóm `editor`; sau lần đăng nhập mới token có `/tenants/acme/editor`; Article → 201 | Nhóm chính là role. Thay đổi áp dụng ở lần đăng nhập hoặc refresh tiếp theo. | | Khoá | PATCH `{"op":"Replace","path":"active","value":"False"}` → password grant `invalid_grant` "Account disabled", refresh "Session not active" | Đăng nhập bị chặn ngay; access token đã cấp vẫn hợp lệ đến khi hết hạn. | | Fail closed | Token của globex: user của acme → 404, filter → 0 kết quả, `X-Astra-Tenant: acme` → 403, thêm user acme vào role globex → 400 `invalidValue`, filter `co` → 400 `invalidFilter` | Lỗi theo RFC 7644; token của tenant khác không thấy gì. | | Xoá | `DELETE` → 204, GET → 404 | User đã xoá là mất hẳn. | ![Hình 2: Assertion bị giả mạo](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-21-figure-2.png) *Assertion bị giả mạo.* ## Cách hoạt động ### SAML: Keycloak làm broker, kernel không nhìn thấy ```mermaid sequenceDiagram autonumber participant D as Desk (trình duyệt) participant K as Keycloak realm astra
(SP, entityId urn:astra:sp) participant I as IdP realm acme-corp-idp participant A as Astra kernel D->>K: OIDC /auth (PKCE) + kc_idp_hint=acme-corp K->>I: SAML AuthnRequest (POST binding) I->>I: linh đăng nhập (mật khẩu ở lại đây) I-->>K: SAMLResponse, có chữ ký (RSA-SHA256)
NameID linh, memberOf astra-editors K->>K: kiểm chữ ký, ánh xạ memberOf → /tenants/acme/editor
user acme-corp.linh K-->>D: mã OIDC D->>K: code + code_verifier K-->>D: access token, groups ["/tenants/acme/editor"] D->>A: POST /api/doc/Article (Bearer token) A-->>D: 201 ``` Phía service provider là cấu hình trong realm `astra`. Chữ ký là bắt buộc và được kiểm; AuthnRequest không được ký và assertion không được mã hoá, điều mà mục "Nói đúng" sẽ nhắc lại. *`deploy/keycloak/realm/astra-realm.json`* (identity provider `acme-corp`, rút gọn) ```json "alias": "acme-corp", "displayName": "Acme Corp SSO", "providerId": "saml", "config": { "entityId": "urn:astra:sp", "postBindingAuthnRequest": "true", "postBindingResponse": "true", "wantAuthnRequestsSigned": "false", "validateSignature": "true", "wantAssertionsSigned": "true", "wantAssertionsEncrypted": "false", "signatureAlgorithm": "RSA_SHA256", "syncMode": "FORCE" } ``` Ánh xạ nhóm là một mapper Keycloak cho mỗi role. Giá trị `memberOf` của IdP được so khớp chính xác (không dùng regex) và chuyển thành nhóm role của tenant: *`deploy/keycloak/realm/astra-realm.json`* (`identityProviderMappers`, một trong bốn mapper role) ```json { "name": "acme-corp editor", "identityProviderAlias": "acme-corp", "identityProviderMapper": "saml-advanced-group-idp-mapper", "config": { "syncMode": "INHERIT", "attributes": "[{\"key\":\"memberOf\",\"value\":\"astra-editors\"}]", "are.attribute.values.regex": "false", "group": "/tenants/acme/editor" } } ``` Một mapper username với template `${ALIAS}.${NAMEID}` tạo ra `acme-corp.linh`, nên một user đi qua broker không bao giờ trùng với một `linh` local. Vì sao dùng broker thay vì nhúng SAML vào Astra: kiểm chữ ký XML là nguồn lỗ hổng kinh điển, và Keycloak đã lo sẵn metadata, xoay vòng chứng chỉ, lệch đồng hồ (`allowedClockSkew: 30`) và liên kết tài khoản. Kernel giữ một đường tin cậy duy nhất là JWT OIDC, và code trong [Video 11](/astra-cms/vi/keycloak) chạy không cần sửa. Vì vậy cảnh giả mạo là phép thử cho khâu kiểm tra của Keycloak, và đó chính là mục đích: Astra không phải viết gì cho nó. ![Hình 1: SAML SP-initiated qua broker](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-21-figure-1.png) *SAML SP-initiated qua broker.* ### SCIM: tenant lấy từ token, không bao giờ từ request Client SCIM xác thực bằng client credentials của một client Keycloak riêng cho từng tenant, `scim-`. Một mapper claim cố định trên client đó đưa tenant vào token: *`deploy/keycloak/realm/astra-realm.json`* (client `scim-acme`, protocol mapper) ```json { "name": "scim tenant", "protocolMapper": "oidc-hardcoded-claim-mapper", "config": { "claim.name": "scim_tenant", "claim.value": "acme", "jsonType.label": "String", "access.token.claim": "true" } } ``` `ScimResource` chỉ xác định tenant từ claim đó, yêu cầu realm role `scim`, và từ chối header tenant mâu thuẫn: *`astra-iam/src/main/java/io/astra/iam/ScimResource.java`* ```java private String tenant() { if (identity.isAnonymous()) { throw ScimException.unauthorized("a bearer token of the tenant's SCIM client is required"); } if (!identity.hasRole(SCIM_ROLE)) throw ScimException.forbidden("the token lacks the " + SCIM_ROLE + " role"); Object claim = jwt.getClaim(TENANT_CLAIM); String tenant = claim instanceof JsonString s ? s.getString() : claim == null ? null : claim.toString(); if (tenant == null || !tenant.matches(TENANT_ID)) { throw ScimException.forbidden("the token is not bound to a tenant (claim " + TENANT_CLAIM + ")"); } String requested = headers.getHeaderString(TenantResolverFilter.TENANT_HEADER); if (requested != null && !requested.isBlank() && !requested.trim().equals(tenant)) { throw ScimException.forbidden("this SCIM token is bound to tenant " + tenant + " and cannot act on " + requested.trim()); } if (!scim.tenantExists(tenant)) throw ScimException.forbidden("tenant " + tenant + " does not exist"); return tenant; } ``` Vì sao dùng claim thay vì đường dẫn như `/scim/v2/acme/Users`: đường dẫn do bên gọi chọn. Một claim do Keycloak đặt cho một client thì bên gọi không đổi được, nên một secret `scim-globex` bị lộ không bao giờ chạm tới được `acme`. ### User và group của SCIM chính là user và group của Keycloak `ScimDirectory` không lưu gì của riêng nó. Comment của lớp nêu hai quy tắc làm cho việc cô lập tenant hoạt động: *`astra-iam/src/main/java/io/astra/iam/ScimDirectory.java`* ```java /** * SCIM users and groups of one tenant, stored in Keycloak through {@link KeycloakDirectory}'s admin client. * *
    *
  • A tenant's SCIM users are the identities its provisioning client created: direct members of the * tenant group {@code /tenants/}. That membership grants no role; it marks ownership, so a * tenant's SCIM client can neither see nor change local members or another tenant's users.
  • *
  • A tenant's SCIM groups are its role groups {@code /tenants//}. Group membership is * the tenant role that reaches the user's token (claim {@code groups}) at the next sign-in.
  • *
*/ ``` Điều đó giải thích các cảnh trong video: user mới chỉ là thành viên của `/tenants/acme`, thứ mà `Memberships` bỏ qua (nó cần `/`), nên kernel trả 403 "not a member of tenant acme". Một user không thuộc tenant của bên gọi trả về 404 thay vì 403, để không tiết lộ là user đó tồn tại. Tạo hoặc xoá một group trả 501, vì role được định nghĩa bởi DocType của Astra, không phải bởi hệ thống thư mục. Khoá tài khoản cũng kết thúc các phiên Keycloak của user: *`astra-iam/src/main/java/io/astra/iam/ScimDirectory.java`* ```java if (in.password() != null) setPassword(user, in.password()); if (wasActive && !Boolean.TRUE.equals(rep.isEnabled())) user.logout(); return user(tenant, id); ``` Bộ phân tích PATCH chấp nhận những gì client thật gửi, kể cả `"Replace"` viết hoa của Entra và boolean dạng chuỗi như `"False"`: *`astra-iam/src/main/java/io/astra/iam/ScimPatch.java`* ```java static Boolean bool(JsonNode v, String attribute) { if (v == null || v.isNull()) return null; if (v.isBoolean()) return v.booleanValue(); if (v.isTextual() && (v.asText().equalsIgnoreCase("true") || v.asText().equalsIgnoreCase("false"))) { return Boolean.parseBoolean(v.asText().toLowerCase(Locale.ROOT)); } throw ScimException.invalidValue(attribute + " must be a boolean"); } ``` ```mermaid flowchart LR HR["Thư mục của công ty
(SCIM client scim-acme)"] -->|client credentials| KC["Keycloak
token: scim_tenant=acme, role scim"] HR -->|"POST/PATCH/DELETE /scim/v2"| SR["ScimResource.tenant()"] SR --> SD["ScimDirectory"] SD -->|admin client| G1["/tenants/acme (quyền sở hữu)"] SD -->|admin client| G2["/tenants/acme/editor (role)"] G2 -->|lần đăng nhập tiếp theo| T["user token groups[]"] T --> K["Astra kernel: Memberships → DocPermissions"] ``` ## Ví dụ thực tế *Tình huống minh hoạ: công ty, con người và số liệu là giả định; cơ chế và các quy tắc được áp dụng là thật, như đã trình bày ở trên.* Một tập đoàn truyền thông Việt Nam chạy CMS toà soạn của mình dưới dạng tenant `acme` trên Astra. Đội IT quản lý 300 nhân sự trong một hệ thống thư mục doanh nghiệp nói SAML 2.0 và SCIM 2.0. **Vào làm.** Thứ Hai, HR tuyển Chi làm biên tập viên. - Hệ thống thư mục gọi `POST /scim/v2/Users` với `userName: chi`, rồi PATCH nhóm `editor` để thêm chị vào. - Astra (thông qua Keycloak) tạo tài khoản dưới `/tenants/acme` và thêm vào `/tenants/acme/editor`. - Chi đăng nhập bằng thông tin của công ty; token của chị mang `/tenants/acme/editor`; chị tạo được Article (`editor: read, create, write` trong `article.doctype.yaml`). **Chuyển vị trí.** Tháng Sáu, chị chuyển sang chỉ đọc một chuyên mục. - Hệ thống thư mục PATCH `editor` để gỡ chị và `reader` để thêm chị. - Access token hiện tại của chị vẫn ghi editor cho tới khi hết hạn; lần refresh tiếp theo mang `reader`, và lúc đó việc tạo Article trả 403. **Nghỉ việc.** Ngày làm việc cuối cùng, lúc 17:00, HR khoá tài khoản của chị. - Hệ thống thư mục gửi `PATCH active=False`. Keycloak khoá tài khoản và kết thúc các phiên của chị. - 17:00:01: password grant và refresh grant thất bại. Một access token cấp lúc 16:58 vẫn được Astra chấp nhận cho tới khi hết hạn: chậm nhất 5 phút sau với client thường, theo thời hạn 300 giây của realm. - Một tháng sau, hệ thống thư mục gửi `DELETE`; GET trả 404. **Cô lập.** Một công ty chị em ở tenant `globex` dùng client `scim-globex` của riêng họ. Hệ thống thư mục của họ không thể liệt kê, đọc, hay thêm Chi vào một role; mọi lần thử đều trả 404, 0 kết quả, 403 hoặc 400 như trong video. Những gì công ty phải tự cung cấp để chạy được trên production nằm trong danh sách "Nói đúng": mục IdP và các group mapper của riêng họ trong Keycloak, cùng một quy ước đặt tên nhóm đã được kiểm thử. ![Hình 3: SCIM vào làm, chuyển vị trí, nghỉ việc trong một tenant](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-21-figure-3.png) *SCIM vào làm, chuyển vị trí, nghỉ việc trong một tenant.* ## Dành cho tech lead - **Một đường tin cậy.** Kernel chỉ tin một thứ: JWT do Keycloak ký. SAML, mật khẩu local và các broker sau này đều kết thúc ở đó, nên việc review bảo mật code của Astra không phình ra theo mỗi IdP. - **Độ trễ thu hồi bằng thời hạn access token.** Astra xác minh JWT offline. Với 300 giây, một user đã bị khoá vẫn có thể dùng token tối đa 5 phút. Nếu không chấp nhận được, hãy rút ngắn thời hạn hoặc thêm token introspection, đổi lại mỗi request tốn một lời gọi tới Keycloak. - **Onboarding từng khách hàng là cấu hình, không phải code.** Mỗi công ty cần một mục identity provider và một group mapper cho mỗi role trong realm, cộng với một client `scim-`. Đó là việc của người vận hành; hãy tự động hoá trước khi bạn có hàng chục tenant doanh nghiệp. - **Giới hạn quy mô.** Liệt kê user đọc thành viên nhóm từ Keycloak và phân trang trong bộ nhớ, mỗi user thêm một lần đọc group. Ổn với vài trăm hoặc vài nghìn user mỗi tenant, không phải hàng trăm nghìn. - **Username là duy nhất trên toàn realm.** `create` kiểm tra trùng trên toàn bộ realm Keycloak, nên hai tenant không thể cấp cùng một `userName`. Với SCIM, nên dùng username dạng email. - **Nên đo gì.** Tỷ lệ lỗi SCIM theo `scimType` (`uniqueness`, `invalidFilter`, `invalidValue`), thời gian từ sự kiện nghỉ việc ở HR tới `active=false`, số lần đăng nhập qua broker thất bại theo từng IdP (chứng chỉ hết hạn sẽ lộ ra ở đây đầu tiên). - **Khi nào không nên dùng.** Nếu khách hàng cần ngay hôm nay đăng nhập IdP-initiated, Single Logout, assertion mã hoá hoặc SCIM Bulk, thì những thứ đó chưa được xây. - **So với các phương án khác.** Một nền tảng danh tính thương mại cho cùng một broker cộng với danh mục connector SCIM được quản lý sẵn; Keycloak giữ mọi thứ tự host và mã nguồn mở, đổi lại người vận hành phải làm nhiều hơn. ## Tự chạy thử Yêu cầu: stack k3d hoặc Docker Compose, cộng thêm `python3` và `openssl` (script của cảnh kiểm tra cả hai), `jq` và Bash 5. ```bash make k3d-up # or: make infra-up (runs scripts/keycloak-compose-dev.sh) # If Keycloak was already running: scripts/keycloak-sync-realm.sh, then restart astra-server to get /scim/v2 make demo-sso PAUSE=0 # all eight scenes make demo-sso SCENES="1 5" # pick scenes ``` Kết quả mong đợi theo từng cảnh (`scripts/demo-sso.sh`): 1. Assertion đã giải mã của `linh`; claim của token `preferred_username: acme-corp.linh`, `groups: ["/tenants/acme/editor"]`; `/api/iam/me` cho thấy acme `[editor]`; `POST /api/doc/Article` → 201. 2. Không có authorization code cho `bao` sau khi assertion của anh bị sửa. 3. `ServiceProviderConfig` và `ResourceTypes`; claim của token SCIM có `scim_tenant: acme`. 4. Tạo → 201, filter theo `userName` → 1 kết quả, trùng → 409, Article → 403 với user chưa có role. 5. Danh sách group (admin, editor, author, reader); PATCH editor; token mới có `/tenants/acme/editor`; Article → 201. 6. `active=False`; password grant `invalid_grant`; refresh bị từ chối; access token cũ vẫn được chấp nhận, kèm thời điểm hết hạn được in ra. 7. Token của globex: 404, 0 kết quả, 403, 400 `invalidValue`; filter `co` → 400 `invalidFilter`. 8. DELETE → 204, GET → 404. Script xoá user SCIM của nó khi kết thúc. Các test `astra-server/src/test/java/io/astra/server/SamlBrokerTest.java` và `ScimTest.java`, cùng `astra-iam/src/test/java/io/astra/iam/ScimFilterTest.java` và `ScimPatchTest.java`, giữ cố định hành vi này; chạy `make test`. Mật khẩu IdP và secret của các client SCIM trong các file realm JSON chỉ dành cho môi trường local. ## Nói đúng - IdP là một realm Keycloak (`acme-corp-idp`) trong cùng container, không phải Okta, Entra hay ADFS; giao thức SAML giữa hai bên là thật. Demo phát lại các bước của trình duyệt bằng curl và không theo redirect quay về Desk. - Assertion và response có chữ ký và được kiểm; assertion không được mã hoá và AuthnRequest không được ký. Chỉ có đăng nhập SP-initiated; IdP-initiated chưa được cấu hình; Single Logout chưa được nối và chưa được test. - Ở lần đăng nhập đầu, Keycloak tạo tài khoản `acme-corp.`; nếu email trùng với một tài khoản local, Keycloak sẽ hỏi có liên kết hai tài khoản không, bước mà demo không chiếu. Ánh xạ nhóm → role cố định trong cấu hình realm và chỉ cho tenant `acme`; mỗi công ty khác cần IdP và mapper riêng. - SCIM chưa có Bulk, ETag/`meta.version`, sort, `/Me`, `meta.lastModified` hay tham số `attributes`; `excludedAttributes` chỉ áp cho `members` của Groups. Bộ lọc chỉ hỗ trợ `userName eq` và `displayName eq`. `externalId` bị bỏ qua; chỉ giữ một email; không đổi được `userName`. - Groups cố định vì chúng chính là role của tenant: tạo hoặc xoá một group trả 501; chỉ đổi được thành viên. Client SCIM của một tenant chỉ thấy và sửa user do chính nó cấp. Xoá qua SCIM cũng xoá luôn tài khoản Keycloak. - Thay đổi role có hiệu lực từ lần đăng nhập hoặc refresh tiếp theo. Sau `active=false`, access token đã cấp vẫn hợp lệ đến khi hết hạn (5 phút với client thường, 1 giờ với client demo `astra-dev-cli`). - Liệt kê user phân trang trong bộ nhớ, mỗi user thêm một lần đọc group: đủ cho demo, chưa dành cho hàng trăm nghìn user. ## Hình minh hoạ - [Hình 1 · SAML SP-initiated qua broker](#saml-keycloak-làm-broker-kernel-không-nhìn-thấy) - [Hình 2 · Assertion bị giả mạo](#video-cho-thấy-gì) - [Hình 3 · SCIM vào làm, chuyển vị trí, nghỉ việc trong một tenant](#ví-dụ-thực-tế) ## Liên quan - Trước: [Video 20 · STMS: điều phối xe với Timefold + AI copilot](/astra-cms/vi/stms). Tiếp: [Video 18 · Video lộ trình: phần còn lại của use-case-01](/astra-cms/vi/roadmap). - [Video 11 · Keycloak](/astra-cms/vi/keycloak) (role theo tenant từ đường dẫn nhóm, quyền theo DocType) và [Video 7 · Đa thuê bao](/astra-cms/vi/tenants). - Tài liệu: [demo-video-series.md, Video 21](/astra-cms/vi/video-scripts), [iam-mobile-integration.md](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/iam-mobile-integration.md). - Mã nguồn và cấu hình: `deploy/keycloak/realm/astra-realm.json`, `deploy/keycloak/realm/acme-corp-idp-realm.json`, `astra-iam/src/main/java/io/astra/iam/ScimResource.java`, `astra-iam/src/main/java/io/astra/iam/ScimDirectory.java`, `astra-iam/src/main/java/io/astra/iam/ScimPatch.java`, `astra-iam/src/main/java/io/astra/iam/ScimFilter.java`, `astra-kernel/src/main/java/io/astra/kernel/security/Memberships.java`, `scripts/demo-sso.sh`. --- # Sổ cái append-only Tóm tắt một câu: chứng từ nghiệp vụ ghi các bút toán kép cân bằng vào một bảng PostgreSQL duy nhất, astraledger, trong cùng transaction với chứng từ; sửa sai bằng bút toán đảo, và một trigger trong database từ chối… Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/ledger ▶ [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-12-ledger-vi.mp4) · [English](https://media.zerohostcms.com/astra-cms/astra-video-12-ledger-en.mp4) · 2:32 · Kịch bản: [demo-video-series.md](/astra-cms/vi/video-scripts) · [Bản tiếng Anh](/astra-cms/vi/ledger) > **Tóm tắt một câu:** chứng từ nghiệp vụ ghi các bút toán kép cân bằng vào một bảng PostgreSQL duy nhất, `astra_ledger`, trong cùng transaction với chứng từ; sửa sai bằng bút toán đảo, và một trigger trong database từ chối UPDATE và DELETE, kể cả từ chủ sở hữu bảng. > **Nên đọc nếu:** bạn viết một hook phải ghi sổ tiền và muốn biết `GeneralLedger.post` và `reverse` bảo đảm điều gì (developer) / bạn cần giải thích với kiểm toán viên hoặc CFO vì sao sổ sách không thể bị sửa lén, và bảo đảm đó dừng ở đâu (tech lead). ## Vấn đề Astra Express tính phí vận chuyển khi một đơn được submit, thu tiền COD (thu hộ) từ người nhận khi bưu kiện được giao, rồi sau đó chi trả số tiền này cho shop. Mỗi bước đều là tiền, và mỗi bước phải hiện trên sổ cái tổng hợp theo hệ thống tài khoản Việt Nam (Thông tư 200). Thường có ba chỗ hỏng: - **Sổ cái lệch khỏi chứng từ.** Đơn hàng commit xong, rồi một background job ghi bút toán và thất bại. Lúc này đơn nói "đã tính phí" còn sổ sách không nói gì. - **Lịch sử bị sửa.** Một khoản phí sai được "sửa" bằng UPDATE. Số dư thì đúng, nhưng không ai chỉ ra được tháng trước đã ghi những gì, mà đó chính là câu kiểm toán hỏi. - **Số dư nằm trong một bảng tổng hợp** được cập nhật song song với bút toán. Hai thứ lệch nhau ngay sau lỗi đầu tiên. ERPNext giải quyết hai chỗ đầu bằng code ứng dụng: GL Entry được ghi khi submit, và cancel thì ghi bút toán đảo. Astra theo cùng mô hình kế toán đó và thêm hai điều: việc ghi sổ tham gia transaction database của chứng từ thông qua một SPI, và chính database từ chối viết lại các dòng sổ cái. ## Video cho thấy gì | Cảnh | Bạn thấy gì | Điều đó chứng minh | | --- | --- | --- | | Sổ cái tổng hợp | `khoa` mở bảng cân đối số phát sinh: tài khoản 112, 131, 1388, 3388, 511 | Số dư được tính từ các dòng sổ cái; không có bảng tổng hợp. | | Submit | `tuan` submit một đơn; form hiện thẻ "Ledger entries": Nợ 131 / Có 511, 73.000 VND | Bút toán được ghi trong cùng transaction với đơn. | | Cancel | Đơn bị hủy; hai dòng mới xuất hiện, đánh dấu là bút toán đảo; hai dòng gốc giữ nguyên | Hủy không xoá gì. Số dư về không và lịch sử còn nguyên. | | Delivered | `quang` quét bưu kiện Delivered: Nợ 1388 / Có 3388, 850.000 VND | Tiền COD bưu tá đã thu là khoản nợ shop, được ghi trong transaction của lần quét. | | UPDATE | Một lệnh `UPDATE astra_ledger …` trên terminal thất bại với `astra_ledger is append-only` | Một trigger trong database chặn thay đổi, kể cả với role sở hữu bảng. | | Partitions | Mục partitions liệt kê các phân vùng theo tháng | Khi khởi động, kernel tạo phân vùng của tháng này và hai tháng kế tiếp; dòng nằm ngoài khoảng đó đi vào một phân vùng mặc định. | | Chi trả | Một phiếu chi COD 99.000.000 trong khi chỉ đang nợ 350.000 bị từ chối với 422; người lập không được duyệt phiếu chi, Thu thì được | Số đang nợ được đọc từ sổ cái ngay trong hook; người lập ≠ người duyệt ([Video 11](/astra-cms/vi/keycloak)). | ## Cách hoạt động ```mermaid flowchart LR A["POST /api/doc/CustomerOrder/…/submit"] --> B["DocumentStore.submit
(transaction của tenant)"] B --> C["OrderPostingHook.onSubmit"] C --> D["GeneralLedger.post
(LedgerStore)"] D --> E{"nợ = có?
tài khoản tồn tại?"} E -- không --> X["422, rollback toàn bộ transaction"] E -- có --> F["ensurePartition(month)"] F --> G[("astra_ledger
PARTITION BY RANGE (posting_date)")] G --> H["COMMIT: đơn + bút toán cùng lúc"] U["UPDATE / DELETE"] -. trigger .-> G U -.-> R["ERROR: append-only"] ``` ![Hình 1: Một transaction, hai lần ghi](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-12-figure-1.png) *Một transaction, hai lần ghi.* ### 1. App ghi sổ qua SPI, không qua SQL App không bao giờ chạm vào bảng sổ cái. Nó inject `GeneralLedger` từ `astra-spi` và mô tả một chứng từ. *`astra-spi/src/main/java/io/astra/spi/ledger/GeneralLedger.java`* ```java /** * Double-entry postings to the append-only ledger of the current tenant. Called from lifecycle hooks, * a posting joins the triggering document's transaction: the voucher and its entries commit together. * Entries are never changed; {@link #reverse} posts mirror entries instead. */ public interface GeneralLedger { // … /** Posts a balanced voucher (total debit = total credit); zero-amount lines are skipped. */ void post(String voucherType, String voucherNo, LocalDate postingDate, List entries, String remarks); /** Posts the mirror of every entry of a voucher, dated today; refuses a voucher already reversed. */ void reverse(String voucherType, String voucherNo); /** Debit minus credit of an account, optionally for one party. */ BigDecimal balance(String account, String party); } ``` Hook của đơn hàng là toàn bộ phần tích hợp kế toán cho việc tính phí: *`astra-apps/express/src/main/java/io/astra/apps/express/OrderPostingHook.java`* ```java @Override public void onSubmit(Document order) { BigDecimal fee = BigDecimal.valueOf(Numbers.toDouble(order.get("shipping_fee"))); String customer = order.getString("customer"); ledger.post("CustomerOrder", order.name(), LocalDate.now(), List.of( Entry.debit(Accounts.RECEIVABLE, customer, fee), Entry.credit(Accounts.REVENUE, customer, fee)), "Shipping fee " + order.name()); } @Override public void onCancel(Document order) { ledger.reverse("CustomerOrder", order.name()); } ``` Mã tài khoản là hằng số trong app, đặt tên theo Thông tư 200: *`astra-apps/express/src/main/java/io/astra/apps/express/Accounts.java`* ```java static final String BANK = "112"; // Tiền gửi ngân hàng static final String RECEIVABLE = "131"; // Phải thu của khách hàng: shipping fees static final String COD_HELD = "1388"; // Phải thu khác: COD collected, still with couriers static final String COD_PAYABLE = "3388"; // Phải trả khác: COD owed to the shop static final String REVENUE = "511"; // Doanh thu cung cấp dịch vụ ``` Vì sao dùng SPI: cùng một hợp đồng phục vụ mọi app (app HRM ghi bút toán trích lương qua chính nó, xem [Video 19](/astra-cms/vi/hrm)), và kernel có thể đổi cách lưu trữ mà không đụng tới app. ### 2. Kernel kiểm tra, rồi insert trong transaction của tenant `LedgerStore` hiện thực SPI. Nó làm tròn tới hai chữ số thập phân, từ chối số âm, từ chối chứng từ không cân, từ chối tài khoản không có trong DocType `Account` của tenant, và insert bên trong `tx.inTenantTx`, chính là transaction mà chứng từ đang được lưu. *`astra-doctype/src/main/java/io/astra/doctype/ledger/LedgerStore.java`* ```java @Override public void post(String voucherType, String voucherNo, LocalDate postingDate, List entries, String remarks) { List lines = entries.stream().map(LedgerStore::rounded) .filter(e -> e.debit().signum() != 0 || e.credit().signum() != 0).toList(); if (lines.isEmpty()) return; BigDecimal debit = lines.stream().map(Entry::debit).reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal credit = lines.stream().map(Entry::credit).reduce(BigDecimal.ZERO, BigDecimal::add); if (debit.compareTo(credit) != 0) { throw new DocValidationException("unbalanced voucher " + voucherNo + ": debit " + debit + " ≠ credit " + credit); } tx.inTenantTx(conn -> { requireAccounts(conn, lines); ensurePartition(conn, postingDate); insert(conn, voucherType, voucherNo, postingDate, lines, false, remarks); return null; }); } ``` Vì bút toán và chứng từ dùng chung một transaction, một bút toán bị từ chối sẽ rollback cả lần submit. Không có trạng thái nào mà đơn đã submit còn phí thì vắng mặt trên sổ. Mỗi dòng cũng ghi lại `posted_by` (user của token, hoặc `system` khi nằm ngoài một request). ### 3. Bút toán đảo là bản phản chiếu, không bao giờ là sửa *`astra-doctype/src/main/java/io/astra/doctype/ledger/LedgerStore.java`* ```java if (reversed) throw new DocValidationException("voucher " + voucherNo + " is already reversed"); if (original.isEmpty()) return null; LocalDate today = LocalDate.now(); ensurePartition(conn, today); List mirror = original.stream().map(e -> new Entry(e.account(), e.party(), e.credit(), e.debit())).toList(); insert(conn, voucherType, voucherNo, today, mirror, true, "Reversal of " + voucherNo); ``` Nợ và Có đổi chỗ; `is_reversal` là true; ngày ghi sổ là *hôm nay*. Vì sao là hôm nay: một đơn tính phí trong tháng Mười và bị hủy trong tháng Mười Một phải để nguyên số liệu tháng Mười như đã báo cáo. Bút toán đảo rơi vào phân vùng của tháng Mười Một. ![Hình 2: Cancel thêm dòng, không bao giờ sửa](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-12-figure-2.png) *Cancel thêm dòng, không bao giờ sửa.* ### 4. Database tự bảo vệ *`astra-doctype/src/main/resources/db/migration/V3__ledger.sql`* ```sql CREATE TABLE astra_ledger ( id bigint GENERATED ALWAYS AS IDENTITY, tenant_id text NOT NULL, -- … debit numeric(18, 2) NOT NULL DEFAULT 0, credit numeric(18, 2) NOT NULL DEFAULT 0, voucher_type text NOT NULL, voucher_no text NOT NULL, is_reversal boolean NOT NULL DEFAULT false, remarks text, posted_by text NOT NULL, posted_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (id, posting_date), CONSTRAINT astra_ledger_one_side CHECK (debit >= 0 AND credit >= 0 AND (debit = 0) <> (credit = 0)) ) PARTITION BY RANGE (posting_date); -- Rows outside the monthly partitions that the kernel creates ahead of time land here. CREATE TABLE astra_ledger_default PARTITION OF astra_ledger DEFAULT; -- … CREATE FUNCTION astra_ledger_append_only() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'astra_ledger is append-only: post a reversal instead of %', TG_OP USING ERRCODE = 'insufficient_privilege'; END $$; CREATE TRIGGER astra_ledger_no_update_delete BEFORE UPDATE OR DELETE ON astra_ledger FOR EACH ROW EXECUTE FUNCTION astra_ledger_append_only(); ``` Những lựa chọn thiết kế đáng chú ý: - **Cột quan hệ cố định, không dùng JSONB.** Chứng từ nằm trong `astra_doc` dưới dạng JSONB ([Video 2](/astra-cms/vi/doctype)); dòng sổ cái thì hẹp và toàn số, nên phép cộng là số học `numeric(18,2)` chính xác và index là B-tree thông thường (`astra_ledger_voucher_idx`, `astra_ledger_account_idx`). - **Mỗi dòng chỉ một bên**, được cưỡng chế bằng một CHECK constraint, nên một dòng không thể vừa Nợ vừa Có, cũng không thể âm. - **Row-level security** dùng cùng setting `astra.tenant` như chứng từ, và cũng được bật trên từng phân vùng tháng, nên truy vấn thẳng vào một phân vùng cũng cô lập như truy vấn bảng cha. - **Trigger thay cho REVOKE.** Phân quyền không ràng buộc chủ sở hữu bảng; còn trigger thì chạy với mọi role không xoá nó đi. ### 5. Phân vùng được tạo trước *`astra-doctype/src/main/java/io/astra/doctype/ledger/LedgerPartitions.java`* ```java void onStart(@Observes StartupEvent ev) { try (Connection conn = dataSource.getConnection()) { conn.setAutoCommit(true); for (int i = 0; i < 3; i++) { LocalDate month = LocalDate.now().plusMonths(i); try { ledger.ensurePartition(conn, month); } catch (SQLException e) { // Another pod may be creating the same partition; the next posting retries. LOG.warnf("Ledger partition for %s not created yet: %s", month.withDayOfMonth(1), e.getMessage()); } } // … ``` `LedgerStore.ensurePartition` cũng được gọi theo kiểu lười trước mỗi lần ghi sổ. Phân vùng theo tháng giúp việc khoá sổ một kỳ rẻ: một tháng đã qua là một bảng bạn có thể lưu trữ hoặc gắn sang hệ thống báo cáo mà không đụng tới bảng hiện tại. ![Hình 3: Phân vùng theo tháng](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-12-figure-3.png) *Phân vùng theo tháng.* ### 6. COD: số tiền đang nợ lấy từ sổ cái Lúc giao hàng thành công, hệ thống ghi nhận khoản tiền bưu tá đang giữ (`WaybillScanHook`), và một phiếu chi không được vượt quá số tiền mà sổ cái cho biết đang nợ shop đó: *`astra-apps/express/src/main/java/io/astra/apps/express/RemittanceHook.java`* ```java private BigDecimal outstanding(Document doc) { String customer = doc.getString("customer"); return customer == null ? BigDecimal.ZERO : ledger.balance(Accounts.COD_PAYABLE, customer).negate(); } private void check(Document doc) { BigDecimal amount = BigDecimal.valueOf(Numbers.toDouble(doc.get("amount"))); BigDecimal owed = outstanding(doc); if (amount.signum() <= 0) throw new DocValidationException("amount must be positive"); if (amount.compareTo(owed) > 0) { throw new DocValidationException("amount " + plain(amount) + " exceeds the COD owed to " + doc.getString("customer") + " (" + plain(owed) + ")"); } } ``` `check` chạy khi lưu và chạy lại khi submit, sau đó phiếu chi ghi Nợ 3388 / Có 112. Không có cột "còn nợ" nào phải bảo trì; con số đó là tổng trên các dòng append-only. ## Ví dụ thực tế *Tình huống minh hoạ: công ty, con người và số liệu là giả định; cơ chế và các quy tắc được áp dụng là thật, như đã trình bày ở trên.* Một công ty chuyển phát thương mại điện tử nhỏ ở TP. Hồ Chí Minh chạy một ngày kinh doanh trên Astra. **Đầu vào:** 1. Sales submit đơn A cho shop S01 với phí tính được là 45.000 VND. 2. Cân nặng bị sai. Một kế toán hủy đơn A (sales không có quyền `cancel`); sales amend đơn, và đơn đã amend được submit với phí 62.000. 3. Bưu kiện được giao; người nhận trả COD 1.200.000. 4. Kế toán An lập phiếu chi 1.200.000 cho S01; kế toán Bình duyệt. **Astra ghi những gì (`astra_ledger`):** | Chứng từ | Tài khoản | Nợ | Có | `is_reversal` | | --- | --- | ---: | ---: | --- | | Đơn A | 131 | 45.000 | | false | | Đơn A | 511 | | 45.000 | false | | Đơn A | 511 | 45.000 | | true | | Đơn A | 131 | | 45.000 | true | | Đơn đã amend | 131 | 62.000 | | false | | Đơn đã amend | 511 | | 62.000 | false | | Waybill | 1388 | 1.200.000 | | false | | Waybill | 3388 | | 1.200.000 | false | | CODRemittance | 3388 | 1.200.000 | | false | | CODRemittance | 112 | | 1.200.000 | false | **Đầu ra (bảng cân đối số phát sinh, Nợ trừ Có):** 131 = 62.000; 511 = −62.000; 1388 = 1.200.000; 3388 = 0; 112 = −1.200.000. Tổng bằng không. Khoản phí sai 45.000 vẫn còn thấy được, cùng với người đã ghi và người đã đảo nó. Nếu An cố tự duyệt phiếu chi của mình, hoặc chi 1.300.000, kernel sẽ trả 403 hoặc 422 và không có gì được ghi. Lưu ý app demo không ghi bút toán bưu tá nộp tiền mặt về công ty, nên ở đây 1388 vẫn còn số dư; một hệ thống triển khai thật cần chứng từ đó (ví dụ Nợ 111 hoặc 112 / Có 1388). ## Dành cho tech lead - **Bảo đảm có nhiều lớp.** App không thể viết SQL vào sổ cái (SPI), kernel từ chối chứng từ không cân hoặc dùng tài khoản không tồn tại, và database từ chối UPDATE và DELETE. Lớp yếu nhất là role database: hãy chạy kernel bằng một role không sở hữu các bảng, để nó không thể DROP hay ALTER chúng. - **Tính nguyên tử chỉ ở phạm vi cục bộ.** Việc ghi sổ tham gia transaction PostgreSQL của chứng từ. Một bút toán cần commit cùng với một tài nguyên khác (hạn mức tín dụng ở schema khác, năng lực tải trong Redis) thì cần Seata, được trình bày ở [Video 17](/astra-cms/vi/seata). - **Chi phí.** Mỗi lần submit và cancel đều thêm dòng; không có gì bị xoá. Hãy dự trù dung lượng lưu trữ theo số bút toán mỗi tháng, và dùng việc lưu trữ phân vùng làm cơ chế giữ dữ liệu. - **Số dư là phép tổng hợp.** `balance()` và bảng cân đối số phát sinh cộng các dòng khi được hỏi. Ổn với khối lượng của demo; ở mức hàng triệu dòng mỗi tháng, hãy thêm snapshot theo kỳ hoặc một bản sao dành cho báo cáo ([Video 16](/astra-cms/vi/cdc)) thay vì một bảng tổng hợp có thể sửa. - **Nên đo gì.** Số dòng trong `astra_ledger_default` (phải bằng không; khác không nghĩa là có bút toán nằm ngoài các tháng đã tạo trước), số lần từ chối chứng từ không cân (lỗi trong một hook), và tỷ lệ đảo theo loại chứng từ. - **Khi nào không nên dùng.** Nếu hệ thống kế toán gốc của bạn là một ERP bên ngoài và Astra chỉ cấp dữ liệu cho nó, hãy ghi sổ ở đó và giữ chứng từ của Astra làm nguồn. - **So với các phương án khác.** Một kho event-sourced cho mức bất biến tương tự nhưng cần nhiều cơ chế hơn; một bảng GL sửa được kèm audit log cho lịch sử chỉnh sửa nhưng không ngăn được việc sửa. ERPNext dùng GL entry đảo được cưỡng chế bằng code ứng dụng; Astra bổ sung lớp từ chối ở mức database. ## Tự chạy thử Yêu cầu: JDK 25, Docker Desktop với 8 GB cho stack k3d, `kubectl`, `k3d`, `jq`, Bash 5. ```bash make k3d-up make demo-reset && make demo-seed make demo-express SCENES=10 PAUSE=0 open http://localhost:8080/desk/ # sign in as khoa / Astra@123 → General ledger ``` Kết quả mong đợi từ cảnh 10 của `scripts/demo-express.sh`: - `GET /api/ledger/trial-balance` liệt kê `account, debit, credit, balance`. - Sau khi `tuan` submit một đơn, `GET /api/ledger/entries?voucher_no=` cho thấy Nợ 131 / Có 511 và phân vùng chứa từng dòng. - Sau khi cancel, cùng lời gọi đó cho thấy thêm hai dòng với `is_reversal: true`. - Khi script truy cập được database (`DB=k3d` hoặc `DB=compose`), lệnh `UPDATE astra_ledger …` in ra `ERROR: astra_ledger is append-only: post a reversal instead of UPDATE`. - `GET /api/ledger/partitions` liệt kê các phân vùng tháng và phân vùng mặc định kèm số dòng. - `POST /api/doc/CODRemittance` với `amount: 99000000` trả 422; một phiếu chi 100.000 do `khoa` lập bị từ chối khi chính `khoa` submit và được chấp nhận khi `thu` submit. Hành vi này được giữ cố định bởi `astra-server/src/test/java/io/astra/server/LedgerTest.java` (ví dụ `theLedgerIsAppendOnlyEvenForItsOwner`); chạy `make test`. ## Nói đúng - Trigger chỉ chặn UPDATE và DELETE. Role sở hữu bảng vẫn có thể DROP hoặc ALTER nó (và trigger theo dòng không chạy với TRUNCATE). Production nên chạy kernel bằng một role không sở hữu các bảng. - Chuỗi hash audit trên các dòng sổ cái vẫn còn trên roadmap. - Sổ cái nằm trong transaction PostgreSQL của kernel; loại DocType riêng `storage: ledger` và schema theo từng app mô tả trong [use-case-01.md](/astra-cms/vi/use-case) là kiến trúc mục tiêu, chưa được xây. - App demo ghi bút toán tính phí, thu COD và chi trả COD. Nó không ghi việc bưu tá nộp tiền mặt, VAT, hay khoá sổ cuối kỳ. ## Hình minh hoạ - [Hình 1 · Một transaction, hai lần ghi](#cách-hoạt-động) - [Hình 2 · Cancel thêm dòng, không bao giờ sửa](#3-bút-toán-đảo-là-bản-phản-chiếu-không-bao-giờ-là-sửa) - [Hình 3 · Phân vùng theo tháng](#5-phân-vùng-được-tạo-trước) ## Liên quan - Trước: [Video 11 · Keycloak](/astra-cms/vi/keycloak). Tiếp: [Video 13 · Overlay theo tenant](/astra-cms/vi/overlay). - [Video 4 · Vòng đời chứng từ](/astra-cms/vi/lifecycle) (Submit, Cancel, Amend), [Video 5 · Vận hành](/astra-cms/vi/ops) (lần quét vận đơn ghi nhận COD), [Video 17 · Seata](/astra-cms/vi/seata), [Video 19 · HRM](/astra-cms/vi/hrm). - Tài liệu: [demo-code-snippets.md, S6](/astra-cms/vi/code-snippets), [use-case-01.md](/astra-cms/vi/use-case), [explain.md](/astra-cms/vi/why-astra). - Mã nguồn: `astra-doctype/src/main/resources/db/migration/V3__ledger.sql`, `astra-doctype/src/main/java/io/astra/doctype/ledger/LedgerStore.java`, `astra-doctype/src/main/java/io/astra/doctype/ledger/LedgerPartitions.java`, `astra-doctype/src/main/java/io/astra/doctype/api/LedgerResource.java`, `astra-spi/src/main/java/io/astra/spi/ledger/GeneralLedger.java`, `astra-apps/express/src/main/java/io/astra/apps/express/OrderPostingHook.java`, `astra-apps/express/src/main/java/io/astra/apps/express/RemittanceHook.java`, `astra-apps/express/src/main/java/io/astra/apps/express/WaybillScanHook.java`. --- # MCP: agent AI dùng Astra bằng quyền của người dùng Tóm tắt một câu: Astra mở một endpoint MCP duy nhất, /mcp, nơi agent AI đăng nhập bằng đúng token Keycloak của người dùng mà nó phục vụ, nên nó nhận đúng tenant, row-level security và quyền DocType của người đó, còn dữ… Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/mcp ▶ [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-15-mcp-vi.mp4) · [English](https://media.zerohostcms.com/astra-cms/astra-video-15-mcp-en.mp4) · 2:24 · Kịch bản: [demo-video-series.md](/astra-cms/vi/video-scripts) · [Bản tiếng Anh](/astra-cms/vi/mcp) > **Tóm tắt một câu:** Astra mở một endpoint MCP duy nhất, `/mcp`, nơi agent AI đăng nhập bằng đúng token Keycloak của người dùng mà nó phục vụ, nên nó nhận đúng tenant, row-level security và quyền DocType của người đó, còn dữ liệu cá nhân bị che trước khi tới mô hình. > **Nên đọc nếu:** bạn muốn thêm một tool AI vào app Astra chỉ bằng một class, không viết code xác thực (developer) / bạn phải quyết định việc cho LLM chạm vào dữ liệu nghiệp vụ có an toàn, kiểm toán được và có giới hạn hay không (tech lead). ## Vấn đề Astra Express vận hành chăm sóc khách hàng (CARE) cho hai tenant chuyển phát, Sài Gòn và Hà Nội. Nhân viên CSKH nhận những cuộc gọi kiểu "bưu kiện của tôi đang ở đâu, đã có ai khiếu nại chưa?". Để trả lời, họ phải mở vận đơn, lịch sử quét mã, đơn hàng gốc và các ticket hỗ trợ, tức là bốn màn hình. Một trợ lý AI có thể làm việc đó bằng một câu hỏi, nhưng nối LLM vào dữ liệu nghiệp vụ thường hỏng theo một trong ba cách: - **Một service account toàn quyền.** Trợ lý được cấp một user database hoặc API key đọc được mọi tenant, mọi bảng. Những gì nhân viên CSKH không được xem thì mô hình vẫn xem được, và một cú prompt injection có thể khiến nó hành động dựa trên dữ liệu đó. - **Một mô hình phân quyền thứ hai.** Lớp tích hợp AI tự cài lại logic "ai được xem gì". Lần đầu tiên có người thêm một role, nó đã lệch khỏi luật thật. - **Gửi nguyên dữ liệu cá nhân cho mô hình bên thứ ba.** Tên, số điện thoại và địa chỉ người nhận được gửi đầy đủ cho nhà cung cấp mô hình, điều khó bảo vệ trước luật bảo vệ dữ liệu cá nhân của Việt Nam hay nguyên tắc tối thiểu hoá dữ liệu của GDPR. Frappe/ERPNext có REST API và cho phép viết script xung quanh, nhưng không có giao thức agent dựng sẵn và không có lớp che dữ liệu giữa dữ liệu và mô hình: mỗi tích hợp tự giải ba bài toán trên theo cách riêng. Câu trả lời của Astra là **agent chỉ là một client nữa của cùng một kernel**. Nó xác thực với tư cách người dùng, mọi lần gọi tool đi qua cùng những bước kiểm tra quyền như REST API và Desk, và các trường khai báo `pii: true` trong DocType bị che trên kênh MCP. ![Hình 1: Agent mang danh tính của người dùng](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-15-figure-1.png) *Agent mang danh tính của người dùng.* ## Video cho thấy gì | Cảnh | Bạn thấy gì | Điều được chứng minh | | --- | --- | --- | | Tool | `initialize` với giao thức `2025-11-25`, rồi `tools/list` trả về 7 tool | 5 tool của kernel (`list_doctypes`, `search_documents`, `get_document`, `create_document`, `ledger_trial_balance`) và 2 tool do app Express đóng góp (`track_waybill`, `cod_outstanding`) | | PII | `search_documents` tìm đơn hàng của khách C001 | `receiver_phone` trả về `*******888`, `receiver_name` thành `B*** A*** K***`, địa chỉ thành `3*** L*** L*** …`; `shipping_fee` (23.000) giữ nguyên. Desk đã đăng nhập vẫn hiện đầy đủ dữ liệu | | Tra cứu | Claude Code thật, đăng nhập bằng `ha` (care), được hỏi bằng tiếng Việt bưu kiện `WB-2610-000002` đang ở đâu và có khiếu nại không | Claude tự chọn `track_waybill` và trả lời kèm hành trình (Created → Picked Up → HCM-Tân Bình → HN-Long Biên → Out for Delivery), đơn hàng và ticket `TCK-00002` | | Từ chối | `ha` nhờ Claude tạo đơn hàng mới cho C001 | Claude gọi `create_document`, kernel trả lời `not permitted: create CustomerOrder`; Claude giải thích rằng vấn đề nằm ở quyền, không phải ở dữ liệu | | COD | Kế toán `khoa` hỏi shop nào đang chờ trả tiền COD | `cod_outstanding` đọc sổ cái: Shop Hoa Mai (C001), đã thu 350.000 ₫, chưa trả | | Code | `TrackWaybillTool.java` | App thêm một tool chỉ bằng một class; kernel lo xác thực, tenant, RLS, kiểm tra quyền và che dữ liệu | Cảnh terminal (`SCENES=13`) còn cho thấy hai điều mà lời dẫn chỉ lướt qua: `ha` gọi `cod_outstanding` bị từ chối với `not permitted: read Account`, và `ha` tạo một SupportTicket qua MCP thì thành công, đồng thời hook phân loại của app vẫn nâng mức ưu tiên lên High (`auto-escalated: mentions 'vỡ'`). ![Hình 4: Bị từ chối, không phải bị lỗi](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-15-figure-4.png) *Bị từ chối, không phải bị lỗi.* ## Cách hoạt động ### Một endpoint, cùng một danh tính MCP server là một JAX-RS resource trong `astra-ai`. Nó nói JSON-RPC qua transport Streamable HTTP với response JSON thuần (không có stream do server khởi tạo), và nó không có chút code xác thực nào của riêng mình: bearer token và header `X-Astra-Tenant` được kiểm tra bởi chính các filter bảo vệ `/api/doc`, còn `TenantRequest` mang user, tenant và role đã được xác định. *`astra-ai/src/main/java/io/astra/ai/McpServer.java`* ```java @Path("/mcp") @RunOnVirtualThread public class McpServer { private static final Logger LOG = Logger.getLogger(McpServer.class); static final List PROTOCOL_VERSIONS = List.of("2025-11-25", "2025-06-18", "2025-03-26"); static final Set KERNEL_TOOLS = Set.of("list_doctypes", "search_documents", "get_document", "create_document", "ledger_trial_balance"); // … /** No server-initiated stream: the spec allows 405 for clients that try to open one. */ @GET public Response stream() { return Response.status(405).header("Allow", "POST").build(); } ``` Tool của kernel gọi cùng bước kiểm tra `DocPermissions` như REST trước khi chạm vào store. Đó là lý do `ha` nhận đúng câu trả lời mà cô sẽ nhận được từ Desk: *`astra-ai/src/main/java/io/astra/ai/McpServer.java`* ```java private Object create(Map args) { String doctype = required(args, "doctype"); permissions.require(doctype, tenant.roles(), Action.CREATE); if (!(args.get("data") instanceof Map data)) throw new IllegalArgumentException("'data' must be an object"); Map fields = new LinkedHashMap<>(); data.forEach((k, v) -> fields.put(String.valueOf(k), v)); return doc(store.insert(doctype, fields, tenant.userId())); } ``` Luật từ chối cô nằm trong DocType của app, không nằm trong code AI nào. `care` được đọc đơn hàng nhưng không có quyền `create`: *`astra-apps/express/src/main/resources/META-INF/astra/doctypes/customer-order.doctype.yaml`* ```yaml permissions: - { role: admin, read: true, create: true, write: true, submit: true, cancel: true } - { role: sales, read: true, create: true, write: true, if-owner: true, submit: true } - { role: ops, read: true } - { role: care, read: true } - { role: accountant, read: true, cancel: true } ``` Lời từ chối được trả về như một kết quả tool có `isError: true`, không phải lỗi transport, nên mô hình đọc được lý do và giải thích lại cho người dùng (đúng như Claude đã làm trong video): *`astra-ai/src/main/java/io/astra/ai/McpServer.java`* ```java } catch (AccessDeniedException | DocValidationException | DocTypeNotFoundException | IllegalArgumentException e) { result = toolResult(Map.of("error", String.valueOf(e.getMessage())), true); wrote = false; } ``` ```mermaid sequenceDiagram participant U as ha (care) trong Claude Code participant M as Claude (mô hình) participant S as Astra /mcp participant P as DocPermissions participant D as astra_doc (RLS) U->>M: "Bưu kiện WB-2610-000002 đang ở đâu?" M->>S: tools/call track_waybill {waybill} · Bearer · X-Astra-Tenant S->>P: require READ trên Waybill, CustomerOrder, SupportTicket P-->>S: ok với role care S->>D: đọc trong tenant saigon-express D-->>S: vận đơn, đơn hàng, ticket S->>S: PiiMasking.mask(result) S-->>M: kết quả đã che M-->>U: hành trình, đơn hàng, ticket TCK-00002 U->>M: "tạo đơn hàng mới cho C001" M->>S: tools/call create_document CustomerOrder S->>P: require CREATE trên CustomerOrder P-->>S: bị từ chối S-->>M: isError: true, "not permitted: create CustomerOrder" ``` ### Che dữ liệu trên đường ra `PiiMasking` duyệt kết quả của tool và nhận ra document theo hình dạng (một map có `doctype` và `data`). Với mỗi trường mà DocType hiệu lực (đã gồm overlay của tenant) đánh dấu `pii`, nó thay giá trị: *`astra-ai/src/main/java/io/astra/ai/PiiMasking.java`* ```java static Object maskValue(Object v) { if (v == null) return null; String s = v.toString(); String digits = s.replaceAll("\\D", ""); if (digits.length() >= 8 && digits.length() >= s.replaceAll("\\s", "").length() - 2) { return "*".repeat(digits.length() - 3) + digits.substring(digits.length() - 3); } return Arrays.stream(s.trim().split("\\s+")) .map(w -> w.isEmpty() ? w : w.substring(0, w.offsetByCodePoints(0, 1)) + "***") .collect(Collectors.joining(" ")); } ``` Vì sao thiết kế như vậy: - **Việc che dữ liệu do metadata điều khiển.** Người viết DocType đã khai báo trường nào là dữ liệu cá nhân (`receiver_name`, `receiver_phone`, `receiver_address` là `pii: true` trong `customer-order.doctype.yaml`). Không tích hợp AI nào phải tự giữ một danh sách riêng. - **Vẫn giữ lại phần hữu ích.** Ba số cuối của số điện thoại và chữ cái đầu của mỗi từ là đủ để nhân viên CSKH xác nhận "đây có phải khách đang gọi không?" mà mô hình không bao giờ cầm giá trị đầy đủ. - **Áp ở kênh, không áp ở tầng lưu trữ.** Desk hiện đầy đủ dữ liệu cho người dùng đã đăng nhập; chỉ dữ liệu đi ra qua MCP mới bị che. Nhờ vậy quy trình làm việc của con người không bị ảnh hưởng. ![Hình 2: Lớp che làm gì với một đơn hàng](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-15-figure-2.png) *Lớp che làm gì với một đơn hàng.* ### App đóng góp một tool chỉ bằng một class SPI `McpTool` nằm trong `astra-spi`, nên app không phụ thuộc vào thứ gì trong `astra-ai`. Tool khai báo các DocType mà nó đọc; kernel kiểm tra quyền đọc trên tất cả các DocType đó trước khi tool chạy, gắn tenant, ghi log lần gọi và che kết quả. *`astra-apps/express/src/main/java/io/astra/apps/express/TrackWaybillTool.java`* ```java @ApplicationScoped public class TrackWaybillTool implements McpTool { @Inject DocumentService docs; @Override public String name() { return "track_waybill"; } // … @Override public List reads() { return List.of("Waybill", "CustomerOrder", "SupportTicket"); } @Override public Object call(Map arguments) { String name = String.valueOf(arguments.get("waybill")); Document waybill = docs.get("Waybill", name).orElseThrow(() -> new IllegalArgumentException("waybill " + name + " not found")); Map out = new LinkedHashMap<>(); out.put("waybill", doc(waybill)); docs.get("CustomerOrder", waybill.getString("order")).ifPresent(o -> out.put("order", doc(o))); out.put("tickets", docs.find("SupportTicket", Map.of("waybill", name), 20).stream().map(TrackWaybillTool::doc).toList()); return out; } static Map doc(Document d) { return Map.of("doctype", d.doctype(), "name", d.name(), "docstatus", d.docstatus(), "data", d.data()); } } ``` *`astra-ai/src/main/java/io/astra/ai/AppToolInvoker.java`* ```java public McpTool authorize(String name) { McpTool tool = tools.stream().filter(t -> t.name().equals(name)).findFirst() .orElseThrow(() -> new IllegalArgumentException("unknown tool: " + name)); for (String doctype : tool.reads()) permissions.require(doctype, tenant.roles(), Action.READ); return tool; } // … public Outcome call(String channel, String name, Map args) { McpTool tool = authorize(name); LOG.infof("%s tool %s by %s in tenant %s (arguments: %s)", channel, name, tenant.userId(), tenant.tenantId(), args.keySet()); Object result = TenantContext.call(tenant.tenantId(), () -> pii.mask(tool.call(args))); return new Outcome(result, tool.writes()); } ``` `cod_outstanding` khai báo `reads() = List.of("Customer", "Account")`. Role `care` không có luật nào trên `Account`, nên `ha` bị từ chối với `not permitted: read Account` trước khi code của tool kịp chạy; `khoa` (accountant) đọc được `Account` và nhận số dư sổ cái của tài khoản 3388 theo từng shop. Hai ghi chú thiết kế nên biết: - **Kiểm tra quyền mang tính khai báo và làm từ đầu.** Một tool đọc ba DocType sẽ bị từ chối nếu người dùng thiếu quyền trên bất kỳ DocType nào trong đó, kể cả khi lần gọi cụ thể này không chạm tới nó. Như vậy chặt hơn mức cần thiết, nhưng đổi lại người review chỉ cần đọc một dòng là kiểm toán được một tool. - **Việc che dữ liệu phụ thuộc vào hình dạng kết quả.** SPI yêu cầu tool trả document dưới dạng map `{doctype, name, data}`. Một tool tự "làm phẳng" trường PII vào map riêng của nó (ví dụ `{"phone": "0901…"}`) sẽ lọt qua lớp che. Hãy review các tool của app về điểm này. ![Hình 3: Một tool của app, nhiều việc của kernel](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-15-figure-3.png) *Một tool của app, nhiều việc của kernel.* ### Lệnh ghi báo vị trí WAL của mình Mọi lần gọi MCP đều là POST, nên kernel chỉ thêm `X-Tx-LSN` sau một lần gọi thành công tới tool có ghi dữ liệu (`create_document`, hoặc tool của app có `writes()` là true). Log của cảnh cho thấy header này khi tạo SupportTicket (`X-Tx-LSN: 0/432E3F8`). Đây chính là cơ chế read-your-writes được giải thích trong [Video 16](/astra-cms/vi/cdc). ## Ví dụ thực tế **Tổng đài chăm sóc khách hàng của một công ty 3PL ở TP. Hồ Chí Minh.** Một nhân viên CSKH, đăng nhập bằng user `care`, dùng trợ lý AI trong lúc nghe máy. *Tình huống minh hoạ: tình huống dưới đây áp cơ chế ở trên vào các giá trị minh hoạ; đây không phải output của bản demo.* **Đầu vào.** Một khách gọi đến báo bưu kiện `WB-2611-004211` chưa tới. Nhân viên gõ: "WB-2611-004211 đang ở đâu và có khiếu nại nào đang mở không?" **Astra làm gì.** 1. Trợ lý gọi `track_waybill`. Kernel kiểm tra `care` được đọc Waybill, CustomerOrder và SupportTicket, gắn tenant của nhân viên rồi chạy tool. Nhờ row-level security, một mã vận đơn thuộc tenant khác chỉ đơn giản trả về "not found". 2. `receiver_name` "Trần Thị Hồng", `receiver_phone` "0912 345 678" và `receiver_address` "12 Nguyễn Huệ, Quận 1" của đơn hàng bị che thành `T*** T*** H***`, `*******678` và `1*** N*** H*** Q*** 1***`. 3. Nhân viên hỏi khách ba số cuối điện thoại; "678" khớp với giá trị đã che, vậy là nhân viên đã xác minh được người gọi mà mô hình không hề thấy số đầy đủ. **Kết quả.** Trợ lý trả lời kèm hành trình quét mã và ticket đang mở. Khi khách tiếp tục đòi hoàn tiền và nhân viên bảo "tạo đơn hoàn tiền", lệnh `create_document` của trợ lý bị từ chối vì đúng lý do mà chính nhân viên không làm được việc này trên Desk. Dòng log `MCP tool create_document by in tenant ` ghi lại ai đã thử. ## Dành cho tech lead - **Ranh giới bảo mật là người dùng, không phải AI.** Agent không bao giờ làm được nhiều hơn người có token mà nó mang. Điều đó cũng có nghĩa là nó làm được *mọi thứ* người đó làm được, kể cả các thao tác ghi mà role của họ cho phép. Hãy cấp cho user dùng agent role hẹp nhất đủ phục vụ quy trình. - **Token là điểm yếu hiện nay.** Client demo đăng ký một bearer token tĩnh sống khoảng một giờ (`scripts/mcp-claude.sh`). Chưa có luồng OAuth cho client MCP, nên việc quản lý token trên máy client là trách nhiệm của bạn. - **Che dữ liệu không phải là ẩn danh hoá.** Chữ cái đầu cộng ba số cuối giảm mức phơi nhiễm; chúng không làm dữ liệu trở thành ẩn danh. Các trường không đánh dấu `pii: true` (ví dụ tên shop "Shop Hoa Mai" trong `cod_outstanding`) đi tới mô hình đầy đủ. Hãy coi cờ `pii` là một phần của việc review DocType. - **Che dữ liệu chỉ áp cho MCP.** Desk và REST API trả đầy đủ dữ liệu cho người dùng có quyền, đúng như chủ đích. - **Nên đo gì:** số lần gọi tool theo người dùng và tenant (từ các dòng log `MCP tool … by … in tenant …`), tỷ lệ bị từ chối (`isError: true`), tool nào được dùng, và DocType nào vẫn còn thiếu chú thích `pii`. - **Khi nào không nên dùng:** nếu bạn cần mô hình thấy dữ liệu cá nhân chưa che (ví dụ soạn thư gửi một khách hàng có tên cụ thể), kênh này sẽ không đưa cho bạn, và đó là chủ đích thiết kế. Nếu bạn cần agent tự hành chạy khi không có người dùng, phần đó chưa được xây. - **So với các lựa chọn khác:** viết tay một lớp bọc REST cho từng nhà cung cấp AI là nhân đôi logic xác thực và phân quyền; một bản sao database chỉ đọc cho mô hình sẽ mất cô lập tenant, trừ khi bạn dựng lại RLS quanh nó. MCP là giao thức mở, nên cùng một endpoint phục vụ được Claude, Cursor hay một agent nội bộ. ## Tự chạy thử Điều kiện: cụm k3d theo [demo-video-series.md](/astra-cms/vi/video-scripts) (JDK 25, Docker với 8 GB, `kubectl`, `k3d`, `jq`, Bash 5), và Claude Code (`claude`) cho phần agent. ```bash make k3d-up make demo-reset && make demo-seed make demo-express SCENES=13 PAUSE=0 ``` Output mong đợi (rút gọn từ lần quay): `protocolVersion: "2025-11-25"`, danh sách 7 tool, các giá trị `receiver_phone` đã che như `*******888`, `track_waybill` với năm lần quét, rồi `not permitted: create CustomerOrder` và `not permitted: read Account` cho `ha`, còn với `khoa` là `[{"customer": "C001", "customer_name": "Shop Hoa Mai", "cod_owed_vnd": 350000.00}]`. Để trò chuyện với nó bằng một agent thật: ```bash scripts/mcp-claude.sh ha saigon-express # registers the "astra" MCP server in Claude Code with ha's token claude # ask: "Bưu kiện WB-2610-000002 đang ở đâu, có khiếu nại không?" scripts/mcp-claude.sh khoa saigon-express # then ask: "Khách nào đang chờ trả COD?" ``` Script chạy `claude mcp add --transport http astra http://localhost:8080/mcp` với bearer token và header `X-Astra-Tenant` của người dùng. Hãy chạy lại khi token hết hạn (khoảng một giờ). Mã vận đơn chứa tháng của dữ liệu seed, nên hãy chọn một mã có trong seed của bạn. Các integration test nằm ở `astra-server/src/test/java/io/astra/server/McpTest.java` (handshake, che dữ liệu, phân quyền, tool không tồn tại) và chạy bằng `make test`. ## Nói đúng - Client dùng bearer token tĩnh (token một giờ của client dev). OAuth discovery cho MCP còn trên roadmap. - AI agent tự hành và vector search (pgvector RAG) còn trên roadmap, chưa được xây. - Che PII áp dụng cho kênh MCP, không áp cho Desk. - Câu trả lời của Claude trên màn hình là thật nhưng đã rút gọn (bỏ thời điểm quét cuối và hạn SLA); video có ghi chú là đã rút gọn. - MCP server chỉ trả lời bằng JSON; client nào mở stream GET sẽ nhận 405. ## Hình minh hoạ - [Hình 1 · Agent mang danh tính của người dùng](#vấn-đề) - [Hình 2 · Lớp che làm gì với một đơn hàng](#che-dữ-liệu-trên-đường-ra) - [Hình 3 · Một tool của app, nhiều việc của kernel](#app-đóng-góp-một-tool-chỉ-bằng-một-class) - [Hình 4 · Bị từ chối, không phải bị lỗi](#video-cho-thấy-gì) ## Liên quan - Trước: [Video 14 · Tier 2 Wasm](/astra-cms/vi/wasm) · Tiếp: [Video 16 · Rust CDC + Sync-Aware LSN](/astra-cms/vi/cdc) - [Video 11 · Keycloak](/astra-cms/vi/keycloak) (role và quyền DocType đến từ đâu) · [Video 20 · STMS](/astra-cms/vi/stms) (thêm các tool MCP của app) - Đi qua code: [S9 MCP for AI agents](/astra-cms/vi/code-snippets) - Bối cảnh: [explain.md](/astra-cms/vi/why-astra) (bề mặt AI và MCP trong kiến trúc mục tiêu) - Mã nguồn: `astra-ai/src/main/java/io/astra/ai/McpServer.java`, `astra-ai/src/main/java/io/astra/ai/PiiMasking.java`, `astra-ai/src/main/java/io/astra/ai/AppToolInvoker.java`, `astra-spi/src/main/java/io/astra/spi/ai/McpTool.java`, `astra-apps/express/src/main/java/io/astra/apps/express/TrackWaybillTool.java`, `astra-apps/express/src/main/java/io/astra/apps/express/CodOutstandingTool.java`, `scripts/mcp-claude.sh` --- # Rust CDC + Sync-Aware LSN Tóm tắt một câu: Một worker Rust đọc write-ahead log của PostgreSQL và duy trì các bảng replica cho chăm sóc khách hàng, mỗi lần ghi đều báo cho client vị trí WAL của nó (X-Tx-LSN), và một lần đọc replica có yêu cầu vị… Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/cdc ▶ [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-16-cdc-vi.mp4) · [English](https://media.zerohostcms.com/astra-cms/astra-video-16-cdc-en.mp4) · 2:36 · Kịch bản: [demo-video-series.md](/astra-cms/vi/video-scripts) · [Bản tiếng Anh](/astra-cms/vi/cdc) > **Tóm tắt một câu:** Một worker Rust đọc write-ahead log của PostgreSQL và duy trì các bảng replica cho chăm sóc khách hàng, mỗi lần ghi đều báo cho client vị trí WAL của nó (`X-Tx-LSN`), và một lần đọc replica có yêu cầu vị trí đó sẽ nhận `202 Sync Pending` cho tới khi replica bắt kịp, nên CSKH không bao giờ đọc phải dữ liệu cũ. > **Nên đọc nếu:** bạn cần một module đọc dữ liệu của module khác mà không truy vấn thẳng vào bảng của nó, nhưng vẫn thấy ngay những gì mình vừa ghi (developer) / bạn đang cân nhắc giữa eventual consistency và sự phụ thuộc chặt giữa các module, và muốn một hợp đồng cụ thể, đo được (tech lead). ## Vấn đề Trong Astra Express, bộ phận vận hành (OPS) quét mã bưu kiện còn bộ phận chăm sóc khách hàng (CARE) trả lời các cuộc gọi về chúng. Hai module không nên dùng chung bảng: OPS thay đổi schema theo lịch riêng của mình, CARE cần một góc nhìn hẹp và ổn định, và trong thiết kế mục tiêu phía phân tích cũng đọc cùng luồng thay đổi đó. Lời giải chuẩn là change data capture (CDC): đẩy các thay đổi từ database vận hành sang một bản sao do module kia sở hữu. CDC kéo theo vấn đề kinh điển của mọi bản replica: **dữ liệu cũ**. Bưu tá quét một bưu kiện sang "Out for Delivery"; hai giây sau khách gọi đến; nhân viên CSKH mở bưu kiện và thấy "At Hub". Không ai biết được bản sao đang chậm hay lần quét kia chưa hề xảy ra. Các đội thường chọn một trong những cách sau: - **Đọc thẳng bảng nguồn.** Dữ liệu mới, nhưng CARE giờ bị trói vào schema và tải của OPS. - **Chấp nhận eventual consistency và cầu may.** Phần lớn thời gian bản sao chỉ chậm vài trăm mili giây, và lần đọc dữ liệu cũ hiếm hoi biến thành một ticket hỗ trợ không ai tái hiện được. - **Thêm một lệnh sleep hoặc banner "vui lòng tải lại sau một phút".** Chậm hơn với tất cả mọi người, mà vẫn không có gì bảo đảm. Frappe/ERPNext để mọi module trong cùng một database và đọc thẳng bảng nguồn, nên không gặp vấn đề này, nhưng cũng không có được sự tách rời. Astra giữ sự tách rời và làm cho độ trễ dữ liệu trở nên **nhìn thấy được và có giới hạn**: lệnh ghi trả về một vị trí trong log; lệnh đọc có thể nói "tôi cần ít nhất vị trí đó"; kernel hoặc trả dữ liệu đã bao gồm lần ghi, hoặc trả lời "chưa xong" mà không kèm dữ liệu. ![Hình 1: Ghi, vị trí, đọc](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-16-figure-1.png) *Ghi, vị trí, đọc.* ## Video cho thấy gì | Cảnh | Bạn thấy gì | Điều được chứng minh | | --- | --- | --- | | Trang Replication | Desk → Replication (CDC): worker giải mã WAL (`pgoutput`), độ trễ 77 ms, số byte còn lại | Worker đang chạy và báo tiến độ qua kernel | | Code | `astra-cdc-rust/cdc-routes.yaml` và vòng lặp của worker | Route được khai báo trong YAML (DocType, bảng đích, cột); worker tự giải mã pgoutput và ghi các dòng replica cùng watermark trong một transaction; slot chỉ tiến lên sau khi commit | | Ghi | Tạm dừng worker (`kubectl scale … --replicas=0`), `quang` (OPS) chuyển `WB-2610-000003` sang Out for Delivery | Response mang `X-Tx-LSN: 0/387F920` | | Chờ | `ha` (CARE) đọc `replica_waybill` với `X-Required-LSN: 0/387F920` | `202` với `"status": "sync-pending"`, vị trí đã replicate `0/387CFC0`, không có dòng dữ liệu cũ | | Chạy lại | Bật worker trở lại | Cùng lệnh đọc đó trả 200 sau 26 ms kể từ khi worker chạy lại, với `ops_status: Out for Delivery`. Ở trạng thái ổn định: một lần ghi rồi đọc ngay lập tức bắt kịp sau 117 ms. Con số thay đổi theo từng lần chạy | | Desk | Thẻ "CARE replica (CDC)" trên form Waybill | Hiện "sync pending" và tự hỏi lại trong 10 s. Trong lần quay, worker tắt lâu hơn thế, nên form được mở lại khi worker đã chạy | | Slot | `pg_replication_slots`, cờ của bảng replica | Một logical slot `astra_cdc`, plugin `pgoutput`, `active = f` (bình thường, xem bên dưới); bảng replica bị ép row-level security, và role của worker không có SUPERUSER lẫn BYPASSRLS | ## Cách hoạt động ```mermaid sequenceDiagram participant OPS as quang (OPS) participant K as Kernel participant PG as PostgreSQL (astra_doc, WAL) participant W as astra-cdc (Rust) participant CARE as ha (CARE) OPS->>K: PUT /api/doc/Waybill/WB-2610-000003 {"ops_status":"Out for Delivery"} K->>PG: UPDATE astra_doc … COMMIT K-->>OPS: 200 · X-Tx-LSN: 0/387F920 CARE->>K: GET /api/replica/replica_waybill?name=… · X-Required-LSN: 0/387F920 K->>PG: max(last_lsn) >= 0/387F920 ? (_cdc_sync_watermark) K-->>CARE: 202 sync-pending (đã replicate 0/387CFC0) W->>PG: pg_logical_slot_peek_binary_changes(astra_cdc, current LSN) W->>PG: BEGIN · upsert replica_waybill (dưới RLS) · watermark = current LSN · COMMIT W->>PG: pg_replication_slot_advance CARE->>K: lại đúng lệnh GET đó K-->>CARE: 200 · dòng có ops_status = Out for Delivery ``` ### 1. Cái gì được replicate là do khai báo, không phải do viết code *`astra-cdc-rust/cdc-routes.yaml`* ```yaml slot: astra_cdc publication: astra_cdc poll_ms: 50 routes: - doctype: Waybill target: replica_waybill columns: - { name: order_name, from: order } - { name: ops_status, from: ops_status } - { name: current_hub, from: current_hub } - { name: receiver_name, from: receiver_name } - { name: scans, from: scan_log, transform: length, type: integer } # … ``` Mọi DocType nằm trong một bảng JSONB, `astra_doc` ([Video 2](/astra-cms/vi/doctype)). Publication chỉ bao bảng đó, và mỗi route chọn một DocType rồi chiếu các key JSON được chọn thành các cột có kiểu. CARE nhận `scans` dưới dạng số đếm thay vì cả log quét mã, và đó chính là ý nghĩa của một replica: một góc nhìn hẹp do bên tiêu thụ sở hữu. Tên trong route trở thành định danh SQL, nên worker từ chối mọi thứ không phải là một tên viết thường đơn giản hoặc một trong bốn kiểu. ### 2. Vòng lặp của worker: peek, áp dụng cùng watermark, rồi advance *`astra-cdc-rust/src/main.rs`* ```rust loop { let cur: String = client.query_one("SELECT pg_current_wal_lsn()::text", &[]).await?.get(0); let rows = client .query( "SELECT lsn::text, data FROM pg_logical_slot_peek_binary_changes($1, $2::text::pg_lsn, NULL, \ 'proto_version', '1', 'publication_names', $3)", &[&cfg.slot, &cur, &cfg.publication], ) .await?; let tx = client.transaction().await?; // … decode each row with pgoutput::decode, upsert matching astra_doc rows into their replica … if applied > 0 || last_watermark.elapsed() >= Duration::from_secs(1) { tx.execute( "INSERT INTO _cdc_sync_watermark (slot, last_lsn, events, lag_ms, updated_at) VALUES ($1, $2::text::pg_lsn, $3, $4, now()) \ ON CONFLICT (slot) DO UPDATE SET last_lsn = excluded.last_lsn, events = _cdc_sync_watermark.events + excluded.events, \ lag_ms = coalesce(excluded.lag_ms, _cdc_sync_watermark.lag_ms), updated_at = now()", &[&cfg.slot, &cur, &(applied as i64), &lag_ms.map(|v| v as i32)], ) .await?; last_watermark = Instant::now(); } tx.commit().await?; client.execute("SELECT pg_replication_slot_advance($1, $2::text::pg_lsn)", &[&cfg.slot, &cur]).await?; // … tokio::time::sleep(Duration::from_millis(cfg.poll_ms)).await; } ``` Vì sao làm như vậy: - **Watermark commit cùng với dữ liệu.** Các dòng replica và `_cdc_sync_watermark.last_lsn` được ghi trong cùng một transaction, nên "watermark là X" luôn có nghĩa "mọi thay đổi tới X đã có trong replica". Không có khoảng thời gian nào watermark đi trước dữ liệu. - **Slot tiến lên sau khi commit.** Nếu worker sập giữa lúc commit và lúc advance, lần poll sau sẽ peek lại đúng những thay đổi đó. Upsert theo khoá `(tenant_id, name)` khiến việc phát lại vô hại: giao ít nhất một lần, áp dụng idempotent. - **Peek/advance bằng SQL thay cho kết nối streaming.** Worker dùng một kết nối bình thường và các hàm SQL dành cho logical slot. Cách này đơn giản hơn để vận hành và dễ suy luận hơn, đổi lại phải có chu kỳ poll (50 ms) và slot hiện `active = f` giữa các lần poll. Streaming còn trên roadmap. - **pgoutput được giải mã ngay trong worker.** `src/pgoutput.rs` giải mã giao thức phiên bản 1 (Begin, Commit, Relation, Insert, Update, Delete, Truncate). pgoutput là output plugin có sẵn của PostgreSQL, nên database không cần cài extension nào. Lệnh Delete bị bỏ qua vì document không bao giờ bị xoá, chỉ bị hủy. ![Hình 2: Bên trong một lần poll của worker](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-16-figure-2.png) *Bên trong một lần poll của worker.* ### 3. Dòng replica được ghi dưới RLS Worker không được cấp role đặc quyền. Tenant của từng dòng được gắn trước khi upsert: *`astra-cdc-rust/src/main.rs`* ```rust // The replica tables are under row-level security; bind this row's tenant for the write. tx.execute("SELECT set_config('astra.tenant', $1, true)", &[&tenant]).await?; ``` *`astra-doctype/src/main/resources/db/migration/V5__cdc_replicas.sql`* ```sql ALTER TABLE replica_waybill ENABLE ROW LEVEL SECURITY; ALTER TABLE replica_waybill FORCE ROW LEVEL SECURITY; CREATE POLICY replica_waybill_tenant ON replica_waybill USING (tenant_id = current_setting('astra.tenant', true)) WITH CHECK (tenant_id = current_setting('astra.tenant', true)); ``` *`deploy/db/20-cdc.sql`* ```sql -- REPLICATION reads the WAL through a slot; no SUPERUSER and no BYPASSRLS: replica rows are -- written under row-level security like any other. CREATE ROLE astra_cdc LOGIN REPLICATION PASSWORD 'astra_cdc' NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE; ``` `WITH CHECK` nghĩa là nếu worker có lỗi gắn nhầm tenant thì lệnh ghi thất bại, thay vì làm rò một dòng sang replica của tenant khác. File `replica-rls.txt` ghi lại trong lần quay xác nhận `relforcerowsecurity = t` trên cả hai bảng replica, và `rolsuper = f`, `rolbypassrls = f` cho `astra_cdc`. ### 4. Cổng LSN của kernel *`astra-doctype/src/main/java/io/astra/doctype/sync/SyncAwareLsnFilter.java`* ```java @Override public void filter(ContainerRequestContext req) { String required = req.getHeaderString(REQUIRED_LSN); if (required == null || required.isBlank() || !path(req).startsWith("api/replica")) return; // … reject a malformed LSN with 400 … if (!watermark.reached(required)) { Map body = new LinkedHashMap<>(); body.put("status", "sync-pending"); body.put("requiredLsn", required); body.put("replicatedLsn", watermark.status().get("replicatedLsn")); body.put("retryAfterMs", 50); req.abortWith(Response.status(202).entity(body).build()); } } @Override public void filter(ContainerRequestContext req, ContainerResponseContext res) { String path = path(req); if (WRITES.contains(req.getMethod()) && res.getStatus() / 100 == 2 && path.startsWith("api/doc/")) { res.getHeaders().putSingle(TX_LSN, watermark.currentLsn()); } } ``` *`astra-doctype/src/main/java/io/astra/doctype/sync/SyncWatermark.java`* ```java /** Whether everything up to {@code required} has been applied to the replicas. */ public boolean reached(String required) { try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement( "SELECT coalesce(max(last_lsn) >= ?::pg_lsn, false) FROM _cdc_sync_watermark")) { // … ``` Ghi chú thiết kế: - **LSN được lấy sau khi commit.** Response filter chạy sau khi transaction của document đã commit và đọc `pg_current_wal_lsn()`, giá trị này nằm ở hoặc sau bản ghi commit. Vị trí đó có thể muộn hơn một chút so với mức lần ghi thực sự cần (các lần ghi khác có thể chen vào giữa), nên client có thể phải chờ lâu hơn cần thiết vài mili giây; nhưng nó không bao giờ được báo "đã đồng bộ" quá sớm. Trong lần quay, `source_lsn` của dòng (`0/387EFF0`) chỉ nhỏ hơn một chút so với `X-Tx-LSN` mà client nhận được (`0/387F920`). - **Trả 202, không giữ request lại.** Kernel không giữ một thread để chờ worker. Nó trả lời ngay với các vị trí và một gợi ý thời gian thử lại, còn client tự quyết định chờ bao lâu. Lệnh đọc không có `X-Required-LSN` vẫn được phục vụ như cũ, nên một dashboard chấp nhận độ trễ không tốn thêm gì. - **Hợp đồng là tuỳ chọn theo từng lần đọc.** Chỉ client mới biết nó vừa ghi thứ gì cần nhìn thấy. Desk nhớ `X-Tx-LSN` của lần ghi gần nhất và gửi kèm khi đọc replica: *`astra-server/src/main/resources/META-INF/resources/desk/desk.js`* ```js for (let attempt = 0; attempt < 100; attempt++) { const required = state.lastLsn; const res = await api('GET', '/api/replica/' + spec[0] + '?name=' + enc(key), undefined, required ? { 'X-Required-LSN': required } : {}); if (res.status === 202) { // … show "sync pending" with required and replicated positions … await new Promise((r) => setTimeout(r, 100)); continue; } // … show "in sync" and the replica row … } status.textContent = 'Still pending after 10 s: is the CDC worker running? See Replication (CDC).'; ``` Bản thân endpoint replica (`ReplicaResource`) đòi quyền đọc trên DocType *nguồn*, nên role nào không đọc được Waybill thì cũng không đọc được `replica_waybill`. ![Hình 3: Cổng 202](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-16-figure-3.png) *Cổng 202.* ## Ví dụ thực tế **Một đơn vị giao hàng chặng cuối ở Hà Nội có đội hỗ trợ riêng.** Bưu tá cập nhật trạng thái bưu kiện từ máy cầm tay qua module OPS; đội hỗ trợ chỉ làm việc trên các bảng replica. *Tình huống minh hoạ: các giá trị dưới đây chỉ mang tính minh hoạ.* **Đầu vào.** Lúc 14:02:10, bưu tá Minh đánh dấu `WB-2611-018803` là "Failed Delivery" (khách không có nhà). Lệnh PUT trả `200` với `X-Tx-LSN: 1/0A41C2D8`. Ứng dụng trên máy cầm tay chuyển vị trí đó sang hệ thống hỗ trợ cùng mã bưu kiện, như một phần của tác vụ "gọi cho khách". **Astra làm gì.** 1. Lúc 14:02:11, nhân viên hỗ trợ Lan mở bưu kiện. Client của cô gửi `X-Required-LSN: 1/0A41C2D8`. 2. Worker đang giữa một lần poll; watermark là `1/0A41B900`. Kernel trả `202 sync-pending` kèm cả hai vị trí. 3. Màn hình của Lan hiện "sync pending" thay vì trạng thái cũ "Out for Delivery". Ở lần poll kế tiếp, worker áp dụng thay đổi, commit dòng replica cùng watermark `1/0A41D010`, và lần thử lại trả `200` với `ops_status: Failed Delivery`. **Kết quả.** Lan không bao giờ thấy trạng thái cũ và không bao giờ phải hỏi "anh tải lại giúp em được không?". Nếu worker ngừng chạy, cô thấy "still pending" và trang Replication báo worker không còn gửi báo cáo, biến một lỗi dữ liệu âm thầm thành một cảnh báo vận hành. ## Dành cho tech lead - **Bạn nhận được gì:** tách rời module bằng CDC với một hợp đồng read-your-writes rõ ràng. Cam kết là "không bao giờ cũ với những lần ghi bạn chỉ định", không phải "lúc nào cũng mới nhất". - **Chi phí:** một process Rust (`astra-cdc`), một logical replication slot, một bảng watermark và một truy vấn thêm cho mỗi lần đọc replica có `X-Required-LSN`. PostgreSQL bắt buộc phải bật `wal_level=logical`. - **Rủi ro vận hành: slot giữ lại WAL.** Khi worker dừng, PostgreSQL giữ lại phần WAL mà slot chưa tiêu thụ. Worker dừng lâu sẽ làm WAL trên đĩa phình ra. Hãy theo dõi `bytesBehind` từ `GET /api/sync/status` và cảnh báo khi `workerReporting: false`. - **Độ trễ là con số đo được, không phải lời hứa.** Lần quay ghi nhận độ trễ 77 ms trên trang Replication, 26 ms từ lúc worker khởi động lại tới lần đọc đồng bộ đầu tiên, và 117 ms từ lúc ghi tới lúc đồng bộ ở trạng thái ổn định. Chu kỳ poll (50 ms) đặt ra mức sàn. Hãy đo p50/p99 của thời gian từ ghi tới đồng bộ trên tải của chính bạn. - **Một watermark cho mọi tenant.** Worker áp dụng thay đổi của mọi tenant theo thứ tự, nên một đợt ghi dồn dập từ một tenant sẽ làm chậm watermark của tất cả. Ở quy mô demo thì ổn; hãy để mắt tới điểm này khi tải nhiều tenant. - **Khi nào không nên dùng:** nếu một màn hình luôn phải hiện dữ liệu mới nhất và không xử lý được trạng thái "pending", hãy đọc DocType nguồn qua REST API. Nếu bạn cần replica nằm ở một database khác hoặc một kho phân tích, phần đó chưa được xây. - **So với các lựa chọn khác:** Debezium cộng Kafka là một pipeline CDC tổng quát đã trưởng thành nhưng thêm một broker và một connector JVM, và tự nó không cho bạn hợp đồng về độ mới theo từng request. Đọc thẳng bảng nguồn thì dữ liệu mới nhưng trói các module vào nhau. Cách của Astra nhỏ và cụ thể: một bảng, các route khai báo, và một cái bắt tay LSN giữa bên ghi và bên đọc. ## Tự chạy thử Điều kiện: cụm k3d theo [demo-video-series.md](/astra-cms/vi/video-scripts) (JDK 25, Docker với 8 GB, `kubectl`, `k3d`, `jq`, Bash 5). `make k3d-up` khởi động PostgreSQL với `wal_level=logical` và chạy `scripts/db-setup.sh`, script này tạo role CDC và publication. Chỉ cần Rust nếu bạn sửa worker. ```bash make k3d-up make demo-reset && make demo-seed make demo-express SCENES=14 PAUSE=0 ``` Cảnh này tạm dừng worker (`kubectl -n astra scale deployment/astra-cdc --replicas=0`), ghi, đọc kèm LSN, bật lại worker và poll cho tới khi lệnh đọc trả 200. Output mong đợi, trích từ lần quay (vị trí và thời gian khác nhau giữa các lần chạy): *`docs/video/v16-cdc/assets/scene-14.log`* ```text HTTP 200 X-Tx-LSN: 0/387F920 // … HTTP 202 { "status": "sync-pending", "requiredLsn": "0/387F920", "replicatedLsn": "0/387CFC0", "retryAfterMs": 50 } // … in sync 26 ms after the worker came back // … replica caught up 117 ms after the write: {"ops_status":"Delivered","scans":5,"source_lsn":"0/3880788"} // … slot_name | plugin | active | confirmed_flush_lsn -----------+----------+--------+--------------------- astra_cdc | pgoutput | f | 0/38812D0 ``` Để xem trên Desk: mở `http://localhost:8080/desk/`, vào Replication (CDC), rồi lưu một Waybill và theo dõi thẻ "CARE replica (CDC)" bên dưới form. Phía kernel được kiểm thử bởi `astra-server/src/test/java/io/astra/server/SyncTest.java` (chạy bằng `make test`). Test này tự đẩy `_cdc_sync_watermark` lên thay vì chạy worker Rust; worker được kiểm tra đầu cuối bằng cảnh 14 trên cụm k3d. ## Nói đúng - Worker đọc slot bằng `pg_logical_slot_peek_binary_changes` mỗi 50 ms (ít nhất một lần, upsert idempotent) và chưa dùng giao thức streaming replication. - Chưa có gì được đẩy sang Apache Doris; pipeline phân tích trong [use-case-01.md](/astra-cms/vi/use-case) là kiến trúc mục tiêu. - Use case mô tả worker dựng trên `supabase/etl`, với replica nằm trong schema riêng của từng module và một cache Redis cho watermark. Worker đang chạy là một bộ giải mã tự viết nhỏ gọn trên `tokio-postgres`, các replica nằm trong schema `public`, và watermark được đọc từ bảng của nó ở mỗi request đi qua cổng. - Desk tự hỏi lại trong 10 s. Nếu worker tắt lâu hơn, thẻ báo vẫn đang chờ và bạn mở lại form. - Số liệu độ trễ thay đổi giữa các lần chạy; con số "khoảng 50 ms" trong kịch bản gốc thành 117 ms ở lần quay này, và video nói rõ điều đó. ## Hình minh hoạ - [Hình 1 · Ghi, vị trí, đọc](#vấn-đề) - [Hình 2 · Bên trong một lần poll của worker](#2-vòng-lặp-của-worker-peek-áp-dụng-cùng-watermark-rồi-advance) - [Hình 3 · Cổng 202](#4-cổng-lsn-của-kernel) ## Liên quan - Trước: [Video 15 · MCP](/astra-cms/vi/mcp) · Tiếp: [Video 17 · Seata](/astra-cms/vi/seata) - [Video 6 · CSKH nhìn thấy vận hành](/astra-cms/vi/care) (nơi thẻ CARE replica xuất hiện lần đầu) · [Video 7 · Đa thuê bao](/astra-cms/vi/tenants) (row-level security) - Đi qua code: [S10 Rust CDC and Sync-Aware LSN](/astra-cms/vi/code-snippets) - Thiết kế mục tiêu: [use-case-01.md](/astra-cms/vi/use-case) (Rust CDC, Sync-Aware LSN, Doris) - Mã nguồn: `astra-cdc-rust/src/main.rs`, `astra-cdc-rust/src/pgoutput.rs`, `astra-cdc-rust/cdc-routes.yaml`, `astra-doctype/src/main/java/io/astra/doctype/sync/SyncAwareLsnFilter.java`, `astra-doctype/src/main/java/io/astra/doctype/sync/SyncWatermark.java`, `astra-doctype/src/main/java/io/astra/doctype/api/ReplicaResource.java`, `astra-doctype/src/main/resources/db/migration/V5__cdc_replicas.sql`, `deploy/db/20-cdc.sql` --- # Seata: một lần submit, ba tài nguyên Tóm tắt một câu: Chỉ một dòng YAML, transaction: global, biến việc submit đơn hàng thành một global transaction duy nhất của Apache Seata, trải qua hạn mức công nợ BMS (một nhánh AT trong schema riêng), một chỗ lấy hàng… Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/seata ▶ [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-17-seata-vi.mp4) · [English](https://media.zerohostcms.com/astra-cms/astra-video-17-seata-en.mp4) · 2:26 · Kịch bản: [demo-video-series.md](/astra-cms/vi/video-scripts) · [Bản tiếng Anh](/astra-cms/vi/seata) > **Tóm tắt một câu:** Chỉ một dòng YAML, `transaction: global`, biến việc submit đơn hàng thành một global transaction duy nhất của Apache Seata, trải qua hạn mức công nợ BMS (một nhánh AT trong schema riêng), một chỗ lấy hàng trên Redis (một nhánh TCC) và transaction chứng từ của chính kernel, nên hoặc cả ba cùng thay đổi, hoặc không cái nào thay đổi. > **Nên đọc nếu:** bạn viết một hook phải thay đổi dữ liệu do module khác sở hữu và muốn có rollback mà không phải tự viết code bù trừ (developer) / bạn đang chọn giữa distributed transaction, saga và một database dùng chung, và muốn thấy các đánh đổi thật trong code đang chạy (tech lead). ## Vấn đề Khi một nhân viên bán hàng của Astra Express submit đơn hàng của khách, ba việc phải xảy ra cùng nhau: 1. **BMS (billing)** trừ phí vận chuyển vào hạn mức công nợ của khách. BMS sở hữu dữ liệu trong schema riêng (`bms`), dưới role database riêng. Module đơn hàng không thể ghi vào đó trong transaction của chính mình. 2. **OPS (vận hành)** giữ một chỗ lấy hàng cho hôm nay. Năng lực lấy hàng là một bộ đếm nhanh trên Redis, không phải một dòng trong database. 3. **Kernel** đánh dấu đơn hàng là Submitted, tạo vận đơn và ghi bút toán, tất cả trong PostgreSQL. Nếu BMS đã nhận khoản trừ mà OPS hết chỗ, khách bị trừ công nợ cho một đơn hàng sẽ không bao giờ được lấy. Nếu đơn đã được submit mà khoản trừ công nợ thất bại, billing bị sai. Các đội thường chọn một trong những cách sau: - **Một database dùng chung và một transaction.** Đây là cách Frappe/ERPNext làm: mọi module dùng chung một database, nên kiểm tra công nợ và submit là một database transaction. Cách này đơn giản, nhưng từ bỏ quyền sở hữu dữ liệu của từng module, và Redis vẫn nằm ngoài transaction. - **Tự viết code bù trừ (saga).** Mỗi bước có một thao tác "hoàn tác" viết tay; mọi developer đều phải làm đúng thứ tự khi lỗi, việc thử lại và trùng lặp. - **Mặc kệ.** Đối soát bằng một job chạy ban đêm và sửa chênh lệch bằng tay. Astra dùng một bộ điều phối transaction. App nói nó muốn *gì* (dùng công nợ, giữ một chỗ); Seata ghi nhận từng nhánh và điều khiển commit hoặc rollback cho tất cả. Các lệnh ghi database được hoàn tác tự động (chế độ AT); các tài nguyên không phải database thì cài đặt try/confirm/cancel (chế độ TCC). ![Hình 1: Một lần submit, ba tài nguyên](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-17-figure-1.png) *Một lần submit, ba tài nguyên.* ## Video cho thấy gì | Cảnh | Bạn thấy gì | Điều được chứng minh | | --- | --- | --- | | YAML | `customer-order.doctype.yaml`: `transaction: global` | Kernel mở một global transaction quanh submit và cancel | | Thành công | Submit đơn `ORD-20261021-00007` của C001 (phí 73.000 ₫) | Công nợ đã dùng từ 123.000 lên 196.000; số chỗ lấy hàng đã đặt từ 5 lên 6; vận đơn `WB-2610-000006` được tạo. Seata Server ghi hai nhánh, AT rồi TCC, và commit cả hai | | Vượt hạn mức | Submit đơn nháp `ORD-20261021-00005` của C002 | `422 credit limit of C002 exceeded: 54000.00 used of 150000.00, this order needs 105000.00`. Đơn vẫn là nháp, không vận đơn, không bút toán; AT từ chối trước, TCC chưa hề giữ chỗ | | Từ chối muộn | Ngày được đặt thành kín chỗ (200 trên 200); submit `ORD-20261021-00008` | AT đã trừ công nợ, rồi TCC từ chối: `no pickup slot left for 2026-10-21 (capacity 200)`. Seata khôi phục công nợ của C001 từ undo log (vẫn là 196.000), `undo_rows` bằng 0, số chỗ đang giữ vẫn là 0 | | Log server | Log thật của Seata Server 2.6.0 (JDK 25) | Begin, hai lần đăng ký nhánh, cả hai bị rollback: khoảng 75 ms theo đồng hồ của server | | Code | `OrderCreditHook`, `PickupCapacity` | App gọi một SPI và không thấy kiểu nào của Seata. AT là JDBC thuần trên một datasource được proxy; TCC là một participant của app, ném lỗi khi hết chỗ | ![Hình 2: Rollback sau khi một nhánh đã commit](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-17-figure-2.png) *Rollback sau khi một nhánh đã commit.* ## Cách hoạt động ```mermaid sequenceDiagram participant C as tuan (sales) participant K as Kernel (DocumentStore.submit) participant TC as Seata Server 2.6.0 participant B as bms.customer_credit (AT) participant R as Bộ đếm lấy hàng trên Redis (TCC) C->>K: POST /api/doc/CustomerOrder/ORD-…/submit K->>TC: bắt đầu global transaction K->>K: mở tx của kernel (schema public, RLS) K->>B: UPDATE customer_credit SET used = used + fee (qua DataSourceProxy) B->>TC: đăng ký nhánh AT, dòng undo_log (ảnh trước/sau) K->>R: tcc prepare: reserved + 1 R->>TC: đăng ký nhánh TCC K->>K: vận đơn, docstatus = 1, sổ cái · commit tx của kernel sau cùng alt mọi bước thành công K->>TC: global commit TC->>B: phase 2: xoá undo_log TC->>R: confirm: reserved - 1, booked + 1 else có bước ném lỗi K->>TC: global rollback TC->>B: khôi phục ảnh trước từ undo_log TC->>R: cancel: reserved - 1 end ``` ### 1. Một dòng trong DocType *`astra-apps/express/src/main/resources/META-INF/astra/doctypes/customer-order.doctype.yaml`* ```yaml name: CustomerOrder module: oms kind: transaction submittable: true # Draft (0) -> Submitted (1) -> Cancelled (2) -> Amend transaction: global # submit/cancel span BMS credit (Seata AT) and pickup capacity (Seata TCC) ``` Kernel đọc cờ này và bọc submit cùng cancel. Các DocType khác không bị ảnh hưởng: lưu nháp, hay submit một DocType không có cờ này, không bao giờ liên lạc với bộ điều phối. *`astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`* ```java public Optional submit(String doctype, String name) { DocTypeDefinition def = requireSubmittable(doctype); return global(def, "submit " + doctype + " " + name, () -> inTenantTx(conn -> { // … load the draft, check the caller may submit … hooks.onSubmit(doc); DocumentValidator.applyDefaultsAndValidate(def, doc); validateLinks(conn, def, doc); return Optional.of(write(conn, doc, 1)); })); } // … private T global(DocTypeDefinition def, String name, Supplier work) { if (def.globalTransaction() && coordinator.isResolvable() && coordinator.get().available()) { return coordinator.get().inGlobal(name, work); } return work.get(); } ``` Global transaction là phạm vi bên ngoài, còn database transaction của kernel nằm bên trong, nên các lệnh ghi của chính kernel commit sau cùng, khi mọi nhánh đã xong phase 1. Nếu transaction của kernel thất bại, exception đi tới phạm vi global và mọi nhánh đều bị rollback. ### 2. Bộ điều phối: begin, chạy, commit hoặc rollback *`astra-tx-seata/src/main/java/io/astra/tx/seata/SeataTransactions.java`* ```java @Override public T inGlobal(String name, Supplier work) { if (RootContext.inGlobalTransaction()) return work.get(); GlobalTransaction gtx = GlobalTransactionContext.getCurrentOrCreate(); // … gtx.begin(timeoutMillis, name) … String xid = gtx.getXid(); try { T result = work.get(); gtx.commit(); LOG.infof("Global transaction %s committed (%s)", xid, name); return result; } catch (TransactionException e) { throw new IllegalStateException("global commit failed: " + e.getMessage(), e); } catch (Throwable e) { // Any failure, Errors included: a branch may already hold global locks that only a rollback releases. try { gtx.rollback(); LOG.infof("Global transaction %s rolled back (%s): %s", xid, name, e.getMessage()); } catch (TransactionException te) { LOG.errorf(te, "Global transaction %s rollback failed; the coordinator retries it", xid); } throw e; } finally { if (xid != null && xid.equals(RootContext.getXID())) RootContext.unbind(); } } ``` Bắt `Throwable` thay vì `Exception` là có chủ ý: một khi nhánh AT đã chạy, nó giữ global lock trên dòng công nợ, và chỉ rollback mới giải phóng được. Seata bị tắt trừ khi `astra.seata.enabled` là true; khi tắt, `global(...)` chạy công việc cục bộ và hook bên dưới bỏ qua các bước kiểm tra. ![Hình 3: Các trạng thái của TCC participant](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-17-figure-3.png) *Các trạng thái của TCC participant.* ### 3. Hook của app thấy một SPI, không thấy Seata *`astra-apps/express/src/main/java/io/astra/apps/express/OrderCreditHook.java`* ```java @Override public void onSubmit(Document order) { GlobalTransactions tx = coordinator(); if (tx == null) return; CreditLimits.use(tx, order.getString("customer"), fee(order)); tx.tcc(PickupCapacity.NAME, LocalDate.now().toString(), Map.of("capacity", DAILY_PICKUP_CAPACITY, "order", order.name())); } ``` `GlobalTransactions` và `TccParticipant` là các interface trong `astra-spi`. App Express chỉ phụ thuộc vào SPI, nên có thể thay bộ điều phối mà không đụng tới code của app. **Nhánh AT: JDBC thuần.** `CreditLimits.use` lấy kết nối từ `tx.atDataSource("bms")`, tức datasource của BMS được bọc trong `DataSourceProxy` của Seata. Việc kiểm tra và việc trừ công nợ nằm trong một câu lệnh: *`astra-apps/express/src/main/java/io/astra/apps/express/CreditLimits.java`* ```java try (PreparedStatement ps = conn.prepareStatement( "UPDATE customer_credit SET used = used + ?, updated_at = now() WHERE account_key = ? AND used + ? <= credit_limit")) { ps.setBigDecimal(1, amount); ps.setString(2, accountKey(customer)); ps.setBigDecimal(3, amount); if (ps.executeUpdate() == 1) return; } // … otherwise read the row and throw "credit limit of … exceeded: … used of …, this order needs …" ``` Proxy ghi ảnh trước và ảnh sau của dòng vào `bms.undo_log` (bảng này được tạo bởi `astra-tx-seata/src/main/resources/db/bms/V1__customer_credit.sql`) và commit thay đổi của BMS cục bộ. Khi global commit, Seata xoá các dòng undo; khi rollback, nó ghi ảnh trước trở lại. Đó là lý do video có thể cho thấy "công nợ đã được khôi phục, `undo_rows` bằng 0" mà app không có chút code bù trừ nào. **Nhánh TCC: một participant của app.** Redis không có undo log, nên app tự cài đặt cả ba pha: *`astra-apps/express/src/main/java/io/astra/apps/express/PickupCapacity.java`* ```java @Override public void prepare(String branchId, String day, Map args) { if (counters.get("tcc-done:" + branchId) > 0) { throw new DocValidationException("pickup reservation " + branchId + " was already cancelled"); // late try after rollback } counters.add("tcc-try:" + branchId, 1, KEEP); long capacity = Math.round(Numbers.toDouble(args.get("capacity"))); long reserved = counters.add(reservedKey(day), 1, KEEP); if (reserved + counters.get(bookedKey(day)) > capacity) { // The branch is registered, so Seata calls rollback, which gives this slot back. throw new DocValidationException("no pickup slot left for " + day + " (capacity " + capacity + ")"); } } @Override public void commit(String branchId, String day, Map args) { if (!counters.markOnce("tcc-done:" + branchId, KEEP)) return; counters.add(reservedKey(day), -1, KEEP); counters.add(bookedKey(day), 1, KEEP); } @Override public void rollback(String branchId, String day, Map args) { if (!counters.markOnce("tcc-done:" + branchId, KEEP)) return; if (counters.get("tcc-try:" + branchId) > 0) counters.add(reservedKey(day), -1, KEEP); // nothing to give back after an empty rollback } ``` Ba rủi ro kinh điển của TCC được xử lý theo từng nhánh, với khoá là `branchId`: - **Phase 2 idempotent.** Seata có thể thử lại commit hoặc rollback. `markOnce("tcc-done:…")` biến lần gọi thứ hai thành không làm gì. - **Empty rollback.** Một lệnh rollback có thể tới cho một nhánh mà bước try chưa từng chạy. `tcc-try:…` được kiểm tra trước khi trả lại chỗ. - **Suspension.** Một bước try tới sau chính lệnh rollback của nó sẽ bị từ chối. Kernel đăng ký một TCC resource duy nhất của Seata, `astra-tcc`, và chuyển tới participant của app theo tên. Phase 2 chạy trên một thread của Seata, không có HTTP request nào, nên dispatcher gắn lại tenant đã ghi nhận lúc prepare: *`astra-tx-seata/src/main/java/io/astra/tx/seata/AstraTccDispatcher.java`* ```java private void phase2(BusinessActionContext c, boolean commit) { String tenant = String.valueOf(c.getActionContext("tenant")); TccParticipant p = find(String.valueOf(c.getActionContext("participant"))); String key = String.valueOf(c.getActionContext("key")); Map args = arguments(String.valueOf(c.getActionContext("arguments"))); String branch = c.getXid() + "/" + c.getBranchId(); TenantContext.run(tenant, () -> { if (commit) p.commit(branch, key, args); else p.rollback(branch, key, args); }); } ``` ![Hình 4: Ai nhìn thấy Seata](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-17-figure-4.png) *Ai nhìn thấy Seata.* ### 4. Bộ điều phối đã thấy gì Log server trong video (`docs/video/v17-seata/assets/seata-server.log`) cho thấy, với trường hợp từ chối muộn, global begin lúc `00:48:03.690`, lần đăng ký nhánh AT, lần đăng ký nhánh TCC, một `GlobalRollbackRequest` và `BranchRollbackResponse` cuối cùng lúc `00:48:03.765`: khoảng 75 ms theo đồng hồ của server. Thứ tự nhánh trong log (AT trước TCC) khớp với `OrderCreditHook`. ## Ví dụ thực tế **Một nhà phân phối B2B có điều khoản công nợ và lịch xếp dỡ tại kho.** Bộ phận tài chính sở hữu công nợ khách hàng trong schema riêng; kho quản lý lịch hẹn xếp dỡ trên Redis vì chúng thay đổi vài giây một lần. *Tình huống minh hoạ: các giá trị dưới đây chỉ mang tính minh hoạ; cơ chế là cơ chế trong video này.* **Đầu vào.** Khách hàng "Minh Phát Trading" có hạn mức công nợ 500.000.000 ₫, đã dùng 462.000.000 ₫. Một nhân viên bán hàng submit đơn bán 31.500.000 ₫ cần một chỗ xếp dỡ vào ngày 15 tháng 11. Kho có 40 chỗ mỗi ngày; 39 chỗ đã được đặt. **Astra làm gì.** 1. Bộ điều phối bắt đầu một global transaction. 2. Nhánh AT: `used` đi từ 462.000.000 lên 493.500.000 (trong hạn mức); ảnh trước nằm trong `undo_log`. 3. Nhánh TCC: `reserved` lên 1; 1 + 39 ≤ 40, nên chỗ được giữ. 4. Kernel ghi đơn hàng ở trạng thái Submitted và ghi bút toán, rồi commit; bộ điều phối commit cả hai nhánh: các dòng undo bị xoá, `reserved` về 0 và `booked` thành 40. **Kết quả, và trường hợp thất bại.** Một phút sau, một đơn thứ hai của cùng khách hàng, 4.000.000 ₫, được gửi tới. Nhánh AT thành công (493.500.000 + 4.000.000 ≤ 500.000.000), nhưng kho giờ đã kín: bước try của TCC thấy 1 + 40 > 40 và ném lỗi. Bộ điều phối khôi phục `used` về 493.500.000 từ undo log và hủy chỗ đã giữ. Đơn vẫn là nháp, và nhân viên bán hàng thấy "hết chỗ" thay vì một đơn làm dở mà bộ phận tài chính phải đi đuổi theo. ## Dành cho tech lead - **Bạn nhận được gì:** tất cả hoặc không gì cả trên schema riêng của một module, Redis và kernel, với hoàn tác tự động cho SQL (AT) và một hợp đồng rõ ràng, kiểm thử được cho mọi thứ khác (TCC). Code của app không dính kiểu nào của bộ điều phối. - **Chi phí:** một Seata Server phải vận hành, thêm vài lượt đi về tới nó cho mỗi lần submit global (trong trường hợp commit được ghi lại, từ begin tới "Committing global transaction is successfully done" mất khoảng 67 ms theo đồng hồ của server), và một bảng `undo_log` trong mỗi schema AT. Chỉ DocType nào đánh dấu `transaction: global` mới phải trả chi phí này. - **Dòng nóng bị tuần tự hoá.** Một nhánh AT giữ global lock trên các dòng nó đã đổi cho tới phase 2. Nhiều lần submit đồng thời cho một khách hàng lớn sẽ xếp hàng trên dòng công nợ của khách đó. Hãy đo thời gian chờ lock nếu một shop chiếm phần lớn sản lượng của bạn. - **Bộ điều phối là điểm trung tâm.** Trong demo này nó chạy với file store trong một pod (`deploy/k8s/base/seata.yaml`); mất pod đó là mất trạng thái của các transaction đang dở. Production cần store database hoặc Raft của Seata. - **Hiểu rõ khoảng hở của last-resource.** Transaction của chính kernel không phải là một nhánh Seata; nó commit sau cùng, rồi lệnh global commit mới được gửi đi. Nếu không liên lạc được bộ điều phối trong khoảng đó, đơn hàng đã được commit trong khi các nhánh chờ bộ điều phối xử lý. Biến kernel thành một nhánh (AT cho chính kernel) còn trên roadmap. Hãy theo dõi `global commit failed` trong log. - **TCC participant là code thật.** Mỗi participant phải idempotent và xử lý được empty rollback lẫn suspension. Hãy review và test chúng như code thanh toán; `PickupCapacity` là mẫu tham chiếu. - **Khi nào không nên dùng:** nếu toàn bộ dữ liệu nằm trong một database PostgreSQL và một schema, transaction cục bộ đơn giản và nhanh hơn. Nếu các bước trải qua đối tác bên ngoài hoặc kéo dài nhiều phút (API của hãng vận chuyển, một bước duyệt của con người), hãy dùng saga bất đồng bộ hoặc outbox; không nên giữ global lock lâu như vậy. - **So với các lựa chọn khác:** two-phase commit kiểu XA đòi mọi tài nguyên phải hỗ trợ XA và giữ lock database qua cả hai pha; Redis không có XA. Saga tránh được lock nhưng đẩy việc bù trừ và các trạng thái trung gian nhìn thấy được sang developer của app. [explain.md](/astra-cms/vi/why-astra) ghi lại phương án dự phòng là transactional outbox cộng TCC idempotent nếu Seata không thành công. ## Tự chạy thử Điều kiện: cụm k3d theo [demo-video-series.md](/astra-cms/vi/video-scripts) (JDK 25, Docker với 8 GB, `kubectl`, `k3d`, `jq`, Bash 5). `make k3d-up` triển khai Seata Server (`apache/seata-server:2.6.0.jdk25`) và chạy `scripts/db-setup.sh`, script này chuẩn bị schema và role của BMS. ```bash make k3d-up make demo-reset && make demo-seed make demo-express SCENES=15 PAUSE=0 ``` Output mong đợi, trích từ lần quay (số đơn hàng mang ngày của dữ liệu seed): *`docs/video/v17-seata/assets/scene-15.log`* ```text customer | credit_limit | used ----------+--------------+----------- C001 | 2000000.00 | 196000.00 C002 | 150000.00 | 54000.00 (2 rows) pickup slots today: reserved=0 booked=6 // … "credit limit of C002 exceeded: 54000.00 used of 150000.00, this order needs 105000.00" // … "no pickup slot left for 2026-10-21 (capacity 200)" // … undo_rows ----------- 0 (1 row) ``` Cảnh này lấp kín chỗ lấy hàng của hôm nay để ép ra trường hợp từ chối muộn, sau đó đặt lại, để các cảnh khác vẫn submit đơn được. Đơn nháp của C002 (`ORD-…-00005`) do dữ liệu seed tạo ra cho cảnh này; nếu bạn đã submit hoặc sửa nó, hãy chạy lại `make demo-reset && make demo-seed`. Integration test `astra-server/src/test/java/io/astra/server/SeataTest.java` chạy đúng ba trường hợp đó (commit, BMS từ chối, từ chối sau khi nhánh AT đã commit) trên Seata Server và Redis thật; test chạy bằng `make test`. ## Nói đúng - Seata Server dùng file store, đủ cho demo; production nên dùng store database hoặc Raft. - TCC tự xử lý idempotent, empty rollback và suspension theo từng nhánh, chưa dùng bảng fence của Seata. - Kernel commit transaction của chính mình sau cùng (làm last resource) và chưa là một nhánh Seata. - Mới chỉ có hạn mức công nợ (AT) và năng lực lấy hàng (TCC) được nối vào; thiết kế mục tiêu trong [use-case-01.md](/astra-cms/vi/use-case) và [explain.md](/astra-cms/vi/why-astra) mô tả nhiều hơn (ví dụ cơ chế bỏ qua bộ điều phối một cách thông minh và annotation `@AstraGlobalTransactional`), những phần đó chưa được xây. Trong code đang chạy, cờ YAML `transaction: global` là công tắc. ## Hình minh hoạ - [Hình 1 · Một lần submit, ba tài nguyên](#vấn-đề) - [Hình 2 · Rollback sau khi một nhánh đã commit](#video-cho-thấy-gì) - [Hình 3 · Các trạng thái của TCC participant](#2-bộ-điều-phối-begin-chạy-commit-hoặc-rollback) - [Hình 4 · Ai nhìn thấy Seata](#3-hook-của-app-thấy-một-spi-không-thấy-seata) ## Liên quan - Trước: [Video 16 · Rust CDC + Sync-Aware LSN](/astra-cms/vi/cdc) · Tiếp: [Video 18 · Video lộ trình](/astra-cms/vi/roadmap) - [Video 4 · Vòng đời chứng từ](/astra-cms/vi/lifecycle) (submit, cancel, amend trong một transaction của kernel) · [Video 12 · Sổ cái](/astra-cms/vi/ledger) (bút toán được ghi khi submit) - Đi qua code: [S11 Seata AT and TCC](/astra-cms/vi/code-snippets) - Bối cảnh: [explain.md](/astra-cms/vi/why-astra) (vì sao chọn Seata, rủi ro và phương án dự phòng) · [use-case-01.md](/astra-cms/vi/use-case) (thiết kế mục tiêu) - Mã nguồn: `astra-tx-seata/src/main/java/io/astra/tx/seata/SeataTransactions.java`, `astra-tx-seata/src/main/java/io/astra/tx/seata/AstraTccDispatcher.java`, `astra-spi/src/main/java/io/astra/spi/tx/GlobalTransactions.java`, `astra-spi/src/main/java/io/astra/spi/tx/TccParticipant.java`, `astra-apps/express/src/main/java/io/astra/apps/express/OrderCreditHook.java`, `astra-apps/express/src/main/java/io/astra/apps/express/CreditLimits.java`, `astra-apps/express/src/main/java/io/astra/apps/express/PickupCapacity.java`, `astra-tx-seata/src/main/resources/db/bms/V1__customer_credit.sql`, `deploy/k8s/base/seata.yaml` --- # HRM: nhân sự và tiền lương Tóm tắt một câu: Một app tính lương chỉ dùng SPI công khai của Astra và theo mô hình Frappe HRMS: chấm công quyết định ngày công, module Wasm của tenant tính thưởng bưu tá, một Payroll Entry tính song song mọi phiếu… Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/hrm ▶ [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-19-hrm-vi.mp4) · [English](https://media.zerohostcms.com/astra-cms/astra-video-19-hrm-en.mp4) · 2:40 · Kịch bản: [demo-video-series.md](/astra-cms/vi/video-scripts) · [Bản tiếng Anh](/astra-cms/vi/hrm) > **Tóm tắt một câu:** Một app tính lương chỉ dùng SPI công khai của Astra và theo mô hình Frappe HRMS: chấm công quyết định ngày công, module Wasm của tenant tính thưởng bưu tá, một Payroll Entry tính song song mọi phiếu lương, và khi submit thì ghi đúng một bút toán dồn tích cân đối vào sổ cái append-only. > > **Nên đọc nếu:** bạn muốn xem một module nghiệp vụ thật (tám DocType, năm hook, một tool MCP) được xây trên SPI mà không đụng vào kernel (góc nhìn developer) / bạn đang đánh giá liệu một nền tảng metadata có gánh được việc xử lý theo lô nhạy cảm về tiền như tính lương cuối tháng, và còn thiếu gì trước khi nó trở thành phần mềm tính lương (góc nhìn tech lead). ## Vấn đề Astra Express trả bưu tá lương cơ bản cộng thưởng theo mỗi kiện giao. Cuối tháng, bộ phận nhân sự ở tenant Sài Gòn phải quy chấm công ra ngày công, áp biểu thưởng theo kiện, khấu trừ bảo hiểm xã hội, y tế và thất nghiệp phần người lao động, rồi chuyển cho kế toán một bút toán khớp với phiếu lương đến từng đồng. Những kiểu hỏng quen thuộc: - **Lương nằm trong bảng tính.** Ngày công gõ tay, công thức thưởng chép qua lại giữa các tab, kế toán nhập lại số tổng vào sổ cái. Không ai chỉ ra được dữ liệu đầu vào nào sinh ra dòng nào. - **Quy tắc thưởng đổi theo vùng và theo quý.** Với ERP truyền thống, điều đó nghĩa là sửa code hoặc nhờ nhà cung cấp tuỳ biến. Mỗi tenant muốn biểu thưởng riêng mà không phải phát hành lại toàn bộ sản phẩm. - **Phiếu lương và sổ cái lệch nhau.** Nếu phiếu lương lưu ở một bước và bút toán ghi ở bước khác, một sự cố giữa hai bước sẽ để lại một tháng mới dồn tích một nửa. Huỷ một lượt chạy về sau phải hoàn tác cả hai mà không xoá lịch sử. Frappe HRMS (chạy trên ERPNext) giải bài này bằng một bộ khái niệm quen thuộc: Employee, Salary Component, Salary Structure, Salary Structure Assignment, Attendance, Additional Salary, Payroll Entry và Salary Slip. Một Payroll Entry tạo phiếu lương cho một kỳ và ghi một bút toán dồn tích. Công thức của salary component ở đó là biểu thức Python do framework đánh giá. App HRM của Astra **chỉ lấy tên khái niệm**. Kịch bản nói thẳng: không chép code, schema hay giao diện nào của ERPNext hay HRMS. Khác biệt nằm ở chỗ mỗi phần chạy ở đâu: các công thức khác nhau theo tenant chạy trong sandbox Wasm Tier 2, phiếu lương được tính trên virtual thread, và bút toán dồn tích đi qua cùng SPI `GeneralLedger` mà app Express dùng cho doanh thu cước. ## Video cho thấy gì Bản quay chạy `make demo-hrm` trên stack k3d sau `make demo-seed`; lệnh seed nạp tháng 9/2026 (26 ngày làm việc, Thứ 2 đến Thứ 7) cho `saigon-express`. Kịch bản đăng nhập bằng `hanh` (role `hr`). 1. **App** (cảnh 1). App `io.astra.hrm`, tám DocType trong module `hrm`, chỉ phụ thuộc `astra-spi`. Lời dẫn: "Tên khái niệm của Frappe HRMS, không có code." 2. **Thành phần lương** (cảnh 1). Tám Salary Component: lương cơ bản, phụ cấp ăn trưa và xăng xe, thưởng KPI, thưởng một lần, và bảo hiểm phần người lao động: BHXH 8 %, BHYT 1,5 %, BHTN 1 %, ghi vào các tài khoản 3383, 3384 và 3386. Tỷ lệ là giá trị seed cho demo. 3. **Thưởng bưu tá bằng Wasm** (cảnh 2). Mã Rust trong `astra-apps/hrm/wasm/courier-kpi`: 1.500 ₫ mỗi kiện đến kiện thứ 600, 2.000 ₫ đến kiện thứ 1.000, 2.500 ₫ từ đó trở đi, cộng 500.000 ₫ chuyên cần nếu đủ công. Khi lưu, kernel chạy thử: `kpi(1000 pieces, 26/26 days) = 2200000 VND`. Một module lặp vô hạn bị dừng ở 50 ms và bị từ chối với mã 422. 4. **Chấm công** (cảnh 3). NV002 vắng ngày 15/9 và nghỉ nửa ngày 22/9, nên còn 24,5 ngày công. NV004 nghỉ không lương ngày 29/9. Ngày không có bản ghi được tính là có đi làm. `quang` (ops) submit được chấm công nhưng nhận 403 với Salary Slip; `tuan` (sales) nhận 403 với Payroll Entry. 5. **Một lần lưu, mọi phiếu lương** (cảnh 4). Lưu nháp một Payroll Entry cho ngày 1 đến 30 tháng 9 tạo ra bốn phiếu lương nháp, tính song song mỗi phiếu một virtual thread, trong cùng transaction với entry. Trong lần quay, entry là `PAY-2610-002` (series dùng tháng tạo) và các phiếu là `SAL-2610-00005` đến `SAL-2610-00008`. 6. **Một phiếu lương, từng dòng** (cảnh 5). NV002: `100% of 6500000 (base) × 24.5/26 days` = 6.125.000; KPI `Wasm kpi(1007 pieces, 24.5/26 days)` = 1.717.500; BHXH `8% of 6500000 (base)` = 520.000; gross 9.001.539, net 8.319.039. NV003 vào làm ngày 14/9 nên có 15 ngày công, cộng một Additional Salary 500.000 ₫. Client gửi `net_pay` nhận 422 `field 'net_pay' is read-only`. 7. **Submit** (cảnh 6). Bốn nhân viên, gross 35.837.501, khấu trừ 3.517.500, net 32.320.001. Một bút toán dồn tích năm dòng: Nợ 642 35.837.501; Có 3383 2.680.000; Có 3384 502.500; Có 3386 335.000; Có 334 32.320.001. `accrual_voucher` của mỗi phiếu được gán bằng tên entry. 8. **Ba lần từ chối** (cảnh 6). Huỷ một phiếu của lượt chạy đã submit trả về 422 "cancel the payroll entry instead". Chạy lương tháng 9 lần thứ hai trả về 422 "NV001 already has submitted salary slip …". Việc client tự đặt số tiền đã bị từ chối ở cảnh 5. 9. **MCP** (cảnh 7). `payroll_summary` trả về tổng và các dòng phiếu với tên đã che (`N*** V*** A***`). `tuan` gọi cùng tool đó nhận `isError: true`, "not permitted: read PayrollEntry". 10. **Cancel** (cảnh 8). Các phiếu chuyển sang docstatus 2, bút toán có thêm năm dòng với `is_reversal: true`, và số dư thử của 334, 3383, 3384, 3386 và 642 về 0. Không dòng nào bị xoá. ![Hình 3: Cancel thêm dòng, không bao giờ xoá](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-19-figure-3.png) *Cancel thêm dòng, không bao giờ xoá.* ## Cách hoạt động ### Hình dạng của app `HrmApp` là một app Tier 0: một CDI bean liệt kê các file YAML DocType của nó. Mọi thứ còn lại là hook và một tool MCP. *`astra-apps/hrm/src/main/java/io/astra/apps/hrm/HrmApp.java`* ```java @ApplicationScoped public class HrmApp implements AstraApp { @Override public String id() { return "io.astra.hrm"; } // … @Override public List doctypeResources() { return List.of( "META-INF/astra/doctypes/employee.doctype.yaml", "META-INF/astra/doctypes/salary-component.doctype.yaml", "META-INF/astra/doctypes/salary-structure.doctype.yaml", "META-INF/astra/doctypes/salary-structure-assignment.doctype.yaml", "META-INF/astra/doctypes/attendance.doctype.yaml", "META-INF/astra/doctypes/additional-salary.doctype.yaml", "META-INF/astra/doctypes/payroll-entry.doctype.yaml", "META-INF/astra/doctypes/salary-slip.doctype.yaml"); } } ``` Payroll Entry là một DocType submittable bình thường. Các trường tổng là `read-only`, nên chỉ hook mới đặt được giá trị, và bảng quyền là nơi sinh ra "sales nhận 403": *`astra-apps/hrm/src/main/resources/META-INF/astra/doctypes/payroll-entry.doctype.yaml`* ```yaml name: PayrollEntry module: hrm kind: transaction submittable: true naming: { series: "PAY-.YYMM.-.###" } # … fields: - { name: start_date, type: Date, required: true, in-list: true } # … - { name: total_net, type: Currency, read-only: true, in-list: true } # … permissions: - { role: admin, read: true, create: true, write: true, submit: true, cancel: true } - { role: hr, read: true, create: true, write: true, submit: true, cancel: true } - { role: accountant, read: true } ``` ![Hình 1: Một lần lưu, bốn phiếu lương, một transaction](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-19-figure-1.png) *Một lần lưu, bốn phiếu lương, một transaction.* ### Lưu, submit, cancel ```mermaid sequenceDiagram participant HR as hanh (hr) participant K as Kernel (DocumentStore) participant PE as PayrollEntryHook participant SC as SalaryCalculator participant W as Sandbox Wasm participant SS as SalarySlipHook participant GL as GeneralLedger SPI HR->>K: POST /api/doc/PayrollEntry (1–30/9) K->>PE: afterSave (cùng transaction) PE->>PE: PayrollInputs.load cho từng nhân viên PE->>SC: computeAll(batch) par mỗi phiếu một virtual thread SC->>W: kpi(pieces, days×10, working) end SC-->>PE: các phiếu đã tính PE->>K: insert SalarySlip ×4 (gắn PRECOMPUTED) K->>SS: beforeSave lấy giá trị đã tính sẵn HR->>K: POST …/PAY-2610-002/submit K->>PE: onSubmit PE->>K: submit từng phiếu nháp PE->>GL: ghi một bút toán (Nợ 642 · Có 3383/3384/3386 · Có 334) K-->>HR: 200, docstatus 1 (một lần commit) ``` Lưu một entry nháp sẽ dựng lại các phiếu nháp. Hook đọc mọi dữ liệu đầu vào trong transaction của entry trước, rồi giao phần tính toán thuần cho `SalaryCalculator`, rồi ghi các phiếu lại trong cùng transaction: *`astra-apps/hrm/src/main/java/io/astra/apps/hrm/PayrollEntryHook.java`* ```java @Override public void afterSave(Document entry) { if (entry.docstatus() != 0) return; // … List batch = new ArrayList<>(); List unassigned = new ArrayList<>(); for (Document e : employees) { Document slip = existing.get(e.name()); if (slip != null && slip.docstatus() != 0) continue; // submitted or cancelled on its own: leave it inputs.load(e, start, end).ifPresentOrElse(batch::add, () -> unassigned.add(e.name())); } // … List> computed = calculator.computeAll(batch); // … ScopedValue.where(SalarySlipHook.PRECOMPUTED, byEmployee).run(() -> { for (SalaryInput in : batch) { // … docs.insert("SalarySlip", period, entry.owner()); // … } }); } ``` **Vì sao tách đọc, tính và ghi.** Truy cập cơ sở dữ liệu nằm trên thread của request và trong transaction duy nhất của nó. Phần tính toán không chạm vào lưu trữ, nên có thể toả ra song song một cách an toàn. Lời gọi KPI Wasm chiếm phần lớn chi phí, vì thế `computeAll` dùng mỗi phiếu một virtual thread: *`astra-apps/hrm/src/main/java/io/astra/apps/hrm/SalaryCalculator.java`* ```java List> computeAll(List inputs) { try (ExecutorService threads = Executors.newVirtualThreadPerTaskExecutor()) { List>> futures = inputs.stream().map(in -> threads.submit(() -> compute(in))).toList(); // … } } ``` **Vì sao dùng `ScopedValue` cho kết quả tính sẵn.** `SalarySlipHook.beforeSave` tính phiếu mỗi khi một phiếu nháp được lưu. Khi entry tự ghi các phiếu của nó, entry gắn `PRECOMPUTED` để hook của phiếu dùng lại kết quả của cả lô thay vì tính hai lần. Việc gắn này chỉ có hiệu lực trong tiến trình (xem `PRECOMPUTED` trong `SalarySlipHook`), nên một REST client không thể lén đưa giá trị đã tính vào qua đường này. ### Ngày công và bốn kiểu tính Ngày công được trả lương bằng số ngày làm việc trừ số ngày ngoài thời gian làm việc, trừ ngày Absent, trừ một nửa mỗi ngày Half Day, trừ ngày Leave Without Pay. Ngày không chấm công được tính là có đi làm. Đây là quy tắc cho NV002 24,5 trên 26 ngày: *`astra-apps/hrm/src/main/java/io/astra/apps/hrm/SalaryCalculator.java`* ```java lost += switch (att.getString("status")) { case "Absent" -> 1; case "Half Day" -> 0.5; case "On Leave" -> "Leave Without Pay".equals(att.getString("leave_type")) ? 1 : 0; default -> 0; }; } double paymentDays = Math.max(0, working - outside - lost); ``` Mỗi thành phần lương có một trong bốn kiểu tính: Fixed, Percent of Base (có thể có mức trần), Percent of Gross (chỉ cho khấu trừ) và Wasm. Mỗi dòng ghi lại một `basis` dễ đọc, chính là thứ video gọi là "truy vết từng dòng": *`astra-apps/hrm/src/main/java/io/astra/apps/hrm/SalaryCalculator.java`* ```java case "Wasm" -> { long v; try { v = kpi(scripts, c, ctx.pieces(), Math.round(ctx.paymentDays() * 10), ctx.workingDays()); } catch (ScriptException e) { throw new DocValidationException("component " + c.name() + ": formula failed (" + e.reason() + "): " + e.getMessage()); } // … basis = "Wasm kpi(" + ctx.pieces() + " pieces, " + Values.plain(ctx.paymentDays()) + "/" + ctx.workingDays() + " days)"; } ``` Ngày công được truyền vào Wasm dưới dạng `payment_days × 10`, một số nguyên, vì sandbox chỉ trao đổi giá trị `i64`. ### Công thức thưởng của tenant *`astra-apps/hrm/wasm/courier-kpi/src/lib.rs`* ```rust const TIERS: [(i64, i64); 3] = [(600, 1_500), (1_000, 2_000), (i64::MAX, 2_500)]; // (up to pieces, VND per piece) const FULL_ATTENDANCE_BONUS: i64 = 500_000; #[unsafe(no_mangle)] pub extern "C" fn kpi(pieces: i64, payment_days_x10: i64, working_days: i64) -> i64 { let pieces = pieces.max(0); let mut bonus = 0; let mut floor = 0; for (ceiling, rate) in TIERS { if pieces <= floor { break; } bonus += (pieces.min(ceiling) - floor) * rate; floor = ceiling; } if working_days > 0 && payment_days_x10 >= working_days * 10 { bonus += FULL_ATTENDANCE_BONUS; } bonus } ``` 1.007 kiện của NV002: 600 × 1.500 + 400 × 2.000 + 7 × 2.500 = 1.717.500 ₫, không có thưởng chuyên cần vì 24,5 < 26. Một module không thể đi vào bảng lương nếu không qua bài tự kiểm tra khi lưu. Module lặp vô hạn bị dừng ở deadline và lần lưu bị từ chối: *`astra-apps/hrm/src/main/java/io/astra/apps/hrm/SalaryComponentHook.java`* ```java try { bonus = SalaryCalculator.kpi(scripts, c, 1_000, 260, 26); } catch (ScriptException e) { throw new DocValidationException("module rejected (" + e.reason() + "): " + e.getMessage()); } if (bonus < 0 || bonus > 1_000_000_000L) throw new DocValidationException("module rejected: kpi(1000 pieces, 26/26 days) returned " + bonus); // … c.set("self_test", "kpi(1000 pieces, 26/26 days) = " + bonus + " VND in " + micros + " µs"); ``` ![Hình 4: Các bậc thưởng bưu tá](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-19-figure-4.png) *Các bậc thưởng bưu tá.* ### Mỗi lượt chạy một bút toán dồn tích Khi submit, entry submit mọi phiếu nháp, cộng thu nhập và khấu trừ theo từng tài khoản sổ cái, rồi ghi một bút toán qua SPI `GeneralLedger`, bên trong transaction của entry. Nếu bước nào lỗi, cả lượt chạy được rollback: *`astra-apps/hrm/src/main/java/io/astra/apps/hrm/PayrollEntryHook.java`* ```java List lines = new ArrayList<>(); byAccount(submitted, "earnings").forEach((account, amount) -> lines.add(Entry.debit(account, null, amount))); byAccount(submitted, "deductions").forEach((account, amount) -> lines.add(Entry.credit(account, null, amount))); lines.add(Entry.credit(SALARY_PAYABLE, null, BigDecimal.valueOf(sum(submitted, "net_pay")))); ledger.post("PayrollEntry", entry.name(), Values.requireDate(entry, "posting_date"), lines, "Payroll accrual " + entry.getString("start_date") + " – " + entry.getString("end_date") + " (" + submitted.size() + " slips)"); ``` Bút toán cân đối theo cách dựng: gross (Nợ 642) bằng khấu trừ (Có 3383, 3384, 3386) cộng net (Có 334). Trong lần quay: 2.680.000 + 502.500 + 335.000 + 32.320.001 = 35.837.501. **Vì sao phiếu lương không tự ghi sổ.** Mỗi lượt chạy một bút toán giúp sổ cái dễ đọc với kế toán và biến việc huỷ thành một lần đảo duy nhất. Cái giá là 334 giữ một con số tổng, không phải số dư theo từng nhân viên (xem "Nói đúng"). Cancel là hình ảnh phản chiếu: entry huỷ các phiếu của chính nó (gắn `CANCELLING_ENTRY` để guard của phiếu cho chúng đi qua) và yêu cầu sổ cái ghi một bút toán đảo. Bản thân bảng sổ cái từ chối UPDATE và DELETE (xem [Video 12](/astra-cms/vi/ledger)). *`astra-apps/hrm/src/main/java/io/astra/apps/hrm/PayrollEntryHook.java`* ```java @Override public void onCancel(Document entry) { ScopedValue.where(SalarySlipHook.CANCELLING_ENTRY, entry.name()).run(() -> { for (Document slip : slips(entry)) { if (slip.docstatus() == 1) docs.cancel("SalarySlip", slip.name()); } }); ledger.reverse("PayrollEntry", entry.name()); } ``` ![Hình 2: Bút toán dồn tích cân đối](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-19-figure-2.png) *Bút toán dồn tích cân đối.* ### Góc nhìn của agent AI `PayrollSummaryTool` (`payroll_summary`) là một tool MCP do app đóng góp. `reads()` của nó trả về `PayrollEntry` và `SalarySlip`, nên MCP server kiểm tra quyền đọc của người gọi trên cả hai DocType trước khi gọi; đó là lý do `tuan` nhận "not permitted: read PayrollEntry". `employee_name` được đánh dấu `pii: true` trong các DocType, nên tên đi ra ngoài đã bị che qua cơ chế che dữ liệu MCP của kernel (xem [Video 15](/astra-cms/vi/mcp)). ## Ví dụ thực tế Một công ty chuyển phát ở TP. Hồ Chí Minh tính lương cho nhân viên giao hàng theo cấu trúc COURIER có sẵn trong seed. Lấy một bưu tá trong tháng 9/2026 (26 ngày làm việc) với lương cơ bản theo hợp đồng 7.000.000 ₫, vắng một ngày và giao 820 kiện. Các giá trị bên dưới được tính tay theo quy tắc của code và các thành phần lương trong seed; chúng mang tính minh hoạ, không phải kết quả của một lần chạy. **Đầu vào:** Chấm công: một bản ghi `Absent` vào một ngày làm việc. Số kiện nhập kèm chấm công: 820. Assignment: COURIER, lương cơ bản 7.000.000. **Astra làm gì:** | Dòng | Quy tắc | Số tiền (₫) | | --- | --- | --- | | BASIC | 100 % của 7.000.000 × 25/26 ngày | 6.730.769 | | LUNCH | 730.000 cố định × 25/26 ngày | 701.923 | | FUEL | 500.000 cố định × 25/26 ngày | 480.769 | | KPI (Wasm) | 600 × 1.500 + 220 × 2.000; không có thưởng chuyên cần (25 < 26) | 1.340.000 | | **Gross** | | **9.253.461** | | BHXH | 8 % của 7.000.000 (lương cơ bản, không chia theo ngày công) | 560.000 | | BHYT | 1,5 % của 7.000.000 | 105.000 | | BHTN | 1 % của 7.000.000 | 70.000 | | **Net** | gross − 735.000 | **8.518.461** | **Đầu ra:** phiếu lương mang mọi dòng kèm chuỗi `basis`, nên câu hỏi của nhân viên ("sao phụ cấp ăn trưa của tôi là 701.923?") được trả lời ngay từ chứng từ. Khi nhân sự submit Payroll Entry của tháng, các khoản của bưu tá này được gộp vào một bút toán cho cả lượt chạy: 9.253.461 vào Nợ 642, 560.000 vào Có 3383, và cứ thế. Nếu quý sau quản lý vùng đổi biểu thưởng theo kiện, tenant tải một module Wasm mới lên thành phần KPI; bài tự kiểm tra chạy khi lưu và không dòng code Java nào thay đổi. Một lượt chạy giới hạn ở 1.000 nhân viên (`MAX_EMPLOYEES` trong `PayrollEntryHook`). Công ty lớn hơn mức đó sẽ phải chia lượt chạy, ví dụ theo `designation` (trường lọc của entry: All, Courier, Driver, Hub Staff, Office). ## Dành cho tech lead - **Điều này chứng minh gì.** Một lô xử lý nhiều chứng từ, nhạy cảm về tiền, có thể viết thành app Tier 0 trên SPI công khai: entry, các phiếu lương và bút toán sổ cái của nó cùng commit hoặc cùng rollback, và sổ cái append-only giữ cho việc huỷ có thể kiểm toán được. - **Điều này không chứng minh gì.** Đây là app tính lương cho demo, không phải phần mềm tính lương. Thuế thu nhập cá nhân, phần bảo hiểm doanh nghiệp đóng, chi trả lương và file ngân hàng đều chưa có (xem bên dưới). Đừng so từng tính năng với Frappe HRMS. - **Công thức theo tenant mà không cần phát hành code.** Thành phần Wasm đưa việc đổi biểu thưởng ra khỏi chu kỳ phát hành. Đổi lại, công thức là module đã biên dịch chứ không phải trình soạn công thức; nhân sự không tự đổi được nếu không có developer. - **Kích thước lượt chạy và độ trễ.** Lô chạy bên trong HTTP request lưu entry, tối đa 1.000 nhân viên. Hãy đo thời gian request và độ dài transaction ở quy mô nhân sự của bạn trước khi dựa vào nó; job nền cuối tháng có bầu leader nằm trong lộ trình, chưa được xây. - **Độ chi tiết của sổ cái.** Mỗi lượt chạy một bút toán, tổng theo tài khoản. Nếu kiểm toán viên cần 334 theo từng nhân viên hay phân bổ theo trung tâm chi phí, phần đó chưa có. - **Bảo vệ dữ liệu.** Tên nhân viên đến tay agent AI ở dạng đã che, và agent kế thừa quyền của người gọi. Nhân viên chưa tự đọc được phiếu lương của mình (`if-owner` chỉ áp cho ghi). - **Khi nào chưa nên dùng.** Bất kỳ bảng lương thật nào phải khai thuế hay trả lương cho nhân viên. Hãy dùng nó để đánh giá hình dạng của nền tảng: SPI, hook, Wasm, sổ cái và MCP cùng làm việc trên một luồng nghiệp vụ thật. ## Tự chạy thử Yêu cầu: JDK 25, Docker với khoảng 8 GB RAM, `kubectl`, `k3d`, `jq`, Bash 5 (`brew install bash` trên macOS). User `hanh` phải có trong Keycloak; nếu realm đã đổi kể từ lúc tạo cluster, chạy `scripts/keycloak-sync-realm.sh`. ```bash make k3d-up # cluster: kernel pods, PostgreSQL, Redis, Keycloak, Seata, CDC worker make demo-seed # loads September 2026 for saigon-express (and hanoi-express) make demo-hrm # all scenes, Enter between steps make demo-hrm SCENES="4 5 6" PAUSE=0 # open, inspect and submit a payroll run without pausing ``` Kết quả mong đợi (số liệu từ lần quay; tên entry và tên phiếu phụ thuộc vào số lượt chạy bạn đã mở): - Cảnh 4: `HTTP 201` với `"docstatus": 0`, rồi bốn phiếu nháp với `payment_days` 26.0, 24.5, 15.0 và 25.0. - Cảnh 6: `"employees": 4`, `"gross": 35837501`, `"net": 32320001`, và năm dòng sổ cái cho bút toán. - Cảnh 8: số dư thử hiện `"balance": 0.00` cho 334, 3383, 3384, 3386 và 642. Sau đó mở Desk tại `http://localhost:8080/desk/` bằng `hanh` (mật khẩu `Astra@123`, chỉ dùng cho stack local) để xem form Payroll Entry và Salary Slip. Test tích hợp là `astra-server/src/test/java/io/astra/server/HrmPayrollTest.java`. ## Nói đúng Theo mục "Nói đúng" của kịch bản Video 19: - **Không tính thuế thu nhập cá nhân**; net là gross trừ bảo hiểm phần người lao động. Phần doanh nghiệp đóng (BHXH, BHYT, BHTN, KPCĐ) không được tính. Tỷ lệ và mức trần bảo hiểm là cấu hình seed cho demo, chưa đối chiếu với quy định hiện hành; quy tắc nghỉ từ 14 ngày trở lên thì không đóng BHXH chưa được xử lý. - **Chấm công tối giản:** không có lịch ngày lễ, ca làm, check-in hay đơn nghỉ phép có số dư phép. Tuần làm việc cố định Thứ 2 đến Thứ 7, và ngày không có bản ghi chấm công được tính là có đi làm. Số kiện do ops nhập kèm chấm công, chưa lấy từ Waybill hay STMS qua CDC như use case mô tả. - **Chỉ có bốn kiểu tính** (Fixed, % lương cơ bản, % gross, Wasm), chưa có trình soạn công thức. Không có bút toán chi trả lương (Nợ 334 / Có 112) và không xuất file ngân hàng. Bút toán dồn tích ghi tổng theo tài khoản, không ghi theo từng nhân viên trên 334; toàn bộ chi phí vào 642, không phân bổ theo trung tâm chi phí. - **Lô chạy ngay trong request** lưu entry (tối đa 1.000 nhân viên). Không có job nền cuối tháng hay bầu leader. Phiếu lương và sổ cái chung một transaction PostgreSQL, không dùng Seata. - **Nhân viên chưa tự xem được phiếu lương của mình:** quyền `if-owner` chỉ áp cho ghi, chưa áp cho đọc. - **Chưa có Desk riêng cho tính lương:** Desk chung hiển thị các dòng phiếu dưới dạng "N item(s)". - Số liệu trong kịch bản được tính theo logic của hook; các con số ở mục "Video cho thấy gì" của bài này lấy từ lần quay, còn các giá trị ở "Ví dụ thực tế" được tính tay và mang tính minh hoạ. ## Hình minh hoạ - [Hình 1 · Một lần lưu, bốn phiếu lương, một transaction](#cách-hoạt-động) - [Hình 2 · Bút toán dồn tích cân đối](#mỗi-lượt-chạy-một-bút-toán-dồn-tích) - [Hình 3 · Cancel thêm dòng, không bao giờ xoá](#video-cho-thấy-gì) - [Hình 4 · Các bậc thưởng bưu tá](#công-thức-thưởng-của-tenant) ## Liên quan - Trước: [Video 17 · Seata: một lần submit, ba tài nguyên](/astra-cms/vi/seata). Tiếp: [Video 20 · STMS: điều phối xe với Timefold + AI copilot](/astra-cms/vi/stms). Lộ trình: [Video 18](/astra-cms/vi/roadmap). - Các cơ chế được dùng lại ở đây: [Video 4 · vòng đời chứng từ](/astra-cms/vi/lifecycle), [Video 11 · phân quyền](/astra-cms/vi/keycloak), [Video 12 · sổ cái append-only](/astra-cms/vi/ledger), [Video 14 · Wasm Tier 2](/astra-cms/vi/wasm), [Video 15 · MCP](/astra-cms/vi/mcp). - Thiết kế: [use-case-01.md](/astra-cms/vi/use-case) (module 4, `astra-app-hrm`, có ghi chú trạng thái về những điểm khác với lát cắt đang chạy). - Mã nguồn: [`HrmApp`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/hrm/src/main/java/io/astra/apps/hrm/HrmApp.java), [`PayrollEntryHook`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/hrm/src/main/java/io/astra/apps/hrm/PayrollEntryHook.java), [`SalaryCalculator`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/hrm/src/main/java/io/astra/apps/hrm/SalaryCalculator.java), [`SalarySlipHook`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/hrm/src/main/java/io/astra/apps/hrm/SalarySlipHook.java), [`PayrollSummaryTool`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/hrm/src/main/java/io/astra/apps/hrm/PayrollSummaryTool.java), [`courier-kpi`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/hrm/wasm/courier-kpi/src/lib.rs), [`demo-hrm.sh`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/scripts/demo-hrm.sh), test [`HrmPayrollTest`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-server/src/test/java/io/astra/server/HrmPayrollTest.java). --- # STMS: điều phối xe với Timefold + AI copilot Tóm tắt một câu: Timefold Solver lập tuyến giao hàng ngay trong kernel của Astra và lập lại từ các tuyến hiện tại khi một xe hỏng, một đơn gấp đến hay giao thông chậm lại; một copilot điều phối biến câu lệnh tiếng Việt… Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/stms ▶ [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-20-stms-vi.mp4) · [English](https://media.zerohostcms.com/astra-cms/astra-video-20-stms-en.mp4) · 2:58 · Kịch bản: [demo-video-series.md](/astra-cms/vi/video-scripts) · [Bản tiếng Anh](/astra-cms/vi/stms) > **Tóm tắt một câu:** Timefold Solver lập tuyến giao hàng ngay trong kernel của Astra và lập lại từ các tuyến hiện tại khi một xe hỏng, một đơn gấp đến hay giao thông chậm lại; một copilot điều phối biến câu lệnh tiếng Việt thành lời gọi tới chính các tool MCP đó. > > **Nên đọc nếu:** bạn muốn xem một constraint solver, việc lập lại kế hoạch trực tiếp và các tool mà agent gọi được, tất cả xây như một app Tier 0 bình thường trên SPI (góc nhìn developer) / bạn đang cân nhắc tối ưu hoá ngay trong nền tảng và một trợ lý điều phối AI, và cần biết chính xác phần nào là thật hôm nay (góc nhìn tech lead). > [!IMPORTANT] > **Mô hình ngôn ngữ của copilot là LLM giả lập (kịch bản demo).** Không có LLM nào được gọi ở bất kỳ đâu trong demo > này. `MockLlmClient` khớp từng câu lệnh với các mẫu tiếng Việt cố định và một danh sách địa danh nhỏ có sẵn, rồi trả > về các tool call định sẵn; câu lệnh nằm ngoài kịch bản không sinh tool call nào. Mọi phản hồi của copilot đều mang > `llm: "mock"` và một `llm_note`, và mọi lời giải thích đều bắt đầu bằng `[LLM giả lập – kịch bản demo]`. Phần thật gồm: > Timefold solver, việc lập lại kế hoạch, các tool MCP, phân quyền và nhật ký sự kiện. Điểm cắm cho một mô hình thật là > interface `LlmClient`, hiện chưa có bản hiện thực thật nào. ## Vấn đề Mỗi sáng, người điều phối ở kho Tân Bình của Astra Express có ba xe tải và hai mươi điểm dừng: cửa hàng ở Quận 1, một nhà thuốc ở Thủ Đức, khách VIP phải được phục vụ trước 10:00, và các điểm chỉ nhận hàng từ 09:00 đến 11:00. Các xe chở được 300, 250 và 250 kg. Một tuyến bỏ qua tải trọng hay khung giờ sẽ hỏng ngoài hiện trường; một tuyến bỏ qua quãng đường sẽ đốt xăng. Rồi một ngày làm việc bắt đầu. Một xe hỏng lúc 08:30. Một khách gọi đặt đơn gấp 30 kg. Kẹt xe dồn quanh Thủ Đức. Ở phần lớn công ty logistics vừa và nhỏ tại Việt Nam, việc này được xử lý bằng điện thoại và bảng tính: người điều phối xếp lại điểm dừng theo trí nhớ, và về sau không ai nói được cái gì đã đổi và vì sao. Các lựa chọn thường gặp: - **Một SaaS định tuyến riêng**, tích hợp bằng xuất/nhập. Nó giải tốt nhưng nằm ngoài mô hình đơn hàng, phân quyền và audit; mỗi lần lập lại kế hoạch là thêm một vòng trao đổi file hoặc API. - **Một solver nhúng trong service tự viết.** Linh hoạt, nhưng mỗi đội lại dựng lại đa thuê bao, phân quyền và audit quanh nó. - **Frappe/ERPNext** có DocType Delivery Trip ghi các điểm dừng của một xe cho một chuyến; định tuyến nhiều xe với ràng buộc tải trọng và khung giờ không nằm trong đó, nên các đội phải thêm một service bên ngoài. Lát cắt STMS của Astra đặt solver sau một hook của DocType và một bộ tool MCP, nhờ vậy việc định tuyến thừa hưởng đa thuê bao, phân quyền, vòng đời (một kế hoạch đã điều phối thì bị đóng băng) và nhật ký của nền tảng. ## Video cho thấy gì Bản quay chạy `make demo-stms` sau `make demo-seed`, đăng nhập bằng `linh` (role `dispatcher`). Mọi khung hình có copilot đều mang nhãn "LLM giả lập (kịch bản demo)". Số liệu lấy từ lần quay. 1. **Dữ liệu gốc** (cảnh 1). Năm DocType: Hub, Vehicle, DeliveryStop, DispatchPlan, DispatchEvent. Hub HCM-TB (Kho Tân Bình), ba xe (51C-12345 300 kg, 51C-67890 250 kg, 51D-24680 250 kg), 20 điểm dừng có khung giờ và mức ưu tiên Normal, VIP hoặc Urgent. Bản đồ trong video chỉ để minh hoạ và vẽ đường thẳng, không phải đường thật. 2. **Lời giải gốc** (cảnh 2). Lưu `DP-2610-0001` với `solve: true` và ngân sách 5 giây: `0hard/-106703soft` (điểm phạt cứng bằng 0 nghĩa là khả thi), 98,8 km, 2 xe, 1.782 ms. 3. **Xe hỏng, qua copilot** (cảnh 3). Câu lệnh: "Xe 51C-12345 hỏng ở Thủ Đức, chuyển hết đơn sang xe khác". LLM giả lập trả về hai tool call, `stms_vehicle_unavailable` rồi `stms_explain_plan`. Solver thật chuyển 10 điểm (9 sang 51C-67890, 1 sang 51D-24680), vẫn 0hard, 98,8 → 98,6 km, 1.019 ms; 3/3 điểm VIP được phục vụ trước 10:00. 4. **Trọng số mới, qua copilot** (cảnh 4). "Ưu tiên khách VIP trước 10h, cân bằng tải giữa các xe". Copilot gọi `stms_set_weights` (priority 500, balance 10). 19 điểm đổi xe, cả hai xe chở 226 kg, quãng đường tăng lên 111,6 km, 1.930 ms. Điểm mềm đổi thang đo khi đổi trọng số, nên chỉ so sánh được các plan có cùng trọng số. 5. **Đơn gấp, copilot qua MCP** (cảnh 5). "Thêm đơn gấp 30kg ở Bến Thành trước 11h" gửi tới tool MCP `stms_copilot`. Toạ độ Bến Thành lấy từ danh sách địa danh cố định của bản giả lập. `DS-2610-00021` được chèn vào 51D-24680; 4/4 điểm VIP hoặc gấp được phục vụ trước 10:00; 111,6 → 112,1 km; 1.010 ms. 6. **Sự kiện trực tiếp không qua copilot** (cảnh 6). `stms_traffic_delay` (Thủ Đức, bán kính 4 km, chậm 2,5 lần): 112,1 → 117,5 km, 1.958 ms. `stms_change_time_window` (DS-2610-00015, Linh kiện Tân Tiến, nay là 08:00–09:00): 117,5 → 137,0 km, 1.638 ms. Cả hai vẫn 0hard và cả hai đều ghi một DispatchEvent. 7. **Desk** (các cảnh Desk). Trang copilot điều phối gửi câu lệnh tới `POST /api/stms/copilot`, cùng copilot với tool MCP; trang hiện nhãn LLM giả lập cả trước và sau khi gửi, các tool call đã chạy, và các điểm đã đổi xe. 8. **Phân quyền và đa thuê bao** (cảnh 7). `quang` (ops) bị từ chối: "not permitted: read DispatchEvent". `ha` (care) bị từ chối: "not permitted: read DispatchPlan". Cùng tên plan từ `hanoi-express`: "not found". 9. **Điều phối** (cảnh 8). Nhật ký DispatchEvent liệt kê từng thay đổi với nguồn, điểm số trước và sau, các điểm bị dời và `solve_ms`. Submit đặt docstatus 1 và trạng thái Dispatched. Một `PUT {solve: true}` sau đó trả về 422 "submitted documents are immutable"; `stms_set_weights` trả về "is dispatched and frozen; amend it to replan". ## Cách hoạt động ### Luồng của một câu lệnh copilot ```mermaid sequenceDiagram participant D as linh (dispatcher) participant R as POST /api/stms/copilot hoặc MCP stms_copilot participant C as DispatchCopilot participant L as MockLlmClient (GIẢ LẬP, không có mô hình) participant T as Tool MCP (stms_*) participant P as Replanner participant H as DispatchPlanHook participant S as RoutePlanner (Timefold) D->>R: "Xe 51C-12345 hỏng ở Thủ Đức, …" R->>R: kiểm tra quyền trên mọi DocType mà tool đọc R->>C: run(plan, instruction) C->>L: toolCalls(instruction) L-->>C: khớp regex → [stms_vehicle_unavailable, stms_explain_plan] C->>T: stms_vehicle_unavailable(plan, 51C-12345) T->>P: replan(plan, patch unavailable_vehicles) P->>H: update DispatchPlan {solve: true} H->>S: solve(problem, warm start = tuyến hiện tại) S-->>H: score, routes, km, solve_ms H-->>P: last_change (các điểm bị dời) P->>P: insert DispatchEvent C->>T: stms_explain_plan C-->>D: llm: "mock", llm_note, tool call, lời giải thích từ kết quả thật ``` ### Solver: ràng buộc cứng và mềm Timefold Solver (bản kế tục của OptaPlanner) tìm một kế hoạch tối thiểu hoá `HardSoftScore`. Ràng buộc cứng phải về 0 thì kế hoạch mới khả thi; ràng buộc mềm xếp hạng các kế hoạch khả thi. *`astra-apps/stms/src/main/java/io/astra/apps/stms/solver/RoutingConstraints.java`* ```java // per kilogram over capacity factory.forEach(PlanVehicle.class) .filter(v -> v.totalDemandGrams() > v.getCapacityGrams()) .penalize(HardSoftScore.ONE_HARD, v -> ceilDiv(v.totalDemandGrams() - v.getCapacityGrams(), 1000)) .asConstraint(CAPACITY), // per minute late factory.forEach(PlanVisit.class) .filter(v -> v.lateSeconds() > 0) .penalize(HardSoftScore.ONE_HARD, v -> ceilDiv(v.lateSeconds(), 60)) .asConstraint(TIME_WINDOW), // per road metre factory.forEach(PlanVehicle.class) .filter(v -> !v.getVisits().isEmpty()) .penalize(HardSoftScore.ONE_SOFT, PlanVehicle::totalMeters) .asConstraint(DISTANCE), // per minute between leaving the depot and starting service at a priority stop // … // squared load per vehicle: for a fixed total it is smallest when loads are equal factory.forEach(PlanVehicle.class) .filter(v -> !v.getVisits().isEmpty()) .penalize(HardSoftScore.ONE_SOFT, v -> v.loadKg() * v.loadKg()) .asConstraint(BALANCE) ``` **Vì sao điểm mềm không phải là "kilômét".** Điểm mềm cộng số mét, số phút điểm ưu tiên phải chờ và bình phương số kg, mỗi thứ nhân với một trọng số. Mặc định là distance 1, priority 20, balance 0 (`RoutingProblem.Weights.DEFAULT`). Khi copilot đặt priority 500 và balance 10, điểm số nhảy từ khoảng −0,1 triệu xuống khoảng −1,26 triệu dù kế hoạch vẫn khả thi: các con số đã ở thang đo khác. Đó là lý do kịch bản nhắc chỉ so sánh các plan có cùng trọng số. Trọng số được áp theo từng plan qua `ConstraintWeightOverrides` của Timefold, nên đổi trọng số không cần deploy lại: *`astra-apps/stms/src/main/java/io/astra/apps/stms/solver/RoutePlanner.java`* ```java RoutingProblem.Weights w = problem.weights(); ConstraintWeightOverrides weights = ConstraintWeightOverrides.of(Map.of( RoutingConstraints.DISTANCE, HardSoftScore.ofSoft(w.distance()), RoutingConstraints.PRIORITY, HardSoftScore.ofSoft(w.priority()), RoutingConstraints.BALANCE, HardSoftScore.ofSoft(w.balance()))); ``` ![Hình 2: Ràng buộc cứng và ràng buộc mềm](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-20-figure-2.png) *Ràng buộc cứng và ràng buộc mềm.* ### Khoảng cách không cần bản đồ Không có mạng lưới đường. Khoảng cách là khoảng cách đường tròn lớn (haversine) nhân với hệ số đường 1,3, và thời gian di chuyển giả định 25 km/h. Một sự kiện giao thông là một vòng tròn mà trong đó các chặng chậm đi theo một hệ số: *`astra-apps/stms/src/main/java/io/astra/apps/stms/solver/RoutePlanner.java`* ```java double metres = Location.haversineMeters(from.latitude(), from.longitude(), to.latitude(), to.longitude()) * roadFactor; from.metersTo[to.index()] = Math.round(metres); double factor = Math.max(slowdown[from.index()], slowdown[to.index()]); from.secondsTo[to.index()] = Math.round(metres / metresPerSecond * factor); ``` Tốc độ, hệ số đường và ngân sách mặc định là cấu hình (`astra.stms.speed-kmh`, `astra.stms.road-factor`, `astra.stms.solve-seconds`), với mức trần cứng 30 giây cho mỗi lần giải. ### Một lần giải có giới hạn trên virtual thread *`astra-apps/stms/src/main/java/io/astra/apps/stms/solver/RoutePlanner.java`* ```java /** Stops when the budget is spent, or earlier once the score has not improved for a fifth of it. */ private RoutePlan solveOnVirtualThread(RoutePlan plan, int seconds) { Duration limit = Duration.ofSeconds(seconds); Duration unimproved = Duration.ofMillis(Math.max(500, limit.toMillis() / 5)); Solver solver = solverFactory.buildSolver(new SolverConfigOverride() .withTerminationConfig(new TerminationConfig().withSpentLimit(limit).withUnimprovedSpentLimit(unimproved))); // … } ``` **Vì sao có giới hạn "không cải thiện".** Với ngân sách 5 giây, solver dừng sau 1 giây không cải thiện. Đó là lý do các lần giải trong bản quay mất 1,0 đến 2,0 giây thay vì trọn 5 giây. ### Giải là một lần lưu, lập lại kế hoạch là một lần lưu có warm start Hành động giải là một trường. Khi một DispatchPlan nháp được lưu với `solve: true`, hook giải nó ngay trong lần lưu đó và ghi kết quả, kèm một bản ghi `last_change` về những gì đã dời: *`astra-apps/stms/src/main/java/io/astra/apps/stms/DispatchPlanHook.java`* ```java @Override public void beforeSave(Document doc) { // settings are validated on every save, not only when solving // … if (!Values.flag(doc.get("solve"))) return; doc.set("solve", false); solve(doc); } ``` Bài toán được dựng với warm start từ các tuyến hiện tại của plan (`problem.warmStart()` trong `RoutePlanner.build`), nên một lần lập lại kế hoạch bắt đầu từ những gì tài xế đang có và chỉ dời những gì sự kiện bắt buộc hoặc những gì rõ ràng cải thiện điểm số. Mọi sự kiện trực tiếp đều đi qua `Replanner`, nơi vá plan, giải lại và ghi nhật ký: *`astra-apps/stms/src/main/java/io/astra/apps/stms/Replanner.java`* ```java public Map replan(String planName, String eventType, Map detail, Map planPatch) { draftPlan(planName); Map patch = new LinkedHashMap<>(planPatch); patch.put("solve", true); Document after = docs.update("DispatchPlan", planName, patch) .orElseThrow(() -> new DocValidationException("dispatch plan " + planName + " not found")); // … Document logged = docs.insert("DispatchEvent", event, OWNER); ``` `draftPlan` là thứ khiến một plan đã điều phối bị đóng băng với các tool; vòng đời của kernel khiến nó bất biến với REST (xem [Video 4](/astra-cms/vi/lifecycle)). ![Hình 3: Lập lại kế hoạch bắt đầu từ các tuyến hiện tại](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-20-figure-3.png) *Lập lại kế hoạch bắt đầu từ các tuyến hiện tại.* ### Phân quyền qua danh sách đọc mà tool khai báo Mỗi tool lập lại kế hoạch khai báo các DocType nó đọc. DispatchEvent chỉ admin và dispatcher đọc được, nên đưa nó vào danh sách chính là cách ngăn ops và care lập lại kế hoạch, không cần một hệ thống phân quyền riêng: *`astra-apps/stms/src/main/java/io/astra/apps/stms/Replanner.java`* ```java /** * DocTypes a replanning tool declares it reads. DispatchEvent is readable only by roles that may replan * (admin, dispatcher), so the MCP server's read check is what keeps other roles from replanning. */ public static final List REPLAN_READS = List.of("DispatchPlan", "DispatchEvent", "Vehicle", "DeliveryStop", "Hub"); ``` ![Hình 4: Ai được lập lại kế hoạch](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-20-figure-4.png) *Ai được lập lại kế hoạch.* ### Copilot, và vì sao LLM của nó là giả lập Copilot phụ thuộc vào một interface, không phụ thuộc vào một mô hình: *`astra-apps/stms/src/main/java/io/astra/apps/stms/copilot/LlmClient.java`* ```java /** * The language model behind the dispatch copilot: turns a dispatcher's instruction into tool calls. Implementations * are CDI beans; {@code astra.stms.llm} selects one by {@link #id()}. Only {@link MockLlmClient} exists today; a real * provider plugs in here with the same contract. */ public interface LlmClient { // … /** True when no model is called (scripted answers); the copilot then says so in every response. */ boolean simulated(); ``` Bản hiện thực duy nhất là một tập biểu thức chính quy: *`astra-apps/stms/src/main/java/io/astra/apps/stms/copilot/MockLlmClient.java`* ```java /** * NOT a language model. A deterministic stand-in for the demo: each clause of the instruction is matched against a * few Vietnamese patterns (breakdown, urgent order, congestion, time window, priorities) and mapped to the tool calls * a model would be expected to return. Places are looked up in a small built-in gazetteer instead of being geocoded. * Anything outside the script yields no tool call. The copilot labels every answer produced through this client. */ @ApplicationScoped public class MockLlmClient implements LlmClient { // … static final Pattern BREAKDOWN = Pattern.compile( "xe\\s+(\\d{2}[a-z]{1,2}\\d?-?[\\d.]{4,6})\\b.*?(hỏng|hư|chết máy|nổ lốp|bể lốp|tai nạn|không chạy|nghỉ)", FLAGS); // … @Override public String id() { return "mock"; } @Override public boolean simulated() { return true; } ``` **Vì sao lời giải thích vẫn đáng tin.** `DispatchCopilot` không bao giờ hiển thị văn bản từ "mô hình". Nó chạy các tool call trên solver thật, rồi viết lời giải thích từ kết quả thực tế (điểm số trước và sau, các điểm bị dời, thời gian giải). Khi client là giả lập, nó thêm nhãn và ghi chú: *`astra-apps/stms/src/main/java/io/astra/apps/stms/copilot/DispatchCopilot.java`* ```java Map out = new LinkedHashMap<>(); out.put("llm", llm.id()); if (llm.simulated()) out.put("llm_note", MOCK_NOTE); // … static String explain(LlmClient llm, List calls, List> results, Document before, Document after) { StringBuilder sb = new StringBuilder(llm.simulated() ? "[LLM giả lập – kịch bản demo] " : ""); ``` Copilot chỉ được gọi bảy tool STMS trong tập `TOOLS` của nó; nó không thể gọi chính mình hay bất kỳ tool nào của kernel. Thiết kế này là thứ sẽ cho phép thêm một mô hình thật về sau mà không đổi ranh giới tin cậy: mô hình chỉ chọn tool call, các tool vẫn thực thi phân quyền và solver vẫn tạo ra các con số. Đó là thiết kế dự định; nó chưa được thử với một mô hình thật. ![Hình 1: Phần nào thật, phần nào giả lập](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-20-figure-1.png) *Phần nào thật, phần nào giả lập.* ## Ví dụ thực tế Một hãng chuyển phát khu vực ở TP. Hồ Chí Minh chạy đợt giao buổi sáng từ một kho. Kịch bản dưới đây là lần chạy demo đã quay, đọc như buổi sáng của một người điều phối. Các mốc giờ in đậm chỉ là khung kể chuyện; đầu vào và đầu ra là các giá trị đã ghi trong nhật ký. **07:00, lập kế hoạch trong ngày.** Đầu vào: kho HCM-TB, mở cửa 07:00; 3 xe; 20 điểm dừng, mỗi điểm 15,0 đến 30,2 kg, bốn điểm có khung giờ 09:00–11:00, ba điểm VIP. Người điều phối lưu một DispatchPlan với "Solve on save". Đầu ra: khả thi, 98,8 km, dùng hai xe (xe thứ ba ở lại kho), mỗi xe 226 kg, giải trong 1,8 giây. **08:30, một xe hỏng.** Đầu vào: 51C-12345 ngừng chạy. Trong demo, người điều phối gõ một câu lệnh và copilot giả lập ánh xạ nó thành `stms_vehicle_unavailable`; người điều phối cũng có thể gọi thẳng tool hoặc sửa trường `unavailable_vehicles` của plan. Đầu ra: 10 điểm của xe đó được dời (9 sang 51C-67890, 1 sang 51D-24680), vẫn khả thi, 98,6 km, và một DispatchEvent ghi lại ai đã đổi gì. **09:15, một đơn gấp.** Đầu vào: 30 kg ở Bến Thành, cần giao trước 11:00. Đầu ra: điểm dừng `DS-2610-00021` được tạo và xếp vào 51D-24680, được phục vụ lúc 08:00 theo kế hoạch; mọi điểm VIP hoặc gấp vẫn trước 10:00; 112,1 km. **09:40, kẹt xe và khách đổi giờ.** Đầu vào: Thủ Đức chậm 2,5 lần trong bán kính 4 km; một cửa hàng thu hẹp khung giờ còn 08:00–09:00. Đầu ra: thêm hai lần lập lại kế hoạch khả thi, 117,5 km rồi 137,0 km, mỗi lần đều được ghi nhật ký. **10:00, điều phối.** Submit đóng băng kế hoạch. Mọi thay đổi sau đó đều cần Amend, nhờ vậy phiên bản đã điều phối được giữ lại làm hồ sơ. Lưu ý cho một hãng chuyển phát thật: trong lát cắt này thời gian không trôi. Đơn gấp lúc 09:15 được phục vụ "lúc 08:00" vì solver không biết các điểm trước 09:15 đã giao xong hay xe đang chạy trên đường. Việc khoá các điểm đã giao được liệt kê ở mục "Nói đúng". ## Dành cho tech lead - **Thật và giả lập.** Solver, việc lập lại kế hoạch, tool MCP, phân quyền, đa thuê bao và nhật ký sự kiện chạy thật. Mô hình ngôn ngữ của copilot chưa tồn tại; đó là LLM giả lập (kịch bản demo) dựa trên regex. Đừng đánh giá độ vững của ngôn ngữ tự nhiên từ demo này. - **Vì sao đặt solver trong nền tảng.** Định tuyến trở thành một hành động trên chứng từ, có sẵn đa thuê bao, phân quyền, vòng đời và audit. Cái giá là CPU trong pod kernel: mỗi lần giải giữ một virtual thread tới hết ngân sách (mặc định 5 giây, trần 30 giây). Hãy đo CPU của solver theo tenant và cân nhắc một pool hoặc pod riêng trước khi mở rộng. - **Quy mô chưa được đo.** Demo có khoảng 20 điểm dừng và giải trong 1 đến 2 giây. Bài toán lớn hơn chưa được đo; hãy benchmark với số điểm dừng của chính bạn trước khi cam kết. - **Độ trung thực của mô hình.** Đường chim bay × 1,3 ở 25 km/h, một tải trọng gộp cho mỗi xe, một kho cho mỗi plan. Đủ để chứng minh cơ chế; không thay được một engine định tuyến trên mạng lưới đường. Một hệ thống production sẽ thay ma trận khoảng cách bằng một dịch vụ định tuyến. - **Ranh giới tin cậy cho một LLM tương lai.** Mô hình chỉ chọn tool call; tool kiểm tra lại quyền; con số đến từ solver. Điều này giới hạn những gì một mô hình "ảo giác" có thể làm, nhưng một nhà cung cấp thật còn cần xử lý PII, kiểm soát chi phí và đánh giá chất lượng, và chưa phần nào được xây. - **Khoảng trống audit.** DispatchEvent do tool tạo thuộc sở hữu của `stms-mcp` / `stms-copilot`; người gọi thật nằm trong log của MCP server, không nằm trên chứng từ. - **Khi nào chưa nên dùng.** Bất kỳ đội xe đang vận hành thật nào. Hãy dùng nó để đánh giá xem "solver đặt sau DocType, tool cho agent" có phải kiến trúc bạn muốn hay không. ## Tự chạy thử Yêu cầu: JDK 25, Docker với khoảng 8 GB RAM, `kubectl`, `k3d`, `jq`, Bash 5. Seed và demo phải dùng cùng `STMS_DATE` (mặc định: hôm nay theo giờ Việt Nam). Cảnh 3 đến 8 dùng plan do cảnh 2 tạo, nên hãy chạy theo thứ tự. ```bash make k3d-up make demo-seed # hub, 3 vehicles and 20 stops per tenant for STMS_DATE make demo-stms # all scenes, Enter between steps make demo-stms SCENES="2 3" PAUSE=0 # baseline solve, then the breakdown through the copilot STMS_DATE=2026-10-22 make demo-stms # pin the date if the seed ran on another day ``` Kết quả mong đợi theo từng cảnh: - Cảnh 2: `HTTP 201`, `"status": "Solved"`, điểm số dạng `0hard/…soft`, `"feasible": true`, và bảng tuyến. Số km và mili giây chính xác thay đổi giữa các lần chạy. - Cảnh 3: `"llm": "mock"`, một `llm_note`, các tool call `stms_vehicle_unavailable` và `stms_explain_plan`, và một lời giải thích bắt đầu bằng `[LLM giả lập – kịch bản demo]`. - Cảnh 7: `not permitted: read DispatchEvent` với `quang` và `not found` từ `hanoi-express`. Trong Desk (`http://localhost:8080/desk/`, user `linh`, mật khẩu `Astra@123`, chỉ dùng cho stack local), mở trang copilot điều phối và gõ một trong các câu lệnh của cảnh 3 đến 5. Một câu lệnh ngoài kịch bản không sinh tool call nào và nhận lời giải thích bằng tiếng Việt rằng không khớp kịch bản nào và không có gì thay đổi: LLM giả lập đang cho thấy giới hạn của nó. Test: `astra-apps/stms/src/test/java/io/astra/apps/stms/solver/RoutePlannerTest.java`, `astra-server/src/test/java/io/astra/server/StmsDispatchTest.java`, `astra-server/src/test/java/io/astra/server/StmsMcpTest.java`. ## Nói đúng Theo mục "Nói đúng" của kịch bản Video 20: - **Copilot không gọi mô hình ngôn ngữ nào.** `MockLlmClient` khớp câu lệnh với các mẫu tiếng Việt cố định và một danh sách địa danh nhỏ; câu lệnh ngoài kịch bản không sinh tool call. Lời giải thích được viết từ kết quả thật của solver. Điểm cắm cho một nhà cung cấp LLM thật là interface `LlmClient`; hiện chưa có bản hiện thực nào. - **Không có bản đồ, mạng lưới đường hay giao thông trực tiếp.** Khoảng cách là đường chim bay × 1,3 ở tốc độ cố định 25 km/h; "kẹt xe" là một vùng chậm do người điều phối khai báo. Không geocoding: các điểm dừng mang sẵn toạ độ. - **Lập lại kế hoạch là giải lại từ các tuyến hiện tại**, không giữ solver chạy cả ngày. Thời gian không trôi, nên các điểm đã giao và xe đang chạy trên đường không bị khoá. Thêm đơn gấp và giải lại là hai transaction: nếu giải lại lỗi, đơn mới vẫn được lưu. - **Mô hình tải trọng đơn giản hoá:** một tổng gộp cho mỗi xe (giao và nhận cộng lại); không có ràng buộc lấy-trước-giao, nhiều chuyến, ca và giờ nghỉ của tài xế, hạn quay về kho, loại xe hay chi phí xe. Mỗi plan một kho, không trung chuyển giữa các kho. - **Chưa xây:** dự báo tải bằng AI (Tier 1), app tài xế, GPS, cập nhật ETA từ hiện trường, bản đồ tuyến. Desk hiển thị bảng tuyến dạng chữ và JSON. - **Chỉ bài toán nhỏ:** khoảng 20 điểm dừng, 1 đến 2 giây. Bài toán lớn hơn chưa được đo. Số liệu trên màn hình lấy từ bản quay. - **Quyền sở hữu:** DispatchEvent và đơn gấp do tool tạo có chủ sở hữu `stms-mcp` / `stms-copilot`; người gọi thật được ghi trong log của MCP server. ## Hình minh hoạ - [Hình 1 · Phần nào thật, phần nào giả lập](#copilot-và-vì-sao-llm-của-nó-là-giả-lập) - [Hình 2 · Ràng buộc cứng và ràng buộc mềm](#solver-ràng-buộc-cứng-và-mềm) - [Hình 3 · Lập lại kế hoạch bắt đầu từ các tuyến hiện tại](#giải-là-một-lần-lưu-lập-lại-kế-hoạch-là-một-lần-lưu-có-warm-start) - [Hình 4 · Ai được lập lại kế hoạch](#phân-quyền-qua-danh-sách-đọc-mà-tool-khai-báo) ## Liên quan - Trước: [Video 19 · HRM: nhân sự và tiền lương](/astra-cms/vi/hrm). Tiếp: [Video 21 · SAML SSO và SCIM](/astra-cms/vi/sso). Lộ trình: [Video 18](/astra-cms/vi/roadmap). - Các cơ chế được dùng lại ở đây: [Video 4 · vòng đời chứng từ](/astra-cms/vi/lifecycle), [Video 11 · phân quyền](/astra-cms/vi/keycloak), [Video 7 · đa thuê bao](/astra-cms/vi/tenants), [Video 15 · MCP](/astra-cms/vi/mcp). - Thiết kế: [use-case-01.md](/astra-cms/vi/use-case) (module 6, `astra-app-stms`, có ghi chú trạng thái về những điểm khác với lát cắt đang chạy). - Mã nguồn: [`RoutingConstraints`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/stms/src/main/java/io/astra/apps/stms/solver/RoutingConstraints.java), [`RoutePlanner`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/stms/src/main/java/io/astra/apps/stms/solver/RoutePlanner.java), [`DispatchPlanHook`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/stms/src/main/java/io/astra/apps/stms/DispatchPlanHook.java), [`Replanner`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/stms/src/main/java/io/astra/apps/stms/Replanner.java), [`ReplanTools`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/stms/src/main/java/io/astra/apps/stms/ReplanTools.java), [`DispatchCopilot`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/stms/src/main/java/io/astra/apps/stms/copilot/DispatchCopilot.java), [`LlmClient`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/stms/src/main/java/io/astra/apps/stms/copilot/LlmClient.java), [`MockLlmClient`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/stms/src/main/java/io/astra/apps/stms/copilot/MockLlmClient.java), [`StmsCopilotResource`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-server/src/main/java/io/astra/server/StmsCopilotResource.java), [`demo-stms.sh`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/scripts/demo-stms.sh). --- # Lộ trình: phần còn lại của use-case-01 Tóm tắt một câu: Video 1 đến 17 và 19 đến 21 cho thấy các lát cắt mỏng của code đang chạy; video này, và bài viết này, liệt kê những gì use case chuyển phát còn thiếu và gắn nhãn Đang phát triển cho từng hạng mục. Nên… Company: AstraCMS Docs API version: Không áp dụng Requested language: vi Content language: vi Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/vi/roadmap ▶ [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-18-roadmap-vi.mp4) · [English](https://media.zerohostcms.com/astra-cms/astra-video-18-roadmap-en.mp4) · 1:36 · Kịch bản: [demo-video-series.md](/astra-cms/vi/video-scripts) · [Bản tiếng Anh](/astra-cms/vi/roadmap) > **Tóm tắt một câu:** Video 1 đến 17 và 19 đến 21 cho thấy các lát cắt mỏng của code đang chạy; video này, và bài viết này, liệt kê những gì use case chuyển phát còn thiếu và gắn nhãn **Đang phát triển** cho từng hạng mục. > > **Nên đọc nếu:** bạn muốn biết những extension point nào đã có trong code để thấy các mảnh còn thiếu sẽ gắn vào đâu (góc nhìn developer) / bạn đang cân nhắc có thí điểm Astra hay không và cần một ranh giới trung thực giữa phần đã chạy và phần mới chỉ có thiết kế (góc nhìn tech lead). > [!WARNING] > **Mọi thứ gắn nhãn "Đang phát triển" trong bài này đều chưa được xây.** Đó là thiết kế mục tiêu lấy từ > [use-case-01.md](/astra-cms/vi/use-case) và [lộ trình trong README](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/README.vi.md#lộ-trình). Không video nào trình > diễn chúng, và không đoạn code nào bên dưới hiện thực chúng. Các trích đoạn code trong bài lấy từ những thành phần > **đang chạy hôm nay**; chúng được đưa ra vì đó chính là chỗ các mảnh còn thiếu sẽ gắn vào. Chú giải trạng thái dùng bên dưới: **Đang chạy** = có trong một video demo và có trong repository. **Đang phát triển** = chưa xây; mới là thiết kế mục tiêu. ## Vấn đề Demo nền tảng hay làm nhoè hai thứ: điều sản phẩm làm được hôm nay và điều slide kiến trúc hứa hẹn. Người đánh giá sau đó mới phát hiện khoảng trống trong lúc thí điểm, tức là thời điểm tốn kém nhất để phát hiện. Loạt demo của Astra đi theo một mạch truyện, [use-case-01](/astra-cms/vi/use-case): Astra Express, một công ty chuyển phát với bảy module nghiệp vụ (OMS đơn hàng, OPS vận hành bưu kiện, BMS tính cước và sổ cái, HRM tiền lương, NOC phân tích, STMS điều phối, CARE chăm sóc khách hàng) trên cùng một nền tảng. Các video chứng minh cơ chế bằng lát cắt mỏng: đủ code để thấy cơ chế chạy, không phải toàn bộ module. Mỗi video về cơ chế kết thúc bằng một ghi chú "Nói đúng" về giới hạn của chính nó. Video 18 gom các giới hạn đó về một chỗ. Frappe và ERPNext, mà Astra dùng làm tham chiếu thiết kế (chỉ khái niệm, không dùng chung code), là những sản phẩm trưởng thành với nhiều năm phát triển module phía sau. Astra chưa ở mức đó. Phép so sánh có ý nghĩa với người đọc hôm nay không phải là số lượng tính năng, mà là liệu những cơ chế Astra đang chạy (DocType zero-DDL, overlay theo tenant, sandbox Wasm, sổ cái append-only, CDC có read-your-writes, MCP có phân quyền) có phải là nền móng đúng cho phần còn lại hay không. ## Video cho thấy gì Chín mươi sáu giây, không có cảnh quay demo. Mọi khung hình đều mang nhãn **"Roadmap / Đang phát triển"**. Các cảnh đi theo lời dẫn: 1. **Tiêu đề.** "Phần còn lại là code đang chạy. Đây là phần đang làm." 2. **Những gì đã chạy.** Các lát cắt mỏng từ DocType đến Seata, cùng HRM, STMS và SAML SSO (Video 19 đến 21). Mỗi video tự liệt kê giới hạn của mình; video này gom chúng lại. 3. **Lưu trữ.** Đang chạy: JSONB, sổ cái append-only, schema BMS riêng, Seata. Đang phát triển: mỗi module một schema, bảng fence TCC, store Seata cho production. 4. **Danh tính và khả năng mở rộng.** Đang chạy: Keycloak, SAML SSO, SCIM, Wasm. Đang phát triển: quyền theo dòng, Single Logout, SCIM Bulk, JVM cô lập Tier 1. 5. **Dữ liệu và AI.** Đang chạy: Rust CDC, MCP có che dữ liệu cá nhân. Đang phát triển: CDC streaming, Apache Doris, agent tự hành, RAG vector. 6. **Module nghiệp vụ.** HRM cần thuế thu nhập cá nhân, chi trả lương và một job cuối tháng. STMS cần LLM thật, bản đồ thật và app tài xế. Audit hash chain và mã hoá trường chưa bắt đầu. 7. **Kết.** Mỗi bước phải chạy thật rồi mới có video; README giữ lộ trình. Góp ý hữu ích nhất lúc này là phản biện mô hình DocType và overlay. ## Cách hoạt động Phần này mô tả **thiết kế mục tiêu**. Mỗi tiểu mục nêu phần đang chạy, phần đang phát triển, và đoạn code đang chạy mà phần đang phát triển sẽ mở rộng từ đó. ```mermaid flowchart LR subgraph RUN["Đang chạy hôm nay (Video 1–17, 19–21)"] J[DocType JSONB + projection index] L[Sổ cái append-only] S[Seata AT + TCC] K[Keycloak, SAML SSO, SCIM] W[App Tier 0, Wasm Tier 2] C[Rust CDC + Sync-Aware LSN] M[MCP + che PII] H[Lát cắt HRM] T[Lát cắt STMS, LLM giả lập] end subgraph DEV["Đang phát triển (chưa xây)"] J2[Mỗi module một schema, hot column] L2[Audit hash chain, mã hoá trường] S2[Kernel là nhánh AT, TCC fence, store DB/Raft] K2[Điều kiện quyền theo dòng, Single Logout, SCIM Bulk/ETag] W2[JVM cô lập Tier 1, đếm lệnh Wasm] C2[CDC streaming, Apache Doris, NOC] M2[Agent tự hành, RAG pgvector, OAuth cho MCP] H2[Thuế TNCN, chi trả lương, job cuối tháng, tự xem phiếu lương] T2[LLM thật, bản đồ và giao thông, dự báo, app tài xế] end J --> J2 L --> L2 S --> S2 K --> K2 W --> W2 C --> C2 M --> M2 H --> H2 T --> T2 ``` ![Hình 1: Đang chạy và Đang phát triển](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-18-figure-1.png) *Đang chạy và Đang phát triển.* ### Lưu trữ - **Đang chạy:** mọi DocType nằm trong một bảng JSONB, với expression index tạo online cho các trường `projected: true` ([Video 2](/astra-cms/vi/doctype)); sổ cái append-only phân vùng theo tháng ([Video 12](/astra-cms/vi/ledger)); dữ liệu BMS nằm trong schema `bms` riêng với role riêng ([Video 17](/astra-cms/vi/seata)). - **Đang phát triển:** use-case-01 đặt mỗi module vào một schema PostgreSQL riêng (`schema_oms`, `schema_ops`, `schema_hrm`, …) có row-level security, và thêm "hot-field materialisation" cho các chứng từ đổi trạng thái nhiều lần trong ngày (trạng thái bưu kiện, vị trí xe): các trường cập nhật thường xuyên được chuyển ra cột vật lý để PostgreSQL cập nhật chúng mà không phải ghi lại cả chứng từ JSONB. - **Đang phát triển:** audit hash chain trên mọi thay đổi và mã hoá ở mức trường khai báo trong YAML của DocType (`encrypt: field`). Lời dẫn nói rõ các phần này chưa bắt đầu. Bảo đảm append-only của sổ cái hôm nay là một trigger trong cơ sở dữ liệu. Hash chain sẽ bổ sung khả năng phát hiện giả mạo lên trên khả năng chống sửa này: *`astra-doctype/src/main/resources/db/migration/V3__ledger.sql`* (đang chạy) ```sql CREATE FUNCTION astra_ledger_append_only() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'astra_ledger is append-only: post a reversal instead of %', TG_OP USING ERRCODE = 'insufficient_privilege'; END $$; CREATE TRIGGER astra_ledger_no_update_delete BEFORE UPDATE OR DELETE ON astra_ledger FOR EACH ROW EXECUTE FUNCTION astra_ledger_append_only(); ``` **Vì sao theo thứ tự này.** Trigger chặn UPDATE và DELETE nhưng không chặn DROP hay ALTER của chủ sở hữu bảng (mục "Nói đúng" của Video 12). Hash chain phát hiện những lần giả mạo lọt qua kiểm soát truy cập; nó nằm trong các bước tiếp theo của README. ### Giao dịch - **Đang chạy:** nhánh AT của Seata 2.6 trên hạn mức tín dụng BMS, nhánh TCC trên sức chứa lấy hàng trong Redis, và transaction của chính kernel commit sau cùng ([Video 17](/astra-cms/vi/seata)). - **Đang phát triển:** bản thân kernel là một nhánh AT của Seata (hôm nay nó là "last resource"), bảng TCC fence của Seata cho idempotency và suspension (hôm nay mỗi bên tham gia TCC tự xử lý), và store DB hoặc Raft cho Seata Server (hôm nay dùng file store). ### Danh tính và phân quyền - **Đang chạy:** Keycloak OIDC với role theo tenant và quyền theo DocType, tách bạch nhiệm vụ ([Video 11](/astra-cms/vi/keycloak)); SAML SSO và SCIM 2.0 theo tenant ([Video 21](/astra-cms/vi/sso)). - **Đang phát triển:** điều kiện quyền theo dòng (ví dụ "kế toán chỉ thấy chứng từ của chi nhánh mình"), SAML Single Logout, SCIM Bulk và ETag, và bộ lọc SCIM đầy đủ (hôm nay chỉ có `userName eq` và `displayName eq`). ### Các tầng mở rộng - **Đang chạy:** các app Tier 0 biên dịch vào image (Express, HRM, STMS, CMS) và sandbox Wasm Tier 2 cho công thức của tenant ([Video 14](/astra-cms/vi/wasm)). - **Đang phát triển:** Tier 1, một class loader JVM cô lập để hot-swap plugin mà không phải khởi động lại pod. use-case-01 dự định dùng nó cho các bản địa hoá như hoá đơn điện tử Việt Nam, VAT, cổng thanh toán và connector 3PL. Cũng đang phát triển: đếm lệnh ("fuel") cho Wasm, để giới hạn CPU không chỉ là một deadline theo thời gian thực. Hôm nay, kiểm soát CPU của Wasm là một deadline, như chính tài liệu của sandbox ghi: *`astra-plugin-host/src/main/java/io/astra/pluginhost/WasmSandbox.java`* (đang chạy) ```java /** * Tier 2 sandbox on Chicory, a pure-Java WebAssembly runtime: no native code, no JNI, nothing the * module can reach outside its own linear memory. Each call gets a fresh instance (no state survives * between calls or leaks between tenants), memory is capped at {@code astra.scripts.max-memory-pages} * (64 KiB each), host imports are refused, and the call runs on its own virtual thread that is * interrupted at the deadline. Chicory has no instruction metering, so the deadline is the CPU control. */ ``` ### Dữ liệu, phân tích và AI - **Đang chạy:** một worker Rust CDC giải mã `pgoutput` vào bản sao CARE, có read-your-writes qua `X-Tx-LSN` / `X-Required-LSN` ([Video 16](/astra-cms/vi/cdc)); một MCP server có che PII và tool do app đóng góp ([Video 15](/astra-cms/vi/mcp)). - **Đang phát triển:** giao thức streaming replication (hôm nay worker poll slot), đẩy thay đổi vào Apache Doris cho module NOC, và dashboard NOC. use-case-01 mô tả các bảng Doris được phơi ra dưới dạng "virtual DocType" để cùng quyền và cùng giao diện sinh tự động áp dụng cho phân tích. - **Đang phát triển:** agent tự hành (vượt ra ngoài các tool do client gọi), truy xuất dựa trên pgvector cho CARE, và OAuth discovery cho MCP client (hôm nay client dùng bearer token tĩnh). Vòng đọc hiện tại của worker CDC là poll, đúng phần mà giao thức streaming sẽ thay thế: *`astra-cdc-rust/src/main.rs`* (đang chạy) ```rust loop { let cur: String = client.query_one("SELECT pg_current_wal_lsn()::text", &[]).await?.get(0); let rows = client .query( "SELECT lsn::text, data FROM pg_logical_slot_peek_binary_changes($1, $2::text::pg_lsn, NULL, \ 'proto_version', '1', 'publication_names', $3)", &[&cfg.slot, &cur, &cfg.publication], ) .await?; // … ``` ### Module nghiệp vụ - **HRM, đang chạy** ([Video 19](/astra-cms/vi/hrm)): chấm công, Payroll Entry, phiếu lương tính trên virtual thread, thưởng KPI bằng Wasm, một bút toán dồn tích cho mỗi lượt chạy. **Đang phát triển:** thuế thu nhập cá nhân, phần bảo hiểm doanh nghiệp đóng, bút toán chi trả lương, job nền cuối tháng có bầu leader, nhân viên tự xem phiếu lương, và số kiện lấy qua CDC từ OPS và STMS thay vì nhập kèm chấm công. - **STMS, đang chạy** ([Video 20](/astra-cms/vi/stms)): định tuyến Timefold trong kernel, lập lại kế hoạch, tool MCP, một copilot điều phối mà **LLM là LLM giả lập (kịch bản demo)**. **Đang phát triển:** nhà cung cấp LLM thật, bản đồ và giao thông thật, dự báo tải (Tier 1), app tài xế. - **NOC và thiết kế CARE đầy đủ:** **Đang phát triển.** CARE hôm nay là lát cắt phân loại ticket và bản sao CDC của [Video 6](/astra-cms/vi/care). Copilot STMS cho thấy khuôn mẫu mà phần lớn hạng mục trong lộ trình đi theo: interface đã có trong code, bản hiện thực thật thì chưa. Tài liệu của interface nói rõ điều đó: *`astra-apps/stms/src/main/java/io/astra/apps/stms/copilot/LlmClient.java`* (interface đang chạy; chỉ có một bản mock hiện thực nó) ```java /** * The language model behind the dispatch copilot: turns a dispatcher's instruction into tool calls. Implementations * are CDI beans; {@code astra.stms.llm} selects one by {@link #id()}. Only {@link MockLlmClient} exists today; a real * provider plugs in here with the same contract. */ public interface LlmClient { ``` ![Hình 2: Interface đã có, bản hiện thực thì chưa](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-18-figure-2.png) *Interface đã có, bản hiện thực thì chưa.* ### Các khoảng trống nhỏ hơn nêu ở video khác Những mục này không nằm trong bảng của Video 18 nhưng được ghi trong phần "Nói đúng" của các kịch bản. Tất cả đều **Đang phát triển**: | Mảng | Khoảng trống | Nêu ở đâu | | --- | --- | --- | | Tiến hoá schema | Xoá một trường khiến các chứng từ cũ còn mang trường đó không sửa được nữa | [Video 9](/astra-cms/vi/developer) | | Rolling restart | Chưa có preStop hook hay drain; request rơi đúng lúc pod cũ dừng có thể lỗi | [Video 10](/astra-cms/vi/k8s) | | Overlay | Overlay thêm hoặc chỉnh trường; không thêm được hook hay quyền, không xoá được trường của app | [Video 13](/astra-cms/vi/overlay) | | MCP | Che PII áp dụng cho kênh MCP, không áp dụng cho Desk | [Video 15](/astra-cms/vi/mcp) | ## Ví dụ thực tế *Tình huống minh hoạ: công ty, con người và giá trị là hư cấu; cơ chế và các quy tắc nó áp dụng là thật, như đã trình bày ở trên.* Một công ty 3PL quy mô vừa ở Việt Nam, có chi nhánh ở Hà Nội và TP. Hồ Chí Minh, đang cân nhắc Astra cho tiếp nhận đơn, vận hành bưu kiện, đối soát COD, tính lương và điều phối. CTO của họ đối chiếu yêu cầu với trạng thái ở trên: | Yêu cầu | Trạng thái | Ý nghĩa với đợt thí điểm | | --- | --- | --- | | Mỗi chi nhánh là một tenant, không nhìn thấy nhau | Đang chạy | Thí điểm có thể dựa vào RLS và guard fail-closed (Video 7, 8) | | Đơn → vận đơn → bút toán doanh thu trong một transaction | Đang chạy | Thí điểm vòng đời chứng từ và sổ cái (Video 4, 12) | | Hà Nội cần thêm một trường VAT, Sài Gòn thì không | Đang chạy | Overlay, không đổi code (Video 13) | | Maker/checker cho COD | Đang chạy | Tách bạch nhiệm vụ (Video 11, 12) | | Kế toán chỉ thấy chứng từ chi nhánh mình trong cùng một tenant | Đang phát triển | Cần điều kiện quyền theo dòng; tách tenant hoặc chờ | | Lương tháng có thuế thu nhập cá nhân và file ngân hàng | Đang phát triển | Lát cắt HRM chỉ tính dồn tích; giữ hệ thống lương hiện tại | | Lập tuyến hằng ngày cho 20–50 xe trên đường thật | Đang phát triển | Lát cắt STMS dùng khoảng cách đường chim bay và mới đo ở khoảng 20 điểm dừng | | Trợ lý chat tiếng Việt cho người điều phối | Đang phát triển | LLM của copilot là LLM giả lập (kịch bản demo); chưa nối mô hình nào | | Audit chống giả mạo cho cơ quan quản lý | Đang phát triển | Sổ cái append-only đã chạy; hash chain chưa bắt đầu | | Dashboard NOC thời gian thực | Đang phát triển | Chưa có pipeline Doris | **Kết quả của bài tập:** một đợt thí điểm giới hạn ở OMS, OPS, BMS và overlay theo tenant, còn tính lương và điều phối vẫn ở công cụ hiện có, kèm danh sách năm hạng mục lộ trình cần theo dõi. Đó chính là quyết định mà Video 18 muốn hỗ trợ. ![Hình 3: Phạm vi thí điểm của một CTO](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-18-figure-3.png) *Phạm vi thí điểm của một CTO.* ## Dành cho tech lead - **Đọc trạng thái, đừng đọc sơ đồ kiến trúc.** README ghi rằng mọi thứ ngoài mục "Trạng thái dự án" là kiến trúc mục tiêu, và các con số hiệu năng ở đó là mục tiêu thiết kế, không phải số đo. - **Nên dồn thời gian đánh giá vào đâu.** Lời dẫn đề nghị phản biện mô hình DocType và overlay. Đó là những quyết định khó đổi nhất về sau; phần lớn hạng mục lộ trình gắn vào phía sau các interface đã có (`LlmClient`, SPI của sổ cái, sandbox, worker CDC). - **Rủi ro của các khoảng trống.** Những khoảng trống làm thay đổi bảo đảm về dữ liệu hay bảo mật (điều kiện quyền theo dòng, audit hash chain, mã hoá trường, tiến hoá schema an toàn) quan trọng với đợt thí điểm hơn những khoảng trống chỉ thêm tính năng (Doris, app tài xế). - **Nên đo gì khi thí điểm.** Độ trễ request ở các hook làm việc theo lô (tính lương chạy trong request), CPU của solver theo tenant (STMS), độ trễ CDC ở tốc độ ghi của bạn, và lỗi khi rolling restart (Video 10 đo được 2 trên 47 request lỗi trong một lần restart không có drain). - **Khi nào chưa nên dùng.** Nếu workload đầu tiên phụ thuộc vào một hạng mục Đang phát triển (lương tuân thủ thuế, định tuyến trên đường thật, trợ lý LLM), Astra chưa sẵn sàng cho nó. ## Tự chạy thử Không có gì để chạy cho các hạng mục Đang phát triển. Mọi thứ trong các danh sách "Đang chạy" đều chạy được từ repository. Yêu cầu: JDK 25, Docker với khoảng 8 GB RAM, `kubectl`, `k3d`, `jq`, Bash 5. ```bash make k3d-up && make demo-seed # the whole stack, two tenants make demo-express SCENES="9 10" PAUSE=0 # Keycloak permissions and SoD, then the ledger make demo-express SCENES="11 12 13" PAUSE=0 # overlays, Wasm, MCP make demo-express SCENES="14 15" PAUSE=0 # Rust CDC + LSN, Seata AT + TCC make demo-hrm # HRM slice (Video 19) make demo-stms # STMS slice (Video 20), copilot LLM simulated make demo-sso # SAML SSO and SCIM (Video 21); see its script for Keycloak setup ``` Video 8 (kernel từ chối khởi động khi chạy bằng superuser) cần stack Docker Compose: `make infra-up && make build && make demo-express SCENES=8`. Kết quả mong đợi của từng cảnh được mô tả trong bài viết tương ứng và trong [demo-video-series.md](/astra-cms/vi/video-scripts). ## Nói đúng - Mọi hạng mục gắn nhãn **Đang phát triển** đều chưa được xây. Mô tả về chúng lấy từ [use-case-01.md](/astra-cms/vi/use-case) và [README](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/README.vi.md#lộ-trình), và có thể thay đổi khi bắt tay vào làm. - Bảng trong video bao quát các khoảng trống chính, không phải mọi giới hạn. Danh sách đầy đủ của từng cơ chế nằm ở mục "Nói đúng" của video tương ứng trong [demo-video-series.md](/astra-cms/vi/video-scripts). - Các hạng mục "Đang chạy" là lát cắt mỏng: đủ để chứng minh cơ chế, không phải toàn bộ phạm vi của use case. - Bài này không đưa ra mốc thời gian hay thứ tự bàn giao cho các hạng mục Đang phát triển; danh sách phase trong README là nguồn có thẩm quyền về trình tự. ## Hình minh hoạ - [Hình 1 · Đang chạy và Đang phát triển](#cách-hoạt-động) - [Hình 2 · Interface đã có, bản hiện thực thì chưa](#module-nghiệp-vụ) - [Hình 3 · Phạm vi thí điểm của một CTO](#ví-dụ-thực-tế) ## Liên quan - Trước: [Video 17 · Seata: một lần submit, ba tài nguyên](/astra-cms/vi/seata). Tiếp: [Video 19 · HRM: nhân sự và tiền lương](/astra-cms/vi/hrm). - Các lát cắt module mà lộ trình này xây tiếp: [Video 19 · HRM](/astra-cms/vi/hrm), [Video 20 · STMS](/astra-cms/vi/stms), [Video 21 · SAML SSO và SCIM](/astra-cms/vi/sso). - Thiết kế mục tiêu: [use-case-01.md](/astra-cms/vi/use-case) (tiếng Việt; các module có ghi chú trạng thái ở chỗ đã có lát cắt chạy), [lộ trình trong README](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/README.vi.md#lộ-trình), [trạng thái dự án trong README](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/README.vi.md#trạng-thái-dự-án), lập luận thiết kế trong [explain.md](/astra-cms/vi/why-astra). - Các điểm nối đang chạy được trích ở trên: [`V3__ledger.sql`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-doctype/src/main/resources/db/migration/V3__ledger.sql), [`WasmSandbox`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-plugin-host/src/main/java/io/astra/pluginhost/WasmSandbox.java), [`astra-cdc-rust/src/main.rs`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-cdc-rust/src/main.rs), [`LlmClient`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/stms/src/main/java/io/astra/apps/stms/copilot/LlmClient.java).