/** * 阿里云 OSS 上传封装(demo) * * 提供两种模式,二选一即可: * · uploadDirect() 模式B:向后端取签名 → 小程序直接传 OSS(省服务器带宽,推荐) * · uploadViaServer() 模式A:文件先传后端,由后端转存 OSS(改动最小,兼容现有流程) * * 依赖:utils/api.js 里的 request / BASE_URL * * ⚠️ 用模式B之前,必须先把 OSS 域名加进微信后台「上传域名(uploadFile 合法域名)」, * 否则真机上会直接 fail(errMsg: url not in domain list)。 */ const api = require('./api'); const SIGN_API = '/api/v1/oss-demo/signature'; const CONFIRM_API = '/api/v1/oss-demo/confirm'; const SERVER_UPLOAD_API = '/api/v1/oss-demo/upload'; /** 支持的扩展名(与后端白名单保持一致) */ const ALLOWED_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp']; /** 单文件上限,需与后端 OSS_MAX_SIZE 一致,前端只是提前拦截省流量 */ const MAX_FILE_SIZE = 10 * 1024 * 1024; function extOf(path) { const m = /\.([a-zA-Z0-9]+)(?:\?.*)?$/.exec(path || ''); return m ? m[1].toLowerCase() : ''; } function baseNameOf(path) { const noQuery = String(path || '').split('?')[0]; const parts = noQuery.split('/'); return parts[parts.length - 1] || 'upload.jpg'; } /** * 前端先做一次类型/大小拦截,避免无谓的上传流量。 * @param {string} filePath * @param {number} [size] wx.chooseMedia 返回的 size(字节),不传则跳过体积校验 */ function checkFile(filePath, size) { const ext = extOf(filePath); if (ALLOWED_EXT.indexOf(ext) === -1) { return { ok: false, msg: '仅支持 jpg/png/gif/webp/bmp 图片' }; } if (size && size > MAX_FILE_SIZE) { return { ok: false, msg: '单张图片不能超过 ' + Math.round(MAX_FILE_SIZE / 1024 / 1024) + 'MB' }; } return { ok: true }; } /** * 模式B:取签名 → 直传 OSS → 回执后端 * @param {string} filePath wx.chooseMedia 返回的 tempFilePath * @param {object} options { planType, periodKey, filename, onProgress } * @returns {Promise<{key:string,url:string,signedUrl:string}>} */ function uploadDirect(filePath, options) { const opts = options || {}; const filename = opts.filename || baseNameOf(filePath); const guard = checkFile(filePath); if (!guard.ok) return Promise.reject({ error: guard.msg }); const query = [ 'plan_type=' + encodeURIComponent(opts.planType || ''), 'period_key=' + encodeURIComponent(opts.periodKey || ''), 'filename=' + encodeURIComponent(filename) ].join('&'); return api.request({ url: SIGN_API + '?' + query }).then((sign) => { if (!sign || !sign.success) { return Promise.reject({ error: (sign && sign.error) || '获取上传签名失败' }); } // 注意:formData 的字段名由 OSS 约定,不能改名; // `key` 决定对象落到哪个路径;`file` 必须是最后一个表单域(原生实现已保证)。 const formData = Object.assign({}, sign.form_data, { key: sign.key, 'x-oss-content-type': sign.content_type || 'image/jpeg' }); return new Promise((resolve, reject) => { const task = wx.uploadFile({ // 直传的地址是 OSS Bucket 域名,不是自家后端 url: sign.host, filePath, name: 'file', formData, timeout: 60000, success(res) { // success_action_status=200 时成功即 200,返回体为空 if (res.statusCode === 200 || res.statusCode === 204) { resolve({ key: sign.key, host: sign.host }); } else { reject({ error: '上传 OSS 失败(' + res.statusCode + ')', raw: res.data }); } }, fail(err) { const msg = (err && err.errMsg) || ''; if (/url not in domain list|domain not/i.test(msg)) { reject({ error: 'OSS 域名未加入小程序上传域名白名单' }); return; } reject({ error: msg || '上传失败' }); } }); if (opts.onProgress && task && task.onProgressUpdate) { task.onProgressUpdate((p) => opts.onProgress(p.progress)); } }); }).then((uploaded) => { // 直传时后端不知情,必须回执一次:后端会校验对象确实存在,并写业务表 return api.request({ url: CONFIRM_API, method: 'POST', data: { key: uploaded.key, size: 0 } }).then((res) => ({ key: uploaded.key, url: (res && res.url) || '', signedUrl: (res && res.signed_url) || '' })); }); } /** * 模式B-批量:顺序上传(串行避免并发把弱网打爆) * @returns {Promise>} */ function uploadDirectBatch(filePaths, options) { const results = []; return filePaths.reduce((chain, path) => { return chain.then(() => uploadDirect(path, options)).then((r) => { results.push(r); if (options && options.onItemDone) options.onItemDone(r, results.length); }); }, Promise.resolve()).then(() => results); } /** * 模式A:走自家后端中转(与现有 plan-submit.js 的写法一致) * 好处是不用配 OSS 域名白名单,改造时把 url 从 /plan/upload 换成本接口即可。 */ function uploadViaServer(filePath, options) { const opts = options || {}; const token = wx.getStorageSync('token'); return new Promise((resolve, reject) => { wx.uploadFile({ url: api.BASE_URL + SERVER_UPLOAD_API, filePath, name: 'files', header: token ? { Authorization: 'Bearer ' + token } : {}, formData: { plan_type: opts.planType || 'weekly', period_key: opts.periodKey || '' }, success(res) { let data = {}; try { data = JSON.parse(res.data); } catch (e) { reject({ error: '返回格式异常' }); return; } if (res.statusCode >= 200 && res.statusCode < 300 && data.success) { resolve({ key: (data.attachments || [])[0] || '', items: data.items || [] }); } else { reject({ error: data.detail || data.error || '上传失败' }); } }, fail: reject }); }); } /** * 私有 Bucket 取临时可读 URL(给 用) * key 必须是 attachments/{userId}/... 形式 */ function getFileUrl(key) { return api.request({ url: '/api/v1/oss-demo/file-url?key=' + encodeURIComponent(key) }).then((res) => (res && res.url) || ''); } module.exports = { uploadDirect, uploadDirectBatch, uploadViaServer, getFileUrl, ALLOWED_EXT };