Skip to main content

อัปโหลดไฟล์ชั่วคราว (Upload File)

ภาพรวม

บริการอัปโหลดที่ใช้ร่วมกันระหว่างฟอร์ม (คำถามชนิด file_upload) และบอร์ดประกาศ (รูปภาพในโพสต์และคอมเมนต์) ไฟล์จะถูกเก็บลง prefix temp/ ก่อน แล้ว feature ที่เป็นเจ้าของจะย้ายไปยังที่เก็บถาวรตอนบันทึกข้อมูลจริง

feature นี้ ไม่แตะ database เลย และทั้งสอง endpoint เป็น endpoint สาธารณะที่ไม่ต้องใช้ LIFF token จึงต้องอ่านคู่กับกลไก commit ของฝั่งผู้ใช้งาน ซึ่งเป็นตัวพิสูจน์ความเป็นเจ้าของไฟล์ที่แท้จริง

Business Flow

POST /api/upload-file/temp (multipart, field ชื่อ file)

  1. หากไม่มี field file หรืออ่านไม่ได้ จะตอบ 400 file must be a file (เทียบเท่า @IsFile())
  2. ขนาดเกิน 10 MB จะตอบ 400 file must be smaller than or equal to 10485760 bytes (เทียบเท่า @MaxFileSize)
  3. อ่าน buffer แล้ว detect mime จากเนื้อไฟล์จริง ผ่าน util.DetectExtension และ util.MIMEForExt โดยไม่เชื่อ Content-Type ที่ client ส่งมา ซึ่งเลียนพฤติกรรมของ nestjs-form-data
  4. mime ที่อนุญาตมีเพียง image/jpeg, image/jpg, image/png และ application/pdf นอกเหนือจากนี้จะตอบ 400 file has invalid mime type
  5. PutTemp เขียนไฟล์เป็น temp/temp-{unixMilli}.{ext} แล้วคืน {path: publicURL}

POST /api/upload-file/temp-base64 (JSON ที่มีฟิลด์ file)

  1. DTO validation กำหนดให้ file เป็น string ที่ไม่ว่าง (เทียบเท่า @IsString และ @IsNotEmpty)
  2. DetectExtensionFromBase64 ตรวจชนิดไฟล์จาก signature หากตรวจไม่ได้จะตอบ 400 Unable to detect file type. Supported types: jpg, png, gif, pdf, webp สังเกตว่าชุดชนิดไฟล์ที่รองรับ กว้างกว่า endpoint แบบ multipart
  3. createFileFromBase64 ตัด data-URI prefix ออก (ทุกอย่างก่อนเครื่องหมาย , ตัวแรก) แล้ว base64-decode โดยลองแบบ standard ก่อนแล้วจึง raw หาก decode ไม่ได้จะได้ buffer ว่างและตกไปที่การตรวจถัดไป (ใน source ตัว Buffer.from ของ JS ไม่ throw ทำให้ catch เป็นโค้ดตาย) buffer ว่างจะตอบ 400 Empty file content ส่วน mime ที่ map ไม่ได้จะกลายเป็น application/octet-stream และ originalName ถูกตั้งเป็น file-{unixMilli}
  4. PutTemp เขียนไฟล์เป็น temp/temp-{unixMilli}.{ext} แล้วเก็บ public URL ไว้
  5. เรียก CopyObject ไปยัง public/mock-line-oa-hash/images/{filename} ซึ่ง port ไว้ตาม source ทั้งที่ปลายทางนี้ไม่มีใครใช้งาน เป็น parity โดยเจตนา
  6. คืน {path: publicURL} ซึ่งเป็น URL ของไฟล์ใน temp/ ไม่ใช่ของสำเนา

สิ่งที่เกิดขึ้นต่อจากนี้

ขั้นตอนหลังการอัปโหลดมีความสำคัญมากกว่าตัว endpoint เอง

  • ฟอร์ม: ค่าที่ตอบกลับมาเป็น URL ใน temp/ จากนั้น ส่งคำตอบฟอร์ม จะ copy ไปยัง form-builder/{formId}/{filename} ตอน submit โดยตรวจชื่อไฟล์ด้วย pattern ^[a-zA-Z0-9._-]+$
  • บอร์ดประกาศ: การเขียนโพสต์ใช้ image committer ซึ่งจะเรียก HeadObject เพื่อพิสูจน์ว่า object นั้นมีอยู่จริงก่อน copy นับเป็นด่านเดียวที่ป้องกันการอ้างถึง object ของ tenant อื่นหรือ key ที่ไม่เคยถูกอัปโหลด และรองรับทั้ง public URL และ key ดิบที่ตรงกับ ^temp/temp-[0-9]+\.[A-Za-z0-9]+$
  • object ใน temp/ ไม่เคยถูกลบโดย request ใด การกวาดล้าง prefix นี้เป็นงาน operations ที่แยกออกไปต่างหาก

ไฟล์และฟังก์ชันหลัก

RouteHandler
POST /api/upload-file/tempinternal/uploadfile/handler.go(*Handler).UploadFileTemp
POST /api/upload-file/temp-base64(*Handler).UploadFileTempBase64
  • internal/uploadfile/register.goRegister(r, deps)
  • internal/uploadfile/service.goNewService, UploadFileTemp, UploadFileTempBase64, createFileFromBase64, nowMilli และ interface storage
  • internal/uploadfile/handler.gotempBase64DTO, allowedTempMimes, maxTempFileSize ซึ่งกำหนดเป็น 10*1024*1024
  • internal/storagex/storagex.goNew(cfg), PutTemp, GetPublicURL, CopyObject, HeadObject, IsFileExist และ type UploadFile
  • internal/s3x/s3x.go — client ที่สร้างบน aws-sdk-go-v2
  • internal/util/hash.go และ base64.goDetectExtension, DetectExtensionFromBase64, MIMEForExt

จุดเชื่อมต่อกับ Service อื่น

  • Object storage (S3/MinIO) เพียงอย่างเดียว ไม่ใช้ทั้ง database และ Redis
  • Config deps.Config.Storage ซึ่งประกอบด้วย endpoint, bucket และ PublicHost ที่ถูกใช้ตัด prefix ตอน copy ฝั่งฟอร์ม
  • ผู้ใช้งานปลายทาง ได้แก่ ส่งคำตอบฟอร์ม รวมถึงการเขียนโพสต์และคอมเมนต์ของบอร์ดประกาศ
  • ฝั่ง client-web ที่เกี่ยวข้องคือ feature file-upload