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

@@ -30,6 +30,12 @@ function hasImageExt(path) {
return !!ext && ALLOWED_IMAGE_EXT.indexOf(ext) !== -1;
}
function baseName(path) {
const noQuery = String(path || '').split('?')[0];
const parts = noQuery.split('/');
return parts[parts.length - 1] || '';
}
Page({
data: {
planType: 'weekly',
@@ -110,8 +116,10 @@ Page({
checked: !!res.checked,
record,
content: record ? (record.content || '') : '',
// attachments 是规范相对路径,提交打卡时会被原样发回后端,**不要**拿它做展示
attachments,
attachmentThumbs: attachments.map((p) => api.planFileUrl(p))
// 展示用签名地址,与 attachments 同长同序
attachmentThumbs: api.attachmentThumbs(record)
});
},
@@ -120,7 +128,7 @@ Page({
.then((res) => {
const history = (res.list || []).map((item) => ({
...item,
thumbSrcs: (item.attachments || []).map((p) => api.planFileUrl(p))
thumbSrcs: api.attachmentThumbs(item)
}));
this.setData({ history });
}).catch(() => {});
@@ -151,6 +159,72 @@ Page({
});
},
// 把一张图压缩到 MAX_IMAGE_SIZE 以内,返回实际要上传的本地路径。
// 必须是独立方法:压缩会**换掉临时文件名**,而直传的签名是按目录签的,
// 真正的最终文件名只能在上传那一刻才确定(见 putToOss)。
prepareImage(src, level) {
return getFileSize(src).then((size) => {
if (!size || size <= MAX_IMAGE_SIZE || level >= COMPRESS_LEVELS.length) {
return src;
}
return new Promise((resolve) => {
wx.compressImage({
src,
quality: COMPRESS_LEVELS[level],
success: (res) => {
const compressed = res.tempFilePath || src;
if (!hasImageExt(compressed)) {
// 压缩产物扩展名丢失时回退当前图,避免按扩展名校验失败
resolve(src);
return;
}
resolve(this.prepareImage(compressed, level + 1));
},
fail: () => resolve(src)
});
});
});
},
// 直传 OSS:图片字节只走「小程序 → OSS」,服务器带宽为零。
// 缩略图直接用本地临时路径,省一次「取签名 URL」的请求 ——
// 它只在本次会话有效,页面重进后由 applyRecord 用后端的 attachment_urls 重建。
putToOss(src, sig) {
const filename = baseName(src);
if (!filename || !hasImageExt(filename)) {
return Promise.resolve({ error: '不支持的图片格式' });
}
const key = sig.key_prefix + filename;
return new Promise((resolve) => {
wx.uploadFile({
// 直传的地址是 OSS 域名,不是自家后端
url: sig.host,
filePath: src,
name: 'file',
// formData 的字段名由 OSS 约定,不能改名。
// `file` 必须排在最后 —— wx.uploadFile 的原生实现天然满足,无需手动控制顺序。
formData: Object.assign({}, sig.form_data, { key }),
timeout: 60000,
success: (res) => {
// PostObject 成功默认返回 204(没让 OSS 返回 body,省几百字节)
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve({ relPath: sig.rel_prefix + filename, thumb: src });
return;
}
resolve({ error: '上传失败(' + res.statusCode + ')' });
},
fail: (err) => {
const msg = (err && err.errMsg) || '';
if (/url not in domain list|domain not/i.test(msg)) {
resolve({ error: 'OSS 域名未加入上传域名白名单' });
return;
}
resolve({ error: msg || '上传失败' });
}
});
});
},
uploadImages(paths) {
if (this.data.uploading || !paths.length) {
return;
@@ -158,73 +232,52 @@ Page({
const planType = this.data.planType;
const periodKey = this.data.periodKey;
let attachments = this.data.attachments.slice();
// 展示地址必须和 attachments 平行推进,不能在结尾用 attachments 现算:
// 新上传的图只在 OSS 上,光有相对路径拼不出可用地址
let thumbs = (this.data.attachmentThumbs || []).slice();
this.setData({ uploading: true });
const run = (index) => {
if (index >= paths.length) {
this.setData({
uploading: false,
attachmentThumbs: attachments.map((p) => api.planFileUrl(p))
});
// 先取一次直传签名。一份 policy 覆盖本次所有图片(它锁的是目录、不锁文件名)。
// 取不到签名(网络异常 / 后端异常 / 后端没配对象存储)就无法上传,直接报错。
api.request({
url: '/api/v1/plan/upload-signature',
method: 'POST',
data: { plan_type: planType, period_key: periodKey }
}).then((res) => {
if (!res || !res.success || !res.enabled) {
this.setData({ uploading: false });
wx.showToast({ title: '上传服务不可用,请稍后再试', icon: 'none' });
return;
}
const rawPath = paths[index];
const doUpload = (src) => {
wx.uploadFile({
url: api.BASE_URL + '/api/v1/plan/upload',
filePath: src,
name: 'files',
formData: { plan_type: planType, period_key: periodKey },
header: { Authorization: 'Bearer ' + auth.getToken() },
success: (res) => {
let data = {};
try {
data = JSON.parse(res.data);
} catch (e) {
data = {};
}
if (data.success && Array.isArray(data.attachments)) {
attachments = attachments.concat(data.attachments).slice(0, MAX_IMAGES);
this.setData({
attachments,
attachmentThumbs: attachments.map((p) => api.planFileUrl(p))
});
const sig = res;
const run = (index) => {
if (index >= paths.length) {
// ⚠️ 不能直接 setData:上一个文件成功时刚在**同一个同步块**里写过
// setData({attachments, attachmentThumbs}),紧接着 run(下一个) 就走到这里,
// 两次更新会撞在一起(本项目的数据更新纪律:同一同步树内只发一次 setData)。
// 用 nextTick 错开一个 tick,UI 表现不变。
wx.nextTick(() => this.setData({ uploading: false, attachmentThumbs: thumbs }));
return;
}
// 按大小限制压缩:小图直接上传,大图逐级降质量直到 ≤ MAX_IMAGE_SIZE
this.prepareImage(paths[index], 0).then((src) => {
this.putToOss(src, sig).then((item) => {
if (item && item.relPath) {
attachments = attachments.concat([item.relPath]).slice(0, MAX_IMAGES);
thumbs = thumbs.concat([item.thumb || src]).slice(0, MAX_IMAGES);
this.setData({ attachments, attachmentThumbs: thumbs });
} else {
wx.showToast({ title: data.error || '上传失败', icon: 'none' });
wx.showToast({ title: (item && item.error) || '上传失败', icon: 'none' });
}
run(index + 1);
},
fail: () => {
wx.showToast({ title: '上传失败', icon: 'none' });
run(index + 1);
}
});
};
// 按大小限制压缩:小图直接上传,大图逐级降质量直到 ≤ MAX_IMAGE_SIZE
const tryCompress = (src, level) => {
getFileSize(src).then((size) => {
if (!size || size <= MAX_IMAGE_SIZE || level >= COMPRESS_LEVELS.length) {
doUpload(src);
return;
}
wx.compressImage({
src,
quality: COMPRESS_LEVELS[level],
success: (res) => {
const compressed = res.tempFilePath || src;
if (!hasImageExt(compressed)) {
// 压缩产物扩展名丢失时回退当前图,避免后端按扩展名校验失败
doUpload(src);
return;
}
tryCompress(compressed, level + 1);
},
fail: () => doUpload(src)
});
});
};
tryCompress(rawPath, 0);
};
run(0);
run(0);
}).catch(() => {
this.setData({ uploading: false });
wx.showToast({ title: '获取上传签名失败,请稍后再试', icon: 'none' });
});
},
removeImage(e) {
@@ -240,10 +293,12 @@ Page({
const dropLocal = () => {
const attachments = this.data.attachments.slice();
attachments.splice(index, 1);
this.setData({
attachments,
attachmentThumbs: attachments.map((p) => api.planFileUrl(p))
});
// 展示数组与 attachments 同长同序,必须一起删;否则缩略图会和实际文件错位
const thumbs = (this.data.attachmentThumbs || []).slice();
thumbs.splice(index, 1);
// 顺带清掉这条的签名缓存:否则以后重传**同名**文件时会命中已删对象的旧地址
api.forgetSignedUrl(path);
this.setData({ attachments, attachmentThumbs: thumbs });
};
if (parts.length < 4) {
dropLocal();