const { camelName, toWire, fromWire } = require('./api-naming'); const dataCache = require('./data-cache'); /** * 网络请求工具 - 对接后端 API */ const API_BASE = 'https://past.sundynix.cn/api'; const MEDIA_BASE = 'https://past.sundynix.cn/backend/'; /** * 将相对路径转为完整URL(供小程序端显示图片使用) * @param {string} path - 相对路径,如 "uploads/avatars/xxx.jpg" * @returns {string} 完整URL,如 "http://192.168.2.103/uploads/avatars/xxx.jpg" */ function getImageUrl(path) { if (!path) return ''; if (path.startsWith('http://') || path.startsWith('https://')) return path; if (path.startsWith('/backend/')) return MEDIA_BASE + path.substring('/backend/'.length); if (path.startsWith('/')) return MEDIA_BASE + path.substring(1); return MEDIA_BASE + path; } /** * 解析上传响应,兼容各种格式,提取 error 和 code 字段 * @param {*} raw - 原始响应数据 * @returns {object|null} 解析后的对象,包含可能的 error 和 code 字段 */ function parseUploadResponse(raw) { if (!raw) return null; if (typeof raw === 'object') return fromWire(raw); var str = String(raw).trim(); // 去除 BOM 和不可见字符 str = str.replace(/^\uFEFF/, '').replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, ''); try { return fromWire(JSON.parse(str)); } catch(e) { // 尝试提取 JSON 片段(Android 端可能返回带额外字符的响应) var match = str.match(/\{[\s\S]*\}/); if (match) { try { return 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 { 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; } // 存储配额错误码(与后端一致),上传超限时引导用户付费。 var STORAGE_QUOTA_EXCEEDED = 10001; function isStorageQuotaError(data) { return !!(data && data.code === STORAGE_QUOTA_EXCEEDED); } function maybeRedirectStorage(data) { if (!isStorageQuotaError(data)) return false; wx.showModal({ title: '存储空间不足', content: '免费空间已用完,升级为无限空间会员后即可继续上传。', confirmText: '去升级', cancelText: '暂不', success: function (res) { if (res.confirm) wx.navigateTo({ url: '/pages/storage/storage' }); } }); 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') { const name = camelName(action); await waitForAuthIfNeeded(name); if (method === 'GET') return dataCache.read(name, toWire(data), () => authenticatedRequest(action, data, method)); const result = await authenticatedRequest(action, data, method); dataCache.mutation(name, result); return result; } async function waitForAuthIfNeeded(action) { if (action !== 'wechatLogin') await waitForAuth(); } async function authenticatedRequest(action, data = null, method = 'POST') { if (camelName(action) === 'wechatLogin') return sendRequest(action, data, method); await waitForAuth(); var token = wx.getStorageSync('token'); try { return await sendRequest(action, data, method); } catch (error) { var app = authApp(); if (error._statusCode !== 401 || !app || !app.refreshAuth) throw error; await app.refreshAuth(token); // 每个业务请求最多重试一次,防止 401 导致无限登录。 return sendRequest(action, data, method); } } function sendRequest(action, data = null, method = 'POST') { 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(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(); } } try { wx.hideLoading(); } catch(e) {} if (res.statusCode >= 400 || (res.data && res.data.error)) { reject({ ...fromWire(res.data), _statusCode: res.statusCode }); } else { resolve(fromWire(res.data)); } }, fail: (err) => { try { wx.hideLoading(); } catch(e) {} wx.showToast({ title: '网络请求失败', icon: 'none' }); reject(err); } }); }); } // ========== API 方法封装 ========== const api = { // 用户相关 wechatLogin: (code, region) => request('wechatLogin', { code: code, region: region || '' }), checkAuth: () => request('checkAuth', null, 'GET'), logout: () => request('logout', null, 'POST'), updateProfile: (data) => request('updateProfile', data), updateLocation: (location) => request('updateLocation', { location: location }), updateIpLocation: (data) => request('updateIpLocation', data || {}, 'POST'), _uploadFileDirect: async function(url, filePath, name, formData) { await waitForAuth(); return new Promise((resolve, reject) => { const token = wx.getStorageSync('token'); var uploadUrl = url; var sep = uploadUrl.indexOf('?') > -1 ? '&' : '?'; uploadUrl += sep + 'token=' + encodeURIComponent(token || ''); var finalFormData = toWire(formData || {}); finalFormData.token = token || ''; // 从 URL 中提取 action 参数并添加到 formData(解决非开发者模式下 URL 参数被剥离的问题) var actionMatch = url.match(/\/api\/([^/?]+)|[?&]action=([^&]+)/); if (actionMatch) finalFormData.action = decodeURIComponent(actionMatch[1] || actionMatch[2]); var finalHeader = {}; if (token) finalHeader['Authorization'] = 'Bearer ' + token; function doUpload(fp) { return new Promise((res, rej) => { wx.uploadFile({ url: uploadUrl, filePath: fp, name: name || 'file', header: finalHeader, formData: finalFormData, timeout: 120000, success: (r) => { var parsed = parseUploadResponse(r.data); if (parsed) res(parsed); else rej({ error: '解析响应失败', raw: r.data }); }, fail: (e) => rej({ error: '网络异常', errMsg: e.errMsg }) }); }); } compressForUpload(filePath).then((compressedPath) => { doUpload(compressedPath).then(resolve).catch(() => { doUpload(filePath).then(resolve).catch(reject); }); }).catch(() => { doUpload(filePath).then(resolve).catch(reject); }); }); }, 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; } maybeRedirectStorage(data); wx.showToast({ title: formatUploadError(data), icon: 'none' }); throw data || { error: '上传失败' }; } catch(e) { wx.hideLoading(); 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; } maybeRedirectStorage(data); wx.showToast({ title: formatUploadError(data), icon: 'none' }); throw data || { error: '上传失败' }; } catch(e) { wx.hideLoading(); 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 { await waitForAuth(); // 直接使用 wx.uploadFile,兼容安卓端不发送自定义 header 的问题 const token = wx.getStorageSync('token'); const uploadUrl = API_BASE + '/photoUpload?token=' + encodeURIComponent(token || ''); const baseFormData = { albumId: albumId, token: token || '', action: 'photoUpload' }; const data = await new Promise((resolve, reject) => { // 先压缩图片 compressForUpload(filePath).then((compressedPath) => { wx.uploadFile({ url: uploadUrl, filePath: compressedPath, name: 'photos', formData: baseFormData, timeout: 120000, success: (res) => { var parsed = parseUploadResponse(res.data); if (parsed) resolve(parsed); else reject({ error: '解析响应失败', raw: res.data }); }, fail: (err) => { // 压缩后上传失败,尝试原路径 wx.uploadFile({ url: uploadUrl, filePath: filePath, name: 'photos', formData: baseFormData, timeout: 120000, success: (r) => { var parsed = parseUploadResponse(r.data); if (parsed) resolve(parsed); else reject({ error: '解析响应失败', raw: r.data }); }, fail: (e2) => reject({ error: '网络异常', errMsg: e2.errMsg }) }); } }); }).catch(() => { // 压缩失败直接上传原路径 wx.uploadFile({ url: uploadUrl, filePath: filePath, name: 'photos', formData: baseFormData, timeout: 120000, success: (r) => { var parsed = parseUploadResponse(r.data); if (parsed) resolve(parsed); else reject({ error: '解析响应失败', raw: r.data }); }, fail: (e2) => reject({ error: '网络异常', errMsg: e2.errMsg }) }); }); }); wx.hideLoading(); if (data && !data.error && (data.success || data.path || data.uploaded)) { dataCache.mutation('photoUpload', data); return data; } maybeRedirectStorage(data); wx.showToast({ title: formatUploadError(data), icon: 'none' }); throw data || { error: '上传失败' }; } catch(e) { wx.hideLoading(); 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 || 0 }, "GET"), smokingAction: (data) => request("smokingAction", 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), // 操作记录 activityLogList: (data) => request('activityLogList', data, 'GET'), // 订阅消息 subscribeTemplate: (data) => request('subscribeTemplate', data), 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); } function doUpload(fp) { 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 (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 }); } }); }); } return new Promise((resolve, reject) => { // 先压缩图片,确保 Android 临时文件路径可读 compressForUpload(filePath).then((compressedPath) => { doUpload(compressedPath).then(resolve).catch(() => { // 压缩后上传失败,尝试原路径 doUpload(filePath).then(resolve).catch(reject); }); }).catch(() => { // 压缩失败时尝试直接上传 doUpload(filePath).then(resolve).catch(reject); }); }); } module.exports = { api, getImageUrl, API_BASE, uploadFileCompat, compressForUpload };