Các Tuyệt Chiêu (Pattern) Sống Còn 🥷¶
Dưới đây là những "bí kíp" viết code được dùng xuyên suốt dự án này. Hiểu và xài quen mấy chiêu này, code của bạn sẽ không chỉ chạy được, mà còn sạch đẹp (clean), dễ bảo trì và an toàn trước các anh hacker. Cùng đi qua từng món nhé!
Chiêu 1 — Quy Về Một Mối (Centralized API Client)¶
Nỗi đau: Giả sử bạn có 10 trang web, trang nào cũng gọi API. Nếu trang nào bạn cũng tự viết hàm fetch, tự nhét token, tự bắt lỗi... thì code sẽ lặp lại một đống. Mai mốt đổi cách gọi là phải sửa lại cả 10 chỗ. Khóc thét!
Cách giải quyết: Tạo ra một "anh tiếp tân" tên là request(). Tất cả mọi lời thỉnh cầu lên server đều phải qua tay anh này.
// web/src/lib/api.ts
// Tìm xem server đang ở đâu (nếu chạy trên máy thì là localhost)
const API_BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:8787';
// ── Hàm "tiếp tân" xử lý mọi request ───────────────────────────────
async function request<T>(
path: string,
options: RequestInit & { token?: string } = {}
): Promise<T> {
const { token, ...init } = options;
// Tự động kẹp thẻ hành nghề (Auth header) vào nếu có token
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init.headers as Record<string, string>),
};
const res = await fetch(`${API_BASE}${path}`, { ...init, headers });
const data = await res.json();
// Bắt lỗi tập trung: Nếu server chê (lỗi), ném thẳng lỗi ra luôn
if (!res.ok) throw new Error((data as any).error ?? `Lỗi mạng ${res.status}`);
return data as T;
}
// ── Đóng gói API thành một cục gọn gàng, dễ gọi ──────────────────────
export const api = {
auth: {
login: (body: LoginInput) =>
request<LoginResponse>('/auth/login', { method: 'POST', body: JSON.stringify(body) }),
setup: (body: SetupInput) =>
request<LoginResponse>('/auth/setup', { method: 'POST', body: JSON.stringify(body) }),
},
groups: {
list: (token: string) =>
request<GroupSummary[]>('/groups', { token }),
get: (groupId: string, token: string) =>
request<GroupDetail>(`/groups/${groupId}`, { token }),
create: (body: CreateGroupInput, token: string) =>
request<Group>('/groups', { method: 'POST', body: JSON.stringify(body), token }),
},
transactions: {
list: (groupId: string, token: string) =>
request<Transaction[]>(`/groups/${groupId}/transactions`, { token }),
create: (groupId: string, body: CreateTransactionInput, token: string) =>
request<Transaction>(`/groups/${groupId}/transactions`, {
method: 'POST', body: JSON.stringify(body), token,
}),
delete: (groupId: string, txId: string, token: string) =>
request<{ success: boolean }>(`/groups/${groupId}/transactions/${txId}`, {
method: 'DELETE', token,
}),
},
};
Sướng ở chỗ:
- Muốn đổi cấu hình mạng? Vào sửa đúng một chỗ.
- Bấm dấu chấm api. là nó gợi ý tên hàm ra ngay (nhờ TypeScript).
- Lỗi được báo thống nhất, không phải đoán mò.
Chiêu 2 — Đóng Băng Dữ Liệu (Tránh bị hack SQL Injection)¶
// ❌ TOANG — Nối chuỗi kiểu này là mời hacker vào nhà
const user = await env.DB.prepare(
`SELECT * FROM users WHERE email = '${email}'`
).first();
// Lỡ tay user nhập email là: "'; DROP TABLE users; --" → Bay màu luôn cái database!
// ✅ CHUẨN — Dùng dấu chấm hỏi (?) làm người thế thân
const user = await env.DB.prepare(
'SELECT * FROM users WHERE email = ?'
).bind(email).first<User>();
// Truyền nhiều tham số một lúc
const member = await env.DB.prepare(
'SELECT id, role FROM group_members WHERE group_id = ? AND user_id = ?'
).bind(groupId, userId).first<{ id: string; role: string }>();
Vì sao dùng dấu ? lại an toàn?
Kiểu này bắt database hiểu rằng: "Đoạn chữ người dùng nhập vào chỉ là dữ liệu thôi nhé, cấm tuyệt đối không được coi nó là lệnh thao tác". Hacker có nhập lệnh xóa bảng thì hệ thống cũng chỉ coi đó là một cái tên vô hại.
Chiêu 3 — Phân Chia Giai Cấp (Role-based Access Control - RBAC)¶
Hệ thống phải biết ai là sếp, ai là lính. Và phải kiểm tra ở CẢ HAI NƠI: Giao diện (Frontend) và Não bộ (Backend).
Backend — Cửa từ an ninh (Middleware)¶
// Chốt chặn 1: Chỉ Sếp tổng (super admin) mới qua được
const superAdminMiddleware = async (c: any, next: () => Promise<void>) => {
await jwtMiddleware(c, async () => {
const { role } = c.get('jwtPayload') as JwtPayload;
if (role !== 'super_admin') return c.json({ error: 'Quay xe! Bạn không đủ quyền.' }, 403);
await next();
});
};
// Chốt chặn 2: Chỉ Admin của nhóm đó mới qua được
const groupAdminMiddleware = async (c: any, next: () => Promise<void>) => {
await groupAccessMiddleware(c, async () => {
const { role } = c.get('groupMember');
if (role !== 'admin') return c.json({ error: 'Chỉ admin nhóm mới được làm trò này.' }, 403);
await next();
});
};
// Gắn chốt chặn vào các đường link
app.delete('/groups/:id', jwtMiddleware, superAdminMiddleware, deleteGroupHandler);
app.put('/groups/:groupId/members/:memberId/role', groupAdminMiddleware, updateMemberRoleHandler);
Frontend — Giấu đi cho đỡ ngứa mắt¶
function GroupPage() {
const memberRole = group.currentMember?.role ?? 'member';
const isGroupAdmin = memberRole === 'admin';
const { role: systemRole } = useAuthStore();
const isSuperAdmin = systemRole === 'super_admin';
return (
<div>
{/* Phải là Admin nhóm thì web mới hiện nút Xóa */}
{isGroupAdmin && (
<button onClick={() => deleteTransaction(tx.id)}>Xóa</button>
)}
{/* Sếp tổng mới nhìn thấy nút Cài đặt */}
{isSuperAdmin && (
<button onClick={() => setShowSettings(true)}>Cài đặt hệ thống</button>
)}
</div>
);
}
Lưu ý cực mạnh: Frontend chỉ là tấm màn che!
Giấu nút bấm trên web chỉ để trông cho gọn và lịch sự thôi. Hacker hoàn toàn có thể tự gõ link (gọi API) để lách qua giao diện. Bảo mật thật sự phải nằm ở Backend — nơi kiểm tra vé vào cổng cuối cùng!
Chiêu 4 — Báo Động Cập Nhật (Invalidate Cache)¶
Khi bạn vừa mua đồ xong (data bị thay đổi do Thêm/Sửa/Xóa), bạn phải báo cho cái màn hình biết để nó tải lại dữ liệu mới, nếu không nó vẫn hiện dữ liệu cũ xì.
// Vừa xóa 1 khoản chi tiêu → cần làm mới:
// 1. Danh sách khoản chi
// 2. Tổng quỹ của nhóm (vì tiền vừa bị trừ đi)
const deleteTransaction = useMutation({
mutationFn: (txId: string) => api.transactions.delete(groupId, txId, token!),
onSuccess: () => {
// "Này React Query, data cũ rồi, dọn đi và tải lại nhé!"
queryClient.invalidateQueries({ queryKey: ['transactions', groupId] });
queryClient.invalidateQueries({ queryKey: ['group', groupId] });
},
onError: (error) => {
// Báo lỗi cho người dùng biết
alert(error.message);
},
});
// Có người mới vào nhóm → cần làm mới:
// 1. Danh sách nhóm (lỡ có thêm nhóm mới)
// 2. Danh sách thành viên trong nhóm đó
const joinGroup = useMutation({
mutationFn: (inviteCode: string) => api.auth.joinExisting(inviteCode, token!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['groups'] });
queryClient.invalidateQueries({ queryKey: ['group-members', groupId] });
},
});
Thần chú: Cứ sửa đổi cái gì xong, phải tự hỏi "Cái này ảnh hưởng đến những màn hình nào?" -> Nhắm vào mấy chỗ đó mà invalidate (làm mới) hết.
Chiêu 5 — Biến Môi Trường (Giấu Chìa Khóa Nhà)¶
Đừng bao giờ gõ thẳng mật khẩu hay đường link trực tiếp vào trong code. Chúng ta dùng Biến môi trường.
Bên Backend¶
// Định nghĩa sẵn để code tự gợi ý
export interface Env {
DB: D1Database;
JWT_SECRET: string; // Tạo bằng lệnh: wrangler secret put JWT_SECRET
TURNSTILE_SECRET_KEY: string; // Tạo bằng lệnh: wrangler secret put TURNSTILE_SECRET_KEY
FRONTEND_URL: string; // Cái này để trong file wrangler.toml cũng được
}
// Gọi ra xài như vầy
app.use('*', cors({
origin: (origin, c) => {
const allowed = [c.env.FRONTEND_URL, 'http://localhost:5173'];
return allowed.includes(origin) ? origin : null;
},
}));
Bên Frontend¶
// Gắn vào web
const API_BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:8787';
// File .env.local (Dùng khi dev ở máy, nhớ đừng push lên mạng)
// VITE_API_URL=http://localhost:8787
// Lúc đưa web lên mạng thật (Production)
// Điền thẳng VITE_API_URL=[https://my-api.workers.dev](https://my-api.workers.dev) trong Dashboard của Cloudflare
Quy tắc nằm lòng:
- Chữ bắt đầu bằng VITE_: Ai cũng xem được, đưa lên web thoải mái.
- Dùng wrangler secret: Chìa khóa nhà, cực kỳ nhạy cảm, chỉ có Cloudflare biết, cấm để lộ.
- File wrangler.toml: Lưu cấu hình linh tinh (không chứa bí mật) thì cứ đẩy lên Github vô tư.
Chiêu 6 — Xóa Giả Vờ (Soft Delete)¶
Dữ liệu là vàng, đừng dùng lệnh DELETE xóa trắng nó khỏi Database. Lỡ bấm nhầm thì sao? Hãy làm trò "xóa giả vờ".
// Khi muốn "Xóa" một ai đó: Chỉ cần cập nhật ngày xóa thành hôm nay
await env.DB.prepare(
'UPDATE users SET deleted_at = datetime("now") WHERE id = ?'
).bind(userId).run();
// Chú ý: Lúc lấy dữ liệu ra hiển thị, nhớ LỌC BỎ mấy người đã bị gắn mác xóa
const user = await env.DB.prepare(
'SELECT * FROM users WHERE id = ? AND deleted_at IS NULL' // <-- Nhớ cái vụ IS NULL này nha
).bind(userId).first();
const activeUsers = await env.DB.prepare(
'SELECT * FROM users WHERE deleted_at IS NULL'
).all();
Tại sao phải phiền vậy?
- Để mốt lỡ có cãi nhau thì còn tra lại lịch sử.
- Lỡ xóa nhầm người tốt, sửa deleted_at = NULL là họ sống lại ngay.
- Làm app dính tới tiền bạc thì việc lưu trữ lịch sử là bắt buộc theo luật pháp đấy.
Chiêu 7 — Ở Chung Một Nhà (Monorepo)¶
Thay vì tạo 2 dự án riêng biệt, ta gom Backend và Frontend vào chung một thư mục gốc cho dễ tìm.
my-project/
package.json ← Bộ máy điều khiển chính
Makefile ← Nơi lưu mấy câu lệnh chạy nhanh
api/ ← Toàn bộ não bộ (Backend) ở đây
package.json
wrangler.toml
tsconfig.json
.dev.vars ← (Đã giấu kĩ) Chìa khóa chạy local
migrations/
0001_initial.sql ← Kịch bản xây Database
src/
index.ts ← Trạm gác cổng (Chạy đầu tiên)
types.ts ← Khai báo kiểu dữ liệu dùng chung
utils.ts ← Những hàm tiện ích dùng nhiều lần
routes/ ← Nơi chia đường dẫn API
web/ ← Toàn bộ mặt tiền (Frontend) ở đây
package.json
vite.config.ts
tailwind.config.js
tsconfig.json
.env.local ← (Đã giấu kĩ) Cấu hình chạy local
public/
_redirects ← Sửa lỗi tải lại trang của React
src/
main.tsx ← Nơi web bắt đầu khởi động
App.tsx ← Chia đường dẫn cho web
lib/
api.ts ← Nơi nói chuyện với Backend (Tiếp tân)
store/
authStore.ts ← Nơi lưu trí nhớ (Ví dụ: Nhớ là đã đăng nhập)
pages/ ← Các trang giao diện to đùng
components/ ← Các nút bấm, khung nhỏ để dùng lại
🚫 Những Nước Đi Sai Lầm Nhất Định Phải Tránh (Anti-patterns)¶
❌ Trực tiếp gọi API ở giữa giao diện¶
// NGU CỰC KỲ — Ai lại đi làm thế này
function GroupPage() {
useEffect(() => {
fetch(`/api/groups/${groupId}`, {
headers: { Authorization: `Bearer ${token}` }
}).then(r => r.json()).then(setGroup);
}, [groupId]);
}
// SẠCH SẼ — Phải qua React Query quản lý giùm
const { data: group } = useQuery({
queryKey: ['group', groupId],
queryFn: () => api.groups.get(groupId!, token!),
});
❌ Public chìa khóa nhà cho bàn dân thiên hạ¶
# KHÔNG BAO GIỜ được gõ cái này rùi đẩy lên Github
[vars]
JWT_SECRET = "my-secret-key" # ← Thế là toi cái server!
# Thay vào đó hãy gõ lệnh này trên Terminal
# wrangler secret put JWT_SECRET