// 根据小程序版本自动选择后端地址 function _getBaseUrls() { let envType = 'release'; try { const accountInfo = wx.getAccountInfoSync(); envType = accountInfo.miniProgram.envVersion || 'release'; } catch (e) { // 低版本基础库不支持时默认线上环境 envType = 'release'; } switch (envType) { case 'develop': // 开发版:本地服务 return { baseUrl: 'https://test.muguaclub.cn/clock', //baseUrl: 'http://localhost:8000', staticUrl: 'https://test.muguaclub.cn', h5Url: 'https://test.muguaclub.cn' }; case 'trial': // 体验版:测试环境 return { baseUrl: 'https://test.muguaclub.cn/clock', staticUrl: 'https://test.muguaclub.cn', h5Url: 'https://test.muguaclub.cn' }; case 'release': default: // 线上版:正式环境 return { baseUrl: 'https://m.muguaclub.cn/clock', staticUrl: 'https://m.muguaclub.cn', h5Url: 'https://m.muguaclub.cn' }; } } 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; let redirectingProfile = false; function goProfile() { if (redirectingProfile) { return; } const pages = getCurrentPages(); const current = pages.length ? pages[pages.length - 1] : null; const route = current && current.route; if (route === 'pages/profile/profile') { return; } redirectingProfile = true; wx.switchTab({ url: '/pages/profile/profile', complete() { setTimeout(() => { redirectingProfile = false; }, 1200); } }); } function request(options) { const { url, method = 'GET', data = {}, auth = true } = options; const isIdempotent = method === 'GET' || method === 'DELETE'; let retried = false; return new Promise((resolve, reject) => { const doRequest = () => { const header = { 'Content-Type': 'application/json' }; const token = wx.getStorageSync('token'); if (auth && token) { header['Authorization'] = 'Bearer ' + token; } wx.request({ url: BASE_URL + url, method, data, header, success(res) { if (res.statusCode >= 200 && res.statusCode < 300) { resolve(res.data); } else if (res.statusCode === 401) { wx.removeStorageSync('token'); if (auth) { goProfile(); } reject(res.data || { error: '未登录' }); } else { reject(res.data || { error: '请求失败' }); } }, fail(err) { // 冷启动时首个请求可能因网络栈未就绪而失败,空闲时对幂等请求自动重试一次 const msg = (err && err.errMsg) || ''; const domainError = /url not in domain list|domain not/i.test(msg); if (!retried && isIdempotent && !domainError) { retried = true; setTimeout(doRequest, 800); return; } reject(err); } }); }; doRequest(); }); } // ── 签名 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; } 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 };