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:
@@ -0,0 +1,14 @@
|
||||
// Memory only: a cold start begins a new login session. Never persist this in storage.
|
||||
const seen = new Map();
|
||||
function shouldShow(notice, userId) {
|
||||
if (!notice || !notice.enabled || !String(notice.content || '').trim()) return false;
|
||||
const mode = notice.mode || 'login_once';
|
||||
if (mode === 'off') return false;
|
||||
if (mode === 'always') return true;
|
||||
return mode === 'login_once' && seen.get(String(userId || '')) !== notice.revision;
|
||||
}
|
||||
function markShown(notice, userId) {
|
||||
if (notice && (notice.mode || 'login_once') === 'login_once') seen.set(String(userId || ''), notice.revision);
|
||||
}
|
||||
function clear() { seen.clear(); }
|
||||
module.exports = { shouldShow, markShown, clear };
|
||||
@@ -1,7 +1,8 @@
|
||||
// 仅保存本次进程内的数据,不写入手机本地存储。
|
||||
const TTL = 45000;
|
||||
const TTL = 300000;
|
||||
const policies = {
|
||||
coupleInfo: 300000, getBgConfig: 300000,
|
||||
coupleInfo: TTL, getBgConfig: TTL, myInfo: TTL, storageInfo: TTL, announcement: TTL,
|
||||
checkAuth: 30000, unbindStatus: 30000,
|
||||
timelineList: TTL, timelineDetail: TTL, albumList: TTL, photos: TTL,
|
||||
diaryList: TTL, diaryDetail: TTL, diaryCommentList: TTL,
|
||||
songsList: TTL, checkinList: TTL, checkinStats: TTL, checkinDetail: TTL,
|
||||
@@ -18,12 +19,20 @@ let entries = new Map();
|
||||
let versions = {};
|
||||
let scope = '';
|
||||
let epoch = 0;
|
||||
let remoteRevision;
|
||||
function currentScope() {
|
||||
const next = String(wx.getStorageSync('userId') || '') + ':' + String(wx.getStorageSync('coupleId') || '');
|
||||
if (next !== scope) { scope = next; clear(); }
|
||||
return scope;
|
||||
}
|
||||
function clear() { entries.clear(); versions = {}; epoch++; }
|
||||
function clear() { entries.clear(); versions = {}; remoteRevision = undefined; epoch++; }
|
||||
function applyRemoteRevision(revision) {
|
||||
currentScope();
|
||||
if (!revision) return false;
|
||||
if (remoteRevision === undefined) { remoteRevision = revision; return false; }
|
||||
if (remoteRevision === revision) return false;
|
||||
clear(); remoteRevision = revision; return true;
|
||||
}
|
||||
function signature(actions) {
|
||||
currentScope();
|
||||
return epoch + ':' + actions.map(action => action + ':' + (versions[action] || 0)).join('|');
|
||||
@@ -82,7 +91,10 @@ function mutation(action, result) {
|
||||
else if (/^pet(Adopt|DailyAction|Rename)$/.test(action)) group = 'pet';
|
||||
else if (/^(setBgImage|deleteBgImage)$/.test(action)) group = 'background';
|
||||
if (group) invalidate(groups[group].concat(['activityLogList']));
|
||||
if (group === 'couple') invalidate(['myInfo','checkAuth']);
|
||||
if (action === 'smokingCardVisible') invalidate(['checkAuth','myInfo']);
|
||||
if (/^(upload|photoUpload|uploadAvatar|uploadPartnerAvatar|setBgImage|deleteBgImage|setAlbumCover|photoDelete|albumDelete|accountDelete)$/.test(action) || (action === 'storageOrderStatus' && result && result.status === 'paid')) invalidate(['storageInfo']);
|
||||
// 展示作者资料的列表也需要更新头像/昵称。
|
||||
if (group === 'couple') invalidate(groups.timeline.concat(groups.diary, groups.checkin));
|
||||
}
|
||||
module.exports = { read, invalidate, mutation, clear, signature, policies, TTL };
|
||||
module.exports = { read, invalidate, mutation, clear, signature, policies, TTL, applyRemoteRevision };
|
||||
|
||||
@@ -17,12 +17,12 @@ function withDataCache(definition, loaders) {
|
||||
const ttl = Math.min(...dependencies.map(action => cache.policies[action] || cache.TTL));
|
||||
if (previous && previous.signature === signature && previous.key === key) {
|
||||
if (previous.pending) return previous.pending;
|
||||
if (Date.now() - previous.time < ttl && !this.data.loadError && !this.data.homeError && !this.data.statsError && !this.data.bindingError && !this.data.commentsError) return Promise.resolve();
|
||||
if (Date.now() - previous.time < ttl && !this.data.loadError && !this.data.homeError && !this.data.statsError && !this.data.bindingError && !this.data.commentsError && !this.data.error && !this.data.smokingError) return Promise.resolve();
|
||||
}
|
||||
const record = { signature, key, time: 0 };
|
||||
this._dataLoads[name] = record;
|
||||
record.pending = Promise.resolve().then(() => load.apply(this, args)).then(result => {
|
||||
if (!this.data.loadError && !this.data.homeError && !this.data.statsError && !this.data.bindingError && !this.data.commentsError) {
|
||||
if (!this.data.loadError && !this.data.homeError && !this.data.statsError && !this.data.bindingError && !this.data.commentsError && !this.data.error && !this.data.smokingError) {
|
||||
record.time = Date.now();
|
||||
if (name !== 'loadComments') this._hasCachedData = true;
|
||||
}
|
||||
@@ -32,6 +32,9 @@ function withDataCache(definition, loaders) {
|
||||
return record.pending;
|
||||
};
|
||||
});
|
||||
definition.onDataChanged = function() {
|
||||
return Promise.all(Object.keys(loaders).map(name => this[name]())).catch(error => { console.warn('刷新变动数据失败', error); });
|
||||
};
|
||||
['onPullDownRefresh', 'onRefresh'].forEach(name => {
|
||||
const refresh = definition[name];
|
||||
if (refresh) definition[name] = function(...args) {
|
||||
|
||||
+63
-132
@@ -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 };
|
||||
|
||||
+57
-20
@@ -6,9 +6,9 @@
|
||||
*/
|
||||
const { api } = require('./request');
|
||||
|
||||
// 统一使用「每日记录提醒」模板,所有操作都通过这个模板通知
|
||||
// 操作和打卡共用同一模板、同一份一次性授权。
|
||||
const SUBSCRIBE_TEMPLATES = {
|
||||
notify: 'tOaqM_Z6pNSih2pVpaUmP86MgwnDT5ZlwOqd0lgmXP8',
|
||||
notify: 'K_iQMwE_jre0j6QDVL78DZz7wiR1SKVY5h2YJCK6uPU',
|
||||
};
|
||||
|
||||
// 只合并正在进行的授权请求,后续用户点击仍可补充一次性授权。
|
||||
@@ -19,7 +19,7 @@ let _pendingSubscribe = null;
|
||||
* @param {string|string[]} templateTypes - 模板类型或模板类型数组
|
||||
* @returns {Promise<object>} 订阅结果 { templateType: true/false }
|
||||
*/
|
||||
function requestSubscribe(templateTypes) {
|
||||
function requestSubscribe(templateTypes, options = {}) {
|
||||
if (_pendingSubscribe) return _pendingSubscribe;
|
||||
const pending = new Promise((resolve, reject) => {
|
||||
if (!Array.isArray(templateTypes)) {
|
||||
@@ -49,7 +49,7 @@ function requestSubscribe(templateTypes) {
|
||||
});
|
||||
|
||||
// 将授权结果同步到后端
|
||||
syncSubscribeResults(results).then(() => resolve(results), reject);
|
||||
syncSubscribeResults(results, options).then(() => resolve(results), reject);
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('[subscribe] 请求订阅失败:', err);
|
||||
@@ -57,31 +57,22 @@ function requestSubscribe(templateTypes) {
|
||||
}
|
||||
});
|
||||
});
|
||||
_pendingSubscribe = pending.finally(() => { _pendingSubscribe = null; });
|
||||
_pendingSubscribe = pending.finally(() => { _pendingSubscribe = null; prepareLoginRenewal(); });
|
||||
return _pendingSubscribe;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将订阅结果同步到后端
|
||||
*/
|
||||
function syncSubscribeResults(results) {
|
||||
function syncSubscribeResults(results, options) {
|
||||
return Promise.all(Object.keys(results).filter(type => results[type]).map(type => {
|
||||
return api.subscribeTemplate({template_type:type, template_id:SUBSCRIBE_TEMPLATES[type]}).then(result => {
|
||||
return api.subscribeTemplate({template_type:type, template_id:SUBSCRIBE_TEMPLATES[type]}, options).then(result => {
|
||||
if (!result || !result.success) throw new Error('订阅授权保存失败');
|
||||
wx.setStorageSync('subscribed_notify', true);
|
||||
wx.setStorageSync('subscribed_' + type, true);
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
// 必须由用户点击直接调用;补充的是当前用户接收TA提醒的次数。
|
||||
function requestSubscribeAfterAction(scene) {
|
||||
if (wx.getStorageSync('subscribed_notify') === false || !wx.getStorageSync('coupleId')) return Promise.resolve({});
|
||||
return requestSubscribe('notify').catch(error => {
|
||||
console.warn('[subscribe] 授权或保存失败,下次点击可重试:', error);
|
||||
return {};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户当前的订阅状态
|
||||
* 从后端获取
|
||||
@@ -93,7 +84,7 @@ async function getSubscribeStatus(forceRefresh = false) {
|
||||
const res = await api.mySubscribeTemplates();
|
||||
const status = {};
|
||||
Object.keys(SUBSCRIBE_TEMPLATES).forEach(type => {
|
||||
status[type] = !!res.templates[type];
|
||||
status[type] = !!SUBSCRIBE_TEMPLATES[type] && res.templates[type] === SUBSCRIBE_TEMPLATES[type];
|
||||
});
|
||||
return status;
|
||||
} catch (e) {
|
||||
@@ -103,6 +94,52 @@ async function getSubscribeStatus(forceRefresh = false) {
|
||||
|
||||
module.exports = {
|
||||
requestSubscribe,
|
||||
requestSubscribeAfterAction,
|
||||
getSubscribeStatus
|
||||
resetLoginRenewal,
|
||||
prepareLoginRenewal,
|
||||
tryLoginRenewal,
|
||||
getSubscribeStatus,
|
||||
isTemplateConfigured: type => !!SUBSCRIBE_TEMPLATES[type],
|
||||
subscribeErrorMessage
|
||||
};
|
||||
|
||||
function subscribeErrorMessage(error) {
|
||||
const code = Number(error && error.errCode);
|
||||
if (code === 20001) return '微信未找到该提醒模板,请核对当前小程序的模板配置';
|
||||
if (code === 20004) return '微信消息通知总开关已关闭,请在小程序设置中开启';
|
||||
if (code === 10002 || code === 10003) return '网络异常,暂时无法订阅,请稍后重试';
|
||||
return error && (error.error || error.message) || '订阅失败,请稍后重试';
|
||||
}
|
||||
|
||||
// Reading settings at login never asks for authorization. Only a later real tap
|
||||
// may renew, and only when WeChat reports a remembered acceptance.
|
||||
let renewal = {generation:0,userId:'',allowed:false,attempted:false};
|
||||
function resetLoginRenewal() {
|
||||
renewal = {generation:renewal.generation+1,userId:'',allowed:false,attempted:false};
|
||||
}
|
||||
function prepareLoginRenewal() {
|
||||
renewal.allowed = false;
|
||||
if (typeof wx.getSetting !== 'function' || typeof wx.getStorageSync !== 'function') return;
|
||||
const userId = String(wx.getStorageSync('userId') || '');
|
||||
if (!userId) return;
|
||||
if (renewal.userId && renewal.userId !== userId) resetLoginRenewal();
|
||||
renewal.userId = userId;
|
||||
const generation = ++renewal.generation;
|
||||
try {
|
||||
wx.getSetting({withSubscriptions:true,success(result){
|
||||
if (renewal.generation !== generation || String(wx.getStorageSync('userId') || '') !== userId) return;
|
||||
const settings = result.subscriptionsSetting || {};
|
||||
renewal.allowed = settings.mainSwitch === true && !!settings.itemSettings && settings.itemSettings[SUBSCRIBE_TEMPLATES.notify] === 'accept';
|
||||
},fail(){ /* Unknown permission means no automatic request. */ }});
|
||||
} catch(error) { /* Unsupported API: keep the manual entry. */ }
|
||||
}
|
||||
function tryLoginRenewal() {
|
||||
const app = typeof getApp === 'function' ? getApp() : null;
|
||||
if (!app || !app.globalData || app.globalData.authState !== 'ready' || !renewal.allowed || renewal.attempted) return;
|
||||
if (!wx.getStorageSync('coupleId') || wx.getStorageSync('subscribed_notify') === false || String(wx.getStorageSync('userId') || '') !== renewal.userId) return;
|
||||
renewal.attempted = true;
|
||||
// Intentionally no await before requesting: preserve this tap's user gesture.
|
||||
return requestSubscribe('notify', {silent:true}).catch(error => {
|
||||
console.warn('[subscribe] 本次静默续订未完成,保留手动订阅入口:',error);
|
||||
return {};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user