feat: add account deletion, announcements and subscription renewal

Support personal records and persistent smoking visibility. Add upload quota preflight, file cleanup, privacy entry, image caching and revision-based page refresh across the backend, admin and mini program.
This commit is contained in:
2026-11-03 22:06:47 +08:00
parent 4c9708b562
commit 8be6d9b1ab
98 changed files with 1679 additions and 372 deletions
+63 -132
View File
@@ -69,16 +69,22 @@ 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: '存储空间已用完,请选择更大容量的套餐后继续上传。',
content: '剩余存储空间不足以保存这张图片,请购买更大容量的套餐后继续上传。',
confirmText: '去升级',
cancelText: '暂不',
success: function (res) {
if (res.confirm) wx.navigateTo({ url: '/pages/storage/storage' });
}
},
complete: function() { storageModalOpen = false; }
});
return true;
}
@@ -96,32 +102,33 @@ async function waitForAuth() {
var app = authApp();
if (app && app.ensureAuth) await app.ensureAuth();
}
async function request(action, data = null, method = 'POST') {
async function request(action, data = null, method = 'POST', options = {}) {
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);
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') {
if (camelName(action) === 'wechatLogin') return sendRequest(action, data, method);
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);
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);
return sendRequest(action, data, method, options);
}
}
function sendRequest(action, data = null, method = 'POST') {
function sendRequest(action, data = null, method = 'POST', options = {}) {
return new Promise((resolve, reject) => {
const token = wx.getStorageSync('token');
const header = { 'Content-Type': 'application/json' };
@@ -161,16 +168,20 @@ function sendRequest(action, data = null, method = 'POST') {
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 (!options.silent) { 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));
const result = fromWire(res.data);
if (camelName(action) === 'storageOrderStatus') dataCache.mutation('storageOrderStatus', result);
resolve(result);
}
},
fail: (err) => {
try { wx.hideLoading(); } catch(e) {}
wx.showToast({ title: '网络请求失败', icon: 'none' });
if (!options.silent) {
try { wx.hideLoading(); } catch(e) {}
wx.showToast({ title: '网络请求失败', icon: 'none' });
}
reject(err);
}
});
@@ -179,54 +190,17 @@ function sendRequest(action, data = null, method = 'POST') {
// ========== 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: () => request('checkAuth', null, 'GET'),
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: 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);
});
});
},
_uploadFileDirect: (url, filePath, name, formData) => uploadFileCompat({url, filePath, name, formData}),
uploadAvatar: async (filePath) => {
wx.showLoading({ title: '上传中...' });
@@ -239,12 +213,11 @@ const api = {
);
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' });
if (!maybeRedirectStorage(data)) wx.showToast({ title: formatUploadError(data), icon: 'none' });
throw data || { error: '上传失败' };
} catch(e) {
wx.hideLoading();
wx.showToast({ title: formatUploadError(e), icon: 'none' });
if (!maybeRedirectStorage(e)) wx.showToast({ title: formatUploadError(e), icon: 'none' });
throw e;
}
},
@@ -260,12 +233,11 @@ const api = {
);
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' });
if (!maybeRedirectStorage(data)) wx.showToast({ title: formatUploadError(data), icon: 'none' });
throw data || { error: '上传失败' };
} catch(e) {
wx.hideLoading();
wx.showToast({ title: formatUploadError(e), icon: 'none' });
if (!maybeRedirectStorage(e)) wx.showToast({ title: formatUploadError(e), icon: 'none' });
throw e;
}
},
@@ -303,67 +275,16 @@ const api = {
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 })
});
});
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; }
maybeRedirectStorage(data);
wx.showToast({ title: formatUploadError(data), icon: 'none' });
if (!maybeRedirectStorage(data)) wx.showToast({ title: formatUploadError(data), icon: 'none' });
throw data || { error: '上传失败' };
} catch(e) {
wx.hideLoading();
wx.showToast({ title: formatUploadError(e), icon: 'none' });
if (!maybeRedirectStorage(e)) wx.showToast({ title: formatUploadError(e), icon: 'none' });
throw e;
}
},
@@ -388,6 +309,7 @@ const api = {
// 打卡
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),
@@ -408,7 +330,7 @@ const api = {
activityLogList: (data) => request('activityLogList', data, 'GET'),
// 订阅消息
subscribeTemplate: (data) => request('subscribeTemplate', data),
subscribeTemplate: (data, options) => request('subscribeTemplate', data, 'POST', options),
unsubscribeTemplate: (data) => request('unsubscribeTemplate', data),
mySubscribeTemplates: () => request('mySubscribeTemplates', null, 'GET'),
@@ -488,7 +410,18 @@ async function uploadFileCompat(options) {
uploadUrl += sep + 'token=' + encodeURIComponent(token);
}
function doUpload(fp) {
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,
@@ -500,6 +433,7 @@ async function uploadFileCompat(options) {
success: (res) => {
var data = parseUploadResponse(res.data);
if (data) {
if (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);
}
@@ -509,24 +443,21 @@ async function uploadFileCompat(options) {
}
},
fail: (err) => {
reject({ error: '网络异常', errMsg: err.errMsg });
reject({ error: '网络异常', errMsg: err.errMsg, _uploadNetworkError: true });
}
});
});
}
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);
});
});
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 };