const { fileId, normalizeUploadResult, filePayload } = require('./file-media'); const { camelName, toWire, fromWire } = require('./api-naming'); const dataCache = require('./data-cache'); /** * 网络请求工具 - 对接后端 API */ const { API_BASE } = require('../config/env'); /** * 将文件 UUID 或 /api/files/{id} 转成完整访问地址。 * 完整 URL 和小程序本地图片路径直接使用。 */ function getImageUrl(path) { if (!path) return ''; if (typeof path === 'object') path = fileId(path) || path.path || path.url || ''; if (typeof path !== 'string') return ''; if (path.startsWith('/api/files/')) return API_BASE.replace(/\/api$/, '') + path; if (fileId(path) === path) return API_BASE + '/files/' + path; if (path.startsWith('http://') || path.startsWith('https://')) return path; return path; } /** * 解析上传响应,兼容各种格式,提取 error 和 code 字段 * @param {*} raw - 原始响应数据 * @returns {object|null} 解析后的对象,包含可能的 error 和 code 字段 */ function parseUploadResponse(raw) { if (!raw) return null; if (typeof raw === 'object') return normalizeUploadResult(fromWire(raw)); var str = String(raw).trim(); // 去除 BOM 和不可见字符 str = str.replace(/^\uFEFF/, '').replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, ''); try { return normalizeUploadResult(fromWire(JSON.parse(str))); } catch(e) { // 尝试提取 JSON 片段(Android 端可能返回带额外字符的响应) var match = str.match(/\{[\s\S]*\}/); if (match) { try { return normalizeUploadResult(fromWire(JSON.parse(match[0]))); } catch(e2) {} } // 最后尝试:查找 "success" 或 "path" 关键词,手动构建响应对象 if (str.indexOf('success') > -1 || str.indexOf('path') > -1) { var pathMatch = str.match(/"path"\s*:\s*"([^"]+)"/); if (pathMatch) { return normalizeUploadResult({ success: true, path: pathMatch[1] }); } } return null; } } /** * 格式化上传错误信息,包含错误码 * @param {object} data - 解析后的响应数据 * @returns {string} 格式化的错误信息 */ function formatUploadError(data) { if (!data) return '上传失败'; var msg = data.error || '上传失败'; if (data.code) { msg += ' (错误码: ' + data.code + ')'; } return msg; } function maybeRedirectBinding(data) { if (!data || Number(data.code) !== 10002) return false; if (!data._bindingPromptShown) { data._bindingPromptShown = true; wx.removeStorageSync('coupleId'); dataCache.clear(); require('./record-access').requireRecordAccess(); } return true; } // 存储配额错误码(与后端一致),上传超限时引导用户付费。 var STORAGE_QUOTA_EXCEEDED = 10001; function isStorageQuotaError(data) { return !!(data && data.code === STORAGE_QUOTA_EXCEEDED); } var storageModalOpen = false; function maybeRedirectStorage(data) { if (!isStorageQuotaError(data)) return false; if (data._storagePromptShown || storageModalOpen) return true; data._storagePromptShown = true; storageModalOpen = true; try { wx.hideLoading(); } catch(e) {} wx.showModal({ title: '存储空间不足', content: '剩余存储空间不足以保存这张图片,请购买更大容量的套餐后继续上传。', confirmText: '去升级', cancelText: '暂不', success: function (res) { if (res.confirm) wx.navigateTo({ url: '/pages/storage/storage' }); }, complete: function() { storageModalOpen = false; } }); return true; } /** * 通用请求方法 * @param {string} action - API action 名称 * @param {object} data - 请求数据 * @param {string} method - 请求方法 GET/POST */ function authApp() { return typeof getApp === 'function' ? getApp() : null; } async function waitForAuth() { var app = authApp(); if (app && app.ensureAuth) await app.ensureAuth(); } async function request(action, data = null, method = 'POST', options = {}) { const name = camelName(action); await waitForAuthIfNeeded(name); if (method === 'GET' && options.cache === false) return authenticatedRequest(action, data, method, options); if (method === 'GET') return dataCache.read(name, toWire(data), () => authenticatedRequest(action, data, method, options)); const result = await authenticatedRequest(action, data, method, options); dataCache.mutation(name, result); return result; } async function waitForAuthIfNeeded(action) { if (action !== 'wechatLogin') await waitForAuth(); } async function authenticatedRequest(action, data = null, method = 'POST', options = {}) { if (camelName(action) === 'wechatLogin') return sendRequest(action, data, method, options); await waitForAuth(); var token = wx.getStorageSync('token'); try { return await sendRequest(action, data, method, options); } catch (error) { var app = authApp(); if (error._statusCode !== 401 || !app || !app.refreshAuth) throw error; await app.refreshAuth(token); // 每个业务请求最多重试一次,防止 401 导致无限登录。 return sendRequest(action, data, method, options); } } function sendRequest(action, data = null, method = 'POST', options = {}) { return new Promise((resolve, reject) => { const token = wx.getStorageSync('token'); const header = { 'Content-Type': 'application/json' }; if (token) { header['Authorization'] = 'Bearer ' + token; } let url = API_BASE + '/' + encodeURIComponent(camelName(action)); let requestData = toWire(filePayload(camelName(action), data)); if (method === 'GET' && data) { const params = []; for (let key in data) { if (data[key] !== null && data[key] !== undefined) { params.push(camelName(key) + '=' + encodeURIComponent(data[key])); } } if (params.length > 0) { url += '?' + params.join('&'); } requestData = null; } // 后端返回 no-store;请求地址仅保留业务参数。 wx.request({ url: url, method: method, timeout: 15000, data: requestData, header: header, success: (res) => { const relation = res.header && (res.header['X-Couple-ID'] !== undefined ? res.header['X-Couple-ID'] : res.header['x-couple-id']); const owner = res.header && (res.header['X-User-ID'] || res.header['x-user-id']); if (relation !== undefined && String(owner) === String(wx.getStorageSync('userId'))) { const next = String(relation) === '0' ? '' : String(relation); const previous = String(wx.getStorageSync('coupleId') || ''); if (next !== previous) { wx.setStorageSync('coupleId', next); dataCache.clear(); const app = typeof getApp === 'function' && getApp(); if(app && app.onRelationChanged) app.onRelationChanged(); } } if (!options.silent) { try { wx.hideLoading(); } catch(e) {} } if (res.statusCode >= 400 || (res.data && res.data.error)) { maybeRedirectBinding(res.data); reject({ ...fromWire(res.data), _statusCode: res.statusCode }); } else { const result = fromWire(res.data); if (camelName(action) === 'storageOrderStatus') dataCache.mutation('storageOrderStatus', result); resolve(result); } }, fail: (err) => { if (!options.silent) { try { wx.hideLoading(); } catch(e) {} wx.showToast({ title: '网络请求失败', icon: 'none' }); } reject(err); } }); }); } // ========== API 方法封装 ========== const api = { announcement: () => request('announcement', null, 'GET'), accountDeletionPreview: () => request('accountDeletionPreview', null, 'POST'), accountDelete: (data) => request('accountDelete', data), // 用户相关 wechatLogin: (code, region) => request('wechatLogin', { code: code, region: region || '' }), checkAuth: (options) => request('checkAuth', null, 'GET', options), logout: () => request('logout', null, 'POST'), updateProfile: (data) => request('updateProfile', data), updateLocation: (location) => request('updateLocation', { location: location }), updateIpLocation: (data) => request('updateIpLocation', data || {}, 'POST'), _uploadFileDirect: (url, filePath, name, formData) => uploadFileCompat({url, filePath, name, formData}), uploadAvatar: async (filePath) => { wx.showLoading({ title: '上传中...' }); try { const data = await api._uploadFileDirect( API_BASE + '/uploadAvatar', filePath, 'avatar', {} ); wx.hideLoading(); if (data && !data.error && (data.path || data.success)) { dataCache.mutation('uploadAvatar', data); return data; } if (!maybeRedirectBinding(data) && !maybeRedirectStorage(data)) wx.showToast({ title: formatUploadError(data), icon: 'none' }); throw data || { error: '上传失败' }; } catch(e) { wx.hideLoading(); if (!maybeRedirectBinding(e) && !maybeRedirectStorage(e)) wx.showToast({ title: formatUploadError(e), icon: 'none' }); throw e; } }, uploadPartnerAvatar: async (filePath, partnerId) => { wx.showLoading({ title: '上传中...' }); try { const data = await api._uploadFileDirect( API_BASE + '/uploadPartnerAvatar', filePath, 'avatar', { partner_id: partnerId } ); wx.hideLoading(); if (data && !data.error && (data.path || data.success)) { dataCache.mutation('uploadAvatar', data); return data; } if (!maybeRedirectBinding(data) && !maybeRedirectStorage(data)) wx.showToast({ title: formatUploadError(data), icon: 'none' }); throw data || { error: '上传失败' }; } catch(e) { wx.hideLoading(); if (!maybeRedirectBinding(e) && !maybeRedirectStorage(e)) wx.showToast({ title: formatUploadError(e), icon: 'none' }); throw e; } }, // 情侣相关 coupleInfo: () => request('coupleInfo', null, 'GET'), updateCoupleInfo: (data) => request('updateCoupleInfo', data), invitePartner: (data) => request('invitePartner', data), unbindRequest: () => request('unbindRequest', null, 'POST'), unbindRespond: (data) => request('unbindRespond', data), unbindCancel: () => request('unbindCancel', null, 'POST'), unbindStatus: () => request('unbindStatus', null, 'GET'), myInvitations: () => request('myInvitations', null, 'GET'), respondInvitation: (data) => request('respondInvitation', data), generateInviteLink: () => request('generateInviteLink', null, 'POST'), acceptShareInvite: (data) => request('acceptShareInvite', data), updatePartnerProfile: (data) => request('updatePartnerProfile', data), // 时间线 timelineList: (data = {}) => request('timelineList', { page: 1, page_size: 20, ...data }, 'GET'), timelineAdd: (data) => request('timelineAdd', data), timelineUpdate: (data) => request('timelineUpdate', data), timelineDelete: (data) => request('timelineDelete', data), timelineDetail: (id) => request('timelineDetail', { id: id }, 'GET'), // 相册 albumList: (data, method) => request('albumList', { page: 1, page_size: 20, ...data }, method || 'GET'), albumAdd: (data) => request('albumAdd', data), albumUpdate: (data) => request('albumUpdate', data), albumDelete: (data) => request('albumDelete', data), photoDelete: (data) => request('photoDelete', data), photoAdd: (data) => request('photoAdd', data), photoUpdate: (data) => request('photoUpdate', data), photos: (albumId, data, method) => request('photos', { page: 1, page_size: 20, album_id: albumId, ...data }, method || 'GET'), photoUpload: async (filePath, albumId) => { wx.showLoading({ title: '上传中...' }); try { const data = await uploadFileCompat({ url: API_BASE + '/photoUpload', filePath, name: 'photos', formData: {albumId} }); wx.hideLoading(); if (data && !data.error && (data.success || data.path || data.uploaded)) { dataCache.mutation('photoUpload', data); return data; } if (!maybeRedirectBinding(data) && !maybeRedirectStorage(data)) wx.showToast({ title: formatUploadError(data), icon: 'none' }); throw data || { error: '上传失败' }; } catch(e) { wx.hideLoading(); if (!maybeRedirectBinding(e) && !maybeRedirectStorage(e)) wx.showToast({ title: formatUploadError(e), icon: 'none' }); throw e; } }, // 日记 diaryList: (data) => request('diaryList', data || null, 'GET'), diaryAdd: (data) => request('diaryAdd', data), diaryUpdate: (data) => request('diaryUpdate', data), diaryDelete: (data) => request('diaryDelete', data), diaryDetail: (id) => request('diaryDetail', { id: id }, 'GET'), diaryCommentList: (data) => request('diaryCommentList', { page: 1, page_size: 20, ...data }, 'GET'), diaryCommentAdd: (data) => request('diaryCommentAdd', data), diaryCommentDelete: (data) => request('diaryCommentDelete', data), // 点赞/收藏功能已移除 // 一起听过的歌 songsList: (data = {}) => request('songsList', { page: 1, page_size: 20, ...data }, 'GET'), songsAdd: (data) => request('songsAdd', data), songsUpdate: (data) => request('songsUpdate', data), songsDelete: (data) => request('songsDelete', data), // 打卡 smokingInfo: (userId, data = {}) => request("smokingInfo", { page: 1, page_size: 20, ...data, user_id: userId || "" }, "GET"), smokingAction: (data) => request("smokingAction", data), smokingCardVisible: (data) => request("smokingCardVisible", data), checkinList: (data = {}) => request('checkinList', { page: 1, page_size: 20, ...data }, 'GET'), checkinAdd: (data) => request('checkinAdd', data), checkinDo: (data) => request('checkinDo', data), checkinStats: () => request('checkinStats', null, 'GET'), checkinDetail: (id, pagination = {}) => request('checkinDetail', { ...pagination, id: id }, 'GET'), checkinDelete: (data) => request('checkinDelete', data), // 背景图 getBgConfig: () => request('getBgConfig', null, 'GET'), deleteBgImage: () => request('deleteBgImage', {}), // 存储空间 storageInfo: () => request('storageInfo', null, 'GET'), storageCreateOrder: (data) => request('storageCreateOrder', data), storageOrderStatus: (number) => request('storageOrderStatus', { out_trade_no: number }, 'GET'), // 操作记录 activityLogList: (data) => request('activityLogList', data, 'GET'), // 订阅消息 subscribeTemplate: (data, options) => request('subscribeTemplate', data, 'POST', options), unsubscribeTemplate: (data) => request('unsubscribeTemplate', data), mySubscribeTemplates: () => request('mySubscribeTemplates', null, 'GET'), // 宠物养成 petInfo: () => request('petInfo', null, 'GET'), petAdopt: (data) => request('petAdopt', data), petDailyAction: (data) => request('petDailyAction', data), petAchievements: () => request('petAchievements', null, 'GET'), petRename: (data) => request('petRename', data), // 姨姨 // 权限和周期数据会随伴侣设置立即变化,始终请求最新结果。 periodList: (userId) => request('periodList', userId ? {user_id:userId} : null, 'GET'), periodAdd: (data) => request('periodAdd', data), periodUpdate: (data) => request('periodUpdate', data), periodDelete: (data) => request('periodDelete', data), periodDailySave: (data) => request('periodDailySave', data), // 爱爱记录(支持一天多条) periodSexAdd: (data) => request('periodSexAdd', data), periodSexDelete: (data) => request('periodSexDelete', data), periodSexList: (data) => request('periodSexList', data, 'GET'), // 姨姨权限(对方可见) periodVisibleGet: (userId) => request('periodVisibleGet', userId ? {user_id:userId} : null, 'GET'), periodVisibleSet: (data) => request('periodVisibleSet', data), periodSourceSelect: (data) => request('periodSourceSelect', data), }; /** * Android 兼容上传:先压缩图片再上传 * 解决 Android 端 wx.uploadFile 上传照片失败的问题 * @param {string} filePath - 原始图片路径 * @returns {Promise} 压缩后的图片路径 */ function compressForUpload(filePath) { return new Promise((resolve) => { if (!filePath) { resolve(filePath); return; } // Android 端 chooseImage 返回的路径可能是 http://tmp/xxx 或 wxfile://xxx 格式 // 统一尝试压缩,压缩失败也不影响上传 wx.compressImage({ src: filePath, quality: 80, success: (res) => { resolve(res.tempFilePath); }, fail: () => { // 压缩失败,使用原路径 resolve(filePath); } }); }); } /** * Android 兼容的 wx.uploadFile 封装 * 先压缩图片,再上传,确保 Android 端能正常上传 */ async function uploadFileCompat(options) { await waitForAuth(); const { url, filePath, name, formData, timeout, header } = options; const token = wx.getStorageSync('token'); const finalHeader = header || {}; if (token && !finalHeader['Authorization']) { finalHeader['Authorization'] = 'Bearer ' + token; } const finalFormData = toWire(formData || {}); if (token && !finalFormData.token) { finalFormData.token = token; } // 从 URL 中提取 action 参数并添加到 formData(解决非开发者模式下 URL 参数被剥离的问题) var actionMatch = url.match(/\/api\/([^/?]+)|[?&]action=([^&]+)/); if (actionMatch) finalFormData.action = decodeURIComponent(actionMatch[1] || actionMatch[2]); // 将 token 追加到 URL 参数中,解决 Android 端 wx.uploadFile 不传自定义 header 的问题 var uploadUrl = url; if (token) { var sep = uploadUrl.indexOf('?') > -1 ? '&' : '?'; uploadUrl += sep + 'token=' + encodeURIComponent(token); } async function doUpload(fp) { // Hash and measure the compressed file that will actually be uploaded. const info = await new Promise((resolve, reject) => wx.getFileInfo({ filePath: fp, digestAlgorithm: 'md5', success: resolve, fail: () => reject({error:'读取文件信息失败,请重新选择图片'}) })); try { await request('uploadPreflight', {size: info.size, md5: info.digest}); } catch(error) { maybeRedirectStorage(error); throw error; } return new Promise((resolve, reject) => { wx.uploadFile({ url: uploadUrl, filePath: fp, name: name || 'file', timeout: timeout || 120000, header: finalHeader, formData: finalFormData, success: (res) => { var data = parseUploadResponse(res.data); if (data) { if (maybeRedirectBinding(data) || maybeRedirectStorage(data)) { reject(data); return; } if (res.statusCode < 400 && !data.error && (data.success || data.path || data.uploaded)) { dataCache.mutation(actionMatch ? decodeURIComponent(actionMatch[1] || actionMatch[2]) : '', data); } resolve(data); } else { reject({ error: '解析响应失败', raw: res.data }); } }, fail: (err) => { reject({ error: '网络异常', errMsg: err.errMsg, _uploadNetworkError: true }); } }); }); } const compressedPath = await compressForUpload(filePath); try { return await doUpload(compressedPath); } catch(error) { // Business/preflight failures never retry. A network retry with the original // file must pass a fresh quota check because it may be larger. if (error._uploadNetworkError && compressedPath !== filePath) return doUpload(filePath); throw error; } } module.exports = { api, getImageUrl, API_BASE, uploadFileCompat, compressForUpload };