diff --git a/pages/course/course.js b/pages/course/course.js index 310d242..0c44ebe 100644 --- a/pages/course/course.js +++ b/pages/course/course.js @@ -9,16 +9,30 @@ const auth = require('../../utils/auth'); const COURSE_URL = 'https://www.muguaclub.cn/courses/index.html'; +// 「去上课」浮窗的目标小程序(第三方,appId 由业务方提供)。 +// 只用 navigateToMiniProgram,不传 path → 落对方首页;若将来要直达某个页面, +// 在这里补 path: '/pages/xxx/xxx'(以 / 开头)。 +// 白名单:app.json 的 navigateToMiniProgramAppIdList 已于 2020-04-24 取消(配置里若还留着反而报 appJSON 无效), +// 所以不需要任何平台/工程配置。但下面两条绕不过去: +// ① 必须由用户点击触发(基础库 2.3.0 起),所以在 onShow 里自动调会报 +// `navigateToMiniProgram:fail can only be invoked by user TAP gesture`; +// ② 跳转前微信一定弹系统确认框,用户取消时 fail.errMsg 含 'cancel'。 +const CLASS_MINI_APP_ID = 'wxa3589f1193170aa3'; + Page({ data: { webviewUrl: '', webviewError: false, errorMsg: '', - webviewKey: 0 + webviewKey: 0, + // 「去上课」浮窗是否已挂载。**故意不跟 web-view 同帧渲染**,见 _scheduleFloat + showFloat: false }, _webviewKey: 0, _redirecting: false, + _jumping: false, + _floatTimer: null, onShow() { this.ensureAccess(); @@ -46,8 +60,9 @@ Page({ // 清掉可能残留的 web-view(如退出登录后切回本 tab),再切到「我的」 denyAccess() { - if (this.data.webviewUrl) { - this.setData({ webviewUrl: '', webviewError: false, errorMsg: '' }); + clearTimeout(this._floatTimer); + if (this.data.webviewUrl || this.data.showFloat) { + this.setData({ webviewUrl: '', webviewError: false, errorMsg: '', showFloat: false }); } if (this._redirecting) { return; @@ -68,24 +83,72 @@ Page({ webviewUrl: COURSE_URL + '?_t=' + Date.now(), webviewError: false, errorMsg: '', - webviewKey: this._webviewKey + webviewKey: this._webviewKey, + // 与上面的字段合并成同一次 setData,别拆开发(同同步树连发会撞 recursive update) + showFloat: false }); + this._scheduleFloat(); + }, + + // 延迟一拍再把浮窗插进去。 + // + // 为什么不能跟 web-view 同帧渲染:原生组件按「后插入的盖住先插入的」决定层级, + // 而 web-view 每次 onShow 都因 key 自增被销毁重建 —— 同帧渲染时重建后的 web-view + // 反而压在浮窗上,**真机上是「浮窗不见了」**(安卓默认如此,iOS 多数机型不出现; + // 开发者工具里则是完全看不到覆盖效果,无论怎么改都只能真机验证)。 + // 等 web-view 挂载完(250ms)再 setData,浮窗就成了后插入的那一个。 + _scheduleFloat() { + clearTimeout(this._floatTimer); + this._floatTimer = setTimeout(() => { + // 定时器可能落在 webviewError / 已切走的场景里,必须再判一次 + if (this.data.webviewUrl && !this.data.webviewError) { + this.setData({ showFloat: true }); + } + }, 250); }, onWebviewError(e) { + // 加载失败时把浮窗收掉(错误页上不叠浮窗),同时取消待触发的显示定时器 + clearTimeout(this._floatTimer); this.setData({ webviewError: true, - errorMsg: (e && e.detail && e.detail.errMsg) || '加载失败,请检查网络后重试' + errorMsg: (e && e.detail && e.detail.errMsg) || '加载失败,请检查网络后重试', + showFloat: false }); }, onRetry() { - this.setData({ webviewUrl: '', webviewError: false, errorMsg: '' }); + clearTimeout(this._floatTimer); + this.setData({ webviewUrl: '', webviewError: false, errorMsg: '', showFloat: false }); setTimeout(() => { this.loadWebview(); }, 60); }, + // ---- 去上课:跳转到第三方授课小程序 ---- + onGoClass() { + // 防重入:连点会连续弹系统确认框。complete 里复位(跳转成功时本页被挂起, + // 用户从对方小程序返回会走 onShow → 重新加载课程页,这是本页既有行为,不在这里动) + if (this._jumping) { + return; + } + this._jumping = true; + wx.navigateToMiniProgram({ + appId: CLASS_MINI_APP_ID, + fail: (err) => { + const msg = (err && err.errMsg) || ''; + // 用户点了取消,不是错误 + if (msg.indexOf('cancel') >= 0) { + return; + } + wx.showToast({ title: '打开失败,请稍后再试', icon: 'none' }); + }, + complete: () => { + this._jumping = false; + } + }); + }, + // ---- 分享 ---- onShareAppMessage() { return { diff --git a/pages/course/course.wxml b/pages/course/course.wxml index 3086f8c..313968d 100644 --- a/pages/course/course.wxml +++ b/pages/course/course.wxml @@ -1,5 +1,23 @@ + + + + 去上课 + + ! 页面加载失败 diff --git a/pages/course/course.wxss b/pages/course/course.wxss index f031b6e..5007532 100644 --- a/pages/course/course.wxss +++ b/pages/course/course.wxss @@ -49,3 +49,47 @@ .btn-hover { opacity: 0.7; } + +/* ---- 「去上课」浮窗 ---- + 是 cover-view(原生组件),只能用基本定位/布局/文本样式: + 不支持 box-shadow、background-image、单边 border、overflow: visible。 + 层次靠「主色底 + 浅描边」表达,别改成阴影 —— 真机上会静默不生效。 + **2026-09-21 起改为「右侧垂直居中」**(原为右下角 bottom: 40rpx)。 + 本页是 tabBar 页面,可视区已不含 tabBar,所以 top: 50% 直接就是「内容区正中」, + 不需要再为 tabBar 做偏移。 + **2026-09-21 加大尺寸**(水平 padding 40 → 64 → 80 → **96**;垂直一直是 36,从未变过)。 + 尺寸约 286×112rpx(≈143×56px),满足微信建议的点击热区 ≥ 88rpx。 */ +.class-float { + position: fixed; + right: 24rpx; + /* 垂直居中 = top 50% + 负 margin 抵消自身高度的一半。 + ⚠️ 不要改成 transform: translateY(-50%) —— cover-view 对 transform 没有官方支持承诺, + 真机上一旦静默失效,按钮会掉回页面顶部(不是掉到中间,是掉到最上面)。 + ⚠️ 不能同时保留 bottom:position: fixed 下 top/bottom 同时给出且 height 为 auto 时, + 盒子会被拉伸填满整个视口高度,视觉上变成一条竖条。 + ⚠️ -56rpx 与按钮高度强耦合:高度 = padding 36×2 + 文本 30×1.2(=36) + 边框 2×2 = 112rpx, + 补偿值恒等于「-高度/2」。**只调水平 padding 不会影响它** —— 想调尺寸又不想碰这个坑时, + 优先只动水平方向(垂直方向一改就必须重算,否则垂直居中静默偏掉: + 不是报错,只是看着不太居中,很难肉眼发现)。 + 验证脚本 `verify_course_float.js` 的 [7c] 会按上述算式自动推导并断言,漏改会直接红。 + ⚠️ 宽度同理 = 文本 3字×30(=90) + padding 水平 96×2 + 边框 2×2 = 286rpx(≈屏宽 38%)。 + 按钮越宽,横向压住课程列表的区域越大 —— 真机若发现挡住列表可点元素,优先收窄水平 padding。 + **要调宽度只需改这一个数字**(水平 padding),不必动其他任何地方。 */ + top: 50%; + margin-top: -56rpx; + z-index: 999; + padding: 36rpx 96rpx; + background: #C67B30; + border: 2rpx solid #E8C79A; + border-radius: 999rpx; +} +.class-float-text { + color: #FFFFFF; + font-size: 30rpx; + font-weight: 700; + line-height: 1.2; + text-align: center; +} +.class-float-hover { + background: #B06A26; +} diff --git a/pages/plan-submit/plan-submit.js b/pages/plan-submit/plan-submit.js index 9804c5d..1498af7 100644 --- a/pages/plan-submit/plan-submit.js +++ b/pages/plan-submit/plan-submit.js @@ -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(); diff --git a/pages/plan/plan.js b/pages/plan/plan.js index c19dd42..e3e1a9d 100644 --- a/pages/plan/plan.js +++ b/pages/plan/plan.js @@ -185,7 +185,7 @@ Page({ myChecked: !!res.my_checked, myStatus: res.my_status || '', myRecord: res.my_record || null, - myThumbs: ((res.my_record && res.my_record.attachments) || []).map((p) => api.planFileUrl(p)), + myThumbs: api.attachmentThumbs(res.my_record), unassignedCount: res.unassigned_count || 0, isTeacher, myGroup: res.my_group || '', @@ -239,11 +239,10 @@ Page({ url: '/api/v1/plan/group', data: { type, group_name: groupName, period_key: this.data.periodKey } }).then((res) => { - // 附件相对路径转完整 URL(列表已不渲染缩略图,allThumbs 全量留给详情弹层) - const members = (res.members || []).map((m) => { - const all = (((m.record || {}).attachments) || []).map((p) => api.planFileUrl(p)); - return { ...m, allThumbs: all }; - }); + // 附件转展示地址(列表已不渲染缩略图,allThumbs 全量留给详情弹层) + // 注意走 attachmentThumbs 而不是自己 map:后端返回的签名 URL 在 attachment_urls 里, + // 而 attachments 必须保持规范相对路径(提交打卡时会被原样回写) + const members = (res.members || []).map((m) => ({ ...m, allThumbs: api.attachmentThumbs(m.record) })); this.setData({ loading: false, periodKey: res.period_key || this.data.periodKey, @@ -284,7 +283,7 @@ Page({ data: { type, page: 1, size: 20 } }).then((res) => { const history = (res.list || []).map((item) => { - const all = (item.attachments || []).map((p) => api.planFileUrl(p)); + const all = api.attachmentThumbs(item); return { ...item, // 卡片里只铺前 2 张缩略图,弹层用 allThumbs 看全部 diff --git a/pages/plan/plan.wxml b/pages/plan/plan.wxml index ffc3caf..9c521bd 100644 --- a/pages/plan/plan.wxml +++ b/pages/plan/plan.wxml @@ -137,12 +137,19 @@ 📒 暂无{{types[typeIndex]}}记录 - + {{item.period_key}} @@ -161,7 +168,6 @@ mode="aspectFill" > - 查看详情 › diff --git a/pages/plan/plan.wxss b/pages/plan/plan.wxss index f1526fa..b40d8ec 100644 --- a/pages/plan/plan.wxss +++ b/pages/plan/plan.wxss @@ -140,7 +140,9 @@ padding: 24rpx; margin-bottom: 18rpx; box-shadow: 0 6rpx 20rpx rgba(44, 24, 16, 0.05); + transition: transform .15s ease, box-shadow .15s ease; } +.history-card-hover { transform: scale(.99); box-shadow: 0 10rpx 24rpx rgba(44, 24, 16, 0.10); } .history-head { display: flex; align-items: center; diff --git a/pages/profile/profile.js b/pages/profile/profile.js index 65e460c..8e21e59 100644 --- a/pages/profile/profile.js +++ b/pages/profile/profile.js @@ -107,7 +107,10 @@ Page({ profile: res, phoneError: '', loadError: '', - avatarSrc: this.fullUrl(res.wechat_avatar), + // 必须走 api.avatarUrl(内部先查本地签名缓存),不能直接取 res.avatar_url: + // 后端每次请求都现签一个新 URL,直接用会让地址每次进页面都变, + // 而微信是按 URL 缓存图片的 —— 等于每次重新下载头像。 + avatarSrc: this.fullUrl(res), avatarFallback: this.firstChar(displayName), roleLabel: this.roleLabel(res.role), loginTypeLabel: this.loginTypeLabel(res.login_type), @@ -131,9 +134,13 @@ Page({ return LOGIN_TYPE_LABELS[type] || type || '手机号'; }, - fullUrl(rel) { - if (!rel) return ''; - return /^https?:\/\//i.test(rel) ? rel : api.STATIC_BASE_URL + rel; + /** + * 头像转可显示地址。整个 payload 交给 api.avatarUrl,这样它既能查本地签名缓存, + * 也能在后端给了 avatar_url 时写回缓存 —— 与打卡附件走的是同一套逻辑。 + * 兼容 avatars/{uid}/x 与 /clock/static/avatars/{uid}/x 两种历史格式。 + */ + fullUrl(payload) { + return api.avatarUrl(payload); }, firstChar(str) { @@ -162,7 +169,11 @@ Page({ } if (data.success && data.wechat_avatar) { const newAvatar = data.wechat_avatar; - const newUrl = this.fullUrl(newAvatar); + // ⚠️ 换头像 = **key 没变、内容变了**。必须清掉缓存里的那一条, + // 否则下次进「我的」页会命中旧 URL —— 微信按 URL 缓存图片,用户会看到旧头像。 + api.forgetSignedUrl(newAvatar); + // 本页立刻用后端刚签的地址显示(不走缓存),确保看到的就是新头像 + const newUrl = data.avatar_url || this.fullUrl(data); this.setData({ 'profile.wechat_avatar': newAvatar, avatarSrc: newUrl diff --git a/utils/api.js b/utils/api.js index a2780a2..3945e25 100644 --- a/utils/api.js +++ b/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 本地缓存 ───────────────────────────────────────────────────── +// 为什么值得缓存:微信 是**按 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 +}; diff --git a/utils/auth.js b/utils/auth.js index 4deb415..4479a5e 100644 --- a/utils/auth.js +++ b/utils/auth.js @@ -12,6 +12,8 @@ function setToken(token) { function clearToken() { wx.removeStorageSync('token'); + // 顺带清掉签名 URL 缓存:换账号后不该复用上一个人的图片地址 + api.clearSignedCache(); } function wxLogin(retries) { diff --git a/utils/oss-upload.js b/utils/oss-upload.js deleted file mode 100644 index fa4cccd..0000000 --- a/utils/oss-upload.js +++ /dev/null @@ -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>} - */ -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 -};