Tính năng gắn thẻ dữ liệu cho phép gom nhiều loại dữ liệu khác nhau—bài viết, bình luận, người dùng hoặc dữ liệu từ plugin khác—vào cùng một cây thẻ. Để một loại dữ liệu mới tham gia cơ chế này, plugin cần tích hợp ở hai phía:
  • Giao diện admin khai báo bản ghi đang được chọn.
  • Backend cung cấp bộ tải dữ liệu để hệ thống hiển thị lại tên, biểu tượng và đường dẫn của bản ghi đã gắn thẻ.

Kiến trúc tổng quát

Hệ thống không thêm cột tag_id trực tiếp vào bảng dữ liệu nghiệp vụ. Quan hệ giữa thẻ và dữ liệu được lưu dưới dạng metadata:
  • entityType: định danh loại dữ liệu.
  • entityId: ID của bản ghi.
  • tagId: ID của thẻ.
  • taggedAt: thời điểm gắn thẻ.
  • metadata: dữ liệu phụ phục vụ tìm kiếm.
flowchart LR
    A[Trang quản trị plugin] -->|Khai báo bản ghi hiện tại| B[Thanh công cụ gắn thẻ]
    B -->|entityType + entityId + metadata| C[API gắn thẻ]
    C --> D[Kho metadata]
    D --> E[Bộ lập chỉ mục]
    E --> F[Tìm kiếm dữ liệu theo thẻ]

    D --> G[Danh sách dữ liệu đã gắn thẻ]
    G -->|Nhóm ID theo entityType| H[Entity Fetcher]
    H --> I[Tên, mã, biểu tượng và URL]
    I --> B
entityType là khóa liên kết quan trọng nhất. Giá trị được gửi từ trình duyệt phải trùng chính xác với loại dữ liệu mà backend fetcher đăng ký.

Điều kiện tích hợp

Plugin cần bảo đảm:
  • Bản ghi có ID kiểu số nguyên.
  • Loại dữ liệu có một entityType ổn định và không xung đột với module khác.
  • Trang admin có thể xác định bản ghi hiện tại hoặc bản ghi đang được chọn.
  • Backend có thể tải một hoặc nhiều bản ghi theo ID.
  • Người dùng đang đăng nhập có quyền truy cập chức năng quản lý term/tag.
Nên dùng tên bảng hoặc tên có namespace làm entityType, ví dụ:
shop_products
customer_profiles
support_tickets
Không nên dùng tên quá chung như item, data hoặc record.

Khai báo dữ liệu có thể gắn thẻ trên giao diện

Thanh công cụ đọc bản ghi hiện tại từ biến toàn cục:
ezyadmin.taggableEntity = {
    id: product.id,
    code: product.code,
    displayName: product.name,
    type: 'shop_products'
};
Các trường được hỗ trợ:
TrườngBắt buộcÝ nghĩa
idID của bản ghi
typeLoại dữ liệu, phải khớp với backend fetcher
codeKhôngMã hoặc slug của bản ghi
displayNameNên cóTên hiển thị trong hộp thoại gắn thẻ
urlKhôngĐường dẫn mở trang chi tiết
iconKhôngCSS class của biểu tượng
Trong luồng gắn thẻ hiện tại, frontend gửi codedisplayName vào metadata. Các giá trị này được đưa vào chỉ mục tìm kiếm.

Trang chi tiết

Với trang chỉ hiển thị một bản ghi, có thể khai báo ngay sau khi dữ liệu trang đã sẵn sàng:
<script th:inline="javascript">
/*<![CDATA[*/
const product = {
    id: /*[[]]*/ 0,
    code: /*[[]]*/ '',
    name: /*[[]]*/ ''
};

ezyadmin.taggableEntity = {
    id: product.id,
    code: product.code,
    displayName: product.name,
    type: 'shop_products'
};
/*]]>*/
</script>
Khi mở hộp thoại quản lý thẻ, hệ thống sẽ:
  1. Lấy ezyadmin.taggableEntity.
  2. Kiểm tra bản ghi đã thuộc thẻ đang chọn hay chưa.
  3. Hiển thị nút “Thêm vào thẻ” nếu chưa có.
  4. Gửi thông tin bản ghi lên API khi quản trị viên xác nhận.

Trang danh sách

Nếu trang có nhiều bản ghi, hãy cập nhật taggableEntity theo dòng đang được chọn:
function setTaggableProduct(product) {
    ezyadmin.taggableEntity = {
        id: product.id,
        code: product.code,
        displayName: product.name,
        type: 'shop_products'
    };
}

function clearTaggableProduct() {
    ezyadmin.taggableEntity = null;
}
Ví dụ với checkbox:
$('.product-checkbox').on('change', function() {
    if (this.checked) {
        const productId = Number(this.value);
        setTaggableProduct(productById[productId]);
        return;
    }

    const remainingId = $('.product-checkbox:checked')
        .map(function() {
            return Number(this.value);
        })
        .get()
        .pop();

    if (remainingId) {
        setTaggableProduct(productById[remainingId]);
    } else {
        clearTaggableProduct();
    }
});
Giao diện hiện tại chỉ giữ một taggableEntity. Nếu chọn nhiều dòng, plugin cần tự quyết định bản ghi nào là bản ghi hiện tại; cách thông dụng là dùng dòng được chọn gần nhất.

Đăng ký bộ tải dữ liệu ở backend

Việc lưu quan hệ tag chỉ cần entityTypeentityId. Tuy nhiên, để danh sách dữ liệu trong một thẻ hiển thị đúng tên, URL và biểu tượng, plugin phải đăng ký một CommonEntityFetcher.
@EzySingleton
@AllArgsConstructor
public class AdminProductEntityFetcher
    implements CommonEntityFetcher {

    private final ProductService productService;

    @Override
    public CommonEntityModel getEntityById(long entityId) {
        ProductModel product = productService.getProductById(entityId);

        if (product == null) {
            return CommonEntityModel.defaultEntity(
                entityId,
                getEntityType()
            );
        }

        return toCommonEntity(product);
    }

    @Override
    public Map<Long, CommonEntityModel> getEntityMapByIds(
        Collection<Long> entityIds
    ) {
        return productService.getProductMapByIds(entityIds)
            .entrySet()
            .stream()
            .collect(
                Collectors.toMap(
                    Map.Entry::getKey,
                    entry -> toCommonEntity(entry.getValue())
                )
            );
    }

    @Override
    public String getEntityType() {
        return "shop_products";
    }

    private CommonEntityModel toCommonEntity(
        ProductModel product
    ) {
        return CommonEntityModel.builder()
            .id(product.getId())
            .code(product.getCode())
            .displayName(product.getName())
            .url("/shop/products/" + product.getId())
            .icon("fas fa-box")
            .build();
    }
}
@EzySingleton giúp fetcher được đưa vào container để trình quản lý fetcher phát hiện và đăng ký.

Ý nghĩa của dữ liệu trả về

CommonEntityModel.builder()
    .id(product.getId())
    .code(product.getCode())
    .displayName(product.getName())
    .url("/shop/products/" + product.getId())
    .icon("fas fa-box")
    .build();
  • id: phải bằng ID đã dùng khi gắn thẻ.
  • code: mã nghiệp vụ hoặc slug.
  • displayName: tên hiển thị chính.
  • url: đường dẫn trong khu vực admin.
  • icon: class biểu tượng, chẳng hạn Font Awesome.
  • properties: có thể chứa dữ liệu mở rộng nếu ứng dụng cần sử dụng sau này.
Nếu không tìm thấy bản ghi, nên trả về một model mặc định thay vì null. Nhờ đó quan hệ tag cũ vẫn có thể được hiển thị và gỡ bỏ ngay cả khi dữ liệu nguồn đã bị xóa.

Tối ưu truy vấn theo lô

Danh sách dữ liệu đã gắn thẻ được phân trang và nhóm theo entityType. Sau đó hệ thống gọi:
getEntityMapByIds(Collection<Long> entityIds)
Không nên triển khai phương thức này bằng cách gọi getEntityById trong vòng lặp, vì sẽ tạo ra truy vấn N+1.
Nên dùng một truy vấn theo lô:
SELECT *
FROM shop_products
WHERE id IN (...);
Kết quả trả về là map có ID làm khóa:
Map<Long, CommonEntityModel>

Luồng gắn thẻ

sequenceDiagram
    actor Admin as Quản trị viên
    participant Page as Trang plugin
    participant Toolbar as Thanh công cụ tag
    participant API as API quản trị
    participant Meta as Kho metadata
    participant Index as Chỉ mục tìm kiếm

    Page->>Toolbar: Cung cấp taggableEntity
    Admin->>Toolbar: Chọn một thẻ
    Toolbar->>API: Kiểm tra entity đã thuộc thẻ chưa
    API->>Meta: Tìm entityType + entityId + tagId
    Meta-->>API: Có hoặc không
    API-->>Toolbar: contains

    Admin->>Toolbar: Chọn “Thêm vào thẻ”
    Toolbar->>API: tagId, entityType, entityId, metadata
    API->>Meta: Lưu quan hệ
    Meta-->>Index: Bản ghi chờ lập chỉ mục
    Index->>Index: Tách từ khóa từ type, ID và metadata
Payload tương đương được gửi khi gắn dữ liệu:
{
  "entityType": "shop_products",
  "entityId": 1205,
  "metadata": {
    "code": "SKU-1205",
    "displayName": "Bàn phím cơ"
  }
}
Metadata nên ngắn gọn và chỉ chứa dữ liệu phù hợp cho việc tìm kiếm. Không nên đưa thông tin bí mật, token, dữ liệu cá nhân nhạy cảm hoặc toàn bộ nội dung bản ghi vào đây.

Hiển thị dữ liệu đã gắn thẻ

Khi người dùng chọn một thẻ, backend thực hiện các bước:
  1. Truy vấn các quan hệ có tagId tương ứng.
  2. Phân trang kết quả.
  3. Nhóm các ID theo entityType.
  4. Tìm fetcher đã đăng ký cho từng loại.
  5. Tải dữ liệu theo lô.
  6. Ghép thông tin hiển thị vào kết quả.
  7. Trả danh sách cho giao diện.
Nếu không có fetcher tương ứng, hệ thống vẫn có thể trả một bản ghi mặc định gồm ID và loại dữ liệu. Tuy nhiên tên, mã, liên kết và biểu tượng sẽ không đầy đủ.

Tìm kiếm dữ liệu trong thẻ

Bộ lập chỉ mục trích xuất từ khóa từ:
  • entityType;
  • entityId;
  • ID thẻ;
  • tên các trường metadata;
  • giá trị của các trường metadata.
Với payload:
{
  "entityType": "shop_products",
  "entityId": 1205,
  "metadata": {
    "code": "SKU-1205",
    "displayName": "Bàn phím cơ"
  }
}
Người quản trị có thể tìm theo các thành phần như:
shop_products
1205
SKU-1205
Bàn phím cơ
Việc lập chỉ mục chạy độc lập với thao tác lưu quan hệ. Vì vậy dữ liệu vừa gắn thẻ có thể xuất hiện trong danh sách trước khi có thể tìm thấy bằng từ khóa.

Bảo mật

Các API quản lý tag:
  • Yêu cầu người dùng đã đăng nhập.
  • Được bảo vệ bởi feature quản lý term/tag.
  • Chỉ nên được gọi từ khu vực admin.
  • Không xác thực sự tồn tại của bản ghi nghiệp vụ trước khi lưu quan hệ; plugin tích hợp phải cung cấp entityId hợp lệ.
  • Không thay thế cơ chế phân quyền của module dữ liệu nguồn.
Fetcher chỉ nên trả những trường cần thiết cho giao diện. Nếu URL chi tiết còn được bảo vệ bởi quyền riêng của plugin, người dùng vẫn phải vượt qua kiểm tra quyền khi truy cập URL đó.

Các điểm cần lưu ý

  • entityType phía JavaScript và getEntityType() phía backend phải giống nhau tuyệt đối.
  • Một trang chỉ có một ezyadmin.taggableEntity tại một thời điểm.
  • Metadata dùng để tìm kiếm, không phải bản sao của dữ liệu nghiệp vụ.
  • Thay đổi tên bản ghi trong hệ thống nguồn không tự động cập nhật metadata đã lưu. Tuy nhiên tên hiển thị trong danh sách vẫn có thể lấy mới từ fetcher.
  • Xóa bản ghi nguồn không tự động xóa quan hệ tag.
  • Thao tác xóa hiện tại được xác định bằng entityTypeentityId; nó có thể xóa toàn bộ quan hệ system tag của bản ghi, không chỉ quan hệ với thẻ đang được chọn. Plugin không nên tự gọi API xóa nếu chưa hiểu rõ hành vi này.
  • Fetcher phải hỗ trợ tải theo lô để tránh truy vấn N+1.
  • ID hiện được thiết kế theo kiểu long; dữ liệu dùng UUID hoặc khóa chuỗi cần có một ID số ánh xạ ổn định.

Checklist tích hợp

  • [ ] Chọn entityType duy nhất và ổn định.
  • [ ] Khai báo ezyadmin.taggableEntity trên trang chi tiết hoặc khi chọn dòng.
  • [ ] Xóa taggableEntity khi không còn bản ghi nào được chọn.
  • [ ] Đăng ký một CommonEntityFetcher ở admin backend.
  • [ ] Trả về đúng id, displayName, urlicon.
  • [ ] Cài đặt truy vấn nhiều ID trong getEntityMapByIds.
  • [ ] Không đưa dữ liệu nhạy cảm vào metadata.
  • [ ] Kiểm tra quyền truy cập trang chi tiết tại URL do fetcher trả về.
  • [ ] Xử lý trường hợp dữ liệu nguồn đã bị xóa.
  • [ ] Xác nhận hành vi xóa toàn bộ tag của một entity là phù hợp với nghiệp vụ.
Sau khi hoàn tất hai điểm tích hợp—taggableEntity ở frontend và CommonEntityFetcher ở backend—loại dữ liệu mới có thể được thêm vào cây thẻ, hiển thị trong danh sách, tìm kiếm theo metadata và mở lại từ giao diện quản trị.