feat(api): 重构图片地址管理与上传机制
- 替换开发环境API基础URL为本地服务地址 - 实现签名URL本地缓存机制,解决微信按URL缓存图片导致重复下载问题 - 添加缓存清理功能,包括过期清理和按需删除特定缓存项 - 集成阿里云OSS上传功能,支持直传减少服务器带宽占用 - 优化头像地址处理逻辑,统一使用签名缓存避免地址频繁变化 - 移除过时的OSS上传示例文件oss-upload.js - 更新课程页面实现跳转第三方小程序功能 - 调整打卡记录图片地址获取方式,统一使用新的签名缓存逻辑 - 优化图片上传流程,增加压缩处理和批量上传支持 - 完善认证模块中的缓存清理逻辑,切换账号时清除相关图片缓存 - 添加后端环境变量配置模板和代理文档说明
This commit is contained in:
176
utils/api.js
176
utils/api.js
@@ -12,7 +12,8 @@ function _getBaseUrls() {
|
||||
case 'develop':
|
||||
// 开发版:本地服务
|
||||
return {
|
||||
baseUrl: 'https://test.muguaclub.cn/clock',
|
||||
//baseUrl: 'https://test.muguaclub.cn/clock',
|
||||
baseUrl: 'http://localhost:8000',
|
||||
staticUrl: 'https://test.muguaclub.cn',
|
||||
h5Url: 'https://test.muguaclub.cn'
|
||||
};
|
||||
@@ -36,6 +37,8 @@ function _getBaseUrls() {
|
||||
|
||||
const _urls = _getBaseUrls();
|
||||
const BASE_URL = _urls.baseUrl;
|
||||
// ⚠️ 图片**不再**用它拼地址了:图片只在 OSS 上,展示地址一律由后端签发
|
||||
// (attachment_urls / avatar_url)。保留它是因为它和 H5_BASE_URL 同属"部署域名"描述。
|
||||
const STATIC_BASE_URL = _urls.staticUrl;
|
||||
const H5_BASE_URL = _urls.h5Url;
|
||||
|
||||
@@ -109,16 +112,165 @@ function request(options) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 把计划附件的相对路径转为完整的静态资源 URL。
|
||||
* 路径格式:attachments/{user_id}/{plan_type}/{period_key}/{filename}
|
||||
*/
|
||||
function planFileUrl(path) {
|
||||
if (!path) return '';
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
const parts = String(path).replace(/^attachments\//, '').split('/');
|
||||
if (parts.length < 4) return '';
|
||||
return STATIC_BASE_URL + '/clock/static/plan/' + parts.slice(0, 4).map(encodeURIComponent).join('/');
|
||||
// ── 签名 URL 本地缓存 ─────────────────────────────────────────────────────
|
||||
// 为什么值得缓存:微信 <image> 是**按 URL** 缓存图片的。后端每次现签的 URL 里
|
||||
// Expires 都不同(= 现在 + 有效期),URL 一变,微信就当新图**重新下载整个文件**。
|
||||
// 把 URL 留在本地复用,同一张图在窗口期内才是同一个地址,图片缓存才真正生效。
|
||||
//
|
||||
// 判断失效**不需要后端额外给字段**:签名 URL 自带绝对过期的 `Expires` 参数,
|
||||
// 前端解析它就够了。
|
||||
|
||||
const SIGN_CACHE_KEY = 'oss_signed_urls_v1';
|
||||
const SIGN_CACHE_MAX = 400; // 条数上限,超出时丢最早过期的
|
||||
const SIGN_CACHE_SKEW = 3600; // 提前 1 小时判过期,留出时钟偏差与请求耗时
|
||||
|
||||
let _signCache = null;
|
||||
|
||||
function _loadSignCache() {
|
||||
if (_signCache) return _signCache;
|
||||
let raw = null;
|
||||
try {
|
||||
raw = wx.getStorageSync(SIGN_CACHE_KEY);
|
||||
} catch (e) {
|
||||
raw = null;
|
||||
}
|
||||
_signCache = raw && typeof raw === 'object' ? raw : {};
|
||||
return _signCache;
|
||||
}
|
||||
|
||||
module.exports = { request, planFileUrl, BASE_URL, STATIC_BASE_URL, H5_BASE_URL };
|
||||
function _saveSignCache() {
|
||||
try {
|
||||
wx.setStorageSync(SIGN_CACHE_KEY, _signCache || {});
|
||||
} catch (e) {
|
||||
// 写失败(配额满等)不影响展示,只是下次还得重新签
|
||||
}
|
||||
}
|
||||
|
||||
/** 签名 URL 里的绝对过期时间戳(秒);解析不到返回 0(当作不可用)。 */
|
||||
function _expiresAt(url) {
|
||||
const m = /[?&]Expires=(\d+)/.exec(String(url || ''));
|
||||
return m ? Number(m[1]) : 0;
|
||||
}
|
||||
|
||||
function _isFresh(url) {
|
||||
const exp = _expiresAt(url);
|
||||
return !!exp && Date.now() / 1000 + SIGN_CACHE_SKEW < exp;
|
||||
}
|
||||
|
||||
function _pruneSignCache(cache) {
|
||||
const keys = Object.keys(cache);
|
||||
if (keys.length <= SIGN_CACHE_MAX) return;
|
||||
keys.sort((a, b) => _expiresAt(cache[a]) - _expiresAt(cache[b]));
|
||||
keys.slice(0, keys.length - SIGN_CACHE_MAX).forEach((k) => { delete cache[k]; });
|
||||
}
|
||||
|
||||
/**
|
||||
* 取一个相对路径的展示地址。
|
||||
* @param {string} relPath 规范相对路径(attachments/…);外链原样返回
|
||||
* @param {string} freshUrl 后端本次随记录返回的签名 URL(可能为空串)
|
||||
*
|
||||
* 顺序:**缓存未过期就用缓存**(这是 URL 能稳定的关键),否则用 freshUrl 并写回缓存;
|
||||
* 两者都不能用则返回空串 —— 调用方当作「这张图不显示」。**没有静态地址可退**:
|
||||
* 图片只在 OSS 上,nginx 那条路已经拆掉了。
|
||||
*/
|
||||
function signedOrFresh(relPath, freshUrl) {
|
||||
if (!relPath) return '';
|
||||
if (/^https?:\/\//i.test(relPath)) return relPath;
|
||||
|
||||
const cache = _loadSignCache();
|
||||
const hit = cache[relPath];
|
||||
if (hit && _isFresh(hit)) return hit;
|
||||
|
||||
const fresh = String(freshUrl || '');
|
||||
if (_isFresh(fresh)) {
|
||||
cache[relPath] = fresh;
|
||||
_pruneSignCache(cache);
|
||||
_saveSignCache();
|
||||
return fresh;
|
||||
}
|
||||
|
||||
// ⚠️ 「解析不出过期时间」≠「这个 URL 不能用」。后端**刚签出来**的地址必然有效,
|
||||
// 只是格式我们不认识(例如签名版本切到 V4 后参数名会变成 x-oss-expires)。
|
||||
// 所以这里必须仍然用它 —— 直接退回静态地址,在 OSS 化之后就是**裂图**。
|
||||
if (fresh) return fresh;
|
||||
|
||||
// 缓存已过期、后端这次也没给签名:清掉过期项,交给调用方降级
|
||||
if (hit) {
|
||||
delete cache[relPath];
|
||||
_saveSignCache();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 删掉某一条缓存(不动其它)。
|
||||
*
|
||||
* 两个**必须**调用它的时机,漏了都会出现"显示旧图":
|
||||
* · 换了头像:key 没变、内容变了。不清掉就会一直沿用旧 URL,
|
||||
* 而微信是按 URL 缓存图片的 → 用户看到旧头像。
|
||||
* · 删了附件:之后若重传同名文件,会命中已删对象的旧地址。
|
||||
*
|
||||
* 清掉之后,本函数不需要自己写新值 —— 下一次 `signedOrFresh` 拿到后端给的新签名
|
||||
* 会自然写回去。
|
||||
*/
|
||||
function forgetSignedUrl(relPath) {
|
||||
const key = String(relPath || '').trim();
|
||||
if (!key) return;
|
||||
const cache = _loadSignCache();
|
||||
if (Object.prototype.hasOwnProperty.call(cache, key)) {
|
||||
delete cache[key];
|
||||
_saveSignCache();
|
||||
}
|
||||
}
|
||||
|
||||
/** 退出登录 / 换账号时调用,避免把上一个人的图片地址留给下一个人。 */
|
||||
function clearSignedCache() {
|
||||
_signCache = {};
|
||||
try {
|
||||
wx.removeStorageSync(SIGN_CACHE_KEY);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取一条打卡记录的附件展示地址。
|
||||
*
|
||||
* 后端并排给两个字段,**长度与顺序严格一一对应**:
|
||||
* attachments 规范相对路径。会被原样提交回 /plan/checkin,所以**不能拿来展示**,
|
||||
* 否则一保存就把带签名的地址写进数据库了。
|
||||
* attachment_urls OSS 签名 URL,展示专用;OSS 未启用时全是空串。
|
||||
*
|
||||
* 取值顺序:本地签名缓存 → 后端这次的签名 → 外链原样 → **空串**。
|
||||
* 空串表示那张图不显示。**不再退回 nginx 静态地址**:图片只在 OSS 上,静态地址必然
|
||||
* 404,退过去只会多出一张裂图,反而把「OSS 没配对」这个真问题掩盖掉。
|
||||
*/
|
||||
function attachmentThumbs(record) {
|
||||
const rec = record || {};
|
||||
const canon = rec.attachments || [];
|
||||
const urls = rec.attachment_urls || [];
|
||||
// signedOrFresh 内部先查本地缓存(URL 稳定 → 微信图片缓存才命中),再兜后端这次的
|
||||
return canon.map((path, i) => signedOrFresh(path, urls[i]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 头像展示地址。
|
||||
*
|
||||
* 缓存与打卡附件共用同一套:缓存 key 用的是库里的原值,所以新旧两种历史格式各自成键,
|
||||
* 但都指向同一个 OSS 对象:
|
||||
* avatars/{uid}/avatar.jpg 新格式(等于对象 key)
|
||||
* /clock/static/avatars/{uid}/avatar.jpg 旧格式,库里仍有存量值
|
||||
* 微信头像(https://wx.qlogo.cn/...)是外链,原样返回。
|
||||
* 都取不到就是空串,**不退回 nginx 静态地址**(理由同 attachmentThumbs)。
|
||||
*/
|
||||
function avatarUrl(payload) {
|
||||
const data = payload || {};
|
||||
const path = data.wechat_avatar || '';
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
return signedOrFresh(path, data.avatar_url);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
request, attachmentThumbs, avatarUrl, forgetSignedUrl, clearSignedCache,
|
||||
BASE_URL, STATIC_BASE_URL, H5_BASE_URL
|
||||
};
|
||||
|
||||
@@ -12,6 +12,8 @@ function setToken(token) {
|
||||
|
||||
function clearToken() {
|
||||
wx.removeStorageSync('token');
|
||||
// 顺带清掉签名 URL 缓存:换账号后不该复用上一个人的图片地址
|
||||
api.clearSignedCache();
|
||||
}
|
||||
|
||||
function wxLogin(retries) {
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
/**
|
||||
* 阿里云 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<Array<{key:string,url:string}>>}
|
||||
*/
|
||||
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(给 <image src> 用)
|
||||
* 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
|
||||
};
|
||||
Reference in New Issue
Block a user