Hướng dẫn mở rộng thêm các dịch vụ vận chuyển khác với plugin sử dụng EzyDelivery
Back to ezydeliveryEzyDelivery có sẵn một số đơn vị vận chuyển như Giao Hàng Tiết Kiệm, Viettel Post, SPX, 247Express. Khi cần dùng một đơn vị khác, bạn không phải sửa mã nguồn EzyDelivery. Bạn chỉ cần viết một plugin riêng có một lớp cài đặt interface dịch vụ vận chuyển. Khi khởi động, hệ thống tự tìm thấy lớp đó, và dịch vụ mới sẽ hiện trong trang quản trị như các dịch vụ có sẵn.
Bài viết này giải thích cơ chế đó và từng bước để viết một plugin như vậy. Ví dụ xuyên suốt bài là đơn vị vận chuyển giả định ACME Express, mã dịch vụ
ACME_EXPRESS.Kiến trúc tổng quát
Hệ thống chia việc giữa ba bên như sau:
- Lõi thương mại điện tử: quản lý đơn giao hàng, cấu hình dịch vụ vận chuyển, gửi đơn và nhận webhook. Lõi chỉ làm việc qua interface
DeliveryServicevà không biết bên dưới là đơn vị vận chuyển nào. - EzyDelivery: nghiệp vụ vận chuyển, gồm yêu cầu giao hàng, tạo vận đơn, in tem, công nợ... Khi cần gửi đơn đi, EzyDelivery chuyển việc đó cho lõi.
- Plugin của bạn: cài đặt
DeliveryServicecho một đơn vị vận chuyển cụ thể.
flowchart LR
subgraph QT["Trang quản trị (admin)"]
A1["Màn hình tạo vận đơn<br/>(EzyDelivery)"] --> A2["Bộ xử lý gửi đơn<br/>(lõi thương mại điện tử)"]
A2 --> M1["Bộ quản lý dịch vụ vận chuyển"]
end
subgraph WEB["Trang web (web)"]
W1["Endpoint /callback/{mã-dịch-vụ}"] --> M2["Bộ quản lý dịch vụ vận chuyển"]
end
M1 -->|"tìm theo mã"| P1["AcmeExpressDeliveryService<br/>(bản admin)"]
M2 -->|"tìm theo mã"| P2["AcmeExpressDeliveryService<br/>(bản web)"]
P1 -->|"gọi API tạo đơn"| ACME[("API ACME Express")]
ACME -->|"webhook trạng thái"| W1
Dịch vụ vận chuyển chạy ở cả hai phía:
| Phía | Việc của dịch vụ vận chuyển |
|---|---|
| Admin | Đăng nhập hoặc kiểm tra khoá khi lưu cấu hình, gửi đơn sang đơn vị vận chuyển, tạo đường dẫn tra cứu hành trình. |
| Web | Nhận webhook, xác thực, đọc dữ liệu trạng thái đơn và chuyển sang trạng thái của hệ thống. |
Vì vậy plugin của bạn thường có ba phần: phần SDK chứa logic dùng chung, phần admin plugin và phần web plugin. Mỗi phần chỉ cần một lớp con mỏng để đăng ký bean.
Cơ chế tự phát hiện dịch vụ
Hệ thống không có danh sách dịch vụ viết cứng. Khi khởi động, bộ quản lý dịch vụ vận chuyển lấy tất cả bean cài đặt
DeliveryService trong ngữ cảnh của plugin, rồi lập bảng tra cứu theo getCode().
flowchart TD
S["Khởi động admin / web"] --> B["Quét bean của các plugin"]
B --> C{"Bean có cài đặt<br/>DeliveryService?"}
C -->|"Có"| D["Đưa vào bảng tra cứu<br/>khoá = getCode()"]
C -->|"Không"| E["Bỏ qua"]
D --> F["Trang Các dịch vụ vận chuyển<br/>liệt kê mọi mã trong bảng"]
D --> G["Gửi đơn / nhận webhook<br/>tìm dịch vụ theo mã"]
Hệ quả:
- Mã do
getCode()trả về là định danh duy nhất của dịch vụ. Mã này được dùng làm khoá cấu hình, làm giá trị phương thức giao hàng khi tạo vận đơn, và làm đoạn cuối của đường dẫn webhook/callback/<mã>. Khi tra cứu, hệ thống thử khớp chính xác trước, nếu không thấy thì so sánh không phân biệt hoa thường. - Chỉ cần bean xuất hiện là dịch vụ mới hiện trong Thương mại điện tử → Các dịch vụ vận chuyển với trạng thái
INACTIVATED. Quản trị viên điền cấu hình và bậtACTIVATEDthì dịch vụ mới dùng được. - Nếu chỉ đăng ký bean ở phía admin mà quên phía web, đơn vẫn gửi đi được, nhưng webhook sẽ nhận lỗi
deliveryService: notFound.
Interface cần cài đặt
Interface gốc là
DeliveryService của SDK thương mại điện tử. EzyDelivery mở rộng nó thành EzyDeliveryDeliveryService, có thêm một hàm tuỳ chọn để kéo danh sách đơn từ đơn vị vận chuyển về đối soát. Nên cài đặt EzyDeliveryDeliveryService để dùng được các tính năng đó sau này.
classDiagram
class DeliveryService {
<<interface>>
+getCode() String
+deliver(setting, order) DeliveryOrderResultModel
+verifyDeliveryCallbackParameters(...) DeliveryVerifyResponseModel
+extractDeliveryOrder(params, body) DeliveryOrderCallbackModel
+verifyDeliveryResult(order, params, body) DeliveryVerifyResultModel
+getDeliveryOrderNotFoundResponse() DeliveryVerifyResponseModel
+convertVerifyExceptionToResponse(e) DeliveryVerifyResponseModel
+getTrackingDeliveryOrderUrl(serviceUrl, trackingId) String
+isAuthenticatedByUsernamePassword() boolean
+login(settingId, setting, username, password)
+loginWithSecretKey(settingId, setting, secretKey)
+cancelDeliveryOrderById(id)
}
class EzyDeliveryDeliveryService {
<<interface>>
+getOrderList(request, limit) TrackingDeliveryOrderPaginationModel
}
class AcmeExpressDeliveryService
class AdminAcmeExpressDeliveryService
class WebAcmeExpressDeliveryService
DeliveryService <|-- EzyDeliveryDeliveryService
EzyDeliveryDeliveryService <|.. AcmeExpressDeliveryService
AcmeExpressDeliveryService <|-- AdminAcmeExpressDeliveryService
AcmeExpressDeliveryService <|-- WebAcmeExpressDeliveryService
Chỉ có hai hàm bắt buộc:
getCode() và deliver(...). Các hàm còn lại có sẵn cài đặt mặc định, bạn ghi đè khi cần:| Hàm | Khi nào cần ghi đè | Mặc định |
|---|---|---|
getCode() | Luôn luôn | — |
deliver(...) | Luôn luôn | — |
verifyDeliveryCallbackParameters(...) | Khi đơn vị vận chuyển có webhook | Báo lỗi unimplemented |
extractDeliveryOrder(...) | Khi đơn vị vận chuyển có webhook | Báo lỗi unimplemented |
verifyDeliveryResult(...) | Khi muốn kiểm tra thêm hoặc chọn dữ liệu cần lưu | Không kiểm tra, không chọn |
getDeliveryOrderNotFoundResponse() | Khi đơn vị vận chuyển cần một mã HTTP hoặc nội dung trả về riêng | 400 + {"deliveryOrder":"notFound"} |
convertVerifyExceptionToResponse(e) | Khi muốn biến exception thành phản hồi thay vì lỗi 500 | null (ném tiếp exception) |
getTrackingDeliveryOrderUrl(...) | Khi đơn vị vận chuyển có trang tra cứu hành trình | null |
isAuthenticatedByUsernamePassword() + login(...) | Khi API cần đăng nhập bằng tên đăng nhập và mật khẩu để lấy token | false, không làm gì |
loginWithSecretKey(...) | Khi cần kiểm tra khoá bí mật ngay lúc lưu cấu hình | Không làm gì |
cancelDeliveryOrderById(...) | Khi hỗ trợ huỷ đơn từ hệ thống | Báo lỗi unsupported |
getOrderList(...) | Khi cần kéo danh sách đơn về đối soát | Trả về danh sách rỗng |
Luồng gửi đơn
Khi nhân viên tạo vận đơn và chọn dịch vụ ACME Express, EzyDelivery tạo đơn giao hàng rồi giao việc gửi cho lõi:
sequenceDiagram
autonumber
actor NV as Nhân viên
participant ED as EzyDelivery (admin)
participant LOI as Bộ xử lý gửi đơn (lõi)
participant SV as AcmeExpressDeliveryService
participant API as API ACME Express
NV->>ED: Tạo vận đơn, chọn ACME_EXPRESS
ED->>LOI: Gửi đơn với mã ACME_EXPRESS
LOI->>LOI: Tìm dịch vụ theo mã<br/>Kiểm tra cấu hình đang ACTIVATED
LOI->>LOI: Dựng ForDeliveryOrderModel<br/>(người gửi, kho, người nhận, sản phẩm, COD...)
LOI->>SV: deliver(cấu hình, đơn)
SV->>API: Gọi API tạo đơn
API-->>SV: Kết quả + mã vận đơn
SV-->>LOI: DeliveryOrderResultModel
alt SUCCESS
LOI->>LOI: Lưu mã vận đơn, trạng thái "Đã gửi yêu cầu"
else FAILED hoặc có exception
LOI->>LOI: Trạng thái "Gửi yêu cầu thất bại"<br/>Lưu nội dung lỗi
end
LOI->>LOI: Lưu properties vào dữ liệu phụ của đơn
Dữ liệu vào của
deliver(...):-
DeliveryServiceSettingModel: cấu hình quản trị viên đã nhập, gồm đường dẫn API, phiên bản, khoá máy khách, tên đăng nhập... Khoá bí mật lưu riêng và đọc qua service cấu hình theo mã dịch vụ. -
ForDeliveryOrderModel: đơn đã được chuẩn bị sẵn, gồm id đơn, tiền tệ, kho gửi, thông tin người gửi, người nhận (ForDeliveryOrderRecipientModel, có tỉnh, quận, phường đã ghép tiền tố), danh sách sản phẩm (ForDeliveryProductModelcó khối lượng, số lượng và các cờ dễ vỡ, tài liệu, hàng giá trị cao...), tiền thu hộ, giá trị hàng, người trả phí ship, ngày lấy hàng.
Dữ liệu ra là
DeliveryOrderResultModel:
| Trường | Ý nghĩa |
|---|---|
result | SUCCESS hoặc FAILED. |
trackingId | Mã vận đơn của đơn vị vận chuyển. Phải có khi SUCCESS, vì webhook về sau được đối chiếu bằng mã này. |
properties | Dữ liệu tuỳ ý (phí, thời gian giao dự kiến, mã lỗi, thông báo...). Được lưu kèm đơn để xem lại. |
Một số lưu ý khi viết
deliver(...):- Mã đơn gửi sang đơn vị vận chuyển nên có dạng
<id-đơn>-<timestamp>. Khi webhook gọi về, bạn tách lạiid-đơnđể hệ thống tìm đúng đơn. Phần timestamp giúp gửi lại một đơn đã thất bại mà không bị đơn vị vận chuyển báo trùng mã. - Exception ném ra trong
deliverkhông làm hỏng luồng. Đơn được chuyển sang "Gửi yêu cầu thất bại" và nội dung exception được lưu làm thông báo lỗi. Nhưng nếu API trả lỗi nghiệp vụ, nên trảFAILEDkèmpropertieschi tiết thay vì ném exception. - Tiền tệ: nếu đơn vị vận chuyển chỉ nhận một loại tiền, hãy quy đổi
codAmountvàgoodsAmountMoneytừcurrencyIdcủa đơn sang loại tiền đó bằng service tỉ giá. - Thu hộ: chỉ thu khi người nhận chưa thanh toán. Kết hợp thêm
shippingFeePayerđể quyết định ai trả phí vận chuyển.
Luồng nhận webhook
Mọi đơn vị vận chuyển dùng chung một endpoint trên trang web:
GET hoặc POST /callback/<mã-dịch-vụ>. Phần lõi lo các bước chung, còn dịch vụ của bạn chỉ trả lời ba câu hỏi: request có hợp lệ không, request nói về đơn nào và trạng thái gì, và cần lưu thêm dữ liệu gì.
sequenceDiagram
autonumber
participant API as ACME Express
participant CB as Endpoint /callback/ACME_EXPRESS (lõi)
participant SV as AcmeExpressDeliveryService (web)
participant DB as Đơn giao hàng
API->>CB: Webhook (header, query, body)
CB->>CB: Tìm dịch vụ theo mã<br/>(không có thì 404)
CB->>SV: verifyDeliveryCallbackParameters(...)
alt Có lỗi
SV-->>CB: DeliveryVerifyResponseModel (mã + dữ liệu lỗi)
CB-->>API: Trả lỗi, dừng xử lý
end
CB->>SV: extractDeliveryOrder(params, body)
SV-->>CB: DeliveryOrderCallbackModel
CB->>DB: Tìm đơn theo deliveryOrderId
alt Không có đơn hoặc trackingId không khớp
CB->>SV: getDeliveryOrderNotFoundResponse()
CB-->>API: Trả "không tìm thấy"
end
CB->>SV: verifyDeliveryResult(đơn, params, body)
CB->>DB: Cập nhật trạng thái (nếu status khác rỗng)
CB->>DB: Lưu properties (rỗng thì lưu toàn bộ params + body)
CB-->>API: 200 OK
Vai trò của từng hàm:
-
verifyDeliveryCallbackParameters: xác thực nguồn gọi (token trong header hoặc query, chữ ký HMAC...) và kiểm tra các trường bắt buộc. Trảnullnếu hợp lệ. Nếu không hợp lệ, trảDeliveryVerifyResponseModelchứa mã HTTP và dữ liệu lỗi. Tham sốsecretKeylà khoá bí mật đã lưu của dịch vụ. Nếu đơn vị vận chuyển gửi kèm một chuỗi bí mật do bạn tự đặt, nên so với khoá máy khách trong cấu hình dịch vụ. extractDeliveryOrder: chuyển dữ liệu webhook thànhDeliveryOrderCallbackModel:Trường Ý nghĩa deliveryOrderIdId đơn trong hệ thống, thường tách từ mã đơn đã gửi đi ( <id-đơn>-<timestamp>).trackingIdMã vận đơn, phải trùng với mã đã lưu lúc gửi đơn, nếu không webhook bị bỏ qua. statusTên một trạng thái DeliveryOrderStatus(PICKED_UP,IN_TRANSIT,DELIVERED,RETURNING_TO_SENDER...). Mã lạ thì trả vềUNKNOWN. Để rỗng thì đơn không đổi trạng thái.shipperName,shipperPhoneNumber,location,expectedDeliveryDate,notes,issuedAtThông tin bổ sung hiển thị kèm đơn. dataDữ liệu gốc của webhook. Trường Ý nghĩa deliveryOrderIdId đơn trong hệ thống, thường tách từ mã đơn đã gửi đi ( <id-đơn>-<timestamp>).trackingIdMã vận đơn, phải trùng với mã đã lưu lúc gửi đơn, nếu không webhook bị bỏ qua. statusTên một trạng thái DeliveryOrderStatus(PICKED_UP,IN_TRANSIT,DELIVERED,RETURNING_TO_SENDER...). Mã lạ thì trả vềUNKNOWN. Để rỗng thì đơn không đổi trạng thái.shipperName,shipperPhoneNumber,location,expectedDeliveryDate,notes,issuedAtThông tin bổ sung hiển thị kèm đơn. dataDữ liệu gốc của webhook. -
verifyDeliveryResult: chạy sau khi đã tìm thấy đơn. Hàm này dùng để kiểm tra chéo với dữ liệu đơn và chọn các trường cần lưu (properties), ví dụ chỉ lưu mã trạng thái gốc của đơn vị vận chuyển.
Cách làm thường gặp là khai báo một bảng ánh xạ cố định từ mã trạng thái của đơn vị vận chuyển sang
DeliveryOrderStatus:
flowchart LR
subgraph ACME["Mã trạng thái ACME"]
a1["CREATED"]
a2["PICKED"]
a3["SHIPPING"]
a4["DONE"]
a5["RETURN"]
a6["Mã khác"]
end
subgraph EZ["DeliveryOrderStatus"]
e1["REQUEST_RECEIVED"]
e2["PICKED_UP"]
e3["IN_TRANSIT"]
e4["DELIVERED"]
e5["RETURNING_TO_SENDER"]
e6["UNKNOWN"]
end
a1 --> e1
a2 --> e2
a3 --> e3
a4 --> e4
a5 --> e5
a6 --> e6
Luồng lưu cấu hình và đăng nhập
Khi quản trị viên bấm Lưu ở trang sửa dịch vụ, hệ thống có thể gọi dịch vụ của bạn để kiểm tra thông tin đăng nhập ngay lúc đó:
flowchart TD
A["Lưu cấu hình dịch vụ"] --> B["Lưu các ô cấu hình<br/>+ mẫu ánh xạ tiền tố"]
B --> C{"Có nhập khoá bí mật?"}
C -->|"Có"| D["loginWithSecretKey(...)"]
D -->|"Exception"| E["Báo lỗi ô Khoá bí mật"]
C -->|"Không"| F{"isAuthenticatedByUsernamePassword()?"}
D -->|"OK"| F
F -->|"true, có đủ tên đăng nhập + mật khẩu"| G["login(...)"]
G -->|"Exception"| H["Báo lỗi ô Tên đăng nhập / Mật khẩu"]
G -->|"OK"| I["Lưu thành công"]
F -->|"false"| I
- Nếu API dùng token cố định, bạn chỉ cần đọc khoá bí mật trong
deliver. Có thể ghi đèloginWithSecretKeyđể gọi thử một API nhẹ, nhờ đó phát hiện khoá sai ngay lúc lưu. - Nếu API cần đăng nhập để lấy token có hạn, hãy cho
isAuthenticatedByUsernamePassword()trả vềtruevà cài đặtlogin(...). Hàm này gọi API đăng nhập, lưu token và thời điểm hết hạn. Trongdeliver, kiểm tra token sắp hết hạn thì tự đăng nhập lại bằng tên đăng nhập và mật khẩu đã lưu. - Ném exception trong hai hàm này để báo thông tin sai. Hệ thống sẽ hiện lỗi ở đúng ô tương ứng.
Ánh xạ tiền tố đơn vị hành chính
Nhiều đơn vị vận chuyển nhận diện địa chỉ bằng văn bản và cần tiền tố như
TP., Q., P.. Lúc dựng ForDeliveryOrderModel, lõi ghép tiền tố vào tên tỉnh, quận, phường theo mẫu ánh xạ tiền tố của từng mã dịch vụ. Mẫu này được lưu dưới dạng content template kiểu TEMPLATE_TYPE_ADMINISTRATIVE_TYPE_PREFIX_MAPPING, tên template chính là mã dịch vụ.Nếu đơn vị vận chuyển của bạn cần tiền tố, hãy cho admin plugin tạo mẫu mặc định khi khởi động, chỉ tạo khi chưa có để không ghi đè chỉnh sửa của quản trị viên:
[
{ "type": "CITY", "prefix": "TP." },
{ "type": "PROVINCE", "prefix": "T." },
{ "type": "DISTRICT_IN_CITY", "prefix": "Q." },
{ "type": "DISTRICT_IN_PROVINCE", "prefix": "H." },
{ "type": "WARD_IN_CITY", "prefix": "P." },
{ "type": "WARD", "prefix": "X." }
]
Quản trị viên xem và sửa mẫu này ở ô Ánh xạ giữa kiểu và tiền tố địa giới hành chính trên trang sửa dịch vụ.
Các bước viết plugin
Cấu trúc dự án
flowchart TD
R["acme-delivery (pom cha)"] --> SDK["acme-delivery-sdk<br/>AcmeExpressDeliveryService"]
R --> AD["acme-delivery-admin-plugin<br/>AdminAcmeExpressDeliveryService<br/>+ mẫu tiền tố mặc định"]
R --> WB["acme-delivery-web-plugin<br/>WebAcmeExpressDeliveryService"]
AD --> SDK
WB --> SDK
SDK -.->|"phụ thuộc"| E1["ecommerce-sdk<br/>ezydelivery-sdk"]
Mỗi plugin khai báo trong
module.properties, gồm loại plugin, package gốc để quét bean, và danh sách plugin phụ thuộc:type=admin-plugin version=0.0.1 package=com.example.acmedelivery project_name=AcmeDelivery description=ACME Express delivery service for EzyDelivery dependencies=ecommerce,ezydelivery
Phía web làm tương tự với
type=web-plugin. Các thư viện mà EzyPlatform và các plugin phụ thuộc đã cung cấp sẵn (ecommerce-sdk, ezydelivery-sdk, ezyplatform-*) nên khai báo provided hoặc loại khỏi gói build, tránh nạp trùng.Viết lớp dịch vụ trong SDK
@Setter public class AcmeExpressDeliveryService extends EzyLoggable implements EzyDeliveryDeliveryService { public static final String CODE = "ACME_EXPRESS"; private static final Map<String, DeliveryOrderStatus> STATUS_BY_ACME_VALUE = Collections.unmodifiableMap( EzyMapBuilder.mapBuilder() .put("CREATED", DeliveryOrderStatus.REQUEST_RECEIVED) .put("PICKED", DeliveryOrderStatus.PICKED_UP) .put("SHIPPING", DeliveryOrderStatus.IN_TRANSIT) .put("DONE", DeliveryOrderStatus.DELIVERED) .put("RETURN", DeliveryOrderStatus.RETURNING_TO_SENDER) .toMap() ); @EzyAutoBind private HttpClient httpClient; @EzyAutoBind private EcommerceSettingService ecommerceSettingService; @EzyAutoBind private DeliveryServiceSettingService deliveryServiceSettingService; @Override public String getCode() { return CODE; } @Override public DeliveryOrderResultModel deliver( DeliveryServiceSettingModel setting, ForDeliveryOrderModel order ) throws Exception { String apiUrl = setting.getServiceApiUrl(); String secretKey = ecommerceSettingService .getDeliveryServiceSecretKey(CODE); if (isBlank(apiUrl) || isBlank(secretKey)) { throw new IllegalStateException("deliveryServiceNotSetup"); } ResponseEntity response = httpClient.request( new PostRequest() .setURL(apiUrl + "/orders") .setEntity( RequestEntity.builder() .header("Authorization", "Bearer " + secretKey) .contentType("application/json") .body(toCreateOrderBody(order)) .build() ) ); Map<String, Object> body = (Map<String, Object>) response.getBody(); if (response.getStatus() == StatusCodes.OK && body != null) { return DeliveryOrderResultModel.builder() .result(DeliveryOrderResult.SUCCESS) .trackingId((String) body.get("tracking_code")) .properties(EzyMaps.getValues(body, Sets.newHashSet("fee", "eta"))) .build(); } return DeliveryOrderResultModel.builder() .result(DeliveryOrderResult.FAILED) .properties(singletonMap("message", String.valueOf(body))) .build(); } private Map<String, Object> toCreateOrderBody(ForDeliveryOrderModel order) { ForDeliveryOrderRecipientModel recipient = order.getOrderRecipient(); return EzyMapBuilder.mapBuilder() .put("partner_order_id", order.getId() + "-" + System.currentTimeMillis()) .put("sender_name", order.getSenderOrShopFullName()) .put("sender_phone", order.getSenderOrFromWarehousePhoneNumber()) .put("sender_address", order.getSenderOrFromWarehouseAddress()) .put("receiver_name", recipient.getFullName()) .put("receiver_phone", recipient.getPhoneNumber()) .put("receiver_address", recipient.getFullAddress()) .put("cod", order.getCodAmount()) .put("value", order.getGoodsAmountMoney()) .toMap(); } @Override public DeliveryVerifyResponseModel verifyDeliveryCallbackParameters( Function<String, String> headerFetcher, Enumeration<String> parameterNames, Map<String, String> parameterValueMap, Map<String, Object> requestBody, String secretKey ) { Map<String, String> errors = new HashMap<>(); String clientKey = deliveryServiceSettingService .getDeliveryClientKeyByCode(CODE); String token = headerFetcher.apply("X-Acme-Token"); if (isBlank(token) || !token.equals(clientKey)) { errors.put("token", "invalid"); } if (requestBody.get("partner_order_id") == null) { errors.put("partnerOrderId", "required"); } return errors.isEmpty() ? null : DeliveryVerifyResponseModel.builder() .code(StatusCodes.BAD_REQUEST) .data(errors) .build(); } @Override public DeliveryOrderCallbackModel extractDeliveryOrder( Map<String, String> parameterValueMap, Map<String, Object> requestBody ) { String partnerOrderId = (String) requestBody.get("partner_order_id"); DeliveryOrderStatus status = STATUS_BY_ACME_VALUE.getOrDefault( (String) requestBody.get("status"), DeliveryOrderStatus.UNKNOWN ); return DeliveryOrderCallbackModel.builder() .deliveryOrderId(extractDeliveryOrderId(partnerOrderId)) .trackingId((String) requestBody.get("tracking_code")) .status(status.toString()) .shipperName((String) requestBody.get("shipper_name")) .shipperPhoneNumber((String) requestBody.get("shipper_phone")) .issuedAt((String) requestBody.get("updated_at")) .data(requestBody) .build(); } @Override public String getTrackingDeliveryOrderUrl( String deliveryServiceUrl, String trackingId ) { return deliveryServiceUrl + "/tracking/" + trackingId; } }
extractDeliveryOrderId là hàm tiện ích của SDK EzyDelivery. Hàm này tách phần đứng trước dấu - đầu tiên của mã đơn và trả về id đơn.Đăng ký bean ở admin và web
Lớp trong SDK không gắn annotation. Mỗi phía tạo một lớp con mỏng gắn
@Service để được quét thành bean:@Service public class AdminAcmeExpressDeliveryService extends AcmeExpressDeliveryService {}
@Service public class WebAcmeExpressDeliveryService extends AcmeExpressDeliveryService {}
Lớp con phải nằm trong package đã khai báo ở
module.properties của plugin tương ứng.Cài đặt và cấu hình
flowchart LR
A["Build và cài<br/>admin + web plugin"] --> B["Khởi động lại<br/>admin và web"]
B --> C["ACME_EXPRESS xuất hiện<br/>trong Các dịch vụ vận chuyển"]
C --> D["Điền cấu hình<br/>bật ACTIVATED"]
D --> E["Khai báo webhook<br/>trên ACME Express"]
E --> F["Tạo vận đơn thử<br/>chọn ACME Express"]
Ở trang sửa dịch vụ, các ô thường dùng gồm:
| Ô | Dùng cho |
|---|---|
| Tên | Tên hiển thị khi chọn dịch vụ, không được trùng tên dịch vụ khác. |
| Phiên bản | Phiên bản API, nếu đơn vị vận chuyển có. |
| Đường dẫn dịch vụ | Tham số deliveryServiceUrl truyền vào getTrackingDeliveryOrderUrl. |
| Đường dẫn API dịch vụ | Địa chỉ gốc của API, đọc bằng getServiceApiUrl(). |
| Khoá máy khách | Chuỗi bí mật dùng để xác thực webhook. |
| Tên đăng nhập / Mật khẩu | Chỉ hiện khi isAuthenticatedByUsernamePassword() trả về true. |
| Khoá bí mật | Token API, đọc trong deliver. |
| Giá trị hàng hoá mặc định | Giá trị hàng được điền sẵn khi EzyDelivery tạo đơn giao hàng cho dịch vụ này. |
Đường dẫn webhook khai báo với đơn vị vận chuyển là
https://<tên-miền-web>/callback/ACME_EXPRESS.Để thử luồng mà chưa có tài khoản thật, có thể dùng dịch vụ
SANDBOX có sẵn làm mẫu tham khảo. Dịch vụ này trả về SUCCESS ngay khi có khoá máy khách, không gọi ra ngoài.Giới hạn cần lưu ý
- Bảng tra cứu dịch vụ chỉ được lập một lần lúc khởi động. Cài thêm hoặc gỡ plugin thì phải khởi động lại admin và web.
- Hai dịch vụ có cùng
getCode()sẽ ghi đè lẫn nhau. Hãy chọn mã đủ riêng. - Lõi chỉ chấp nhận webhook của đơn do chính hệ thống gửi đi và có
trackingIdkhớp. Đơn tạo thẳng trên trang của đơn vị vận chuyển sẽ bị bỏ qua. - Hệ thống không tự gửi lại đơn khi
deliverthất bại. Nhân viên cần sửa nguyên nhân rồi gửi lại. - Huỷ đơn và kéo danh sách đơn về đối soát là tính năng tuỳ chọn. Nếu không cài đặt
cancelDeliveryOrderByIdhoặcgetOrderList, các thao tác tương ứng sẽ báo không hỗ trợ hoặc trả về rỗng. - Lõi không tự giới hạn số lần gọi webhook. Nếu đơn vị vận chuyển gửi trùng một sự kiện nhiều lần, trạng thái sẽ được cập nhật lại nhiều lần. Hãy dùng
verifyDeliveryResultnếu cần chặn sự kiện cũ.
Kết luận
Thêm một đơn vị vận chuyển vào EzyDelivery là cài đặt một interface, không phải sửa hệ thống. Bạn viết logic gửi đơn và đọc webhook một lần trong SDK, sau đó đăng ký một lớp con ở admin và một lớp con ở web. Lõi lo phần còn lại: tìm dịch vụ theo mã, quản lý cấu hình, đổi trạng thái đơn, lưu lịch sử và hiển thị trong trang quản trị.