1 · Tổng quan
Backend phục vụ nội dung bài vẽ cho app iOS ArtMod. Nó làm đúng ba việc: giữ danh sách bài bày ra màn Home, giữ nội dung từng bài, và phục vụ file (ảnh, mask, nét mẫu).
Server không hiểu nội dung bài học — vào sao ra vậy. Ngoại lệ duy nhất là cờ hidden: worker lọc nó khi trả /home.
Có hai loại client: app iOS (chỉ đọc) và trang /admin (đọc + ghi, cần token). App không có đường ghi nào.
2 · Hạ tầng
| Thành phần | Vai trò |
Cloudflare Worker
colorpop-home | Toàn bộ server: một hàm fetch(), một chuỗi if (path === …). Không framework, không dependency lúc chạy. |
Workers KV binding HOME | Metadata. Đọc nhanh, ghi có độ trễ lan truyền — hợp với thứ đọc nhiều ghi ít. |
R2 bucket binding FILES | Kho object kiểu S3. ~1.400 file, ~500 MB. |
| Firebase Remote Config | Không thuộc backend, nhưng cấp địa chỉ server cho app. |
| XShieldKit | Lớp mã hoá transport cho đường /v2. |
Môi trường là V8 isolate, không phải Node: không fs, không process, không thư viện native. wrangler chỉ là công cụ deploy, không phải runtime.
Năm khoá KV, không hơn:
| Khoá | Nội dung |
home:templates | mảng thẻ Home · 23.915 B |
home:templates:meta | {updatedAt, bytes} |
catalog:full | nội dung mọi bài · 282.120 B |
catalog:full:at | mốc ghi catalog (dùng dựng ETag) |
home:categories | bảng danh mục · 274 B |
3 · Mô hình dữ liệu
HomeEntry — phần tử của /home:
| Trường | Kiểu | Ý nghĩa |
id | string | Mã bài. Khớp catalog.name. Đổi là app coi như bài khác, mất tiến độ người chơi. |
title | string | Tên hiển thị. Chỉ có ở đây, không có trong catalog. |
access | string | free | pro. Hiện 60/60 đều free. |
isNew isDaily | bool | Nhãn Mới / Hôm nay. |
category_id
category_name | string | Danh mục, denormalize sẵn để app khỏi tra bảng. |
stepCount | int | Tuỳ chọn. Đừng tin — vắng ở một số thẻ; đếm steps bên catalog. |
is_survey | bool | Bài dùng cho màn khảo sát onboarding. Vắng = false. |
hidden | bool | Worker lọc trước khi trả, nên client không bao giờ thấy trường này. |
thumbUrl
gifUrl | string | TRƯỜNG CHẾT — app decode rồi bỏ. Xem mục 4. |
Lesson — phần tử trong /catalog:
| Trường | Ý nghĩa |
name | Mã bài (= HomeEntry.id) |
preview
thumb_gif | Khoá tương đối trong kho file — ghép "/data/" + preview mới ra URL |
level | beginner|intermediate|advanced. Vắng ở 26/63 bài → app tự suy beginner |
steps | Mảng bước vẽ — thứ tự trong mảng là thứ tự chơi |
Step — một bước vẽ (1.279 bước toàn catalog):
| Trường | Có | Ý nghĩa |
name | 1279 | Khoá ảnh mask — vùng cần tô |
color | 1279 | ARGB #AARRGGBB, alpha luôn FF |
drawType | 1279 | Data hiện 100% là fill; stroke chỉ dùng ở tutorial dựng trong app |
size | 1279 | Cỡ bút — pixel trên ảnh mask, xem mục 6 |
animated | 1279 | Bước có chạy hoạt ảnh nét không |
brush | 1211 | Tên bút, khớp brush_specs[].name. Vắng → app dùng mặc định |
botReplayName | 1147 | Khoá file nét mẫu. Vắng → app tự sinh nét từ mask |
botDraws | 80 | Bước này bạn-bot vẽ. Catalog là nguồn quyết định — không suy từ việc có botReplayName |
brushSettings | 4 | Thông số bút riêng cho bước |
BrushSpec — /data/brush_specs.json, sinh tự động từ BrushSpecs.swift. Hiện có 24 bút: Torrent, Stardust, Confetti, Caravan, Shimmer, Claw, Sequins, Comet, Prism, Phantom, Meteor, Pollen, Ember, Elemental, Nectar, Twilight, Whisper, Patina, Cinder, Striped Ribbon, Mirage, Chalk, Tangle, Serpent.
Mỗi bút có stops (màu phụ mặc định, [] = bút một màu) và params[] với lo/hi là dải giá trị thật, at là vị trí mặc định 0…1.
brushSettings.positions lưu vị trí 0…1, không phải giá trị thật. Quy đổi: giá_trị = lo + vị_trí × (hi − lo). Nó chỉ chứa thanh người soạn kéo; thanh không khai thì chạy mặc định của bút.
Category — [{id, name}], cả hai bắt buộc và khác rỗng. id sinh máy (c_mtb7impsyfzg), không mang nghĩa, đừng sửa tay.
4 · Kho file (R2)
Có hai quy ước khoá khác nhau trong cùng một bucket:
| /data/<khoá> | /file/<khoá> |
| Khoá | đường dẫn bộ data đặt | máy sinh: thời gian + ngẫu nhiên + tên |
| Ghi bằng | PUT /data/… | POST /upload |
| Cache | max-age=86400 + ETag | immutable, 1 năm |
| Ghi đè | có — xuất lại bài là đè đúng đường dẫn cũ | không — đổi ảnh là đổi khoá |
Bộ data nằm ở /data/, theo cấu trúc:
| File | Nội dung |
preview.png | Ảnh thẻ Home |
thumb.gif | Ảnh động của thẻ |
<số>_<màu>_<cỡ>[_<bút>].png | Mask vùng cần tô của một bước |
<số>.json | Nét mẫu tác giả vẽ |
Tên file chỉ là dấu vết lúc nạp. App luôn đọc color/size/brush từ catalog. Sửa trên admin thì catalog đổi còn tên file giữ nguyên — và catalog là nguồn thắng.
Hai cơ chế dự phòng trong GET /data/:
- Khoá layout cũ: khoá không bắt đầu bằng
DataMultiplayerTransparentV5/ mà miss thì worker thử lại với tiền tố đó, đồng thời làm phẳng /steps/ và /bot/. Chỉ ở GET — HEAD phải trung thực, nếu không bộ đẩy data tưởng khoá mới đã có và bỏ qua không tải lên.
brush_specs.json chưa từng được push lên R2 → worker trả bản đóng gói sẵn trong mã.
5 · API
Mọi lệnh đọc công khai. Mọi lệnh ghi cần authorization: Bearer <ADMIN_TOKEN>. Worker không có ADMIN_TOKEN thì từ chối mọi lệnh ghi — deploy thiếu secret là cửa đóng, không phải cửa mở.
| Route | Method | Ghi chú |
/ /health | GET | Ping, không chạm KV/R2 |
/home | GET PUT DELETE | ?all=1 giữ cả bài ẩn. App không gọi |
/catalog | GET PUT DELETE | App không gọi |
/categories | GET PUT | trần 100 KB |
/data/<khoá> | GET HEAD PUT DELETE | File thô, trần 20 MB |
/upload | POST | Trả URL /file/<khoá> |
/file/<khoá> | GET DELETE | File tải lên rời |
/v2/home /v2/catalog | POST | Đường app dùng |
/admin | GET | HTML tĩnh, không chắn |
Mã lỗi: 400 JSON hỏng / payload rỗng / phong bì sai · 401 thiếu hoặc sai token · 404 không có file, hoặc /v2 chưa cấu hình khoá · 405 kèm header allow · 413 vượt trần.
Hai mã đặc biệt của /home: 204 = chưa đẩy gì lên, không phải lỗi — app dùng data trong bundle rồi đi tiếp. 304 = khớp ETag.
Trần: file 20 MB · catalog 4 MB · home 2 MB · categories 100 KB.
6 · Quy ước bắt buộc
a. Mã bài lặp ở ba chỗ và phải luôn bằng nhau:
home[].idcatalog.lesson[].name- tên thư mục trong
catalog.lesson[].preview
Không có khoá ngoại, không bảng nối — chỉ là cùng một chuỗi lặp lại, và server không kiểm tra. App còn ghép qua hai đường khác nhau: tên tra theo name, lọc và thứ tự tra theo thư mục trong preview. Lệch một cái là hỏng nửa vời: tên đúng mà thẻ biến mất, hoặc thẻ hiện mà tên rơi về id thô.
b. Cỡ bút — size là pixel trên ảnh mask, không phải pixel màn hình:
đơn_vị = size × 1000 / cạnh_dài_mask (mask hiện 850px)
width = clamp(đơn_vị, 0.5 … 159)
điểm_màn_hình = width × cạnh_canvas / 1000
Nhờ vậy nét chiếm cùng tỉ lệ so với bức tranh trên mọi máy. Đổi cỡ ảnh mask khi xuất data là đổi luôn ý nghĩa của size.
c. Màu là ARGB #AARRGGBB, alpha luôn FF. App strip về #RRGGBB.
d. File nét mẫu — mảng phẳng start/move/end. start mang paint, move chỉ x/y, end rỗng. Ghi thô từng điểm, không nén: một bước của easy_2 là 15.049 điểm, 0,85 MB.
Hai hệ toạ độ trong cùng một file: x, y là percent 0–100 trên khung vuông; strokeWidth là pixel ở không gian 850 (cạnh mask). Đọc nhầm là nét ra sai cỡ.
7 · App dùng dữ liệu ra sao
Vai trò tách bạch: /home quyết định bài nào hiện, xếp thứ mấy, tên là gì; /catalog quyết định vẽ ra sao; /data cấp bytes.
/v2/home và /v2/catalog là đường duy nhất app lấy dữ liệu — đường GET cũ đã gỡ khỏi app, chỉ còn cho trang admin và script backup. Cờ use_xshield_transport mặc định bật nhưng base mặc định rỗng: chưa cấp base qua Remote Config thì app chạy bằng data trong bundle, không lùi về GET.
Thứ tự ở Splash — bắt buộc:
- Remote Config cấp địa chỉ (app không gọi được gì trước bước này)
POST /v2/home → áp bảng tên vào LessonKit
POST /v2/catalog → ghi xuống đĩa
Bảng tên chỉ được đọc đúng một lần lúc dựng catalog — áp sau đó là ghi vào chỗ không ai đọc.
Ảnh thẻ: tải trước khi Home bày, cho toàn bộ 63 bài (kể cả 3 bài không bày thẻ). Home đợi preview (8,9 MB), gif đi sau không ai đợi (17,8 MB). Chỉ tải file thiếu — lần mở sau thường 0 request. Nhóm ảnh này không bị dọn.
Mở một bài: tải mask của riêng bài đó — 4 kết nối song song, mỗi file một GET /data/… thường (không phong bì), thử lại 1 lần khi hỏng, ghi đĩa atomic. Thiếu mask thì dừng, không mở bài. Xong lượt thì dọn, mỗi lượt chỉ giữ data một bài.
File bot chỉ tải cho 3 bài — easy_4, easy_7, easy_20 — theo bảng viết tay trong mã. 60 bài còn lại tổng hợp nét từ mask ngay trên máy.
Vì sao phải tải chứ không load thẳng URL: tầng đọc tài nguyên chạy đồng bộ trên queue chấm điểm và tổng hợp nét — không await mạng được. Nó cũng ưu tiên bundle trước. Nên tải và đọc là hai pha tách rời: prefetch lo mạng, loader chỉ lo file.
8 · Vận hành
Nạp bài: luôn file trước, metadata sau — để không có khoảnh khắc nào catalog trỏ tới file chưa tồn tại. Script khôi phục giữ đúng thứ tự này.
Ghi là ghi đè nguyên khối, không có patch từng phần. Không có trạng thái nửa cũ nửa mới.
Cờ ẩn bài: worker lọc hidden lúc trả /home, nên nút Ẩn ăn ngay với cả bản app cũ ngoài chợ. Trang quản lý phải đọc ?all=1 mới thấy bài ẩn — probe bằng /home thường sẽ xoá mất bài ẩn.
Dọn cache biên: PUT và DELETE tự xoá cache của đúng khoá đó. Không dọn thì file cũ còn được phục vụ tới một ngày và người sửa tưởng lệnh của mình không ăn.
Secret nằm trong secret store Cloudflare, không có trong repo: ADMIN_TOKEN, XSHIELD_CONTENT_KEY. Đặt bằng wrangler secret put.
Backup chụp metadata (dùng ?all=1 để giữ bài ẩn) + mọi file catalog tham chiếu. Khôi phục có --dry, --meta, --force.
9 · Hiện trạng & nợ kỹ thuật
| Số liệu | 03/09/2026 |
| Bài trong catalog | 63 |
| Thẻ bày ở Home | 60 |
| Bước vẽ | 1.279 |
| File trong R2 | ~1.400 · ~500 MB |
| Metadata trong KV | ~306 KB |
- 3 bài trong kho không bày thẻ —
v3_lesson_25, v3_lesson_35, v3_lesson_63. Chiều ngược lại sạch.
thumbUrl, gifUrl là trường chết — app decode rồi bỏ. Bỏ đi thì /home nhẹ đi đáng kể.
- Catalog bọc trong mảng một phần tử, hai trường đầu luôn rỗng — di sản; đụng vào phải sửa cả app lẫn admin mà không được gì.
level vắng ở 26/63 bài → app tự suy beginner.
- Khoá layout cũ vẫn phải fallback trong
GET /data/ — gỡ được khi R2 đã đổi hết sang khoá mới.
- Trang
/admin không chắn; cửa thật nằm ở PUT/DELETE.
- App Check: payload
/v2 có kèm token nhưng server chưa verify.
10 · Tham chiếu API đầy đủ
Base: https://colorpop-home.phamvandat130402.workers.dev
Mọi lệnh đọc công khai. Mọi lệnh ghi cần header authorization: Bearer <ADMIN_TOKEN>. Thiếu secret trên worker ⇒ mọi lệnh ghi bị từ chối. Mọi phản hồi JSON đều kèm access-control-allow-origin: *.
GET/ · /health
Ping. Không chạm KV hay R2.
200 { "ok": true, "service": "colorpop-home" }
GET/home
Thẻ bày ở màn Home, đã lọc bài có cờ hidden. Thêm ?all=1 để giữ cả bài ẩn — chỉ trang quản lý dùng. Có ETag.
200 [ HomeEntry, … ] xem mục 3
204 chưa đẩy gì lên — KHÔNG phải lỗi.
App dùng data trong bundle rồi đi tiếp.
304 khớp ETag, không đổi từ lần trước.
PUT/home 🔒 cần token
Ghi đè nguyên khối. Ghi kèm khoá meta home:templates:meta.
// thân: mảng HomeEntry
200 { "ok": true, "bytes": 23915, "updatedAt": "…" }
400 { "error": "invalid JSON" }
401 { "error": "unauthorized" }
413 quá 2 MB
DELETE/home 🔒 cần token
Xoá cả khoá danh sách lẫn khoá meta. Sau lệnh này GET /home trả 204.
200 { "deleted": true }
GET/catalog
Nội dung mọi bài. Nếu KV rỗng, worker trả bản gốc arttrace_catalog.json nằm trong R2 — nhờ vậy trang quản lý luôn có cái để mở, kể cả trước lần lưu đầu tiên.
200 [ { "name": "", "image": "", "lesson": [ … ] } ]
204 KV rỗng và R2 cũng không có bản gốc
304 khớp ETag
PUT/catalog 🔒 cần token
200 { "ok": true, "bytes": 282120, "updatedAt": "…" }
413 quá 4 MB
DELETE/catalog 🔒 cần token
200 { "deleted": true }
GET/categories
Bảng danh mục. Tên đã denormalize sẵn vào từng thẻ Home nên app hầu như không cần gọi.
200 [ { "id": "c_mtb7impsyfzg", "name": "Cute & Kawaii" }, … ]
PUT/categories 🔒 cần token
Worker chặn phần tử thiếu id hoặc name.
200 { "ok": true, "count": 6 }
400 { "error": "invalid JSON" }
413 quá 100 KB
GET/data/<khoá>
File thô từ R2. Không trả JSON — trả chính file. Cache biên max-age=86400 + ETag (không immutable, vì xuất lại bài sẽ đè đúng đường dẫn cũ).
200 content-type: image/png | image/gif | application/json
404 { "error": "not found", "key": "…" }
Hai cơ chế dự phòng:
· khoá không có tiền tố DataMultiplayerTransparentV5/ mà
miss → thử lại với tiền tố đó, làm phẳng /steps/ và /bot/
· brush_specs.json chưa push lên R2 → trả bản trong mã
HEAD/data/<khoá>
Hỏi file có tồn tại không mà không tải về. Không có fallback khoá cũ — HEAD phải trung thực, nếu không bộ đẩy data tưởng khoá mới đã có và bỏ qua không tải lên.
200 content-length: 31104 · etag (không có thân)
404 không có file
PUT/data/<khoá> 🔒 cần token
Đẩy một file lên R2 và dọn cache biên của chính khoá đó.
200 { "ok": true, "key": "…/01.png", "bytes": 31104 }
400 { "error": "empty payload" }
413 quá 20 MB
DELETE/data/<khoá> 🔒 cần token
Xoá file và dọn cache biên. Không dọn thì file đã xoá vẫn được phục vụ tới một ngày.
200 { "deleted": true, "key": "…" }
POST/upload 🔒 cần token
Đẩy file với khoá máy sinh (thời gian + ngẫu nhiên + tên đã làm sạch), tránh hai lần tải lên cùng tên đè nhau. Tên gốc lấy từ ?name=. Trả URL /file/….
200 { "ok": true, "key": "mf3k2a-1b2c3d4e-anh.png",
"url": "https://…/file/mf3k2a-…", "bytes": 48210 }
400 { "error": "empty file" }
405 method not allowed (allow: POST)
413 quá 20 MB
GET/file/<khoá>
File tải lên rời. Cache immutable một năm — khoá đã chứa thời gian và chuỗi ngẫu nhiên nên một khoá luôn ứng với đúng một nội dung.
200 chính file + etag
404 { "error": "not found" }
DELETE/file/<khoá> 🔒 cần token
200 { "deleted": true, "key": "…" }
405 method not allowed (allow: GET, DELETE)
POST/v2/home · /v2/catalog
Đường duy nhất app dùng. Cùng ruột như /home và /catalog, bọc AES-256-GCM. Payload kèm token App Check (server nhận, chưa verify).
// gửi
{ "data": "<base64: nonce(12) | ciphertext | tag(16)>" }
200 { "data": "<base64…>" }
giải ra: { "ts": …, "body": [ … ] }
400 { "error": "bad_envelope" }
sai khoá / sửa byte / quá 300s — chung một lỗi
404 { "error": "not configured" }
chưa đặt secret XSHIELD_CONTENT_KEY
GET/admin
Trang quản lý, HTML tĩnh do chính worker trả. Không chắn — người dùng tự gõ token; cửa thật nằm ở các lệnh ghi mà trang này gọi.
200 content-type: text/html ~92 KB
Mọi đường khác trả 404 { "error": "not found" }. Sai method trả 405 kèm header allow liệt kê method hợp lệ.