Loạt bài này xây một nền tảng media hoàn chỉnh bằng NestJS: người dùng tải video lên, hệ thống tự chuyển mã ra nhiều độ phân giải, đẩy tiến độ về theo thời gian thực, rồi phát lại có tua được. Có tài khoản, có hạn mức, có tiền (credit), có hàng đợi và nhiều tiến trình chạy song song.
Sản phẩm này khiến những kỹ thuật thường bị dạy rời rạc trở thành
bắt buộc. Bạn không thể chuyển mã video mà không đụng child_process. Không
thể nhận file 2 GB mà không hiểu stream. Không thể để người dùng chờ ba phút mà không có realtime. Và
không thể trừ tiền của họ mà không hiểu transaction.
Cụ thể: kiến trúc và cấu trúc thư mục, chuẩn TypeScript nghiêm ngặt, thiết kế cơ sở dữ liệu, TypeORM & migration, rồi tự tay tái hiện lỗi trừ tiền hai lần và vá nó.
1. Yêu cầu tiên quyết
Bài viết giả định bạn đã quen TypeScript và đã từng viết một API HTTP bằng Node. Không cần biết NestJS trước — mọi khái niệm của nó sẽ được giới thiệu khi dùng tới lần đầu.
Về công cụ, chỉ cần đúng hai thứ trên máy:
- Node.js 22 trở lên — kiểm bằng
node -v - Docker và Docker Compose — kiểm bằng
docker compose version
PostgreSQL, Redis và ffmpeg đều chạy trong container,
không cài lên máy thật. Lý do không chỉ là cho gọn: đến Part 3 chúng ta cần biết chính
xác phiên bản ffmpeg đang chạy, vì cùng một lệnh cho kết quả khác nhau giữa các bản. Đóng nó
vào image là cách duy nhất để mọi người đọc chạy ra cùng một thứ.
2. Sản phẩm & kiến trúc
Trước khi gõ dòng code nào, cần thống nhất hệ thống gồm những gì và ai nói chuyện với ai. Đây là kiến trúc sẽ được xây trong Part 1 đến Part 3:
Chú ý worker là một tiến trình riêng, không phải một microservice. Nó dùng
chung codebase, chung database, chung model — chỉ khác ở chỗ nó không mở cổng HTTP mà đọc hàng đợi. Tách
tiến trình vì lý do vận hành (việc nặng không được giành CPU với request), không phải vì kiến trúc phân
tán.
Toàn bộ Part 1 đến Part 3 giữ nguyên hình dạng này: một ứng dụng, một database. Part 4 mới tách thành microservice, và lúc đó bạn sẽ thấy rõ nó giải quyết vấn đề gì — cùng những vấn đề mới nó mang lại.
3. Cấu trúc thư mục
Một ứng dụng NestJS, chia module theo miền nghiệp vụ chứ không theo loại kỹ thuật. Nghĩa
là media/ chứa cả controller, service lẫn entity của nó — không có thư mục
controllers/ gom hết controller của mọi miền lại.
media-forge/
├── src/
│ ├── main.ts # điểm khởi động
│ ├── app.module.ts # module gốc, gom các module con
│ ├── config/ # đọc & kiểm tra biến môi trường
│ ├── common/ # lớp lỗi, interceptor, decorator dùng chung
│ ├── database/
│ │ ├── data-source.ts # cấu hình TypeORM
│ │ └── migrations/ # migration sinh ra, commit vào git
│ ├── auth/ # người dùng, đăng nhập, refresh token
│ ├── media/ # video, asset
│ ├── job/ # vòng đời job chuyển mã
│ └── billing/ # sổ cái credit — nơi phần ACID diễn ra
├── docker/
│ └── docker-compose.yml # chỉ Postgres — redis thêm ở Part 3
├── .env.example
├── tsconfig.json
└── package.json
Ba thư mục sẽ xuất hiện ở các part sau, chưa cần tạo bây giờ:
src/worker/— tiến trình chạy ffmpeg, thêm ở Part 3web/— giao diện React, thêm ở Part 2nginx/— cấu hình reverse proxy, thêm ở Part 2
auth/ tách ra thành một service là chuyện của một buổi chiều. Còn nếu code được chia
theo loại kỹ thuật — mọi controller một thư mục, mọi service một thư mục — thì logic của
auth nằm rải khắp nơi, và tách nó ra là viết lại. Nói cách khác: module hôm nay là đường cắt của ngày mai. Bạn không cần biết trước có tách hay không — chỉ cần vạch đúng ranh giới thì cả hai hướng đều mở.
4. Khởi tạo dự án
Bắt đầu bằng NestJS CLI. Không cần cài toàn cục — npx chạy thẳng bản mới nhất:
npx @nestjs/cli new media-forge --package-manager npm --strict
cd media-forge
Cờ --strict đáng để ý: nó bật sẵn nhóm strict trong tsconfig.json. Chưa đủ so
với chuẩn ở mục 5, nhưng đỡ phải sửa từ con số không.
Chạy thử ngay để chắc mọi thứ ổn trước khi đi tiếp:
npm run start:dev
# → Nest application successfully started
# Mở http://localhost:3000 sẽ thấy: Hello World!
4.1 Cài các gói cần cho Part 1
Part 1 cần ba nhóm gói:
# Cấu hình & kiểm tra biến môi trường
npm i @nestjs/config zod
npm i dotenv
# Cơ sở dữ liệu
npm i @nestjs/typeorm typeorm pg
# Kiểm tra dữ liệu đầu vào (DTO)
npm i class-validator class-transformer
Vì sao cần từng gói:
@nestjs/config— đọc.envvà cung cấp qua DI-
zod— kiểm tra biến môi trường lúc khởi động, và sinh luôn kiểu TypeScript từ chính schema đó. ThiếuDATABASE_URLthì app dừng ngay với thông báo rõ, thay vì chạy được rồi mới sập lúc truy vấn đầu tiên -
dotenv— gói duy nhất màdata-source.ts(mục 7.1) dùng trực tiếp để nạp.env. Script CLI của TypeORM chạy độc lập, ngoài container DI của Nest, nên không cóConfigModuleđể dựa vào typeorm+pg— ORM và driver PostgreSQL-
class-validator+class-transformer— kiểm tra dữ liệu vào ở lúc chạy. kiểu TypeScript biến mất sau khi biên dịch, nên mộtinterfacekhông bảo vệ được gì trước request thật
4.2 Dọn dẹp file mặc định
CLI sinh ra app.controller.ts và app.service.ts chỉ để in "Hello World". Xoá
chúng đi — giữ lại là mang theo rác suốt cả dự án:
rm src/app.controller.ts src/app.controller.spec.ts src/app.service.ts
Rồi mở src/app.module.ts và xoá phần khai báo tương ứng:
import { Module } from '@nestjs/common';
@Module({
imports: [],
controllers: [],
providers: [],
})
export class AppModule {}
4.3 Tạo cấu trúc thư mục
Tạo sẵn khung theo miền nghiệp vụ đã bàn ở mục 3:
mkdir -p src/{config,common,database/migrations,auth,media,job,billing}
mkdir -p docker
4.4 Biến môi trường
Tạo hai file. .env.example được commit để người khác biết cần những biến gì;
.env chứa giá trị thật và không bao giờ commit:
NODE_ENV=development
PORT=3000
# Khớp với docker/docker-compose.yml ở mục dưới
DATABASE_URL=postgres://forge:forge@localhost:5432/media_forge
cp .env.example .env
echo ".env" >> .gitignore
Giờ khai báo schema cấu hình. Đây là chỗ quyết định app sập sớm hay sập muộn — và cũng là nơi kiểu dữ liệu của cấu hình được sinh ra:
import { z } from 'zod';
const configSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().url().startsWith('postgres://'),
});
// Kieu SINH RA tu schema — khong viet tay, nen khong the lech nhau.
export type AppConfig = z.infer<typeof configSchema>;
export function validateEnv(raw: Record<string, unknown>): AppConfig {
const result = configSchema.safeParse(raw);
if (!result.success) {
const issues = result.error.issues
.map((issue) => ` ${issue.path.join('.')}: ${issue.message}`)
.join('\n');
throw new Error(`Bien moi truong khong hop le:\n${issues}`);
}
return result.data;
}
Đọc dòng export type AppConfig = z.infer<typeof configSchema> cho kỹ, vì nó là điểm
chính. Kiểu AppConfig không được viết tay — nó được suy ra từ schema. Thêm một biến
môi trường vào schema thì kiểu tự có thêm trường đó; đổi z.coerce.number() thành
z.string() thì mọi chỗ đang dùng nó như số lập tức báo lỗi biên dịch. Một nguồn sự thật,
không có bản sao nào để trôi khỏi bản gốc.
Nối schema vào ConfigModule:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { validateEnv } from './configuration';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
// Chay luc khoi dong. Nem loi o day thi app dung han, khong the
// chay duoc roi moi sap o truy van dau tien.
validate: validateEnv,
}),
],
})
export class AppConfigModule {}
validate chứ không phải validationSchema
validationSchema của @nestjs/config mong đợi một schema có phương
thức .validate() — đúng hình dạng của joi, không phải zod. Còn validate nhận
một hàm bất kỳ, nên zod vào vừa vặn. Giá trị hàm đó trả về chính là cấu hình mà
ConfigService phục vụ về sau. Nghĩa là
z.coerce.number() không chỉ kiểm tra — nó còn chuyển đổi: biến môi trường
đọc ra luôn là chuỗi, nhưng thứ đi vào ứng dụng đã là số thật.
Mọi nơi trong ứng dụng đọc cấu hình như sau, và trình biên dịch biết kiểu:
constructor(private readonly config: ConfigService<AppConfig, true>) {}
// PORT co kieu number — z.coerce da doi chuoi thanh so tu luc khoi dong
const port = this.config.get('PORT', { infer: true });
true trong ConfigService<AppConfig, true>
ConfigService tin rằng cấu hình đã qua kiểm tra, nên
get() trả về number thay vì number | undefined. Bỏ nó đi thì mọi
chỗ đọc cấu hình đều phải xử lý undefined một cách thừa thãi, dù schema đã chặn từ lúc khởi
động.
safeParse gom toàn bộ lỗi vào error.issues chứ không dừng ở cái đầu
tiên. Thiếu bốn biến môi trường thì nhận được cả bốn dòng trong một lần chạy, thay vì sửa một cái, chạy
lại, thấy lỗi tiếp theo — bốn vòng. Người dựng dự án lần đầu sẽ cảm ơn vòng lặp .map() ở
trên, vì nếu ném thẳng result.error thì thứ hiện ra là một cục JSON khó đọc.
Nạp module cấu hình vào app.module.ts:
import { Module } from '@nestjs/common';
import { AppConfigModule } from './config/config.module';
@Module({
imports: [AppConfigModule],
})
export class AppModule {}
4.5 Dựng PostgreSQL bằng Docker
PostgreSQL chạy trong container, không cài lên máy. Tạo docker/docker-compose.yml:
services:
postgres:
image: postgres:17-alpine
container_name: forge-postgres
environment:
POSTGRES_USER: forge
POSTGRES_PASSWORD: forge
POSTGRES_DB: media_forge
ports:
- '5432:5432'
volumes:
- forge-pgdata:/var/lib/postgresql/data
healthcheck:
# Khong chi doi cong mo — doi Postgres THUC SU san sang nhan truy van
test: ['CMD-SHELL', 'pg_isready -U forge -d media_forge']
interval: 5s
timeout: 3s
retries: 10
volumes:
forge-pgdata:
docker compose -f docker/docker-compose.yml up -d
# Doi toi khi healthy roi hay chay app
docker compose -f docker/docker-compose.yml ps
up -d, nó sẽ báo lỗi kết nối và bạn sẽ đi tìm bug ở sai chỗ. Đó là lý do có
healthcheck ở trên — và ở Part 2, khi app cũng chạy trong compose, nó sẽ dùng
depends_on với condition: service_healthy để chờ đúng cách.
Đến đây bạn đã có: một ứng dụng NestJS sạch, cấu hình được kiểm tra lúc khởi động, và một PostgreSQL đang chạy. Chưa nối vào nhau — việc đó cần các bảng, mà bảng thì cần thiết kế trước.
5. Chuẩn code & kiểu dữ liệu nghiêm ngặt
Cấu hình TypeScript là thứ rất dễ để đó rồi quên. Đặt chuẩn trước khi có code thì rẻ; đặt sau khi đã có 5.000 dòng thì gần như không ai làm.
CLI đã bật sẵn "strict": true nhờ cờ --strict ở mục 4. Nhưng đó mới là điều kiện
cần chưa đủ. Nó không bao gồm bốn cờ nguy hiểm nhất, và đó chính là bốn chỗ TypeScript
hay cho cảm giác an toàn giả:
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
// Bốn cờ KHÔNG nằm trong "strict" — đây mới là phần đắt giá
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"resolveJsonModule": true
}
}
Bốn cờ đó chặn cái gì? Đoạn dưới đây cố tình viết sai bốn chỗ. Với "strict": true đơn thuần,
cả bốn đều biên dịch trót lọt:
// 1. Mảng rỗng vẫn trả về giá trị — lỗi kinh điển khi parse input
const ids: string[] = [];
const first: string = ids[0]; // thực tế là undefined lúc chạy
// 2. Gán undefined vào thuộc tính optional: "có key nhưng rỗng" khác "không có key"
interface Opts {
readonly retries?: number;
}
const o: Opts = { retries: undefined };
// 3. Ghi đè method của lớp cha mà không khai báo — đổi tên ở cha là hỏng lặng lẽ
class BaseWorker {
handle(): void {}
}
class TranscodeWorker extends BaseWorker {
handle(): void {} // thiếu từ khoá override
}
// 4. Đọc biến môi trường không tồn tại mà không hề bị cảnh báo
const env: Record<string, string> = {};
const url = env.DATABASE_URL;
Với cấu hình ở trên, tsc chặn cả bốn:
probe.ts(3,7): error TS2322: Type 'string | undefined' is not assignable to type 'string'.
probe.ts(9,7): error TS2375: Type '{ retries: undefined; }' is not assignable to type 'Opts'
with 'exactOptionalPropertyTypes: true'.
probe.ts(16,3): error TS4114: This member must have an 'override' modifier because it
overrides a member in the base class 'BaseWorker'.
probe.ts(21,17): error TS4111: Property 'DATABASE_URL' comes from an index signature,
so it must be accessed with ['DATABASE_URL'].
ids[0] undefined khi parse metadata
ffmpeg trả về (Part 3), thiếu override khi worker kế thừa lớp cơ sở (Part 3), và biến môi
trường thiếu khi chạy trong Docker (Part 2). Bật cờ từ hôm nay thì trình biên dịch bắt; bật sau thì
người dùng bắt.
src/main.ts do nest new tự sinh ra đọc process.env.PORT — đúng
kiểu truy cập mà noPropertyAccessFromIndexSignature vừa cấm. Bật cờ này xong biên dịch cả
dự án lần đầu, đây là chỗ báo lỗi đầu tiên, dù bạn chưa viết dòng nào trong file đó. Sửa bằng cách đọc
qua ConfigService — dự án đã có sẵn nguồn cấu hình duy nhất qua zod, dùng lại cho nhất quán
thay vì đổi sang process.env['PORT']: const config = app.get(ConfigService<AppConfig, true>);
await app.listen(config.get('PORT', { infer: true }));
strict đã cho sẵn: biến trong catchcatch (err) cho err kiểu any, gọi gì cũng
được. Từ TypeScript 4.4 thì không: useUnknownInCatchVariables nằm TRONG
strict, nên err là unknown và err.message không biên
dịch được cho tới khi bạn thu hẹp kiểu: if (err instanceof Error) logger.error(err.message);
Đáng nhớ vì ở Part 3, lỗi từ tiến trình
ffmpeg đi ra theo đúng đường này — và bạn sẽ bị
buộc phải kiểm kiểu trước khi đọc thông báo lỗi.
Quy ước còn lại
-
Cấm
any. Khi thật sự chưa biết kiểu, dùngunknownrồi thu hẹp bằng type guard.anytắt trình kiểm tra một cách âm thầm và lan sang mọi thứ nó chạm vào. -
Dữ liệu vào từ bên ngoài luôn phải qua DTO + validation. Kiểu TypeScript biến mất lúc
chạy; một
interfacekhông bảo vệ được gì trước request thật. -
Một lớp lỗi thống nhất trong
src/common, không ném chuỗi, không ném object tuỳ hứng — Part 2 sẽ dùng nó để dựng bộ lọc lỗi cho toàn hệ. -
Tên file kebab-case, một khái niệm một file, theo đúng quy ước NestJS:
credit-ledger.service.ts,create-video.dto.ts.
6. Thiết kế cơ sở dữ liệu
Đây là phần quyết định chất lượng của mọi thứ phía sau. Schema sai thì code có sạch đến đâu cũng phải liên tục vá; schema đúng thì phần lớn logic trở nên hiển nhiên. Nên trước khi mở TypeORM, hãy rút thực thể ra từ luồng nghiệp vụ thật, không phải từ tưởng tượng.
6.1 Đi từ luồng nghiệp vụ tới thực thể
Viết ra một câu chuyện người dùng đầy đủ, rồi gạch chân danh từ:
Một người dùng đăng nhập và nhận về một phiên để giữ đăng nhập. Họ tải lên một video. Hệ thống trừ credit rồi tạo một công việc chuyển mã. Công việc chạy xong sinh ra nhiều bản media ở các độ phân giải khác nhau.
Sáu danh từ đó thành sáu bảng:
users— tài khoảnrefresh_tokens— phiên đăng nhập, mỗi thiết bị một dòngvideos— bản gốc người dùng tải lênmedia_assets— các bản đã chuyển mã sinh ra từ một videojobs— một lần chuyển mã, có vòng đời trạng tháicredit_entries— sổ cái credit
credit_entries — và đó là quyết định
quan trọng nhất của cả thiết kế này.
6.2 Vì sao credit là sổ cái, không phải một con số
Cách hiển nhiên nhất là thêm cột credits vào bảng users, rồi mỗi lần chuyển mã
thì UPDATE users SET credits = credits - 10. Nó chạy được, và nó là thiết kế sai.
Ba câu hỏi sẽ đến, sớm hay muộn:
- "Tháng trước tôi bị trừ mất 300 credit, vì cái gì?" — với một cột số, bạn không trả lời được. Giá trị cũ đã bị ghi đè và biến mất vĩnh viễn.
- "Job này thất bại, hoàn tiền cho tôi." — hoàn bao nhiêu? Bạn không biết lúc đó đã trừ bao nhiêu.
- "Số dư đang âm, ai gây ra?" — không có dấu vết nào để lần.
Sổ cái chỉ ghi thêm, không bao giờ sửa hay xoá. Mỗi lần credit thay đổi là một dòng mới
với delta dương hoặc âm. Số dư không được lưu — nó được tính ra:
SELECT COALESCE(SUM(delta), 0) AS balance
FROM credit_entries
WHERE user_id = $1;
-- Lịch sử đầy đủ, luôn có sẵn, không cần bảng log riêng:
-- +100 nạp tiền job_id = NULL
-- -10 chuyển mã job_id = 'a1b2...'
-- +10 hoàn do lỗi job_id = 'a1b2...'
delta ngược dấu và cùng
job_id. Không sửa gì cả, không có gì để làm sai. Và vì cả hai dòng cùng trỏ về một job,
việc đối soát "job này đã hoàn tiền chưa" là một câu truy vấn chứ không phải một cột cờ.
SUM trên toàn bộ lịch sử thay vì đọc một cột. Với người dùng có 200 dòng
thì không đáng kể. Với 2 triệu dòng thì đáng. Cách xử lý không phải quay lại cột số dư, mà là thêm bảng ảnh chụp (snapshot): định kỳ ghi lại "tới dòng thứ N, số dư là X", rồi chỉ cộng phần phát sinh sau đó. Sổ cái vẫn là nguồn sự thật, ảnh chụp chỉ là cache có thể dựng lại bất cứ lúc nào.
Ở quy mô đang xây thì chưa cần tới snapshot. Nhưng biết đường ra tồn tại thì lựa chọn hôm nay không thành ngõ cụt.
6.3 Vòng đời của job
jobs.status không phải một chuỗi tuỳ ý. Nó là máy trạng thái, và mọi bug khó chịu nhất của hệ
thống loại này đều nằm ở chỗ trạng thái nhảy sai đường:
queued ──→ processing ──→ completed
│
├──→ failed ──→ queued (thử lại, tăng attempts)
└──→ failed (hết lượt thử, dừng hẳn)
KHÔNG hợp lệ: completed → bất kỳ đâu (đã xong là xong)
queued → completed (bỏ qua xử lý)
Ba cột đi kèm không phải trang trí: attempts để biết đã thử lại mấy lần,
error để giữ lý do thất bại gần nhất, started_at/finished_at
để tính thời gian chạy thật và phát hiện job treo.
processing vĩnh viễnprocessing, rồi tiến trình bị giết — máy khởi động lại,
container bị OOM, ai đó bấm Ctrl+C. Không ai đặt lại trạng thái, nên job đó nằm mãi ở
processing: không chạy, không thất bại, không thể thử lại, và người dùng thì nhìn thanh
tiến độ đứng yên mãi mãi. Đó là lý do
started_at phải có ngay từ thiết kế: nó cho phép một tác vụ dọn dẹp tìm ra "job
đang processing quá 30 phút" và đưa về failed. Không có cột đó thì không có
cách nào phân biệt job đang chạy với job đã chết.
6.4 Kiểu dữ liệu & ràng buộc
Vài lựa chọn nhỏ nhưng khó sửa về sau:
-
Khoá chính dùng
uuid, không dùng số tự tăng. Lý do thực dụng: ID xuất hiện trong URL và người dùng thấy được — số tự tăng để lộ bạn có bao nhiêu video trong hệ thống, và cho phép đoán ID của người khác. -
Mọi mốc thời gian là
timestamptz, không phảitimestamp. Kiểu không có múi giờ là nguồn của cả một họ bug chỉ xuất hiện khi server và người dùng ở hai múi giờ khác nhau. -
Tiền và credit dùng số nguyên, đơn vị nhỏ nhất. Không bao giờ dùng
floatcho thứ phải cộng đúng. -
Trạng thái dùng enum của Postgres chứ không phải
varchartự do — để database từ chối luôn giá trị lạ, thay vì phát hiện ra sau ba tháng.
Ứng dụng kiểm tra để báo lỗi tử tế cho người dùng: "email này đã được dùng rồi". Database kiểm tra để bảo đảm dữ liệu không bao giờ sai, kể cả khi có hai instance chạy song song, kể cả khi ai đó chạy script sửa tay lúc nửa đêm.
Bỏ tầng ứng dụng thì người dùng nhận một lỗi 500 khó hiểu. Bỏ tầng database thì hai request đồng thời cùng lọt qua kiểm tra rồi cùng ghi — và đó chính xác là cánh cửa dẫn vào phần tiếp theo.
7. TypeORM & migration
Dịch thiết kế ở mục 6 thành entity, rồi sinh migration từ chính entity đó.
7.1 DataSource — một file, hai nơi dùng
Ở đây có một chỗ vướng đáng nói. TypeORM CLI sinh migration bằng cách chạy một script Node độc lập —
không có container DI của Nest, nên không dùng được ConfigService. Còn ứng
dụng thì ngược lại: nó nên đọc qua ConfigService chứ không bốc process.env.
Cách giải quyết là tách đôi. Phần dùng chung — entity ở đâu, migration ở đâu, đặt tên cột thế nào — nằm trong một hằng số để hai bên không trôi khỏi nhau:
import type { DataSourceOptions } from 'typeorm';
import { SnakeNamingStrategy } from './snake-naming.strategy';
// Moi thu KHONG phu thuoc moi truong. Chi thieu duy nhat `url`.
export const sharedOptions = {
type: 'postgres',
entities: [__dirname + '/../**/*.entity.{ts,js}'],
migrations: [__dirname + '/migrations/*.{ts,js}'],
// Sinh cot snake_case tu thuoc tinh camelCase: passwordHash -> password_hash
namingStrategy: new SnakeNamingStrategy(),
// Bat buoc false. Xem canh bao ben duoi.
synchronize: false,
} satisfies Partial<DataSourceOptions>;
typeorm-naming-strategies4.1.0) đã
ngừng cập nhật từ 2022 và khai báo
peerDependencies chỉ chấp nhận typeorm@^0.2.0 || ^0.3.0 — cài thẳng vào dự án
đang dùng TypeORM hiện tại là dính ERESOLVE ngay lập tức, và cách "sửa" phổ biến nhất trên
mạng là thêm cờ --legacy-peer-deps, tức là tắt hẳn việc npm kiểm tra tương thích thay vì
giải quyết nguyên nhân. Việc cần làm — sinh cột
snake_case từ tên thuộc tính camelCase — chỉ cần một
class con của DefaultNamingStrategy mà chính TypeORM đã xuất sẵn, không cần thêm phụ thuộc
nào:
import { DefaultNamingStrategy, NamingStrategyInterface } from 'typeorm';
import { snakeCase } from 'typeorm/util/StringUtils';
export class SnakeNamingStrategy extends DefaultNamingStrategy implements NamingStrategyInterface {
override tableName(targetName: string, userSpecifiedName: string | undefined): string {
return userSpecifiedName ?? snakeCase(targetName);
}
override columnName(propertyName: string, customName: string | undefined, embeddedPrefixes: string[]): string {
return snakeCase(embeddedPrefixes.concat(customName ?? propertyName).join('_'));
}
override relationName(propertyName: string): string {
return snakeCase(propertyName);
}
override joinColumnName(relationName: string, referencedColumnName: string): string {
return snakeCase(`${relationName}_${referencedColumnName}`);
}
override joinTableName(firstTableName: string, secondTableName: string): string {
return snakeCase(`${firstTableName}_${secondTableName}`);
}
override joinTableColumnName(tableName: string, propertyName: string, columnName?: string): string {
return snakeCase(`${tableName}_${columnName ?? propertyName}`);
}
}
Bản cho CLI. Đây là nơi duy nhất trong dự án còn đọc process.env trực tiếp,
và nó có lý do rõ ràng: script này chạy ngoài Nest:
import { config } from 'dotenv';
import { DataSource } from 'typeorm';
import { sharedOptions } from './typeorm.options';
config();
const url = process.env['DATABASE_URL'];
if (!url) {
throw new Error('Thiếu DATABASE_URL — CLI không chạy qua ConfigModule nên phải tự kiểm.');
}
export const AppDataSource = new DataSource({ ...sharedOptions, url });
7.2 Viết entity
Bắt đầu bằng credit_entries — bảng mang quyết định thiết kế ở mục 6.2, và cũng là bảng ngắn
nhất:
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { User } from '../auth/user.entity';
export type CreditReason = 'topup' | 'transcode' | 'refund';
@Entity('credit_entries')
// Index theo dung truy van se chay nhieu nhat: cong so du cua mot user
@Index(['userId', 'createdAt'])
// Chi muc DUY NHAT tren job_id — dieu kien de thao tac tru tien "binh than"
// o Part 3 (muc 4.2) co gi de ON CONFLICT DO NOTHING khop vao. Loai tru NULL
// vi nap tien/hoan tien khong gan voi job nao ca.
@Index(['jobId'], { unique: true, where: '"job_id" IS NOT NULL' })
export class CreditEntry {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column('uuid')
userId!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User;
// So nguyen, co the am. KHONG dung float cho thu phai cong dung.
@Column('integer')
delta!: number;
@Column({ type: 'varchar', length: 32 })
reason!: CreditReason;
// Nap tien thi khong gan voi job nao -> nullable
@Column({ type: 'uuid', nullable: true })
jobId!: string | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}
Không có phương thức update() hay delete() nào cho bảng này — đó là toàn bộ ý
nghĩa của sổ cái. Mọi thay đổi số dư là một dòng mới.
Tiếp đến jobs, nơi trạng thái là enum thật của Postgres chứ không phải chuỗi tự do:
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Video } from '../media/video.entity';
export const JOB_STATUSES = ['queued', 'processing', 'completed', 'failed'] as const;
export type JobStatus = (typeof JOB_STATUSES)[number];
@Entity('jobs')
// Worker lay job theo trang thai + thu tu vao truoc ra truoc
@Index(['status', 'createdAt'])
export class Job {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column('uuid')
videoId!: string;
@ManyToOne(() => Video, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'video_id' })
video!: Video;
@Column({ type: 'enum', enum: JOB_STATUSES, default: 'queued' })
status!: JobStatus;
@Column({ type: 'integer', default: 0 })
attempts!: number;
@Column({ type: 'text', nullable: true })
error!: string | null;
// Thieu cot nay thi khong phan biet duoc job dang chay voi job da chet
@Column({ type: 'timestamptz', nullable: true })
startedAt!: Date | null;
@Column({ type: 'timestamptz', nullable: true })
finishedAt!: Date | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
as const — một mảng, hai công dụngJOB_STATUSES vừa là giá trị chạy được (TypeORM dùng để tạo enum trong Postgres), vừa sinh
ra kiểu JobStatus ở compile time nhờ (typeof JOB_STATUSES)[number]. Một nguồn
sự thật duy nhất — thêm trạng thái mới thì cả database lẫn kiểu TypeScript cùng cập nhật, không thể lệch
nhau. Viết tay
type JobStatus = 'queued' | 'processing' | ... song song với mảng là mở đường cho
hai danh sách trôi khỏi nhau sau vài tháng. Đây cũng chính là lý do ở mục 4 kiểu
AppConfig được suy ra từ schema zod thay vì gõ lại thành một interface riêng —
cùng một nguyên tắc, hai chỗ khác nhau trong dự án.
Ba entity còn lại theo đúng khuôn trên — viết ra đây để migration ở mục sau sinh đủ bảng trong một lần, thay vì phải chạy thêm một migration nữa khi Part 2 cần tới chúng.
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', unique: true })
email!: string;
@Column({ type: 'varchar' })
passwordHash!: string;
@Column({ type: 'varchar', default: 'free' })
plan!: string;
// Dung cho phan quyen o Part 2 (RolesGuard). Dat o day, khong phai them
// bang mot migration rieng sau nay — so do o tren da cap nhat theo.
@Column({ type: 'varchar', default: 'user' })
role!: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('videos')
export class Video {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column('uuid')
userId!: string;
// Duong dan file tren dia (hoac object storage key) — khong luu noi dung
// file trong database, chi luu noi tim thay no.
@Column({ type: 'varchar' })
originalKey!: string;
// Ten file goc nguoi dung upload — dung o Part 2 khi hien thi danh sach video.
@Column({ type: 'varchar', nullable: true })
originalName!: string | null;
@Column({ type: 'bigint', nullable: true })
size!: string | null;
@Column({ type: 'integer', nullable: true })
duration!: number | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}
Bảng cuối cùng của mục này là refresh_tokens — khai báo luôn từ giờ để migration ở mục 7.4
sinh đủ bảng trong một lần:
import { Column, CreateDateColumn, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { User } from './user.entity';
@Entity('refresh_tokens')
export class RefreshToken {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User;
@Column({ name: 'user_id' })
userId!: string;
// SHA-256 cua token. Khong bao gio luu ban goc.
@Index({ unique: true })
@Column({ name: 'token_hash', length: 64 })
tokenHash!: string;
// Ca mot chuoi xoay vong dung chung mot family_id.
@Index()
@Column({ name: 'family_id' })
familyId!: string;
// null = chua dung. Co gia tri = da dung, dung lai la bao dong.
@Column({ name: 'used_at', type: 'timestamptz', nullable: true })
usedAt!: Date | null;
@Column({ name: 'expires_at', type: 'timestamptz' })
expiresAt!: Date;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
}
Logic xoay vòng, và thu hồi cả family khi token cũ bị dùng lại, được viết đầy đủ ở Part 2.
7.3 Nối TypeORM vào ứng dụng
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppConfigModule } from './config/config.module';
import type { AppConfig } from './config/configuration';
import { sharedOptions } from './database/typeorm.options';
@Module({
imports: [
AppConfigModule,
// forRootAsync: cho ConfigModule san sang roi moi dung ConfigService
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService<AppConfig, true>) => ({
...sharedOptions,
url: config.get('DATABASE_URL', { infer: true }),
logging: config.get('NODE_ENV', { infer: true }) === 'development',
}),
}),
],
})
export class AppModule {}
7.4 Sinh và chạy migration
Thêm script vào package.json:
{
"scripts": {
"typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts",
"migration:generate": "npm run typeorm -- migration:generate",
"migration:run": "npm run typeorm -- migration:run",
"migration:revert": "npm run typeorm -- migration:revert"
}
}
Sinh migration đầu tiên. TypeORM so entity với database thật rồi tự viết SQL:
npm run migration:generate -- src/database/migrations/InitSchema
# → Migration src/database/migrations/1755...-InitSchema.ts has been generated successfully.
Mở file vừa sinh ra và đọc nó. Đây là bước hay bị bỏ qua nhất, và cũng là lý do migration tồn tại — bạn thấy chính xác điều gì sắp xảy ra với dữ liệu:
export class InitSchema1755000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TYPE "public"."jobs_status_enum" AS ENUM('queued','processing','completed','failed')`,
);
await queryRunner.query(`CREATE TABLE "credit_entries" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"user_id" uuid NOT NULL,
"delta" integer NOT NULL,
"reason" character varying(32) NOT NULL,
"job_id" uuid,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_credit_entries" PRIMARY KEY ("id")
)`);
// ... cac bang con lai
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Duong lui, sinh tu dong theo thu tu nguoc lai
}
}
Chạy nó:
npm run migration:run
# → Migration InitSchema1755000000000 has been executed successfully.
Kiểm bằng chính psql trong container, đừng tin thông báo:
docker exec -it forge-postgres psql -U forge -d media_forge -c '\dt'
# List of relations
# Schema | Name | Type | Owner
# --------+-----------------+-------+-------
# public | credit_entries | table | forge
# public | jobs | table | forge
# public | migrations | table | forge
# public | refresh_tokens | table | forge
# public | users | table | forge
# public | videos | table | forge
Bảng migrations là của TypeORM: nó ghi lại migration nào đã chạy, nên lần sau gọi
migration:run sẽ không chạy lại cái cũ.
migration:run là có ngay đúng cấu trúc bạn đang có; máy chủ triển khai cũng vậy.
Ngược lại, đừng bao giờ sửa một migration đã chạy trên môi trường khác. Nó đã được ghi vào bảng
migrations ở đó rồi nên sẽ không chạy lại — schema hai nơi lệch nhau âm thầm. Cần đổi gì
thì sinh migration mới.
8. ACID trong thực tế: tự tay làm hỏng số dư
Đến đây database đã sẵn sàng. Viết chức năng trừ credit theo cách tự nhiên nhất, rồi tấn công nó bằng đúng thứ sẽ xảy ra trên production: nhiều request cùng lúc.
8.1 Bản viết tự nhiên — và sai
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreditEntry, type CreditReason } from './credit-entry.entity';
@Injectable()
export class BillingService {
constructor(
@InjectRepository(CreditEntry)
private readonly entries: Repository<CreditEntry>,
) {}
async getBalance(userId: string): Promise<number> {
const row = await this.entries
.createQueryBuilder('e')
.select('COALESCE(SUM(e.delta), 0)', 'balance')
.where('e.user_id = :userId', { userId })
.getRawOne<{ balance: string }>();
return Number(row?.balance ?? 0);
}
async charge(userId: string, amount: number, reason: CreditReason): Promise<void> {
// 1. Doc so du
const balance = await this.getBalance(userId);
// 2. Kiem tra
if (balance < amount) {
throw new BadRequestException('Không đủ credit');
}
// 3. Ghi
await this.entries.insert({ userId, delta: -amount, reason });
}
}
Đọc lại ba bước đó. Giữa bước 2 và bước 3 có một khoảng thời gian — rất ngắn, nhưng khác không. Và trong khoảng đó, không có gì ngăn một request khác cũng đọc được đúng số dư cũ.
8.2 Tái hiện lỗi
Nạp cho một người dùng 50 credit, rồi bắn 10 request đồng thời, mỗi request trừ 10. Đúng ra chỉ 5 request được phép thành công:
# Tao user mau truoc — credit_entries co khoa ngoai tro ve users, khong the
# nap tien cho mot id chua ton tai.
docker exec -it forge-postgres psql -U forge -d media_forge -c \
"INSERT INTO users (id, email, password_hash)
VALUES ('11111111-1111-1111-1111-111111111111', '[email protected]', 'khong-dung-de-dang-nhap');"
# Nap 50 credit
docker exec -it forge-postgres psql -U forge -d media_forge -c \
"INSERT INTO credit_entries (user_id, delta, reason)
VALUES ('11111111-1111-1111-1111-111111111111', 50, 'topup');"
# 10 request DONG THOI, moi cai tru 10
seq 1 10 | xargs -P 10 -I{} curl -s -o /dev/null -w '%{http_code}\n' \
-X POST http://localhost:3000/billing/charge \
-H 'Content-Type: application/json' \
-d '{"userId":"11111111-1111-1111-1111-111111111111","amount":10}'
Cờ -P 10 của xargs là mấu chốt: nó chạy 10 tiến trình song song thay vì lần
lượt. Chạy tuần tự thì lỗi này không bao giờ xuất hiện — và đó chính là lý do nó lọt qua mọi lần thử tay.
Đếm lại số dư:
docker exec -it forge-postgres psql -U forge -d media_forge -c \
"SELECT SUM(delta) AS balance, COUNT(*) FILTER (WHERE delta < 0) AS so_lan_tru
FROM credit_entries
WHERE user_id = '11111111-1111-1111-1111-111111111111';"
Số lần trừ sẽ nhiều hơn 5, và số dư âm. Con số cụ thể thay đổi theo từng lần chạy — đó là dấu hiệu nhận biết của race condition: nó không sai theo một cách cố định để bạn tìm ra.
READ COMMITTED). Transaction bảo đảm "hoặc tất cả, hoặc không gì cả" — chữ A trong ACID. Nhưng ở đây không có bước nào thất bại để cần quay lui: cả hai transaction đều chạy trót lọt. Vấn đề nằm ở chữ I (Isolation) — hai transaction nhìn thấy cùng một trạng thái cũ, và
READ COMMITTED hoàn toàn cho phép điều đó.
8.3 Vá bằng khoá bi quan
Cách trực tiếp nhất: khi đọc, nói với Postgres rằng ta sắp ghi — để nó chặn request thứ hai lại cho đến khi request thứ nhất xong.
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { CreditEntry, type CreditReason } from './credit-entry.entity';
@Injectable()
export class BillingService {
constructor(
@InjectRepository(CreditEntry)
private readonly entries: Repository<CreditEntry>,
private readonly dataSource: DataSource,
) {}
async getBalance(userId: string): Promise<number> {
const row = await this.entries
.createQueryBuilder('e')
.select('COALESCE(SUM(e.delta), 0)', 'balance')
.where('e.user_id = :userId', { userId })
.getRawOne<{ balance: string }>();
return Number(row?.balance ?? 0);
}
async charge(userId: string, amount: number, reason: CreditReason): Promise<void> {
await this.dataSource.transaction(async (manager) => {
// Khoa DONG cua user nay. Request thu hai se DUNG o day cho toi khi
// transaction dau tien commit.
await manager.query('SELECT id FROM users WHERE id = $1 FOR UPDATE', [userId]);
const row = await manager
.createQueryBuilder(CreditEntry, 'e')
.select('COALESCE(SUM(e.delta), 0)', 'balance')
.where('e.user_id = :userId', { userId })
.getRawOne<{ balance: string }>();
const balance = Number(row?.balance ?? 0);
if (balance < amount) {
throw new BadRequestException('Không đủ credit');
}
await manager.insert(CreditEntry, { userId, delta: -amount, reason });
});
}
}
Điểm tinh tế: khoá đặt trên dòng users, không phải trên credit_entries. Lý do là
FOR UPDATE khoá những dòng đang tồn tại — mà vấn đề ở đây lại là các dòng
sắp được thêm vào. Dòng users đóng vai một điểm hẹn: mọi thao tác trừ tiền của cùng
một người dùng đều phải đi qua nó.
Chạy lại đúng lệnh ở mục 8.2. Lần này đúng 5 request trả 201, 5 request trả 400,
và số dư dừng ở 0.
BillingService một mình chưa lộ ra ngoài được — nó chỉ là một provider. Cần thêm một
controller nhận request HTTP và một module gói cả hai lại, đúng khuôn các module khác trong dự án; thiếu
hai thứ này thì lệnh curl ở mục 8.2 chỉ nhận về 404, không phải 201 hay
400.
DTO đứng trước, kiểm dữ liệu vào bằng đúng bộ class-validator đã cài từ mục 4.1:
import { IsIn, IsInt, IsUUID, Min } from 'class-validator';
import type { CreditReason } from './credit-entry.entity';
export class ChargeDto {
@IsUUID()
userId!: string;
@IsInt()
@Min(1)
amount!: number;
@IsIn(['topup', 'transcode', 'refund'])
reason!: CreditReason;
}
Controller gọi thẳng xuống BillingService, nhận userId từ chính body — Part 2 sẽ
vá lại đúng chỗ này khi thêm xác thực:
import { Body, Controller, Post } from '@nestjs/common';
import { BillingService } from './billing.service';
import { ChargeDto } from './charge.dto';
@Controller('billing')
export class BillingController {
constructor(private readonly billing: BillingService) {}
@Post('charge')
charge(@Body() dto: ChargeDto) {
return this.billing.charge(dto.userId, dto.amount, dto.reason);
}
}
Module gói cả hai lại, và export BillingService — Part 3 cần tới nó khi
WorkerModule gọi lại đúng hàm trừ credit này từ phía worker:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CreditEntry } from './credit-entry.entity';
import { BillingController } from './billing.controller';
import { BillingService } from './billing.service';
@Module({
imports: [TypeOrmModule.forFeature([CreditEntry])],
controllers: [BillingController],
providers: [BillingService],
exports: [BillingService],
})
export class BillingModule {}
Cuối cùng, đăng ký BillingModule vào AppModule — thiếu bước này thì route vẫn
nằm trong code nhưng Nest không bao giờ khởi tạo nó:
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppConfigModule } from './config/config.module';
import type { AppConfig } from './config/configuration';
import { sharedOptions } from './database/typeorm.options';
import { BillingModule } from './billing/billing.module';
@Module({
imports: [
AppConfigModule,
// forRootAsync: cho ConfigModule san sang roi moi dung ConfigService
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService<AppConfig, true>) => ({
...sharedOptions,
url: config.get('DATABASE_URL', { infer: true }),
logging: config.get('NODE_ENV', { infer: true }) === 'development',
}),
}),
BillingModule,
],
})
export class AppModule {}
8.4 Còn cách nào khác
Khoá bi quan không phải lựa chọn duy nhất. Hai hướng còn lại, và lý do bài này chọn cách trên:
Khoa bi quan (FOR UPDATE) - request thu 2 CHO
+ De hieu, de suy luan, khong can retry
- Giam thong luong khi tranh chap nhieu; co nguy co deadlock neu khoa
nhieu dong sai thu tu
Muc co lap SERIALIZABLE - Postgres tu phat hien va HUY
+ Khong can nghi ve khoa
- Phai viet retry cho loi 40001, va no huy ca transaction da chay gan xong
Khoa lac quan (version column) - kiem tra luc ghi, that bai thi thu lai
+ Thong luong cao khi it tranh chap
- Hop voi UPDATE mot dong; kho ap cho mo hinh so cai chi INSERT
Với sổ cái, khoá bi quan là lựa chọn hợp lý: thao tác rất ngắn (một SUM và một
INSERT), tranh chấp chỉ xảy ra giữa các request của cùng một người dùng — không phải
toàn hệ thống — và không cần vòng lặp thử lại nào.
Đây cũng là lý do phần này nằm ngay Part 1, trước cả xác thực và upload: những phần sau đều xây trên giả định "trừ tiền là đáng tin".
userId đang là tham số do client
tự gửi. Part 2 dựng xác thực (JWT + refresh token có xoay vòng), API Gateway, giới hạn tần suất, nginx,
và luồng upload/download theo stream.
Bình luận