Chuyển tới nội dung chính

Kiến trúc hệ thống

Tài liệu kỹ thuật

Trang này dành cho người lập trình. Viết theo mẫu arc42 rút gọn.

Ràng buộc chi phối thiết kế

Ràng buộcẢnh hưởng tới kiến trúc
Người dùng không chuyênGiao diện đơn giản, ít bước, thông báo lỗi bằng tiếng Việt dễ hiểu
Chạy trên máy chủ riêng của trườngKhông dùng dịch vụ tính phí theo số người dùng
Quy mô một văn phòng, dưới 50 người dùngKhông cần kiến trúc phân tán, một máy chủ là đủ
Phải mở rộng được ra nhiều phòng banPhạm vi dữ liệu tính theo cây phòng ban ngay từ đầu
Toàn bộ tiếng ViệtTách chuỗi hiển thị ra tệp riêng từ đầu

Tổng quan

Trình duyệt (máy cán bộ)
│ HTTPS

nginx ── phục vụ tệp tĩnh + chuyển tiếp /api

├──→ Giao diện — React tĩnh (đã dựng sẵn)

└──→ Máy chủ ứng dụng — NestJS + TypeScript

├──→ PostgreSQL 16 (dữ liệu)
├──→ Thư mục tệp đính kèm (đĩa máy chủ)
└──→ Tác vụ định kỳ (quét hạn, tạo thông báo)

Lựa chọn công nghệ

Thành phầnChọnLý do
Giao diệnReact + TypeScript + Vite, nền shadcn-adminCó sẵn màn hình danh sách công việc, bộ lọc, quản lý người dùng. Giấy phép MIT. Dựng ra tệp tĩnh, không cần chạy thường trú
Máy chủ ứng dụngNestJS + TypeScript, chạy trên nền FastifyCấu trúc phân lớp sẵn có, hợp với đội nhiều người. Cùng ngôn ngữ với giao diện nên dùng chung kiểu dữ liệu
Cơ sở dữ liệuPostgreSQL 16Xem lý do chi tiết
Truy cập dữ liệuPrismaSinh kiểu dữ liệu tự động, quản lý chuyển đổi cấu trúc bảng rõ ràng
Xác thựcTự làm, dùng JWTBản gốc shadcn-admin dùng dịch vụ Clerk tính phí theo người dùng, phải gỡ
Xuất ExcelExcelJSXuất được tệp có định dạng, không chỉ CSV
Tài liệu API@nestjs/swaggerSinh tự động từ mã nguồn, không lệch với thực tế

Vì sao dùng chung một ngôn ngữ cho cả hai đầu

Dùng TypeScript cả giao diện lẫn máy chủ cho phép định nghĩa kiểu dữ liệu một lần rồi dùng chung. Đổi tên một trường thì trình biên dịch báo lỗi ở mọi chỗ liên quan, thay vì để lỗi lộ ra lúc chạy.

Vì sao NestJS

NestJS áp đặt sẵn cách chia mã nguồn thành mô-đun, tầng điều khiển, tầng dịch vụ. Với dự án nhiều người cùng làm, việc này quan trọng hơn là được tự do sắp xếp: người mới vào nhìn thư mục là biết mã nguồn nằm ở đâu.

Ba thứ NestJS có sẵn khớp thẳng với nhu cầu của ADang:

Cơ chế sẵn cóDùng cho
GuardTầng phân quyền — chặn trước khi vào tầng nghiệp vụ
ValidationPipeKiểm tra dữ liệu đầu vào, trả lỗi tiếng Việt theo mẫu thống nhất
@nestjs/scheduleTác vụ định kỳ quét hạn, không cần công cụ ngoài

Chạy NestJS trên nền Fastify thay vì Express mặc định, vì Fastify xử lý nhanh hơn và đã có kinh nghiệm vận hành trên chính máy chủ này.

Vì sao không dùng Next.js

Giao diện là ứng dụng nội bộ sau màn đăng nhập, không cần công cụ tìm kiếm đọc được, cũng không cần dựng trang phía máy chủ. Dựng ra tệp tĩnh thì nginx phục vụ trực tiếp, không cần tiến trình Node chạy thường trú cho phần giao diện. Đơn giản hơn khi vận hành.

Phân lớp phía máy chủ

Controller — nhận yêu cầu, kiểm tra dữ liệu đầu vào (ValidationPipe)

Guard — kiểm tra đăng nhập, vai trò và phạm vi dữ liệu

Service — quy tắc nghiệp vụ, luồng trạng thái

Repository — Prisma, truy vấn PostgreSQL

Mỗi nhóm chức năng là một mô-đun NestJS riêng, khớp với 5 nhóm trong danh sách chức năng:

src/
├── auth/ đăng nhập, phiên làm việc
├── users/ tài khoản, phòng ban, vai trò
├── tasks/ công việc, bình luận, tệp đính kèm, lịch sử
├── dashboard/ màn hình tổng quan, bộ lọc
├── notifications/ thông báo, tác vụ định kỳ
├── reports/ xuất Excel
└── common/ guard, pipe, bộ lọc lỗi dùng chung

Tầng phân quyền tách riêng và bắt buộc đi qua. Đây là chỗ hay bị bỏ sót nhất. Ẩn nút trên giao diện không phải là phân quyền — người dùng sửa địa chỉ trên trình duyệt là vào được. Mọi yêu cầu đọc hay ghi dữ liệu công việc đều phải qua tầng này.

Cài đặt bằng hai Guard nối tiếp, khai báo ở cấp mô-đun để không thể quên:

GuardViệc
JwtAuthGuardXác định người dùng từ phiên đăng nhập
ScopeGuardTính tập phòng ban được phép, gắn vào yêu cầu

Quy tắc: hàm truy vấn công việc trong tầng Repository luôn nhận tham số phạm vi, không có phiên bản nào lấy tất cả. Nếu một hàm không nhận tham số này thì đó là lỗi thiết kế, không phải tiện lợi.

Cách tính phạm vi dữ liệu

Mỗi yêu cầu được xử lý theo ba bước:

  1. Lấy vai trò và phòng ban của người dùng từ user_departments
  2. Tính tập phòng ban họ được xem, bằng cách duyệt cây từ phòng của họ xuống
  3. Ghép điều kiện đó vào mọi truy vấn công việc
Vai tròTập phòng ban
Ban Giám hiệuToàn bộ cây từ gốc
Trưởng phòngPhòng mình và các phòng con
Nhân viênKhông tính theo phòng — lọc theo task_assignees.user_id

Tác vụ định kỳ

Dùng @nestjs/schedule trong mô-đun notifications, chạy cứ 15 phút một lần:

ViệcNội dung
Nhắc trước hạnTìm việc còn đúng một ngày tới hạn, tạo thông báo
Cảnh báo quá hạnTìm việc đã qua hạn mà chưa xong, báo cho người phụ trách và người giao
Gửi thông báoĐẩy các thông báo chưa gửi

Tách phần tạo thông báo khỏi phần gửi thông báo. Nhờ vậy khi thêm kênh Zalo ở giai đoạn sau, chỉ viết thêm phần gửi, phần tạo giữ nguyên.

Bảo mật

Vấn đềCách xử lý
Mật khẩuMã hoá bằng bcrypt, không lưu bản gốc
Phiên đăng nhậpJWT lưu trong cookie HttpOnly, hết hạn sau 8 giờ
Phân quyềnKiểm tra ở máy chủ cho mọi yêu cầu
Tệp đính kèmLưu ngoài thư mục web, tải qua đường dẫn có kiểm tra quyền
Ghi vếtMọi thao tác ghi dữ liệu vào activity_log
Đường truyềnBắt buộc HTTPS

Triển khai

Môi trườngNơi chạy
Máy cá nhânVite chạy sẵn, PostgreSQL trong Docker
Thử nghiệmstg.sotaylop.com
Chính thứcsotaylop.com — cùng máy chủ với bản thử, nhưng cổng và cơ sở dữ liệu riêng

Chi tiết xem Phân chia môi trường.

Những gì cố tình chưa làm

Ghi lại để người sau không tưởng là thiếu sót.

Không làmVì saoKhi nào cần xem lại
Bộ nhớ đệm RedisDưới 50 người dùng, PostgreSQL thừa sứcKhi vượt 500 người dùng
Tách nhiều dịch vụ nhỏĐội nhỏ, một khối dễ vận hành hơnKhi có nhiều đội cùng phát triển
Cập nhật thời gian thựcNgười dùng tải lại trang là đủKhi cần trao đổi tức thời trong việc
Ứng dụng cài trên điện thoạiGiao diện web hiển thị được trên điện thoạiKhi cán bộ chủ yếu dùng điện thoại
Lưu tệp trên dịch vụ đám mâyĐĩa máy chủ đủ cho quy mô hiện tạiKhi tệp đính kèm vượt 100 GB