Files
past-go/weapp/utils/request.js
T
Blizzard b854d2873e feat: require couple binding and refine miniapp settings
Gate record features and storage entry points with invitation guidance; enforce record access in the backend. Add homepage greetings, right-swipe profile dismissal, and move the smoking switch to settings. Simplify API base configuration and remove the unused legacy tab bar component.
2026-11-04 21:27:03 +08:00

476 lines
19 KiB
JavaScript

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<string>} 压缩后的图片路径
*/
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 };