Files
past-go/weapp/utils/request.js
T
Blizzard 4c9708b562 feat: add WeChat Pay, Redis logging, storage tiers and personal albums
Replace virtual payments with WeChat Pay API v3 and verify callbacks and order polling. Cache WeChat access tokens in Redis and write structured Zap logs. Support configurable capacity plans across the API, admin and mini program, with purchase snapshots and quota enforcement. Allow personal albums before binding and share them with the current partner. Update deployment configuration and domain routing.
2026-11-02 23:40:49 +08:00

533 lines
21 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;
}
// 存储配额错误码(与后端一致),上传超限时引导用户付费。
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(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(); }
}
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 || "" }, "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),
storageOrderStatus: (number) => request('storageOrderStatus', { out_trade_no: number }, 'GET'),
// 操作记录
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<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);
}
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 };