9. 實戰程式碼範本
9.1 前端直傳物件儲存(JavaScript)
javascript
/**
* 物件儲存直傳工具類別
* 支援:阿里雲 OSS、騰訊雲 COS、AWS S3
*/
class DirectUploader {
constructor(config) {
this.provider = config.provider // 'oss' | 'cos' | 's3'
this.region = config.region
this.bucket = config.bucket
this.getCredentials = config.getCredentials // 獲取暫時憑證的函式
}
/**
* 獲取 STS 暫時憑證
*/
async fetchCredentials() {
// 向後端申請暫時憑證
const credentials = await this.getCredentials()
return {
accessKeyId: credentials.accessKeyId,
accessKeySecret: credentials.accessKeySecret,
sessionToken: credentials.securityToken || credentials.sessionToken,
expiration: credentials.expiration
}
}
/**
* 產生上傳簽章(適用於前端計算簽章)
*/
generateSignature(credentials, fileKey, fileType, options = {}) {
const timestamp = new Date().toISOString()
const date = timestamp.slice(0, 10).replace(/-/g, '')
// 不同廠商的簽章演算法略有差異
switch (this.provider) {
case 'oss':
return this._ossSignature(credentials, fileKey, date, options)
case 'cos':
return this._cosSignature(credentials, fileKey, date, options)
case 's3':
return this._s3Signature(credentials, fileKey, date, options)
default:
throw new Error('Unknown provider')
}
}
/**
* 單檔案上傳(小檔案 < 100MB)
*/
async upload(file, options = {}) {
const credentials = await this.fetchCredentials()
const fileKey = this._generateFileKey(file, options.directory)
const formData = new FormData()
// 構建表單欄位(不同廠商欄位名不同)
const formFields = this._buildFormFields(
credentials,
fileKey,
file.type,
options
)
Object.entries(formFields).forEach(([key, value]) => {
formData.append(key, value)
})
formData.append('file', file)
// 傳送上傳請求
const uploadUrl = this._getUploadUrl()
const response = await fetch(uploadUrl, {
method: 'POST',
body: formData,
// 如果上傳大檔案,可能需要設定更長的逾時
signal: options.signal // 支援 AbortController 取消上傳
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Upload failed: ${response.status} ${errorText}`)
}
return {
url: this._getFileUrl(fileKey),
key: fileKey,
etag: response.headers.get('ETag'),
size: file.size
}
}
/**
* 分片上傳(大檔案 > 100MB)
*/
async multipartUpload(file, options = {}) {
const partSize = options.partSize || 10 * 1024 * 1024 // 預設 10MB/片
const parallel = options.parallel || 3 // 預設 3 個並行
const credentials = await this.fetchCredentials()
const fileKey = this._generateFileKey(file, options.directory)
// 1. 初始化分片上傳
const uploadId = await this._initMultipartUpload(
credentials,
fileKey,
file.type
)
// 2. 計算分片
const parts = []
const totalParts = Math.ceil(file.size / partSize)
for (let i = 0; i < totalParts; i++) {
const start = i * partSize
const end = Math.min(start + partSize, file.size)
parts.push({
number: i + 1,
start,
end,
blob: file.slice(start, end)
})
}
// 3. 上傳分片(帶並行控制和斷點續傳)
const uploadedParts = []
const failedParts = []
// 支援斷點續傳:檢查哪些分片已上傳
if (options.resume) {
const existingParts = await this._listParts(
credentials,
fileKey,
uploadId
)
for (const part of existingParts) {
uploadedParts.push(part)
}
}
// 過濾出未上傳的分片
const pendingParts = parts.filter(
(p) => !uploadedParts.some((up) => up.partNumber === p.number)
)
// 並行上傳
const uploadPart = async (part) => {
try {
const etag = await this._uploadPart(
credentials,
fileKey,
uploadId,
part
)
return { partNumber: part.number, etag }
} catch (error) {
failedParts.push({ part, error })
throw error
}
}
// 使用 Promise.all 控制並行
const chunks = []
for (let i = 0; i < pendingParts.length; i += parallel) {
chunks.push(pendingParts.slice(i, i + parallel))
}
for (const chunk of chunks) {
const results = await Promise.allSettled(chunk.map(uploadPart))
for (const result of results) {
if (result.status === 'fulfilled') {
uploadedParts.push(result.value)
}
}
}
// 檢查是否所有分片都上傳成功
if (uploadedParts.length !== totalParts) {
throw new Error(
`Upload incomplete: ${uploadedParts.length}/${totalParts} parts uploaded`
)
}
// 4. 完成分片上傳(合併分片)
await this._completeMultipartUpload(
credentials,
fileKey,
uploadId,
uploadedParts
)
return {
url: this._getFileUrl(fileKey),
key: fileKey,
size: file.size,
parts: totalParts
}
}
/**
* 產生檔案儲存路徑
*/
_generateFileKey(file, directory = '') {
const date = new Date()
const datePath = `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`
const random = Math.random().toString(36).substring(2, 10)
const ext = file.name.split('.').pop() || 'bin'
const key = directory
? `${directory}/${datePath}/${random}.${ext}`
: `${datePath}/${random}.${ext}`
return key
}
// ============ 各廠商特定方法 ============
_getUploadUrl() {
switch (this.provider) {
case 'oss':
return `https://${this.bucket}.oss-${this.region}.aliyuncs.com`
case 'cos':
return `https://${this.bucket}.cos.${this.region}.myqcloud.com`
case 's3':
return `https://${this.bucket}.s3.${this.region}.amazonaws.com`
default:
throw new Error('Unknown provider')
}
}
_getFileUrl(key) {
return `https://${this.bucket}.${this.provider === 'oss' ? 'oss' : 'cos'}-${this.region}.${
this.provider === 'oss'
? 'aliyuncs.com'
: this.provider === 'cos'
? 'myqcloud.com'
: 'amazonaws.com'
}/${key}`
}
// 各廠商的簽章、分片上傳等方法...(根據實際需求實作)
_buildFormFields(credentials, fileKey, fileType, options) {
// 各廠商表單欄位構建邏輯
// 這裡需要根據具體廠商的文件實作
return {}
}
async _initMultipartUpload(credentials, fileKey, fileType) {
// 各廠商初始化分片上傳邏輯
return 'upload-id'
}
async _uploadPart(credentials, fileKey, uploadId, part) {
// 各廠商分片上傳邏輯
return 'etag'
}
async _completeMultipartUpload(credentials, fileKey, uploadId, parts) {
// 各廠商完成分片上傳邏輯
}
async _listParts(credentials, fileKey, uploadId) {
// 各廠商列出已上傳分片邏輯
return []
}
}
// 使用範例
const uploader = new DirectUploader({
provider: 'oss',
region: 'cn-beijing',
bucket: 'myapp-images-prod',
getCredentials: async () => {
// 向後端申請暫時憑證
const res = await fetch('/api/upload/credentials')
return res.json()
}
})
// 小檔案上傳
async function uploadAvatar(file) {
try {
const result = await uploader.upload(file, {
directory: 'avatars',
onProgress: (progress) => {
console.log(`上傳進度: ${progress.percent}%`)
}
})
console.log('上傳成功:', result.url)
return result
} catch (error) {
console.error('上傳失敗:', error)
throw error
}
}
// 大檔案分片上傳
async function uploadVideo(file) {
try {
const result = await uploader.multipartUpload(file, {
directory: 'videos',
partSize: 10 * 1024 * 1024, // 10MB 每片
parallel: 3, // 3 個並行
resume: true, // 支援斷點續傳
onProgress: (progress) => {
console.log(
`上傳進度: ${progress.percent}%, 已傳 ${progress.loaded}/${progress.total}`
)
},
onPartComplete: (part) => {
console.log(`分片 ${part.number} 上傳完成`)
}
})
console.log('上傳成功:', result.url)
return result
} catch (error) {
console.error('上傳失敗:', error)
// 可以在這裡實作重試邏輯或儲存斷點資訊
throw error
}
}9.2 後端暫時憑證服務(Node.js/Express)
javascript
/**
* 物件儲存 STS 暫時憑證服務
* 支援:阿里雲 OSS、騰訊雲 COS、AWS S3
*/
const express = require('express')
const STS = require('ali-oss').STS // 阿里雲
// const COS = require('cos-nodejs-sdk-v5') // 騰訊雲
const router = express.Router()
// 設定
const config = {
// 阿里雲 OSS 設定
oss: {
accessKeyId: process.env.OSS_ACCESS_KEY_ID,
accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET,
region: 'oss-cn-beijing',
bucket: 'myapp-images-prod',
// STS 角色 ARN(需要在 RAM 主控台建立)
roleArn: process.env.OSS_STS_ROLE_ARN
}
}
/**
* 獲取 STS 暫時憑證(阿里雲 OSS)
* POST /api/upload/credentials
*/
router.post('/credentials', async (req, res) => {
try {
// 1. 驗證使用者身分(根據實際情況實作)
const userId = req.user?.id
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' })
}
// 2. 產生唯一的檔案路徑前綴(用於權限隔離)
const date = new Date()
const prefix = `uploads/${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${userId}/`
// 3. 建立 STS 用戶端
const sts = new STS({
accessKeyId: config.oss.accessKeyId,
accessKeySecret: config.oss.accessKeySecret
})
// 4. 申請暫時憑證
const result = await sts.assumeRole(
config.oss.roleArn,
{
// Policy 限制權限範圍(最小權限原則)
Statement: [
{
Effect: 'Allow',
Action: [
'oss:PutObject',
'oss:InitiateMultipartUpload',
'oss:UploadPart',
'oss:CompleteMultipartUpload',
'oss:AbortMultipartUpload',
'oss:ListParts'
],
Resource: [`acs:oss:*:*:${config.oss.bucket}/${prefix}*`]
}
],
Version: '1'
},
3600, // 憑證有效期 1 小時
'web-upload-session-' + Date.now()
)
// 5. 回傳憑證和設定
res.json({
success: true,
data: {
// STS 暫時憑證
credentials: {
accessKeyId: result.credentials.AccessKeyId,
accessKeySecret: result.credentials.AccessKeySecret,
sessionToken: result.credentials.SecurityToken,
expiration: result.credentials.Expiration
},
// 上傳設定
config: {
provider: 'oss',
region: config.oss.region,
bucket: config.oss.bucket,
endpoint: `https://${config.oss.bucket}.${config.oss.region}.aliyuncs.com`,
prefix: prefix, // 檔案路徑前綴
// 安全限制
maxSize: 100 * 1024 * 1024, // 最大 100MB
allowedTypes: [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'video/mp4'
]
}
}
})
} catch (error) {
console.error('Get credentials failed:', error)
res.status(500).json({
success: false,
error: 'Failed to get upload credentials',
message: error.message
})
}
})
/**
* 回呼通知:前端上傳完成後通知後端
* POST /api/upload/callback
*/
router.post('/callback', async (req, res) => {
try {
const { key, etag, size, mimeType, originalName } = req.body
const userId = req.user?.id
// 1. 驗證檔案是否存在
// 2. 儲存檔案資訊到資料庫
const fileRecord = await db.files.create({
userId,
key,
etag,
size,
mimeType,
originalName,
url: `https://cdn.example.com/${key}`,
createdAt: new Date()
})
// 3. 非同步處理:產生縮圖、擷取中繼資料、內容審核等
await processFileAsync(fileRecord)
res.json({
success: true,
data: {
fileId: fileRecord.id,
url: fileRecord.url,
size: fileRecord.size
}
})
} catch (error) {
console.error('Upload callback failed:', error)
res.status(500).json({
success: false,
error: 'Failed to process uploaded file'
})
}
})
module.exports = router9.3 防盜鏈與安全設定
javascript
/**
* CDN 防盜鏈與安全設定範例
*/
// 1. Referer 防盜鏈(防止其他網站直接引用你的資源)
const refererConfig = {
// 白名單模式:只允許以下 Referer 存取
allowList: [
'*.myapp.com', // 主站
'*.myapp.cn', // 國內站
'localhost:*', // 本地開發
'127.0.0.1:*'
],
// 黑名單模式(可選):禁止以下 Referer
blockList: [
'*.competitor.com', // 競爭對手
'spam-site.com'
],
// 空 Referer 處理:是否允許直接存取(瀏覽器網址列輸入 URL)
allowEmptyReferer: false // 正式環境建議 false,測試環境可 true
}
// 2. URL 鑑權(更安全的防盜鏈,帶時間戳和簽章)
class URLAuth {
constructor(config) {
this.key = config.key // 鑑權金鑰,只在伺服器端儲存
this.expireTime = config.expireTime || 3600 // 預設 1 小時有效期
}
/**
* 產生帶鑑權的 URL
* @param {string} url - 原始 URL,如 https://cdn.example.com/images/photo.jpg
* @param {number} expireIn - 有效期(秒)
* @returns {string} 帶鑑權參數的 URL
*/
sign(url, expireIn = this.expireTime) {
const urlObj = new URL(url)
const pathname = urlObj.pathname
const timestamp = Math.floor(Date.now() / 1000) + expireIn
// 構造簽章字串(不同廠商格式不同,這裡是通用範例)
const signStr = `${pathname}-${timestamp}-${this.key}`
const signature = this._md5(signStr)
// 添加鑑權參數
urlObj.searchParams.set('sign', signature)
urlObj.searchParams.set('t', timestamp.toString())
return urlObj.toString()
}
/**
* 驗證 URL 簽章(在 CDN 邊緣或來源站使用)
*/
verify(url) {
const urlObj = new URL(url)
const signature = urlObj.searchParams.get('sign')
const timestamp = parseInt(urlObj.searchParams.get('t'))
const pathname = urlObj.pathname
// 檢查是否過期
if (timestamp < Math.floor(Date.now() / 1000)) {
return { valid: false, error: 'URL expired' }
}
// 驗證簽章
const signStr = `${pathname}-${timestamp}-${this.key}`
const expectedSign = this._md5(signStr)
if (signature !== expectedSign) {
return { valid: false, error: 'Invalid signature' }
}
return { valid: true }
}
_md5(str) {
// 實際專案中使用 crypto-js 或其他 MD5 函式庫
// 這裡僅作範例
return require('crypto').createHash('md5').update(str).digest('hex')
}
}
// 使用範例
const auth = new URLAuth({
key: 'your-secret-key-only-known-by-server',
expireTime: 3600 // 1 小時有效期
})
// 伺服器端產生帶簽章的 URL
const signedUrl = auth.sign(
'https://cdn.example.com/private/document.pdf',
7200
)
// 結果:https://cdn.example.com/private/document.pdf?sign=xxxxx&t=1699123456
// CDN 邊緣或來源站驗證
const result = auth.verify(signedUrl)
if (!result.valid) {
// 回傳 403 Forbidden
}
// 3. IP 黑白名單
const ipConfig = {
// 只允許特定 IP 存取(適合內部系統)
whiteList: [
'192.168.1.0/24', // 內網網段
'10.0.0.0/8'
],
// 禁止特定 IP 存取(封鎖攻擊者)
blackList: ['1.2.3.4', '5.6.7.8']
}
// 4. UA(User-Agent)黑白名單
const uaConfig = {
// 禁止爬蟲/下載工具
blackList: [
'Wget',
'curl',
'python-requests',
'Scrapy',
'AhrefsBot',
'SemrushBot'
],
// 只允許瀏覽器存取(嚴格模式)
whiteList: [
'Mozilla/*', // 現代瀏覽器
'AppleWebKit/*'
]
}