feat(api): 重构图片地址管理与上传机制

- 替换开发环境API基础URL为本地服务地址
- 实现签名URL本地缓存机制,解决微信按URL缓存图片导致重复下载问题
- 添加缓存清理功能,包括过期清理和按需删除特定缓存项
- 集成阿里云OSS上传功能,支持直传减少服务器带宽占用
- 优化头像地址处理逻辑,统一使用签名缓存避免地址频繁变化
- 移除过时的OSS上传示例文件oss-upload.js
- 更新课程页面实现跳转第三方小程序功能
- 调整打卡记录图片地址获取方式,统一使用新的签名缓存逻辑
- 优化图片上传流程,增加压缩处理和批量上传支持
- 完善认证模块中的缓存清理逻辑,切换账号时清除相关图片缓存
- 添加后端环境变量配置模板和代理文档说明
This commit is contained in:
王宏豪
2026-09-23 17:46:40 +08:00
parent ca21b1b9dc
commit ba2d9245ef
11 changed files with 448 additions and 290 deletions

View File

@@ -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
};

View File

@@ -12,6 +12,8 @@ function setToken(token) {
function clearToken() {
wx.removeStorageSync('token');
// 顺带清掉签名 URL 缓存:换账号后不该复用上一个人的图片地址
api.clearSignedCache();
}
function wxLogin(retries) {

View File

@@ -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
};