Sổ tay dựng hệ thống · Bản 2

Dựng Zalo Mini App bán hàng có CRM quản trị

Bản 1 dừng ở chỗ “app bán được hàng và có trang quản trị”. Bản 2 là hệ thống đang chạy thật: thêm hộp thư Zalo cá nhân, trợ lý tự động ba kênh, theo dõi vận chuyển, CRM ngoài hai chiều, và một đường đặt hàng nằm ngoài Mini App cho những máy không mở nổi app. Tài liệu đi theo đúng thứ tự phải dựng: mỗi mục là một khối, mỗi khối nói rõ để làm gì, cách dùng, và tệp nào cầm việc đó.

Hệ tham chiếu: Enzara Việt Nam Cập nhật: 12/09/2026 Bản trước: quy-trinh-zalo-mini-app.html Nhánh: feat/ui-v2

00Bản đồ hệ thống — bốn khối chạy, đừng trộn

Đọc xong mục này là biết mỗi lệnh sau đây gõ ở thư mục nào.

Sơ đồ khối: Mini App và trang đặt hàng gọi vào Worker; Worker nối CRM quản trị, gateway Zalo và các hệ ngoài Zalo Mini App React + zmp-ui · thư mục gốc /src Trang /dat-hang web thường · COD · cho máy kẹt splash enzara-backend · Cloudflare Worker (Hono) D1 “enzara” · Durable Object HOP_THU · 2 cron · zalo-app.enzara.vn CRM quản trị Pages PWA · /admin zalo-gateway Node dài hạn · zca-js CRM ngoài /crm/* kéo · webhook đẩy Hệ ngoài Pancake POS · Zalo Checkout SDK · SePay · Zalo OA / ZBS · Messenger · enzara.vn (WooCommerce, proxy SĐT)

Nét liền: gọi trực tiếp. Nét đứt: sự kiện qua hàng đợi, có thể trễ.

KhốiThư mụcChạy ở đâuĐụng vào là ảnh hưởng
Mini App/srcMáy khách, trong ZaloPhải deploy + Zalo duyệt mới ra khách
Backend/backendCloudflare Worker (biên)Deploy là ăn ngay, cả app lẫn CRM
CRM quản trị/adminCloudflare Pages (PWA)Chỉ nội bộ, deploy tự do
Gateway Zalo/gatewayMáy/VPS chạy Node 24/7Tắt là hộp thư mất tin mới
Khác bản 1 chỗ nào

Bản 1 có ba khối (app · backend · trang quản trị). Bản 2 thêm khối thứ tư (gateway Zalo cá nhân, buộc phải có máy chạy Node dài hạn — Worker không giữ được WebSocket của zca-js), và thêm các mục 07–12: theo dõi vận chuyển, tin ZBS, trợ lý ba kênh, hộp thư, CRM ngoài, đặt hàng ngoài app.

01Giấy tờ và tài khoản phải có trước

Khâu này không viết được dòng mã nào mà vẫn tốn 1–3 tuần. Làm song song với mục 02.

  1. Official Account đã xác thực doanh nghiệp Mini App bắt buộc gắn một OA. Xác thực cần giấy phép kinh doanh khớp tên chủ tài khoản. Chưa xác thực thì không xin được quyền lấy số điện thoại, không gửi được tin ZBS.
  2. Zalo App → lấy App ID và Secret Key Tạo tại Zalo for Developers. ZALO_APP_SECRET dùng để giải mã token số điện thoại (xem mục 13.4 — có cái bẫy IP ở đây).
  3. Mini App → lấy Mini App ID ID này nằm công khai trong URL app (zalo.me/s/<appId>), khai vào app-config.jsonZALO_MINI_APP_ID của Worker.
  4. Khai ngay trong lần đầu vào Portal Tên · mô tả · icon · ảnh bìa · danh mục · chính sách quyền riêng tư · điều khoản sử dụng (hai trang này phải có URL thật — xem docs/dieu-khoan-su-dung.html) · phạm vi quyền xin của app.
  5. Pancake POS → Shop ID + API Key Đây là nguồn sự thật về sản phẩm, tồn kho, đơn hàng. Lấy luôn danh sách kho để chọn DEFAULT_WAREHOUSE_ID (mục 02 bước 6).
  6. Cloudflare Worker + D1 + Pages + một Durable Object. Gói Free chạy đủ, nhưng Durable Object phải khai kiểu new_sqlite_classes mới dùng được ở gói Free.
  7. Cổng thanh toán Zalo Checkout SDK (bắt buộc, xem mục 05) + SePay để đối soát chuyển khoản. SePay nên cấp một tài khoản định danh (VA) riêng cho Mini App để webhook không lẫn với web.
  8. Hạ tầng phụ trên IP Việt Nam Một chỗ chạy PHP trên hosting Việt Nam (ở đây là enzara.vn) để làm proxy giải mã số điện thoại. Cloudflare bị Zalo trả -501 khi giải mã từ IP ngoài VN.
  9. Nếu dùng Messenger Facebook App + Page Access Token + App Secret. Không dùng thì bỏ qua, hệ thống không gãy.
Đừng làm ngược

Xin duyệt Mini App khi chưa có trang điều khoản, chưa có luồng thanh toán qua Checkout SDK, hoặc app còn màn hình trống → bị trả về, và mỗi lượt trả về mất 3–5 ngày chờ.

02Backend — Worker + D1, dựng trước tất cả

Mini App và CRM đều chỉ là mặt tiền. Dựng backend xong thì hai cái kia chỉ còn là giao diện.

enzara-backend

Worker
Để làm gì
Một Worker Hono đứng giữa mọi thứ: giữ khoá bí mật (Mini App không được giữ khoá nào), gọi Pancake, tính lại giá và tổng tiền, ký chữ ký Checkout SDK, ghi điểm/hạng/voucher xuống D1, nhận webhook của SePay · Pancake · Zalo · WooCommerce · Messenger, phục vụ trang /dat-hang và API /admin/* cho CRM.
Cách dùng
cd backendnpm run dev (cổng 8787) → npm run deploy. Xem log bản thật: npx wrangler tail.
Tệp
backend/src/index.ts (gắn route + cron) · routes/*.ts · db.ts · env.ts (danh mục biến) · wrangler.toml.
  1. Cài và đăng nhập
    cd backend
    npm install
    npx wrangler login
  2. Tạo D1 và dán database_id
    npm run db:create     # in ra database_id → dán vào wrangler.toml
    npm run db:schema:local
    npm run db:schema:remote
  3. Chạy toàn bộ migration — bằng vòng lặp, không gõ tay 33 lần

    Tên script có dấu hai chấm, mà zsh hiểu :r là ký hiệu cắt đuôi tệp nên npm run db:migrate$n:remote gọi sai script mà không báo lỗi rõ. Phải dựng tên trước:

    for n in {2..34}; do
      M=$(printf 'db:migrate%s:remote' "$n")
      npm run "$M" || echo "DỪNG ở $n"
    done

    Thứ tự là bắt buộc: bảng sau tham chiếu bảng trước. Danh mục từng migration ở Phụ lục B.

  4. Nạp khoá bí mật

    Dev local: backend/.dev.vars (đã gitignore). Bản thật: npx wrangler secret put <TÊN>. Bảng đầy đủ ở Phụ lục A — cái nào bắt buộc, cái nào thiếu thì tính năng nào tắt.

    Bắt buộc tối thiểu để bán được hàng: PANCAKE_API_KEY, PANCAKE_SHOP_ID, ADMIN_JWT_SECRET, ZALO_CHECKOUT_PRIVATE_KEY.

  5. Khai biến công khai trong wrangler.toml

    PANCAKE_BASE_URL · ALLOWED_ORIGINS · DEFAULT_WAREHOUSE_ID · SEPAY_ACCOUNT/SEPAY_BANK · PHONE_PROXY_URL · ZALO_MINI_APP_ID · BOT_BASE_URL · MESSENGER_GRAPH_VERSION.

  6. Chọn kho mặc định — không được bỏ trống
    curl "http://localhost:8787/api/_pancake/ping?token=<DIAG_TOKEN>"
    curl "http://localhost:8787/api/_pancake/warehouses?token=<DIAG_TOKEN>"

    Lấy id của kho muốn bán và đặt vào DEFAULT_WAREHOUSE_ID.

  7. Khai Durable Object cho hộp thư

    HOP_THU → lớp HopThuHub, migration tag hop-thu-v1 với new_sqlite_classes. Đây là trạm WebSocket duy nhất giữ kết nối của gateway và các tab hộp thư.

  8. Khai hai cron — đọc kỹ giờ UTC
    crons = ["0 23 * * *", "*/10 0-16,23 * * *"]

    0 23 * * * = 6h sáng giờ VN (đồng bộ bài viết từ website). */10 0-16,23 * * * = 10 phút/lần, 6h–24h giờ VN (lưới an toàn hàng đợi CRM; ban đêm nghỉ, sự kiện vẫn nằm trong bảng chờ sáng gom). Viết 0 6 * * * là 13h chiều VN.

  9. Gắn tên miền và deploy
    npx wrangler deploy      # route custom_domain: zalo-app.enzara.vn

    Chờ ~1 phút rồi mới kết luận có lỗi: node biên cần thời gian lan bản mới.

CORS — hiểu đúng một lần

Worker phản chiếu mọi origin và tự bảo vệ bằng Bearer token, nên thêm domain admin mới không cần deploy lại. ALLOWED_ORIGINS chỉ còn là danh sách tham chiếu.

03CRM quản trị — làm trước Mini App

Lý do đảo thứ tự: có CRM thì nhập được sản phẩm, khuyến mãi, quà, hạng — Mini App mới có gì để hiện.

enzara-admin

Pages · PWA
Để làm gì
Trang web riêng (không nhúng trong Zalo) để vận hành toàn bộ cửa hàng: 16 mục, gọi /admin/* bằng JWT. Cài được lên màn hình chính điện thoại như một app.
Cách dùng
cd admin && npm run dev (cổng 5173, đọc VITE_API_BASE). Bản thật: build với API base production rồi đẩy lên Pages.
Tệp
admin/src/App.tsx (gate đăng nhập + NAV/NAV_CHINH) · api.ts · pages/*.tsx · public/sw.js.
  1. Tạo tài khoản chủ đầu tiên (chạy một lần)
    curl -X POST "https://zalo-app.enzara.vn/admin/seed" \
      -H "Authorization: Bearer <ADMIN_JWT_SECRET>" \
      -H "Content-Type: application/json" \
      -d '{"username":"admin","password":"<mật-khẩu-mạnh>"}'

    Mật khẩu lưu dạng băm PBKDF2. Đổi mật khẩu = chạy lại lệnh này, hoặc dùng mục Đổi mật khẩu trong trang.

  2. Build và deploy
    cd admin
    VITE_API_BASE="https://zalo-app.enzara.vn" npm run build
    npx wrangler pages deploy dist --project-name enzara-admin

    Rồi gắn tên miền cho project Pages.

  3. Thêm người dùng và chia vai

    Thiết lập → Người dùng. Vai nhan_vien chỉ dùng trang; mọi giá trị vai khác đều là CHỦ (cố ý: tài khoản cũ mang role='admin' không bị bản cập nhật cướp quyền). Hệ thống chặn tự khoá mình ra ngoài: luôn còn ít nhất một chủ, không tự xoá tài khoản đang đăng nhập.

  4. Cài như ứng dụng

    iPhone: Chia sẻ → Thêm vào MH chính. Android: ⋮ → Cài đặt ứng dụng. Máy tính: biểu tượng cài ở thanh địa chỉ. Service worker chỉ cache vỏ app — mọi lời gọi API luôn đi thẳng ra mạng, số liệu không bao giờ cũ.

16 mục — mỗi mục để làm gì

MụcDùng đểNối với
Hộp thưChat với khách qua số Zalo cá nhân của shop + xem thẻ đơn của khách đang nói chuyệngateway, mục 10
Đơn hàngDanh sách đơn từ app, đổi trạng thái (đẩy ngược lên Pancake), xác nhận đã nhận tiền (báo kết quả về Checkout SDK), xem chi tiết bóc tách voucher/điểmPancake, Zalo
Sản phẩmThêm/sửa/xoá, ẩn–hiện, sửa giá biến thể; thêm là đẩy thẳng lên PancakePancake
Tồn kho & GiáBảng biến thể: giá, giá sau giảm, tồn (đỏ khi ≤ 5), tìm theo tên/barcodePancake
Khuyến mãiVoucher: mã, mức giảm, điều kiện, hạn; khách tự thu thập trong appD1
Quà đổi điểmDanh mục quà, số điểm đổi, trạng thái giao quàD1
Vòng quayGiải thưởng, tỉ lệ, ảnh, lượt quay/ngàyD1
Đại lýXét đơn xin làm đại lý gửi từ appD1
Giới thiệuMức thưởng người giới thiệu / người được giới thiệu, xem cây giới thiệuD1
Bài viếtBài lấy tự động từ website; nút Đồng bộ để đăng xong lấy về ngayenzara.vn
Thành viênHồ sơ khách, điểm, hạng, điều chỉnh điểm thủ công (có ghi lý do)D1
Trợ lýBật/tắt bot từng kênh, nạp khoá mô hình, xem hội thoạimục 09
Tin nhắnGửi tin ZBS/OA cho khách, nhật ký đã gửi, nối lại quyền OAmục 08
CRMKhai URL + secret nơi nhận sự kiện đẩy ra ngoài (đổi không cần deploy)mục 11
Thiết lậpQuy đổi điểm, hạng & quyền lợi, phí ship, kho mặc định, người dùngD1
Đổi mật khẩuTài khoản đang đăng nhập
Thanh điều hướng trên điện thoại

16 mục chia đều thanh dưới thì mỗi ô chưa tới 24px, bấm không trúng. Nay thanh dưới giữ 4 mục hay dùng (NAV_CHINH: Hộp thư · Đơn hàng · Sản phẩm · Tồn kho) cộng nút Thêm mở bảng trượt chứa đủ 16 mục. Máy tính giữ sidebar dọc như cũ.

04Mini App — giao diện khách

Mini App (gốc repo)

zmp · React
Để làm gì
App khách chạy trong Zalo: 24 tuyến đường (trang chủ, danh mục, sản phẩm, giỏ, đơn, điểm, quà, vòng quay, đại lý, bài viết, ưu đãi, cá nhân, và /chan-doan để dò máy khách).
Cách dùng
npm start (dev trên trình duyệt) · zmp start -Z (dev trên điện thoại thật). Bật/tắt giao diện v2 bằng ?uiV2=1 / ?uiV2=0 ngay trên URL — cách duy nhất tiện khi test máy thật vì không có console.
Tệp
src/router.tsx · src/state.ts (Jotai) · src/hooks.ts · src/config.ts (cờ UI v2) · src/utils/* · app-config.json.

Bốn sửa đổi bắt buộc trong vite.config.mts

Thiếu bất cứ cái nào thì preview trên điện thoại ra màn hình trắng, không báo lỗi:

SửaVì sao
server.host = trueVite mặc định chỉ nghe 127.0.0.1. iPhone không có adb, nó tải app qua IP LAN → cổng 2999 thật ra không mở.
server.allowedHosts = trueVite 5+ chặn Host header lạ; điện thoại gọi bằng IP LAN nên bị coi là lạ.
Plugin ép header CORS cho mọi responseZalo bọc app trong trang HTTPS h5.zdn.vn nhưng tải tài nguyên từ localhost:2999.
Tự phục vụ /src/app.jsDev loader của Zalo xin entry này, không qua index.html → thiếu preamble React Refresh → component ném lỗi. Phải cài preamble rồi import() động app.ts (import tĩnh bị hoist, chạy trước preamble).

Quy trình deploy — bắt buộc 3 bước

zmp build
zmp sync-config www/index.html     <-- thiếu bước này = MÀN HÌNH TRẮNG
zmp deploy -p -e -m "mô tả"        # thêm -t nếu cần bản TESTING
Vì sao bước giữa bắt buộc

Zalo không upload index.html. Nó nạp CSS/JS theo listCSS / listSyncJS trong app-config.json, mà mỗi lần build Vite sinh mã băm tên tệp mới. Không đồng bộ thì app đi tìm tệp không tồn tại và dừng ở màn hình trắng, im lặng.

Kiểm nhanh sau khi build:

node -e "const c=require('./app-config.json'),fs=require('fs');
console.log([...c.listCSS,...c.listSyncJS.filter(x=>x.startsWith('./'))]
  .every(f=>fs.existsSync('www/'+f.replace('./',''))) ? 'KHỚP' : 'LỆCH')"

Hạn mức deploy — đừng đốt bản TESTING

Môi trườngHạn mứcLệnhDùng khi
DEVELOPMENT300 lượtzmp deploy -p -e -m "…"Sửa tới sửa lui, tự test
TESTING60 lượtzmp deploy -p -t -e -m "…"Chỉ khi cần bản cho người khác test

Lệnh không có -t đi vào DEVELOPMENT, không phải production: Mini App không có đường deploy thẳng ra khách, mọi bản phát hành phải gửi duyệt trên Portal. zmp deploy chỉ in mã QR, link có dạng https://zalo.me/s/<appId>/?env=TESTING&version=<n>.

Test trên điện thoại thật

zmp start -Z                          # Android: qua adb reverse
zmp start -Z -ios -iosH <IP-LAN>      # iPhone: qua Wi-Fi
adb shell screencap -p /sdcard/a.png  # chụp màn hình khi không có console

Trang /chan-doan là bộ đo trên máy khách: nó gọi lần lượt các API zmp-sdk, đo bộ nhớ ba tầng, báo cầu nối native sống hay chết, rồi gửi kết quả về /api/client-log. Đây là thứ duy nhất cho biết máy khách hỏng ở đâu khi không có DevTools.

05Thanh toán — phần dễ bị trả về nhất

Zalo yêu cầu mọi giao dịch trực tuyến phải đi qua Checkout SDK. COD không tính là trực tuyến.

Zalo Checkout SDK

Worker kýApp gọi
Để làm gì
Mở bảng thanh toán của Zalo, nhận kết quả, và cho phép shop báo ngược “đã nhận tiền”. Private Key là khoá bí mật nên mọi chữ ký phải ký ở Worker, không bao giờ ở app.
Cách dùng
Khai tại Portal → Checkout SDK → Cấu hình chung: Callback URL /webhook/zalo-checkout/callback, Notify URL /webhook/zalo-checkout/notify, rồi nạp ZALO_CHECKOUT_PRIVATE_KEY bằng wrangler secret put.
Tệp
backend/src/zalo-checkout.ts · routes/orders.ts · src/utils/checkout.ts · src/pages/cart/pay.tsx.

Bốn công thức chữ ký, khác nhau hoàn toàn

Đây là chỗ mất thời gian nhất. Chép sai một dấu & là Zalo báo “sai mac” mà không nói sai ở đâu.

Dùng ởChuỗi đem ký (HmacSHA256 với privateKey)
createOrder
app gọi, Worker ký trước
Nối các trường đơn theo thứ tự quy định — KHÔNG&privateKey= ở cuối
updateOrderStatus
shop báo đã nhận tiền
appId=…&orderId=…&resultCode=…&privateKey=…
get-status
tra lại trạng thái
appId=…&orderId=…&privateKey=…
Kiểm notify / callbackNối key=value theo đúng thứ tự Zalo gửi tới (verifyOverallMac bảo toàn cả trường không nằm trong mac)

So sánh chữ ký luôn dùng timingSafeEqual, không dùng ===.

Hai bẫy đã trả giá bằng đơn hàng thật

1. Đừng await createOrder trước khi điều hướng. Bảng thanh toán của Zalo chỉ trình ra khi app đang điều hướng. Chờ kết quả trước rồi mới navigate = nút Thanh toán xoay vĩnh viễn.

2. Zalo chặn tần suất -1409. Bấm dồn hai cú sát nhau là đường ngắn nhất tới đó, và lần bấm sau nữa Zalo không trả lời gì cả. Khi đã dính -1409 thì không được tự đổi đơn chuyển khoản sang COD — trước đây lỗi này làm khách bị thu tiền khi nhận hàng oan. Chống bấm dồn ở src/utils/checkout.ts.

SePay — đối soát chuyển khoản

Worker
Để làm gì
Nghe webhook “tiền đã về” rồi khớp với đơn đang chờ, tự chuyển đơn sang đã thanh toán và báo kết quả về Checkout SDK.
Cách dùng
Khai webhook SePay trỏ tới /webhook/sepay, nạp SEPAY_WEBHOOK_APIKEY + SEPAY_API_TOKEN. Nên dùng VA riêng cho Mini App để tách khỏi webhook của web.
Lưu ý
Đối soát không lọc theo số tài khoản: một hồ sơ SePay có thể gắn nhiều tài khoản, và số định danh khác số hiển thị cho khách. Xem findMatchingTransaction trong sepay-api.ts.
Đường cứu hộ

Máy nào có cầu nối native chết cả phiên vẫn phải đặt được hàng: đơn COD, không qua Checkout SDK (POST /api/orders/:id/cod). Và với máy không mở nổi app thì còn đường ngoài app ở mục 12.

06Khách hàng, điểm, hạng và các cần bán

Tất cả nằm trên D1, chỉnh được từ CRM, Mini App chỉ đọc — không chép hằng số xuống app.

ModuleĐể làm gìCách dùngNơi ở
Hồ sơ & điểm Một hồ sơ theo zaloUserId; tích 10.000đ = 1 điểm, tiêu 1 điểm = 1.000đ App gọi POST /api/points/sync mỗi lần mở; CRM điều chỉnh tay có ghi lý do loyalty.ts, mg 003
Hạng thành viên Xét theo tổng chi tiêu tích luỹ, hệ số nhân vào điểm tích mỗi đơn + quyền lợi riêng Thiết lập → Hạng. App đọc tier/nextTier từ /points/sync mg 005, 014
Voucher Mã giảm giá; khách thu thập voucher về ví của mình rồi mới dùng /api/vouchers, /api/vouchers/collect, /api/vouchers/mine promotions.ts, mg 002, 009
Quà đổi điểm Đổi điểm lấy quà, có trạng thái giao quà để shop theo dõi /api/points/rewards/redeem; CRM đổi trạng thái mg 003, 015
Giới thiệu Mỗi khách một mã; người giới thiệu và người được giới thiệu đều được thưởng (mặc định +10/+10) Link app kèm mã → app lưu referredBy → cộng khi đơn đầu hoàn tất referrals.ts
Vòng quay Giữ khách quay lại mỗi ngày; giải thưởng và tỉ lệ do shop đặt /api/spin/config, /api/spin/play spin.ts, mg 010, 012
Đại lý Khách xin làm đại lý ngay trong app, shop xét trong CRM /api/agents/apply, /api/agents/mine agents.ts, mg 011
Bài viết Kéo bài từ website vào app để có nội dung giữ chân Cron 6h sáng + nút Đồng bộ trong CRM; /api/posts posts.ts, mg 018
Điểm cho khách mua web Khách mua trên enzara.vn cũng được tích điểm, tiêu điểm ngược lại trên web Webhook WooCommerce /webhook/woocommerce + /web/points/* với WEB_POINTS_API_KEY routes/web.ts, mg 004
Địa chỉ giao hàng Tỉnh/huyện/xã + địa chỉ đã lưu của khách /api/address/*; lưu ở server theo zaloUserId, không chỉ ở máy routes/address.ts, mg 020
Vì sao địa chỉ phải lưu ở server

Có iPhone không lưu được gì ở máy (cả localStorage lẫn bộ nhớ bền đều chết, im lặng không báo lỗi). Nếu địa chỉ chỉ nằm ở máy thì khách đó không bao giờ thanh toán được. Kèm theo: không cho xoá địa chỉ cuối cùng.

07Vận chuyển và theo dõi đơn

Theo dõi đơn + báo khách

Worker
Để làm gì
Dò trạng thái đơn bên Pancake, khi đổi mốc thì nhắn cho khách (đã xác nhận · đang giao · đã giao), kèm mã vận đơn và link tra cứu của chính hãng vận chuyển. Mỗi mốc chỉ nhắn một lần.
Cách dùng
Chạy migration 031, khai mẫu tin cho từng mốc. Không có mẫu tin cho mốc nào thì mốc đó im lặng — đó là hành vi đúng, không phải lỗi. Thẻ đơn hiện luôn trong khung chat của Hộp thư.
Tệp
backend/src/theo-doi-don.ts · van-chuyen.ts (link tra cứu từng hãng) · bảng don_bao_khach.

Phí ship & điểm nhận hàng

WorkerCRM
Để làm gì
Bảng phí theo vùng + tuỳ chọn “lấy tại cửa hàng”. Kho mặc định khi lấy tại cửa hàng đặt bằng khoá default_station_idkhông lấy phần tử đầu Pancake trả về (thứ tự đó không do mình quyết, đã có khách vô tình đặt về sai chi nhánh).
Cách dùng
Thiết lập → Phí ship; app đọc /api/pricing/api/stations.
Tệp
pricing.ts · settings.ts · mg 017.

08Tin nhắn ra khách — ZBS thay ZNS

Thay đổi chính sách từ 01/01/2026

Zalo gộp ZNS và tin UID thành ZBS. Có 4 loại tin, mỗi loại một cửa sổ thời gian và một mức giá, và có trần tự đặt để không bao giờ vượt ngân sách. Khung giờ gửi trong hệ thống là 8h–20h (hằng TRAN trong backend/src/zbs.ts) — hẹp hơn khung cron CRM, cố ý, vì đây là nhắn cho người.

Zalo OA + ZBS

WorkerCRM
Để làm gì
Gửi tin có mẫu cho khách (đơn hàng, điểm thưởng, nhắc giao hàng) và nhận tin khách gửi vào OA.
Cách dùng
Nạp ZALO_OA_APP_ID + ZALO_OA_SECRET, đăng ký callback /webhook/zalo-oauth, rồi bấm nút cấp quyền trong CRM (Hộp thư → Số Zalo → dòng Zalo OA, hoặc tab Tin nhắn). Token tự làm mới sau đó.
Tệp
zalo-oa.ts · zbs.ts · mg 006, 022.

Khách quan tâm OA được cộng điểm một lần trọn đời (/api/points/claim-oa-follow) — đây là cách đổi lượt quan tâm OA thành thứ khách thấy đáng.

09Trợ lý tự động — ba kênh

Bot đa kênh

Worker
Để làm gì
Trả lời khách tự động trên Mini App (khung chat trong app), Zalo OAMessenger. Bot tra được sản phẩm, tra địa chỉ, và lên đơn — nó gọi lại chính POST /api/orders của Worker nên đơn bot chốt giống hệt đơn khách tự đặt.
Cách dùng
Chạy mg 023–026 → nạp BOT_API_KEY (nạp qua CRM, khoá được mã hoá trước khi lưu, KEK là ADMIN_JWT_SECRET) → bật từng kênh trong tab Trợ lý. Khi dev, đặt BOT_SELF_URL để đơn thử không bay lên bản thật.
Tệp
backend/src/bot/engine.ts · bot/cong-cu.ts (công cụ bot gọi được) · bot/kenh/{oa,messenger}.ts · bot/ma-khoa.ts.
Cổng gọi mô hình

Cổng đang dùng (BOT_BASE_URL) trả SSE dù đã xin stream:false, nên phải tự bóc dòng data:. Và chỉ nhóm model gh/gpt-4* dùng được ổn định — đổi sang nhóm khác thì bot câm mà không báo lỗi.

Bot im lặng là hành vi đúng trong ba trường hợp: khách đang được người thật trả lời, câu hỏi vượt phạm vi đã khai, và ngoài khung giờ cho phép. Khi bot câm ngoài ba cái đó, tra theo thứ tự: khoá mô hình → kênh đã bật chưa → nhật ký hội thoại trong tab Trợ lý.

10Hộp thư Zalo cá nhân — khối thứ tư

Đây là phần mới nhất của bản 2, và là phần duy nhất không chạy được trên Cloudflare.

zalo-gateway

Node dài hạn
Để làm gì
Nối tài khoản Zalo cá nhân của shop (thư viện không chính thức zca-js) với Hộp thư trong CRM: nhận tin khách, đẩy về Worker, và nhận lệnh gửi tin ngược lại.
Cách dùng
cd gateway && npm install
# .env: WORKER_URL=https://zalo-app.enzara.vn
#       GATEWAY_SECRET=<khớp secret của Worker>
npm run dang-nhap   # quét QR thêm một số Zalo
npm start           # nghe mọi số đã đăng nhập
Thêm số / thoát số cũng làm được ngay trên trang CRM bằng QR. Xem trạng thái: curl http://127.0.0.1:18930/status; giành lại phiên: curl -X POST http://127.0.0.1:18930/ket-noi-lai.
Tệp
gateway/src/index.ts · kenh-lenh.ts (WebSocket nhận lệnh) · hang-doi.ts · chuan-hoa.ts · Worker: routes/gw.ts, hop-thu/{hub,ws,kho,khach}.ts, mg 028–034.

Bốn sự thật phải thiết kế quanh nó

  • Zalo không cho lấy lại lịch sử. zca-js chỉ phát lại tin cũ đúng lúc kết nối. Nên tin tới phải ghi xuống đĩa trước (data/hang-doi.ndjson) rồi mới gửi lô lên Worker — không được phép chỉ nằm trong RAM.
  • Một số Zalo chỉ giữ được một phiên web. Mở Zalo Web của số đó là gateway bị đá (mã 3000/3003) — và khi đó gateway không tự giành lại, phải gọi /ket-noi-lai.
  • Cổng 18930 chỉ một tiến trình. Bật bản mới khi bản cũ còn sống thì chết ngay vì EADDRINUSE. Tìm tiến trình bằng ps -Ao pid,command | grep index.tspgrep -f ".../src/index.ts" KHÔNG khớp vì dòng lệnh thật là node src/index.ts.
  • “Đã gửi lệnh” ≠ “khách đã nhận”. Số đang mất phiên thì gateway nhận lệnh rồi mới từ chối. Nên Worker kiểm hop_thu_tai_khoan.trang_thai = 'dang_ket_noi' trước khi ghi nhật ký thành công.

Chữ ký giữa gateway và Worker: X-Gw-Signature = hex(HMAC-SHA256("<X-Gw-Timestamp>.<thân>", GATEWAY_SECRET)). Chưa nạp GATEWAY_SECRET thì /gw/* trả 503 (đóng hẳn); nạp rồi mà ký sai thì 401. Nhịp tim 30s/lần; quá 3 phút không thấy thì Hộp thư báo “Gateway im lặng”.

Rủi ro phải biết trước khi bật

zca-js dùng API Zalo không chính thức — tài khoản có thể bị khoá. Chỉ dùng số Zalo riêng của shop, không dùng số cá nhân, và không nhắn hàng loạt cho người lạ.

11CRM ngoài — hai chiều

Nhánh /crm/*

Worker
Để làm gì
Cho một hệ chăm sóc khách bên ngoài kéo dữ liệu (khách, điểm, hội thoại, góp ý, hạng, nhật ký tin đã gửi), nhận đẩy sự kiện real-time, và ghi ngược (sửa tên/SĐT, cộng–trừ điểm, đóng ticket).
Cách dùng
npm run db:migrate27:remote
npx wrangler secret put CRM_API_KEY        # khoá CRM gọi vào
npx wrangler secret put CRM_WEBHOOK_SECRET # khoá ký sự kiện đẩy ra
# nơi nhận: khai URL trong tab CRM của trang quản trị (đổi không cần deploy)
Tài liệu đấu nối tự sinh, công khai tại /crm/docs (npm run docs:crmdocs/crm-docs.html) — gửi link này cho đối tác là đủ.
Tệp
backend/src/crm.ts · crm-su-kien.ts · routes/crm.ts · crm-docs.ts · docs/crm-integration.md.
  • Chưa đặt CRM_API_KEY → toàn bộ /crm/* trả 503. Không có khoá thì không mở cửa.
  • Chưa khai nơi nhận → chiều đẩy tắt (sự kiện vẫn được ghi, cron dọn ngay), chiều kéo vẫn chạy.
  • Không đặt CRM_WEBHOOK_SECRET → sự kiện gửi đi không có chữ ký; chỉ chấp nhận khi CRM nằm trong mạng nội bộ.
  • Đường đi chính là bắn thẳng ngay sau khi Worker trả lời xong lượt sinh ra sự kiện (trễ mili-giây). Cron 10 phút chỉ nhặt hai loại sót: lần gửi trước thất bại, và sự kiện sinh ra ban đêm.
  • CRM phải tự chống nhận trùng theo id sự kiện.
  • Không có ở đây: chi tiết đơn hàng. /crm/customers chỉ trả tổng hợp; đơn gốc nằm ở Pancake.
Nếu CRM cũng chạy ở máy dev

CRM chiếm cổng 8787, nên wrangler dev phải đổi cổng (dự án đang dùng 8799).

12Đặt hàng ngoài Mini App

Trang /dat-hang

Worker phục vụ
Để làm gì
Đường sống cho khách mà Zalo không khởi động nổi Mini App (đo được: 3/10 lần trên một số iPhone, app không chạy nổi một dòng mã nào). Mọi lưới an toàn bên trong app đều vô nghĩa với họ, nên đường thoát buộc phải nằm ngoài app: shop gửi link qua chat, khách mở bằng trình duyệt, đặt xong đơn vào Pancake y hệt đơn từ app.
Cách dùng
Không cần cài gì — trang do chính Worker phục vụ. Gửi link cho khách là xong. Đơn từ đây luôn là COD và không cần zaloUserId: backend tự tìm lại chủ đơn theo số điện thoại nên khách cũ vẫn vào đúng hồ sơ và vẫn được cộng điểm.
Tệp
backend/src/dat-hang.ts.

Ba ràng buộc thiết kế của trang này, không được vi phạm khi sửa:

  1. Tự chứa hoàn toàn Không CDN ngoài, không font ngoài, không thư viện. Khách đang ở đây vì app đã hỏng — không được để họ vấp thêm lần nữa.
  2. Cùng gốc với API Gọi /api/... bằng đường dẫn tương đối: không CORS, không preflight.
  3. Không tin giá ở client Trang chỉ hiện tạm tính; tổng tiền thật do POST /api/orders tính lại từ Pancake. Khách sửa HTML cũng không ăn gian được.

13Mười cái bẫy đã trả giá

Mỗi dòng dưới đây từng làm mất nửa ngày đến hai ngày. Đọc trước sẽ rẻ hơn gặp lại.

#Hiện tượngNguyên nhân thật & cách xử
1Mini App màn hình trắng sau deployThiếu zmp sync-config giữa build và deploy. Luôn 3 bước.
2Trang trắng 30 giây trên mạng VNGoogle Fonts / CDN nước ngoài. Mọi font và thư viện phải tự host.
3Nút Thanh toán xoay vĩnh viễnCầu nối native của Zalo im lặng. Mọi lời gọi zmp-sdk phải bọc goiZalo() có mốc chờ.
4Giải mã SĐT trả -501Zalo chặn giải mã từ IP ngoài VN. Phải có proxy PHP trên hosting VN (PHONE_PROXY_URL, mẫu ở docs/zalo-phone.php.example).
5iPhone mất địa chỉ vừa nhập, bấm “Xong” không phản ứnglocalStorage ném lỗi khi GHI nhưng ĐỌC vẫn chạy → Jotai ghi đè giá trị RAM bằng rỗng. Dùng safeStorage ba tầng (RAM → nativeStorage → localStorage); cấm gọi localStorage trực tiếp.
6iPhone kẹt mãi ở Splash Loading của ZaloApp chưa chạy dòng nào, không sửa được bằng mã. Giải pháp là đường ngoài app (mục 12).
7Pancake từ chối đơn “tồn kho không đủ” dù còn hàngBỏ trống warehouse_id. Phải luôn gửi kho.
8Đơn chuyển khoản tự biến thành CODBị Zalo chặn tần suất -1409 và code cũ coi im lặng là thất bại. Không được tự đổi phương thức.
9npm run db:migrate9:remote chạy nhưng không migratezsh hiểu :r là cắt đuôi tệp. Dựng tên bằng printf rồi npm run "$M".
10Bộ đo im lặng, tưởng app lỗiKhoá bộ đo theo loại máy làm “im lặng” mang hai nghĩa. Bộ đo không được tự tắt — mất 35 phút vì đúng chuyện này.

14Nghiệm thu và phát hành

Thứ tự nghiệm thu

  1. Máy tính, trình duyệt npm start + wrangler dev: đủ luồng mua từ trang chủ tới đơn.
  2. Điện thoại Android zmp start -Z: kiểm cầu nối native, quyền, SĐT.
  3. Điện thoại iPhone zmp start -Z -ios -iosH <IP> + /chan-doan: kiểm ba tầng bộ nhớ và cầu nối. Coi iOS là máy chạy không trạng thái.
  4. Bản DEVELOPMENT Đặt thử một đơn COD và một đơn chuyển khoản thật (số tiền nhỏ), xác nhận tiền về và trạng thái đổi.
  5. Bản TESTING Chỉ khi cần người khác test. 60 lượt, dùng dè.
  6. Gửi duyệt Kèm tài khoản test, video luồng mua, trang điều khoản + chính sách.

Checklist trước khi gửi duyệt

  • Mọi giao dịch trực tuyến đi qua Checkout SDK (COD được để ngoài).
  • Không còn màn hình trống, không còn dữ liệu mẫu sót lại.
  • Trang chính sách quyền riêng tư + điều khoản có URL sống.
  • Chỉ xin đúng những quyền app thật sự dùng, và có giải thích trong app trước khi xin.
  • Đơn thật đã chạy trọn vòng: app → Worker → Pancake → SePay/Checkout → tin báo khách.

Ngay sau khi lên sóng

  • Bật npx wrangler tail theo dõi ngày đầu; xem /api/client-log để biết máy khách hỏng ở đâu.
  • Kiểm gateway còn sống (nhịp tim < 3 phút) và cron có chạy đúng giờ VN.
  • Theo dõi hạn mức: ZBS (trần tự đặt), gọi mô hình của bot, và số lượt deploy còn lại.
Ba thứ tốn thời gian nhất — tính vào kế hoạch

Xác thực OA doanh nghiệp (1–3 tuần, không tự tăng tốc được) · bốn công thức mac của Checkout SDK (nửa ngày đến hai ngày nếu chép sai) · những cái bẫy ở mục 13 (mỗi cái nửa ngày nếu chưa biết trước).

APhụ lục — biến và khoá bí mật

Khoá bí mật nạp bằng npx wrangler secret put <TÊN>; biến công khai khai trong [vars] của wrangler.toml. Dev local: backend/.dev.vars.

TênLoạiThiếu thì sao
PANCAKE_API_KEY · PANCAKE_SHOP_IDBí mậtKhông có sản phẩm, không tạo được đơn
ADMIN_JWT_SECRETBí mậtKhông đăng nhập được CRM; cũng là KEK mã hoá khoá bot
ZALO_CHECKOUT_PRIVATE_KEYBí mậtKhông thanh toán trực tuyến được → app không được duyệt
ZALO_APP_SECRETBí mậtKhông giải mã được số điện thoại khách
SEPAY_API_TOKEN · SEPAY_WEBHOOK_APIKEYBí mậtKhông đối soát tự động được chuyển khoản
ZALO_OA_APP_ID · ZALO_OA_SECRETBí mậtKhông gửi/nhận tin OA, không gửi ZBS
BOT_API_KEYBí mậtTrợ lý không trả lời câu nào
MESSENGER_VERIFY_TOKEN · _APP_SECRET · _PAGE_TOKENBí mậtKênh Messenger tắt (phần còn lại chạy bình thường)
CRM_API_KEYBí mật/crm/* trả 503 — nhánh CRM ngoài đóng
CRM_WEBHOOK_SECRETBí mậtSự kiện đẩy đi không có chữ ký
GATEWAY_SECRETBí mật/gw/* trả 503 — hộp thư Zalo cá nhân đóng
WOO_WEBHOOK_SECRET · WEB_POINTS_API_KEYBí mậtKhách mua trên web không được tích/tiêu điểm
PHONE_PROXY_SECRETBí mậtProxy SĐT từ chối
DIAG_TOKENBí mậtKhông gọi được /api/_pancake/* để chẩn đoán
DEFAULT_WAREHOUSE_IDCông khaiPancake từ chối đơn “tồn kho không đủ”
ZALO_MINI_APP_IDCông khaiKhông gọi được updateOrderStatus
PHONE_PROXY_URLCông khaiGiải mã SĐT trả -501
ALLOWED_ORIGINSCông khaiChỉ để tham chiếu (Worker phản chiếu origin)
BOT_BASE_URL · BOT_SELF_URLCông khaiBot gọi sai cổng mô hình / đơn thử bay lên bản thật
MESSENGER_GRAPH_VERSIONCông khaiLời gọi Meta trả 400 khi bản cũ bị khai tử

Phía Mini App

VITE_API_URL (chỉ dev; bản thật đọc apiUrl trong app-config.json) · VITE_UI_V2 (đặt 0 để build ra giao diện cũ) · ZMP_TOKEN trong .env (đã gitignore).

Phía gateway

WORKER_URL · GATEWAY_SECRET trong gateway/.env. Phiên Zalo lưu ở gateway/data/sessions/<ownId>.json (quyền 0600) — đây là dữ liệu đăng nhập thật, không commit, không sao lưu lên chỗ chung.

BPhụ lục — 33 migration, chạy đúng thứ tự

db/schema.sql chạy trước (có sẵn admin_users), rồi 002 → 034.

Thêm gìThêm gì
002Voucher019Nhật ký máy khách
003Điểm & quà đổi điểm020Địa chỉ lưu ở server
004Tích điểm từ web021Hồ sơ khách mở rộng
005Hạng thành viên022Tin ZBS
006Zalo OA023Trợ lý: hội thoại
007Trạng thái đồng bộ024Trợ lý: hồ sơ
008Zalo Checkout025Trợ lý: lên đơn
009Ví voucher của khách026Trợ lý: chai tặng
010Vòng quay027Hàng đợi sự kiện CRM
011Đại lý028Hộp thư: nền tảng
012Ảnh giải thưởng029Hộp thư: gửi tin
013Bảng cấu hình app_settings030Hộp thư: bot gợi ý
014Quyền lợi theo hạng031Theo dõi đơn, báo khách
015Trạng thái giao quà032Hộp thư: ảnh & tệp
016Siết an toàn dữ liệu033Hộp thư: @nhắc tên
017Phí vận chuyển034Hộp thư: SĐT khách
018Bài viết

CPhụ lục — bản đồ endpoint

NhánhAi gọiXác thựcGồm
/api/*Mini App, trang /dat-hangKhông (theo zaloUserId)products · categories · stations · banners · pricing · vouchers(+mine,collect) · posts · spin · referral · agents · phone · client-log · orders · points · address · bot
/admin/*CRM quản trịJWTlogin · seed · nguoi-dung · products · inventory · orders(+status, mark-paid) · promotions · rewards · redemptions · members(+adjust) · spin · sync · messages · crm · settings
/webhook/*Hệ ngoài gọi vàoChữ ký riêng từng hệsepay · pancake · woocommerce · zalo-oa · zalo-oauth · zalo-connect · zalo-checkout/{notify,callback} · messenger
/crm/*CRM ngoàiCRM_API_KEYping · customers(+points, PATCH) · points · conversations · feedback · messages · tiers · events(+drain, retry-failed) · docs
/gw/*zalo-gatewayHMAC GATEWAY_SECRETsu-kien · trang-thai · ws (WebSocket kênh lệnh)
/web/points/*enzara.vnWEB_POINTS_API_KEYsummary · spend · refund
/api/_pancake/*Người dựng hệ thốngDIAG_TOKENping · warehouses · variations