Nhật ký công việc cho nhiệm vụ "Hợp nhất dịch vụ next-js và app"

22 сент. 2026 г., 10:19:05

Di chuyển trang web cũ sang engine mới và chuẩn hóa mô hình dữ liệu

Công việc chính ở giai đoạn này không chỉ đơn thuần là di chuyển từng trang hoặc tài nguyên riêng lẻ, mà thực chất là tái cấu trúc trang web cũ trên engine mới haih-agent cùng với việc loại bỏ hoàn toàn cấu trúc dữ liệu cũ vốn hình thành từ thời MODX.

Vấn đề ở đây cơ bản cũng giống như khi nâng cấp các trang web cũ khác: theo thời gian, xung quanh CMS ban đầu đã tích lũy một cấu trúc dữ liệu riêng, rất nhiều template, các trường TV, các loại tài nguyên đặc biệt và logic ứng dụng gắn liền với các đặc điểm của engine cũ. Do đó, việc "cập nhật" ứng dụng thông thường là không đủ — trang web mới cần phải được di chuyển về mặt kiến trúc, bảo lưu nội dung hiện có nhưng loại bỏ các giới hạn kỹ thuật cũ.

Những gì đã có trong phiên bản cũ

Ban đầu, trang web chạy trên MODX, và phần lớn cấu trúc cơ sở dữ liệu vẫn kế thừa mô hình này cho đến nay. Các thực thể ngữ nghĩa khác nhau tồn tại độc lập với nhau và thường có các template cùng tập hợp trường TV riêng.

Cụ thể, các thành phần tồn tại độc lập gồm có:

  • thành phố;
  • công ty;
  • nhà tắm và phòng xông hơi;
  • đánh giá;
  • bài viết và ấn phẩm;
  • tài nguyên/trang thông thường;
  • blog và các loại nội dung khác.

Nói cách khác, sự khác biệt giữa các đối tượng không chỉ được ấn định ở cấp độ ý nghĩa kinh doanh mà còn ở cấp độ cấu trúc lưu trữ: các thực thể khác nhau, template khác nhau, trường dữ liệu khác nhau và logic xử lý riêng biệt. Đối với MODX, đây là cách tổ chức trang web tự nhiên, nhưng khi phát triển ứng dụng mới, sơ đồ này chỉ làm phức tạp thêm việc bảo trì và buộc phải chuyển các giới hạn cũ sang kiến trúc mới.

Chuyển sang KbConcept

Hiện tại, trang web đang dần tách khỏi cơ sở dữ liệu cũ và mô hình thực thể cũ. Đối với tất cả các loại dữ liệu chính, các trình nhập dữ liệu (importers) đã được viết và hoạt động để chuyển các bản ghi từ cơ sở dữ liệu cũ sang cơ sở dữ liệu mới.

Thay đổi kiến trúc cốt lõi nằm ở chỗ hầu như toàn bộ nội dung giờ đây được quy về một thực thể cơ sở duy nhất — KbConcept. Sự khác biệt về ngữ nghĩa giữa các đối tượng không được xác định bằng một bảng riêng hay một lớp mô hình riêng, mà thông qua trường type.

Ví dụ:

  • city:default — thành phố;
  • company:default — công ty;
  • resource:default — trang web thông thường;
  • review:company — đánh giá công ty;
  • blog:default — blog công khai;
  • blog:personal — blog cá nhân;
  • topic:default — ấn phẩm.

Do đó, thay vì một tập hợp lớn các thực thể phình to theo lịch sử, chúng ta có được một mô hình dữ liệu thống nhất với hệ thống kiểu rõ ràng. Điều này giúp đơn giản hóa đáng kể GraphQL schema, frontend, việc tái sử dụng component, các truy vấn, nhập dữ liệu và sự phát triển tiếp theo của trang web.

Đồng thời, việc chuẩn hóa không có nghĩa là mất đi tính kiểu dữ liệu (typing). Ngược lại, TypeScript cho phép định nghĩa khá chặt chẽ các kiểu phụ cụ thể dựa trên KbConcept chung và làm việc an toàn với chúng trong mã nguồn ứng dụng.

Định kiểu KbConcept thông qua template literal types

Tính năng sử dụng chuỗi ký tự mẫu trong kiểu (template literal types) của TypeScript tỏ ra cực kỳ hữu ích trong trường hợp này. Hiện tại, mã nguồn kiểu dữ liệu trông như sau:

import { EnumValueConfigMap, SchemaTypes } from '@pothos/core'
import { KbConceptFragment } from 'src/gql/generated'

export const CustomKbConceptType = {
  City: {
    value: 'city:default',
    description: 'Город',
  },
  Company: {
    value: 'company:default',
    description: 'Компания',
  },
  ResourceDefault: {
    value: 'resource:default',
    description: 'Веб-страница',
  },
  ReviewCompany: {
    value: 'review:company',
    description: 'Отзыв о компании',
  },
  BlogDefault: {
    value: 'blog:default',
    description: 'Публичный блог',
  },
  BlogPersonal: {
    value: 'blog:personal',
    description: 'Персональный блог',
  },
  TopicDefault: {
    value: 'topic:default',
    description: 'Публикация',
  },
} as const satisfies EnumValueConfigMap<SchemaTypes>

export type MapItemCompany = KbConceptFragment & {
  type: `company:${string}`
  lat: number
  lng: number
}

export function isMapItemCompany(
  concept: KbConceptFragment,
): concept is MapItemCompany {
  return concept.type?.startsWith('company:') && concept.lat && concept.lng
    ? true
    : false
}

export type Company = KbConceptFragment & {
  type: `company:${string}`
}

export function isCompany(concept: KbConceptFragment): concept is Company {
  return concept.type?.startsWith('company:') ? true : false
}

export type City = KbConceptFragment & {
  type: `city:${string}`
}

export function isCity(concept: KbConceptFragment): concept is City {
  return concept.type?.startsWith('city:') ? true : false
}

export type ReviewCompany = KbConceptFragment & {
  type: typeof CustomKbConceptType.ReviewCompany.value
}

export function isReviewCompany(
  concept: KbConceptFragment,
): concept is ReviewCompany {
  return concept.type === CustomKbConceptType.ReviewCompany.value
}

Có một số điểm đặc biệt hữu ích ở đây.

as const satisfies ...

Cú pháp:

} as const satisfies EnumValueConfigMap<SchemaTypes>

giải quyết đồng thời hai nhiệm vụ.

as const ngăn không cho TypeScript mở rộng các giá trị như 'city:default' thành kiểu string chung. Kết quả là các chuỗi cụ thể được giữ nguyên dưới dạng kiểu literal. Ví dụ, CustomKbConceptType.ReviewCompany.value có kiểu chính xác là 'review:company' chứ không chỉ là string.

Đồng thời, satisfies EnumValueConfigMap<SchemaTypes> kiểm tra xem toàn bộ đối tượng có tuân thủ hợp đồng mà Pothos mong đợi hay không, nhưng không làm mất đi thông tin chính xác về các giá trị literal bên trong đối tượng. Kết quả là sự kết hợp thuận tiện giữa kiểm tra cấu trúc nghiêm ngặt và suy luận kiểu chính xác tối đa.

Template literal types trong các kiểu dữ liệu

Phần thú vị nhất:

type: `company:${string}`

và tương tự:

type: `city:${string}`

Điều này cho phép thể hiện ngay ở cấp độ hệ thống kiểu nguyên lý cấu trúc của KbConcept.type: một đối tượng được coi là công ty không chỉ khi có một giá trị cụ thể company:default, mà với bất kỳ loại nào thuộc không gian company:*.

Ví dụ, nếu sau này xuất hiện company:premium, company:branch hoặc các biến thể chuyên biệt khác, kiểu Company đã có thể mô tả chúng mà không cần phải tạo thủ công một union riêng biệt.

Nói cách khác, thỏa thuận đặt tên kiểu dạng:

<nhóm>:<phân_loại_phụ>

không chỉ đơn thuần là một quy ước chuỗi trong cơ sở dữ liệu, mà đã trở thành một phần của hệ thống định kiểu tĩnh trong ứng dụng.

Type guards

Các hàm dạng:

export function isCompany(concept: KbConceptFragment): concept is Company

là các type guard do người dùng định nghĩa. Sau khi kiểm tra qua isCompany(concept), TypeScript biết rằng bên trong nhánh tương ứng, concept.type có dạng company:${string}.

Điều tương tự được áp dụng cho các thành phố và đánh giá.

isMapItemCompany đặc biệt hữu ích ở điểm: nó không chỉ kiểm tra tiền tố company:, mà còn thu hẹp đối tượng về một kiểu dữ liệu đảm bảo có sẵn các tọa độ lat và lng dưới dạng số. Nhờ đó, mã nguồn bản đồ phía sau không phải làm việc với một "đối tượng có thể là công ty và có thể có tọa độ", mà làm việc với một đối tượng bản đồ đã được định kiểu chuẩn chỉnh.

Đối với các biến thể chuyên biệt chính xác, bạn có thể sử dụng một cách kiểm tra nghiêm ngặt hơn nữa:

type: typeof CustomKbConceptType.ReviewCompany.value

Ở đây, kiểu ReviewCompany được liên kết trực tiếp với giá trị từ đối tượng trung tâm CustomKbConceptType. Nếu giá trị chuỗi của loại này bị thay đổi ở đó, kiểu dữ liệu sẽ không cần phải sao chép thủ công ở nhiều nơi.

Cuối cùng, thực thể chung KbConcept không biến ứng dụng thành một tập hợp các đối tượng không có kiểu. Ngược lại, nhờ thỏa thuận về type, template literal types và type guards, chúng ta có thể duy trì sự tiện lợi của một mô hình dữ liệu thống nhất, đồng thời đạt được định kiểu nghiêm ngặt cho các kịch bản cụ thể ở frontend.

Nhập dữ liệu cũ

Tính đến thời điểm hiện tại, các trình nhập dữ liệu cho các thực thể cũ đã được viết và đang hoạt động. Các dữ liệu chính đã được nhập, bao gồm tài nguyên, công ty và các loại nội dung liên quan.

Đây là một giai đoạn quan trọng trong bối cảnh chuyển đổi toàn diện: phiên bản mới của trang web không nên tiếp tục đọc cơ sở dữ liệu MODX cũ làm nguồn dữ liệu chính nữa. Các thực thể cũ được chuyển đổi thành mô hình chuẩn hóa mới, và từ đó ứng dụng sẽ làm việc hoàn toàn với cơ sở dữ liệu mới và KbConcept.

Như vậy, nhiệm vụ dần dịch chuyển từ việc tạo "giao diện mới phủ lên trang web cũ" sang việc di chuyển hoàn toàn sang một nền tảng mới.

Bản đồ

Bản đồ hiển thị các công ty cũng đã được di chuyển. Chức năng phân cụm (clustering) các điểm đánh dấu vẫn được giữ nguyên: khi có số lượng lớn đối tượng nằm gần nhau, chúng sẽ được gom thành các cụm, và khi phóng to bản đồ, chúng sẽ bung ra thành các phần tử riêng lẻ.

Đối với bản đồ, kiểu chuyên biệt MapItemCompany được sử dụng để sau khi lọc ở cấp độ TypeScript, chúng ta có thể đảm bảo chắc chắn rằng thực thể thuộc về company:* và có sẵn tọa độ.

Kết quả hiện tại

Tính đến thời điểm hiện tại:

  • kiến trúc trang web mới đã được xây dựng xoay quanh KbConcept;
  • các thực thể cũ chính không còn yêu cầu các mô hình riêng trong ứng dụng mới;
  • các trình nhập dữ liệu từ cơ sở dữ liệu cũ đã được viết và hoạt động;
  • tài nguyên, công ty và các thực thể chính khác đã được nhập;
  • các loại nội dung đã được quy về sơ đồ thống nhất <nhóm>:<phân_loại_phụ>;
  • các type guard và định kiểu nghiêm ngặt cho các biến thể cụ thể của KbConcept đã được thêm vào frontend;
  • bản đồ đã được di chuyển thành công;
  • tính năng phân cụm đối tượng trên bản đồ đã được khôi phục.

Giai đoạn tiếp theo là hoàn thiện thiết kế và tinh chỉnh phần giao diện trực quan của trang web mới. Sau đó, dự kiến sẽ xuất bản phiên bản trang web mới và chính thức chuyển đổi sang nó.

14.06.2026