import { SubscriptionMessageScene, } from '@mp-pilates/shared' import type { ReportSubscriptionMessageRequestDto, SubscriptionMessageRequestItem, SubscriptionMessageTemplate, SubscriptionMessageTemplateConfig, SubscriptionQuotaItem, SubscriptionQuotasResponse, UserProfileResponse, } from '@mp-pilates/shared' import { get, post } from './request' type TemplateResult = SubscriptionMessageRequestItem['result'] | 'tmplIds empty' | 'err' | 'undefined' type RequestSubscribeMessageSuccess = Record & { errMsg?: string } interface RequestSubscribeMessageFail { errMsg?: string errCode?: number | null } function stringifyDebugPayload(payload: unknown): string { try { return JSON.stringify(payload) } catch { return String(payload) } } function getSubscribeDebugContext() { try { const windowInfo = typeof uni.getWindowInfo === 'function' ? uni.getWindowInfo() : null const appBaseInfo = typeof uni.getAppBaseInfo === 'function' ? uni.getAppBaseInfo() : null const deviceInfo = typeof uni.getDeviceInfo === 'function' ? uni.getDeviceInfo() : null const host = appBaseInfo?.host as { env?: string } | string | undefined return { platform: deviceInfo?.platform ?? 'unknown', hostEnv: typeof host === 'object' && host ? host.env : undefined, app: appBaseInfo?.appName, system: deviceInfo?.system, language: appBaseInfo?.language, version: appBaseInfo?.version, SDKVersion: appBaseInfo?.SDKVersion, windowWidth: windowInfo?.windowWidth, } } catch { return { platform: 'unknown', } } } function buildSubscribeError(err: RequestSubscribeMessageFail, scene: SubscriptionMessageScene, templateIds: string[]): Error { const debugContext = getSubscribeDebugContext() const rawMessage = (err.errMsg || '').trim() if (!rawMessage && debugContext.platform === 'devtools') { return new Error( `开发者工具当前环境不支持订阅消息唤起。请退出游客模式,使用已登录的微信开发者工具并在真机中重试。调试信息: ${stringifyDebugPayload({ scene, templateIds, err, debugContext })}`, ) } return new Error( `订阅消息授权失败: ${stringifyDebugPayload({ scene, templateIds, err, debugContext })}`, ) } const TEMPLATE_CONFIG_STORAGE_KEY = 'subscriptionMessageTemplateConfig' let cachedConfig: SubscriptionMessageTemplateConfig | null = null function isMpWeixin(): boolean { // #ifdef MP-WEIXIN return true // #endif return false } function normalizeResult(result?: TemplateResult): SubscriptionMessageRequestItem['result'] | null { if (result === 'accept' || result === 'reject' || result === 'ban' || result === 'filter') { return result } return null } function getTemplateConfigSync(): SubscriptionMessageTemplateConfig | null { if (cachedConfig) { return cachedConfig } const stored = uni.getStorageSync(TEMPLATE_CONFIG_STORAGE_KEY) as SubscriptionMessageTemplateConfig | '' if (!stored || !Array.isArray(stored.templates)) { return null } cachedConfig = { templates: stored.templates.filter((item) => item.templateId), } return cachedConfig } async function fetchTemplateConfig(): Promise { const config = getTemplateConfigSync() if (!config) { throw new Error('订阅消息模板尚未初始化,请重新进入页面后重试') } return config } function normalizeTemplateConfig(config?: Partial | null): SubscriptionMessageTemplateConfig { const templates = Array.isArray(config?.templates) ? config.templates : [] return { templates: templates.filter((item): item is SubscriptionMessageTemplate => !!item?.templateId), } } export function cacheSubscriptionMessageTemplateConfig( config?: Partial | null, ): SubscriptionMessageTemplateConfig { const normalized = normalizeTemplateConfig(config) cachedConfig = normalized uni.setStorageSync(TEMPLATE_CONFIG_STORAGE_KEY, normalized) return normalized } function getTemplatesByScene( config: SubscriptionMessageTemplateConfig, scene: SubscriptionMessageScene, ): SubscriptionMessageTemplate[] { return config.templates.filter((item) => item.scene === scene && item.templateId) } async function reportResults(requests: SubscriptionMessageRequestItem[]): Promise { if (requests.length === 0) { return } const payload: ReportSubscriptionMessageRequestDto = { requests } await post('/user/subscription-messages/report', payload as unknown as Record) } function normalizeSubscribeResults( templates: SubscriptionMessageTemplate[], result: RequestSubscribeMessageSuccess, ): SubscriptionMessageRequestItem[] { return templates .map((item) => { const normalized = normalizeResult(result[item.templateId]) if (!normalized) { return null } return { templateId: item.templateId, scene: item.scene, result: normalized, } }) .filter((item): item is SubscriptionMessageRequestItem => item !== null) } export const SUBSCRIBE_BUNDLE_BOOKING: SubscriptionMessageScene[] = [ SubscriptionMessageScene.BOOKING_CREATED, SubscriptionMessageScene.CLASS_REMINDER, SubscriptionMessageScene.BOOKING_CANCELLED, ] export const SUBSCRIBE_BUNDLE_CANCEL: SubscriptionMessageScene[] = [ SubscriptionMessageScene.BOOKING_CANCELLED, SubscriptionMessageScene.CLASS_REMINDER, ] /** * 在当前调用栈同步调起 `uni.requestSubscribeMessage`(支持多场景组合打包,最多 3 个模板)。 * 微信要求授权框必须落在 tap / 支付 success 的同步栈里,因此这里不能先 `await`。 */ export function requestSubscriptionBundle(scenes: SubscriptionMessageScene[]): Promise { if (!isMpWeixin()) { return Promise.resolve([]) } const config = getTemplateConfigSync() if (!config) { return Promise.reject(new Error('订阅消息模板尚未初始化,请重新进入页面后重试')) } // 按场景收集模板,去重 templateId,微信单次最多支持 3 个模板 const templateMap = new Map() for (const scene of scenes) { const list = getTemplatesByScene(config, scene) for (const tpl of list) { if (tpl.templateId && !templateMap.has(tpl.templateId)) { templateMap.set(tpl.templateId, tpl) } if (templateMap.size >= 3) break } if (templateMap.size >= 3) break } const templates = Array.from(templateMap.values()) if (templates.length === 0) { console.error('[subscribe] no templates matched bundle', stringifyDebugPayload({ scenes, config, debugContext: getSubscribeDebugContext() })) return Promise.resolve([]) } const templateIds = templates.map((item) => item.templateId) const debugContext = getSubscribeDebugContext() console.log('[subscribe] requestSubscriptionBundle:start', stringifyDebugPayload({ scenes, templateIds, templates, debugContext })) return new Promise((resolve, reject) => { uni.requestSubscribeMessage({ tmplIds: templateIds, success: (res) => { const response = res as RequestSubscribeMessageSuccess const requests = normalizeSubscribeResults(templates, response) console.log('[subscribe] requestSubscriptionBundle:success', stringifyDebugPayload({ scenes, response, templateIds, debugContext })) console.log('[subscribe] requestSubscriptionBundle:normalized', stringifyDebugPayload({ scenes, result: response, requests, templateIds, debugContext })) void reportResults(requests) .then(() => resolve(requests)) .catch(reject) }, fail: (err) => { console.error('[subscribe] requestSubscriptionBundle:fail', stringifyDebugPayload({ scenes, error: err, templateIds, debugContext })) reject(buildSubscribeError(err as RequestSubscribeMessageFail, scenes[0], templateIds)) }, }) }) } export function requestSubscriptionMessage(scene: SubscriptionMessageScene): Promise { return requestSubscriptionBundle([scene]) } /** * 约课三合一组合授权:包含约课成功确认、开课前1小时提醒、课程取消通知 * 一次点击,三个场景额度同时 +1! */ export function requestBookingBundleSubscriptionMessage(): Promise { return requestSubscriptionBundle(SUBSCRIBE_BUNDLE_BOOKING) } /** * 兼容原有命名,直接升级为约课三合一组合授权 */ export function requestBookingCreatedSubscriptionMessage(): Promise { return requestBookingBundleSubscriptionMessage() } /** * 取消预约时的组合授权:课程取消通知 + 下次上课提醒 */ export function requestBookingCancelSubscriptionMessage(): Promise { return requestSubscriptionBundle(SUBSCRIBE_BUNDLE_CANCEL) } /** * 上课前1小时提醒单项授权 */ export function requestClassReminderSubscriptionMessage(): Promise { return requestSubscriptionMessage(SubscriptionMessageScene.CLASS_REMINDER) } /** * 获取当前登录用户的各订阅场景额度水位 */ export async function fetchUserSubscriptionQuotas(): Promise { try { const res = await get('/user/subscription-messages/quotas') return res.quotas || [] } catch (error) { console.warn('[subscribe] fetchUserSubscriptionQuotas failed', error) return [] } } /** * 引导打开微信系统设置页,供用户恢复通知授权 */ export function openSubscribeSettings(): Promise { return new Promise((resolve) => { uni.openSetting({ success: (res) => { resolve(!!res.authSetting) }, fail: () => resolve(false), }) }) } export async function requestAdminBookingSubscriptionCount(): Promise { if (!isMpWeixin()) { return null } const config = await fetchTemplateConfig() const templates = getTemplatesByScene(config, SubscriptionMessageScene.ADMIN_BOOKING_CREATED) .filter((item) => item.usageTarget === 'counter') if (templates.length === 0) { console.error('[subscribe] no admin counter template configured', stringifyDebugPayload({ scene: SubscriptionMessageScene.ADMIN_BOOKING_CREATED, config, debugContext: getSubscribeDebugContext(), })) return null } const templateId = templates[0].templateId const debugContext = getSubscribeDebugContext() console.log('[subscribe] requestSubscribeMessage:adminCounter:start', stringifyDebugPayload({ templateId, debugContext, })) const result = await new Promise((resolve, reject) => { uni.requestSubscribeMessage({ tmplIds: [templateId], success: (res) => { console.log('[subscribe] requestSubscribeMessage:adminCounter:success', stringifyDebugPayload({ response: res, templateId, debugContext })) resolve(res as RequestSubscribeMessageSuccess) }, fail: (err) => { console.error('[subscribe] requestSubscribeMessage:adminCounter:fail', stringifyDebugPayload({ error: err, templateId, debugContext })) reject(buildSubscribeError(err as RequestSubscribeMessageFail, SubscriptionMessageScene.ADMIN_BOOKING_CREATED, [templateId])) }, }) }) const normalized = normalizeResult(result[templateId]) console.log('[subscribe] requestSubscribeMessage:adminCounter:normalized', stringifyDebugPayload({ templateId, normalized, rawResult: result, debugContext, })) if (normalized !== 'accept') { return null } return post('/user/subscription-messages/admin-booking-count') } export function resetSubscriptionMessageTemplateCache(): void { cachedConfig = null uni.removeStorageSync(TEMPLATE_CONFIG_STORAGE_KEY) }