Bỏ qua, tới nội dung chính

Tối ưu tuyến đường

Tạo job tối ưu, theo dõi trạng thái và lấy kết quả. Đây là nhóm trung tâm của Route4Green.

Tích hợp tối ưu tuyến đường

Phần này giải thích cách gọi nhóm endpoint tối ưu tuyến. Danh sách endpoint đầy đủ nằm ngay bên dưới; ở đây là những điều bạn cần biết trước khi gọi lần đầu.

Gửi một job

Tối ưu tuyến chạy bất đồng bộ. Bạn gửi job, hệ thống nhận và xử lý, kết quả lấy về sau bằng cách hỏi trạng thái hoặc nhận webhook.

Trả về 202, không phải 200

Gửi thành công trả về 202 Accepted. Nghĩa là job đã được ghi nhận, chưa phải đã xong. Phản hồi mang mã job để bạn tra cứu, không mang kết quả tối ưu.

Idempotency-Key là bắt buộc

Mỗi request gửi job phải kèm header Idempotency-Key. Khóa này thuộc phạm vi từng client, tối đa 128 ký tự, và hệ thống giữ lại vô thời hạn, nên một giá trị đã dùng thì không dùng lại cho job khác được.

Vì sao nó quan trọng

Khi request bị timeout, phía bạn không biết máy chủ đã nhận job hay chưa. Gửi lại mà không có khóa này thì rất dễ tạo ra job thứ hai cho cùng một chuyến. Idempotency-Key là thứ làm cho việc gửi lại trở nên an toàn.

Gửi lại cùng một khóa thì chuyện gì xảy ra

Cùng khóa và nội dung giống hệt lần trước: hệ thống trả về chính job đã tạo, không tạo thêm và không báo lỗi. Đây là điều làm cho việc gửi lại sau timeout an toàn.

Cùng khóa nhưng nội dung khác đi: hệ thống từ chối với 409 và mã IDEMPOTENCY_CONFLICT. Gặp mã này gần như chắc chắn là bạn đã dùng lại khóa cũ cho một job mới.

Ba quy tắc
  1. Sinh khóa trước lần gửi đầu tiên, không sinh lại ở mỗi lần thử.
  2. Mọi lần gửi lại của cùng một job dùng đúng giá trị đó, và nội dung gửi lên phải giống hệt lần đầu.
  3. Một job thật sự mới thì phải là một giá trị mới.

external_reference

Nội dung gửi lên nhận trường external_reference, là mã định danh phía bạn đặt cho job. Nó khác Idempotency-Key: khóa bảo vệ một lần gửi cụ thể, còn external_reference gắn job với dữ liệu bên bạn. Mã định danh của từng đơn hàng và từng xe bên trong nội dung gửi lên là chuyện thứ ba nữa, và hai mã lỗi liên quan tới chúng nằm ở phần mã lỗi bên dưới.

Gửi một job. Phần thân request tùy theo bài toán của bạn.
const idempotencyKey = crypto.randomUUID();

async function submit(body: unknown) {
  const response = await fetch(
    "https://<your-host>/api/v1/partners/addons/route-optimization/jobs",
    {
      method: "POST",
      headers: {
        "api-key": process.env.ROUTE4GREEN_API_KEY!,
        "Idempotency-Key": idempotencyKey,
        "content-type": "application/json",
      },
      body: JSON.stringify(body),
    },
  );

  // 202 Accepted. The job is recorded, not finished.
  const envelope = await response.json();
  if (!envelope.success) {
    throw new Error(`${envelope.error_code}: ${envelope.message}`);
  }
  return envelope.data;
}

Vòng đời của job

Job đi qua sáu trạng thái. Đây là chuỗi ký tự nguyên văn hệ thống trả về, hãy so khớp đúng chúng, đừng dịch hay đổi dạng chữ.

Trạng tháiKết thúcNghĩa là gìBạn nên làm gì
QUEUEDKhôngJob đã được nhận, chưa bắt đầu xử lý.Chờ tiếp.
PROCESSINGKhôngĐang tính toán.Chờ tiếp.
SUCCEEDEDĐã tính xong toàn bộ.Lấy kết quả.
PARTIALĐã tính xong nhưng không phải mọi phần đều thành công.Lấy kết quả và kiểm tra phần nào chưa được xếp trước khi dùng.
FAILEDJob dừng vì lỗi.Đọc thông tin lỗi trước khi gửi lại, và nhớ dùng khóa mới nếu đây là một lần gửi mới.
CANCELLEDJob đã bị hủy.Không chờ thêm.

Danh sách này lấy từ mô tả của đội backend. Bản đặc tả đã lưu chưa công bố tập giá trị này, nên nếu bạn thấy một trạng thái khác, hãy báo lại cho chúng tôi.

Hỏi trạng thái

Nếu chưa dùng webhook, bạn hỏi trạng thái theo chu kỳ và dừng lại khi job vào một trong bốn trạng thái kết thúc. Luôn đặt giới hạn cho vòng lặp: thời gian xử lý tối đa hiện tại là 30 phút, nên một vòng lặp không giới hạn sẽ chạy mãi khi có sự cố.

Chu kỳ 5 giây trong ví dụ chỉ là một lựa chọn hợp lý, không phải con số hệ thống quy định. Cách nhận kết quả tốt hơn là webhook.
const TERMINAL = new Set([
  "SUCCEEDED",
  "PARTIAL",
  "FAILED",
  "CANCELLED",
]);

async function waitForJob(jobId: string, deadlineMs = 30 * 60 * 1000) {
  const startedAt = Date.now();

  while (Date.now() - startedAt < deadlineMs) {
    const response = await fetch(
      `https://<your-host>/api/v1/partners/addons/route-optimization/jobs/${jobId}`,
      { headers: { "api-key": process.env.ROUTE4GREEN_API_KEY! } },
    );
    const envelope = await response.json();
    if (!envelope.success) {
      throw new Error(String(envelope.error_code));
    }

    const status: string = envelope.data.status;
    if (TERMINAL.has(status)) return status;

    await new Promise((resolve) => setTimeout(resolve, 5_000));
  }

  throw new Error("Job did not reach a terminal state before the deadline");
}

Webhook

Thay vì hỏi liên tục, bạn đăng ký một địa chỉ để hệ thống gọi về khi job kết thúc. Đây là các endpoint webhook của riêng nhóm tối ưu tuyến, khác với nhóm webhook chung trong tài liệu này.

Đăng ký webhook cũng cần khóa API, và khóa đó hiện chưa tự tạo được. Xem phần xác thực ở trang tài liệu chính.

Đăng ký

Địa chỉ nhận phải là HTTPS, và hệ thống chỉ gọi về khi job đã vào trạng thái kết thúc, không gọi ở QUEUED hay PROCESSING. Phản hồi khi đăng ký thành công trả về khóa ký một lần duy nhất, hãy lưu lại ngay.

Xem danh sách

Danh sách trả về không kèm khóa ký.

Bật, tắt hoặc đổi khóa ký

Một endpoint dùng cho cả ba việc. Tài liệu gốc chưa nói rõ khi đổi khóa thì khóa mới trả về ở đâu, nên hãy hỏi chúng tôi trước khi dựa vào điều đó.

MụcGiá trị
Giao thứcChỉ HTTPS
Thời điểm gọiKhi job vào trạng thái kết thúc
Header chữ kýX-Smartway-Signature: sha256=<hex>
Header dấu thời gianX-Smartway-Timestamp, tính bằng giây
Header mã sự kiệnX-Smartway-Event-Id
Header loại sự kiệnX-Smartway-Event
Chuỗi được kýtimestamp.eventId.eventType.rawBody
Thời gian chờ5 giây
Số webhook1 cho mỗi client
Địa chỉ nội bộBị từ chối

Kiểm tra chữ ký

Chữ ký là HMAC-SHA256 dạng hex, tính trên bốn phần nối bằng dấu chấm theo đúng thứ tự này: dấu thời gian, mã sự kiện, loại sự kiện, rồi phần thân thô. Bỏ tiền tố sha256= trước khi so sánh, và so sánh bằng hàm chống dò thời gian.

Phải tính trên đúng chuỗi byte thô của request. Nếu bạn parse JSON rồi tạo lại chuỗi, thứ tự khóa sẽ khác đi và mọi chữ ký đều sai. Trong Next.js nghĩa là đọc bằng request.text() chứ không phải request.json().

Hàm kiểm tra chữ ký đầy đủ, chạy được.
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.ROUTE4GREEN_WEBHOOK_SECRET!;

/**
 * HMAC-SHA256 over the four delivery parts joined by dots, in this order.
 * Anything else, including a different separator, produces a digest that
 * never matches.
 */
function expectedSignature(
  timestamp: string,
  eventId: string,
  eventType: string,
  rawBody: string,
): string {
  const preimage = `${timestamp}.${eventId}.${eventType}.${rawBody}`;
  return createHmac("sha256", SECRET).update(preimage).digest("hex");
}

function safeEqualHex(a: string, b: string): boolean {
  const left = Buffer.from(a, "hex");
  const right = Buffer.from(b, "hex");
  // Compare lengths first: timingSafeEqual throws on a mismatch.
  return left.length === right.length && timingSafeEqual(left, right);
}

export async function POST(request: Request): Promise<Response> {
  // Raw bytes, not a parsed object. Re-serialising JSON reorders keys and
  // every signature then fails.
  const rawBody = await request.text();

  const timestamp = request.headers.get("x-smartway-timestamp") ?? "";
  const eventId = request.headers.get("x-smartway-event-id") ?? "";
  const eventType = request.headers.get("x-smartway-event") ?? "";
  const received = (request.headers.get("x-smartway-signature") ?? "").replace(/^sha256=/, "");

  if (!timestamp || !eventId || !eventType || !received) {
    return new Response("missing signature headers", { status: 400 });
  }

  if (!safeEqualHex(received, expectedSignature(timestamp, eventId, eventType, rawBody))) {
    return new Response("invalid signature", { status: 401 });
  }

  // Your own freshness window. No server-side replay window is documented,
  // so choose one and reject anything older.
  const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(ageSeconds) || ageSeconds > 300) {
    return new Response("stale delivery", { status: 401 });
  }

  // Deliveries can repeat, so key your handler on eventId and make it safe to
  // run twice. Acknowledge first: the delivery timeout is 5 seconds.
  void handleTerminalEvent(eventId, eventType, JSON.parse(rawBody));
  return new Response("ok");
}

Nhận và trả lời

Hãy coi mỗi sự kiện có thể đến nhiều hơn một lần và viết bộ xử lý sao cho chạy lại không gây hậu quả. Trả lời thật nhanh rồi xử lý sau, vì thời gian chờ hiện tại chỉ 5 giây.

Cấu trúc phản hồi

Mọi phản hồi đều nằm trong cùng một lớp bọc gồm success, message, error_code và data. Điều quan trọng nhất: mã trạng thái HTTP không phải lúc nào cũng là tín hiệu. Có trường hợp trả về HTTP 200 nhưng success là false. Luôn kiểm tra phần thân, đừng chỉ kiểm tra mã HTTP.

Kiểm tra phần thân, không chỉ mã HTTP.
// error_code is a string on some responses and a number on others, so the
// only safe shape is a union. Never compare it numerically.
type Envelope<T> = {
  success: boolean;
  message: string;
  error_code: string | number;
  data: T;
};

const response = await fetch(url, options);
const envelope: Envelope<unknown> = await response.json();

// Branch on success first. Do NOT branch on response.ok alone, and do not
// branch on error_code alone: some failures arrive as HTTP 200 with success
// set to false, and a successful response can still carry error_code 0.
if (!envelope.success) {
  handleFailure(String(envelope.error_code), envelope.message);
  return;
}

use(envelope.data);

error_code có thể là chuỗi hoặc số tùy phản hồi: hệ thống thật đã trả về cả error_code là 0 lẫn error_code là một chuỗi. Hãy khai kiểu là string | number, đừng so sánh bằng phép so sánh số, và đừng rẽ nhánh chỉ dựa vào error_code. Rẽ nhánh theo success trước, rồi mới đọc mã.

Đây là lỗi ở phía hệ thống gốc chứ không phải quy ước. Chúng tôi ghi lại để bạn viết mã chịu được cả hai kiểu, và sẽ cập nhật khi phía hệ thống thống nhất lại.

Mã lỗi

Mã lỗi nằm trong trường error_code của phần thân phản hồi.

Khi nào gặp và nên làm gì
ROUTE_OPT_API_KEY_REQUIREDRequest không mang khóa API. Thêm header api-key.
ROUTE_OPT_NOT_ENABLEDKhóa chưa có Quyền dùng API tối ưu tuyến, hoặc tính năng đang tắt ở phía máy chủ. Liên hệ chúng tôi, đây không phải thứ bạn tự bật được.
ROUTE_OPT_VALIDATION_ERRORNội dung gửi lên không hợp lệ. Đọc message để biết trường nào.
DUPLICATE_EXTERNAL_IDTrong cùng một request có hai mục mang trùng mã định danh. Đây là lỗi 400, phát hiện lúc kiểm tra nội dung gửi lên.
ROUTE_OPT_RATE_LIMITEDVượt số lần gửi cho phép trong một phút. Chờ rồi thử lại, và dùng lại đúng Idempotency-Key cũ.
ROUTE_OPT_PAYLOAD_TOO_LARGENội dung vượt dung lượng tối đa hiện tại. Chia nhỏ rồi gửi lại.
ROUTE_OPT_JOB_NOT_FOUNDKhông có job nào ứng với mã này.
ROUTE_OPT_WEBHOOK_NOT_FOUNDKhông có webhook nào ứng với mã này.
ROUTE_OPT_PERSISTENCE_ERRORLỗi phía hệ thống khi lưu dữ liệu. Liên hệ chúng tôi nếu lặp lại.

Hai mã về mã định danh, rất hay bị nhầm

Hai mã này nghe giống nhau nhưng nói về hai chuyện hoàn toàn khác. Đọc kỹ trước khi viết nhánh xử lý.

DUPLICATE_EXTERNAL_ID, lỗi 400: nội dung bạn gửi tự mâu thuẫn. Hai mục trong cùng một request mang trùng mã định danh. Sửa ở phía bạn rồi gửi lại.

ACTIVE_EXTERNAL_ID_CONFLICT, lỗi 409: mã định danh đó đang bận. Một đơn hàng hoặc một xe với mã đó đang gắn với một job khác còn hoạt động. Chờ job kia kết thúc, hoặc dùng mã khác.

Các mã xung đột

Các mã trả về cùng trạng thái 409. Cột nguồn cho biết mã nào đã được đội phát triển xác nhận trong mã nguồn, mã nào mới chỉ có trong bản đặc tả. Với nhóm thứ hai, hãy hỏi lại chúng tôi trước khi viết nhánh xử lý riêng.

EndpointNguồn
IDEMPOTENCY_CONFLICTPOST /jobsĐã xác nhận
ACTIVE_EXTERNAL_ID_CONFLICTPOST /jobsĐã xác nhận
JOB_ALREADY_PROCESSINGPOST /jobsTheo đặc tả
RESULT_NOT_READYGET /jobs/{jobId}/resultTheo đặc tả
RESULT_UNAVAILABLEGET /jobs/{jobId}/resultTheo đặc tả
WEBHOOK_LIMIT_REACHEDPOST /webhooksTheo đặc tả

Giới hạn request

Đây là các giới hạn áp dụng cho khóa chính thức. Chúng là giới hạn về hạ tầng, không phải trần cứng của sản phẩm.

Giới hạnKhóa chính thức
Dung lượng nội dung gửi lên5 MB
Số xe trong một job200
Số lần gửi job mỗi phút10 cho mỗi client
Số điểm giao, chặng đầu và chặng cuối320 (ngưỡng mềm)
Số điểm giao, chặng giữa220 (ngưỡng mềm)
Thời gian xử lý tối đa30 phút
Thời gian chờ webhook5 giây
Số webhook mỗi client1

Hai con số về số điểm giao là ngưỡng mềm: vượt qua thì thời gian tính toán tăng lên đáng kể chứ không bị từ chối ngay.

Khóa thử nghiệm

Khóa thử nghiệm chạy dưới các mức dưới đây, và vượt quá là bị từ chối thẳng chứ không phải chạy chậm lại. Mức này đủ để dựng và kiểm thử trọn vẹn một tích hợp, và cố ý không đủ để chạy vận hành thật.

Giới hạnKhóa thử nghiệm
Số lần gửi job mỗi phút5
Số xe trong một job10
Số điểm dừng trong một job50
Số job mỗi tháng200

Khóa thử nghiệm chỉ chạy được bài toán chặng cuối. Đây là quyết định về phạm vi dùng thử chứ không phải giới hạn kỹ thuật; các bài toán còn lại mở khi bạn dùng khóa chính thức.

Cần mức cao hơn

Nếu khối lượng của bạn vượt các con số này, hãy nói với chúng tôi. Đây là việc mở rộng hạ tầng và chúng tôi xử lý theo từng trường hợp, nên cứ mô tả quy mô thật của bạn.

Trao đổi về hạn mức

Chúng tôi không đưa ra cam kết về thời gian phản hồi hay tỷ lệ sẵn sàng. Hệ thống đang trong giai đoạn chạy thử với một khách hàng và chờ nghiệm thu, nên phần cam kết dịch vụ sẽ chỉ có sau khi hoàn tất.

7 endpoint