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.

🔍 Hết Part 1 bạn sẽ có gì
Một API NestJS chạy trong Docker, nối PostgreSQL, tạo được video và trừ credit an toàn dưới tải đồng thời — kể cả khi hai request cùng lúc nhắm vào một số dư.

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:

Trình duyệt React + TS nginx TLS · tĩnh Ứng dụng NestJS một tiến trình, nhiều module auth media job ws worker tiến trình riêng · ffmpeg PostgreSQL nguồn sự thật Redis queue · cache · pubsub đẩy job vào hàng đợi tiến độ về qua pub/sub Một ứng dụng NestJS + một worker riêng. Chưa có microservice nào.
Điểm cần nhớ: request của người dùng không bao giờ chờ ffmpeg chạy xong. Nó chỉ tạo job rồi trả về ngay; kết quả đi ngược lại bằng một đường khác.
⚠️ Cạm bẫy: để việc nặng chạy ngay trong request
Cách viết tự nhiên nhất là nhận file xong gọi luôn ffmpeg rồi trả kết quả. Nó chạy được trên máy bạn với một video 5 giây. Trên thật thì request treo vài phút, proxy timeout ở 60 giây, người dùng bấm lại, và bạn có hai tiến trình ffmpeg cho cùng một file. Ranh giới request nhanh — việc nặng ở nơi khác phải được vạch ngay từ kiến trúc, không phải sửa sau.

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.

Cấu trúc dự án sau Part 1
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 3
  • web/ — giao diện React, thêm ở Part 2
  • nginx/ — cấu hình reverse proxy, thêm ở Part 2
💡 Vì sao chia theo miền nghiệp vụ lại quan trọng ở đây
Đây không phải chuyện thẩm mỹ. Đến Part 4, việc tách microservice chỉ dễ dàng nếu ranh giới đã nằm đúng chỗ: 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:

Terminal
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:

Terminal
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:

Terminal
# 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 .env và 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ếu DATABASE_URL thì 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ột interface khô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.tsapp.service.ts chỉ để in "Hello World". Xoá chúng đi — giữ lại là mang theo rác suốt cả dự án:

Terminal
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:

src/app.module.ts
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:

Terminal
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:

.env.example
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
Terminal
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:

src/config/configuration.ts
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:

src/config/config.module.ts
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 {}
💡 Vì sao validate chứ không phải validationSchema
Tuỳ chọn 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:

Cách đọc cấu hình trong một service bất kỳ
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 });
💡 Tham số true trong ConfigService<AppConfig, true>
Nó bật chế độ WasValidated: 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.
💡 Báo hết lỗi một lần, không nhỏ giọt
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:

src/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:

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:
Terminal
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
⚠️ Cạm bẫy: cổng 5432 mở không có nghĩa là dùng được
Container Postgres mở cổng gần như tức thì, nhưng phải mất vài giây nữa mới nhận truy vấn. Nếu app khởi động ngay sau 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ả:

tsconfig.json — thêm vào compilerOptions
{
  "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:

probe.ts — bốn lỗi mà "strict" một mình không bắ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:

Kết quả chạy thật: npx tsc --noEmit
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'].
💡 Vì sao đáng chịu phiền ngay từ đầu
Cả bốn lỗi trên đều sẽ xuất hiện thật ở các phần sau: 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.
⚠️ Cờ thứ ba phá luôn file bạn chưa từng đụng tới
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 }));
📝 Một thứ strict đã cho sẵn: biến trong catch
Nhiều người vẫn nghĩ catch (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 errunknownerr.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ùng unknown rồi thu hẹp bằng type guard. any tắ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 interface khô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ản
  • refresh_tokens — phiên đăng nhập, mỗi thiết bị một dòng
  • videos — bản gốc người dùng tải lên
  • media_assets — các bản đã chuyển mã sinh ra từ một video
  • jobs — một lần chuyển mã, có vòng đời trạng thái
  • credit_entries — sổ cái credit
users id · email password_hash plan · role refresh_tokens token_hash · family_id used_at · expires_at credit_entries delta (+/−) reason · job_id chỉ THÊM, không sửa videos id · user_id original_key size · duration jobs video_id · status attempts · error started_at finished_at media_assets video_id · kind resolution · key bytes 1 — N 1 — N 1 — N 1 — N 1 — N job nào trừ Không bảng nào có cột "số dư" — số dư luôn là tổng của credit_entries Đường đứt = khoá ngoại tuỳ chọn (nạp tiền thì không có job_id)
Sáu bảng, không hơn. Điểm bất thường duy nhất nằm ở 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:

Số dư là một phép cộng, không phải một cột
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...'
💡 Một hệ quả bất ngờ: hoàn tiền không cần logic đặc biệt
Với cột số, hoàn tiền là một nhánh code riêng phải nhớ cộng lại đúng con số đã trừ. Với sổ cái, hoàn tiền chỉ là thêm một dòng nữadelta 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ờ.
⚠️ Cái giá phải trả — và khi nào nó thành vấn đề
Đọc số dư giờ là 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:

Các chuyển trạng thái HỢP LỆ
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.

⚠️ Cạm bẫy: job kẹt ở processing vĩnh viễn
Worker nhận job, đặt trạng thái processing, 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ải timestamp. 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 float cho thứ phải cộng đúng.
  • Trạng thái dùng enum của Postgres chứ không phải varchar tự do — để database từ chối luôn giá trị lạ, thay vì phát hiện ra sau ba tháng.
🔍 Ràng buộc nên đặt ở database hay ở ứng dụng?
Câu trả lời ngắn: cả hai, nhưng vì hai lý do khác nhau.

Ứ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:

src/database/typeorm.options.ts — phần dùng chung
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>;
⚠️ Đừng cài typeorm-naming-strategies
Gói này là lựa chọn quen thuộc cho đúng việc trên, nhưng bản mới nhất của nó (4.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:
src/database/snake-naming.strategy.ts
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:

src/database/data-source.ts — chỉ dành cho TypeORM CLI
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:

src/billing/credit-entry.entity.ts
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:

src/job/job.entity.ts
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ụng
JOB_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.

src/auth/user.entity.ts
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;
}
src/media/video.entity.ts
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:

src/auth/refresh-token.entity.ts
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

src/app.module.ts
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:

package.json — phần scripts
{
  "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:

Terminal
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:

src/database/migrations/1755…-InitSchema.ts (trích)
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ó:

Terminal
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:

Terminal
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 phải được commit vào git
File migration là lịch sử schema, không phải file tạm. Đồng nghiệp kéo code về chạy 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

src/billing/billing.service.ts — bản đầu tiên
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:

Terminal
# 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ư:

Terminal
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.

A B đọc: 50 đọc: 50 50 ≥ 10 ✓ 50 ≥ 10 ✓ ghi −10 ghi −10 cửa sổ nguy hiểm: cả hai đều tin số dư là 50 Hai lần kiểm tra đều đúng tại thời điểm kiểm tra. Chỉ có kết quả là sai.
Không request nào làm gì sai. Lỗi nằm ở chỗ giữa đọc và ghi có một khoảng trống.
⚠️ Bọc trong transaction cũng chưa đủ
Phản xạ đầu tiên là gói cả ba bước vào một transaction. Nó không cứu được ở mức cô lập mặc định của PostgreSQL (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.

src/billing/billing.service.ts — bản đã vá
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:

src/billing/charge.dto.ts
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:

src/billing/billing.controller.ts
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:

src/billing/billing.module.ts
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ó:

src/app.module.ts — thêm BillingModule
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:

So sánh ba cách
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.

🔍 Điều đáng nhớ nhất từ mục này
Bản đầu tiên ở mục 8.1 không có lỗi cú pháp, không có lỗi logic khi đọc từng dòng. Nó đúng với mọi bài test chạy tuần tự. Nó chỉ sai khi có hai người dùng bấm cùng lúc — và đó là điều kiện mặc định của mọi hệ thống có người dùng thật.

Đâ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".
💡 Part 2 tiếp tục từ đâu
Hệ thống hiện tại trừ tiền đúng, nhưng ai cũng gọi được — 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.

Các phần trong loạt bài

Part 2: Xác thực, nginx, rate limit & streaming Quay lại Blog

Bình luận