انتقل إلى المحتوى
Sursa Developers

رفع الملفات الكبيرة

يمكن إرسال الملف في طلب واحد بالعملية POST /v1/cards ما دام ضمن حد الرفع في التثبيت (200 ميغابايت افتراضيًا). وفوق ذلك، أو على شبكة يُرجَّح أن ينقطع فيها الطلب الطويل، ارفع الملف على أجزاء:

  1. أنشئ عملية رفع: أخبر Sursa باسم الملف ونوعه وحجمه.
  2. أرسل الأجزاء، بأي ترتيب، وبالتوازي إن شئت. والجزء الفاشل يُرسَل مرة أخرى وحده.
  3. أكمل عملية الرفع بقيمة SHA-256 للملف كله. يجمّع Sursa الملف ويتحقق منه.
  4. استخدم معرّف عملية الرفع بدل الملف: لبطاقة، أو أرشيف، أو مستند في مصدر مخصص.
الحدالقيمة
حجم الملفحتى 5 غيغابايت
حجم الجزءيختاره Sursa في part_size، من 5 إلى 100 ميبيبايت؛ ويمكن أن يكون الجزء الأخير أصغر
عدد الأجزاءحتى 1000
مهلة الإكمال24 ساعة بعد إنشاء عملية الرفع
مهلة استخدام عملية رفع مكتملة24 ساعة

1. أنشئ عملية الرفع

تأخذ العملية POST /v1/uploads اسم الملف ونوعه وحجمه، والغرض الذي ستستخدمه من أجله:

FILE=training-video-transcripts.zip
SIZE=$(wc -c < "$FILE" | tr -d ' ')

curl -X POST https://api.sursa.com/v1/uploads \
  -H "Authorization: Bearer $SURSA_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d "{\"filename\": \"$FILE\", \"content_type\": \"application/zip\", \"size\": $SIZE, \"purpose\": \"archive\"}"
{
  "id": "upl_5d3c2b1a0f9e8d7c6b5a493827161504",
  "status": "pending",
  "part_size": 8388608,
  "part_count": 23,
  "expires_at": "2026-10-25T10:00:00Z"
}
purposeستُستخدم عملية الرفع من أجل
card_fileبطاقة: POST /v1/cards.
archiveأرشيف مستندات: POST /v1/cards/archive.
import_attachmentملف لمستند في مصدر مخصص، في الحقل upload_id أو attachments.

يحتاج المفتاح إلى نطاق المكان الذي تذهب إليه عملية الرفع: cards:write للغرضين card_file وarchive، وimport:write للغرض import_attachment.

يختار Sursa قيمة part_size ويخبرك بعدد الأجزاء التي ترسلها. وإن كنت تعرف قيمة SHA-256 للملف مسبقًا، فيمكنك تمريرها هنا أيضًا في الحقل sha256.

2. أرسل الأجزاء

قسّم الملف إلى part_count من الأجزاء، حجم كل منها part_size بايت (والأخير هو الباقي)، وأرسل كل جزء بالطلب PUT /v1/uploads/{upload_id}/parts/{n}. تُرقَّم الأجزاء بدءًا من 1. ونص الطلب هو بايتات الجزء الخام.

UPLOAD=upl_5d3c2b1a0f9e8d7c6b5a493827161504
split -b 8388608 -a 4 "$FILE" part-

n=1
for part in part-*; do
  curl -X PUT "https://api.sursa.com/v1/uploads/$UPLOAD/parts/$n" \
    -H "Authorization: Bearer $SURSA_API_KEY" \
    -H "Content-Type: application/octet-stream" \
    --data-binary "@$part" --retry 5 --fail
  n=$((n + 1))
done

إرسال جزء مرة أخرى يستبدله، فالجزء الذي فشل أو انتهت مهلته يُرسَل ببساطة من جديد. وعادةً ما تكفي 4 أجزاء بالتوازي لاستنفاد سعة اتصال سريع.

التحقق من الأجزاء

كل جزء يمر عبر واجهة API: لا روابط مباشرة إلى التخزين. وتحمل استجابة الجزء الترويسة ETag، وهي قيمة SHA-256 للجزء بين علامتي تنصيص، والقيمة نفسها في الحقل etag من نص JSON. وتسرد العملية GET /v1/uploads/{upload_id} الأجزاء المستلمة حتى الآن في الحقل parts، فبعد الانقطاع لا ترسل إلا الأجزاء الناقصة.

3. أكمل عملية الرفع

تأخذ العملية POST /v1/uploads/{upload_id}/complete قيمة SHA-256 للملف كله بصيغة 64 حرفًا ست عشريًا، ومعها اختياريًا etag كل جزء في الحقل parts، للتحقق من الأجزاء أيضًا:

SHA=$(shasum -a 256 "$FILE" | cut -d' ' -f1)
curl -X POST "https://api.sursa.com/v1/uploads/$UPLOAD/complete" \
  -H "Authorization: Bearer $SURSA_API_KEY" \
  -H "Idempotency-Key: complete-$UPLOAD" \
  -H "Content-Type: application/json" \
  -d "{\"sha256\": \"$SHA\"}"

يجمّع Sursa الأجزاء، ويقارن قيمة SHA-256، ويفحص الملف كما يفحص أي ملف مرفوع: صيغته، وفحص مكافحة الفيروسات حيث يكون مفعّلًا. وتكون الاستجابة بالحالة 200 حين ينتهي الفحص، وبالحالة 202 ما دام جاريًا. وتنتقل حالة عملية الرفع من processing إلى ready، أو إلى rejected مع السبب في الحقل reject_reason. اقرأها بالطلب GET /v1/uploads/{upload_id}.

4. استخدم عملية الرفع

مرّر upload_id حيث كنت سترسل الملف. وكل عملية رفع تُستخدم مرة واحدة: بعدها تصبح حالتها consumed.

للبطاقة والأرشيف، أرسل upload_id في نص JSON بدل استمارة multipart/form-data. ويأخذ هذا النص المجموعة باسمها في الحقل collection.

# A card from the uploaded file
curl -X POST https://api.sursa.com/v1/cards \
  -H "Authorization: Bearer $SURSA_API_KEY" \
  -H "Idempotency-Key: card-from-$UPLOAD" \
  -H "Content-Type: application/json" \
  -d "{\"upload_id\": \"$UPLOAD\", \"collection\": \"support\", \"async\": true}"

# An archive of documents from the uploaded ZIP
curl -X POST https://api.sursa.com/v1/cards/archive \
  -H "Authorization: Bearer $SURSA_API_KEY" \
  -H "Idempotency-Key: archive-from-$UPLOAD" \
  -H "Content-Type: application/json" \
  -d "{\"upload_id\": \"$UPLOAD\", \"collection\": \"support\"}"

أداة رفع كاملة

import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { open, stat } from "node:fs/promises";
import { basename } from "node:path";

const BASE = "https://api.sursa.com";
const auth = { Authorization: `Bearer ${process.env.SURSA_API_KEY}` };

async function call(method, path, body, headers = {}) {
  const isJson = body !== undefined && !(body instanceof Uint8Array);
  const response = await fetch(BASE + path, {
    method,
    headers: { ...auth, ...(isJson ? { "Content-Type": "application/json" } : {}), ...headers },
    body: isJson ? JSON.stringify(body) : body,
  });
  if (!response.ok) throw new Error(`${method} ${path}: HTTP ${response.status} ${await response.text()}`);
  return response.json();
}

export async function uploadLargeFile(file, contentType, purpose, parallel = 4) {
  const { size } = await stat(file);
  const upload = await call("POST", "/v1/uploads",
    { filename: basename(file), content_type: contentType, size, purpose },
    { "Idempotency-Key": crypto.randomUUID() });

  const hash = createHash("sha256");
  for await (const block of createReadStream(file)) hash.update(block);

  const handle = await open(file);

  let next = 1;
  async function worker() {
    for (let n = next++; n <= upload.part_count; n = next++) {
      const buffer = new Uint8Array(Math.min(upload.part_size, size - (n - 1) * upload.part_size));
      await handle.read(buffer, 0, buffer.length, (n - 1) * upload.part_size);
      for (let attempt = 1; ; attempt++) {
        try {
          await call("PUT", `/v1/uploads/${upload.id}/parts/${n}`, buffer, { "Content-Type": "application/octet-stream" });
          break;
        } catch (error) {
          if (attempt === 5) throw error;
          await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** attempt));
        }
      }
    }
  }
  await Promise.all(Array.from({ length: parallel }, worker));
  await handle.close();

  await call("POST", `/v1/uploads/${upload.id}/complete`, { sha256: hash.digest("hex") },
    { "Idempotency-Key": `complete-${upload.id}` });
  for (;;) {
    const state = await call("GET", `/v1/uploads/${upload.id}`);
    if (state.status === "ready") return upload.id;
    if (state.status === "rejected") throw new Error(`upload rejected: ${state.reject_reason}`);
    await new Promise((resolve) => setTimeout(resolve, 2000));
  }
}

الأخطاء

الرمزالحالةالسبب
uploads.too_large413الملف أكبر من 5 غيغابايت، أو أكبر من مساحة التخزين الكاملة في خطة المؤسسة.
limits.storage402لم تعد لدى المؤسسة مساحة تخزين حرة كافية للملف.
uploads.part_invalid400رقم جزء خارج المدى، أو جزء حجمه خارج الحد المسموح.
uploads.checksum_mismatch422قيمة SHA-256 للملف المجمَّع ليست التي أرسلتها. أحد الأجزاء تالف أو ناقص: أرسل الأجزاء مرة أخرى وأكمل من جديد.
uploads.not_ready409لم تكتمل عملية الرفع ولم يُتحقق منها بعد. انتظر الحالة ready.
uploads.already_used409استُخدمت عملية الرفع من قبل. وكل عملية رفع تُستخدم مرة واحدة.
uploads.expired410مضى أكثر من 24 ساعة. أنشئ عملية رفع جديدة.
uploads.rejected422لم يجتز الملف الفحوص، مثلًا لأن صيغته لا تطابق اسمه. وتبيّن الرسالة السبب.
uploads.not_found404لا توجد عملية رفع كهذه في المؤسسة.