diff --git a/packages/app/src/components/BookingConfirmPopup.vue b/packages/app/src/components/BookingConfirmPopup.vue
index 666eb0b..a38a6cb 100644
--- a/packages/app/src/components/BookingConfirmPopup.vue
+++ b/packages/app/src/components/BookingConfirmPopup.vue
@@ -96,6 +96,11 @@
+
+
+ 🔔 确认预约将同步订阅约课结果、课前1小时提醒与取消通知
+
+
@@ -210,6 +215,8 @@ function handleMaskTap() {
.no-card-text { font-size: 24rpx; color: #8b817b; }
.deduction-tip { padding: 18rpx 4rpx; }
.deduction-text { font-size: 22rpx; color: #8b817b; line-height: 1.6; }
+.subscribe-tip { padding: 0 4rpx 14rpx; text-align: center; }
+.subscribe-tip-text { font-size: 21rpx; color: #7f8a7e; }
.action-row { display: flex; gap: 20rpx; margin-top: 12rpx; }
.btn-outline { flex: 1; height: 88rpx; border-radius: 999rpx; background: #f0eae4; display: flex; align-items: center; justify-content: center; }
.btn-outline-text { font-size: 28rpx; color: #78675c; font-weight: 400; }
diff --git a/packages/app/src/components/ProfileMenu.vue b/packages/app/src/components/ProfileMenu.vue
index 30e93b2..b083c72 100644
--- a/packages/app/src/components/ProfileMenu.vue
+++ b/packages/app/src/components/ProfileMenu.vue
@@ -24,7 +24,7 @@ interface MenuItem {
title?: string
path?: string
isAdmin?: boolean
- action?: 'clear'
+ action?: 'clear' | 'notifications'
requireAuth?: boolean
}
@@ -36,6 +36,7 @@ const props = defineProps<{
const emit = defineEmits<{
(e: 'clear-cache'): void
(e: 'require-login'): void
+ (e: 'open-notifications'): void
}>()
const menuItems = computed
+
+
+
@@ -222,10 +225,35 @@ import CustomNavBar from '../../components/CustomNavBar.vue'
import ClassReviewForm from '../../components/ClassReviewForm.vue'
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
-import { requestSubscriptionMessage } from '../../utils/wechat-subscription'
+import {
+ requestSubscriptionMessage,
+ requestBookingCancelSubscriptionMessage,
+ requestClassReminderSubscriptionMessage,
+ cacheSubscriptionMessageTemplateConfig,
+} from '../../utils/wechat-subscription'
import { get } from '../../utils/request'
-import { cacheSubscriptionMessageTemplateConfig } from '../../utils/wechat-subscription'
import type { SubscriptionMessageTemplateConfig } from '@mp-pilates/shared'
+
+const canSubscribeClassReminder = computed(() => {
+ if (!booking.value || booking.value.status !== BookingStatus.CONFIRMED) return false
+ if (booking.value.userId !== userStore.user?.id) return false
+ const slot = booking.value.timeSlot
+ if (!slot) return false
+ return !isSlotPast(slot.date, slot.startTime)
+})
+
+async function subscribeClassReminder() {
+ try {
+ const results = await requestClassReminderSubscriptionMessage()
+ uni.showToast({
+ title: results.some((r) => r.result === 'accept') ? '上课提醒已开启' : '暂未开启提醒',
+ icon: 'none',
+ })
+ } catch {
+ uni.showToast({ title: '订阅失败,请稍后重试', icon: 'none' })
+ }
+}
+
async function subscribeReview() {
try { const results = await requestSubscriptionMessage(SubscriptionMessageScene.CLASS_REVIEW); uni.showToast({ title: results.some(r => r.result === 'accept') ? '提醒已开启' : '暂未开启提醒', icon: 'none' }) } catch { uni.showToast({ title: '订阅失败,请稍后重试', icon: 'none' }) }
}
@@ -606,6 +634,12 @@ async function handleNoShow() {
}
async function handleCancel() {
+ try {
+ await requestBookingCancelSubscriptionMessage()
+ } catch (err: unknown) {
+ console.warn('[subscribe] cancel pre-subscribe failed', err)
+ }
+
uni.showModal({
title: '取消预约',
content: '确定要取消该预约?',
diff --git a/packages/app/src/pages/booking/index.vue b/packages/app/src/pages/booking/index.vue
index ec3c7f9..37460ec 100644
--- a/packages/app/src/pages/booking/index.vue
+++ b/packages/app/src/pages/booking/index.vue
@@ -98,6 +98,7 @@ import DateSelector from '../../components/DateSelector.vue'
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
import SlotCard from '../../components/SlotCard.vue'
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
+import { requestBookingCancelSubscriptionMessage } from '../../utils/wechat-subscription'
type PeriodKey = keyof typeof TIME_PERIODS | null
@@ -368,6 +369,12 @@ async function onConfirmBooking(payload: { timeSlotId: string; membershipId: str
async function onCancelTap(slot: TimeSlotWithBookingStatus) {
if (!slot.myBookingId) return
+ try {
+ await requestBookingCancelSubscriptionMessage()
+ } catch (err: unknown) {
+ console.warn('[subscribe] cancel pre-subscribe failed', err)
+ }
+
uni.showModal({
title: '取消预约',
content: '确定要取消这个预约吗?',
diff --git a/packages/app/src/pages/profile/bookings.vue b/packages/app/src/pages/profile/bookings.vue
index fa0ff7a..3a89677 100644
--- a/packages/app/src/pages/profile/bookings.vue
+++ b/packages/app/src/pages/profile/bookings.vue
@@ -154,6 +154,7 @@ import {
} from '../../utils/booking-helpers'
import CustomNavBar from '../../components/CustomNavBar.vue'
import LessonSupplementList from '../../components/LessonSupplementList.vue'
+import { requestBookingCancelSubscriptionMessage } from '../../utils/wechat-subscription'
type TabKey = 'upcoming' | 'history'
@@ -327,6 +328,12 @@ function goDetail(booking: BookingWithDetails) {
}
async function handleCancel(booking: BookingWithDetails) {
+ try {
+ await requestBookingCancelSubscriptionMessage()
+ } catch (err: unknown) {
+ console.warn('[subscribe] cancel pre-subscribe failed', err)
+ }
+
const dateLabel = formatDateDisplay(booking.timeSlot.date)
const timeLabel = startTime(booking)
diff --git a/packages/app/src/pages/profile/index.vue b/packages/app/src/pages/profile/index.vue
index c2073b1..88c6bd8 100644
--- a/packages/app/src/pages/profile/index.vue
+++ b/packages/app/src/pages/profile/index.vue
@@ -17,6 +17,7 @@
:require-auth="loggedIn"
@clear-cache="handleClearCache"
@require-login="handleLogin"
+ @open-notifications="showNotificationsModal = true"
>
@@ -25,6 +26,9 @@
+
+
+
@@ -40,11 +44,13 @@ import { getErrorMessage } from '../../utils/auth'
import PracticeActivityCard from '../../components/PracticeActivityCard.vue'
import UserCard from '../../components/UserCard.vue'
import ProfileMenu from '../../components/ProfileMenu.vue'
+import SubscriptionSettingsModal from '../../components/SubscriptionSettingsModal.vue'
const invite = useInviteStore()
const userStore = useUserStore()
const { loggedIn, hasProfile, user, memberships, membershipsLoading, membershipsLoaded, membershipsError, isAdmin } = storeToRefs(userStore)
+const showNotificationsModal = ref(false)
const activityRefreshKey = ref(0)
const membershipNow = ref(Date.now())
const loginLoading = ref(false)
diff --git a/packages/app/src/utils/wechat-subscription.ts b/packages/app/src/utils/wechat-subscription.ts
index 181dfdc..bfeb8bb 100644
--- a/packages/app/src/utils/wechat-subscription.ts
+++ b/packages/app/src/utils/wechat-subscription.ts
@@ -6,9 +6,11 @@ import type {
SubscriptionMessageRequestItem,
SubscriptionMessageTemplate,
SubscriptionMessageTemplateConfig,
+ SubscriptionQuotaItem,
+ SubscriptionQuotasResponse,
UserProfileResponse,
} from '@mp-pilates/shared'
-import { post } from './request'
+import { get, post } from './request'
type TemplateResult = SubscriptionMessageRequestItem['result'] | 'tmplIds empty' | 'err' | 'undefined'
@@ -163,11 +165,22 @@ function normalizeSubscribeResults(
.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`。
+ * 在当前调用栈同步调起 `uni.requestSubscribeMessage`(支持多场景组合打包,最多 3 个模板)。
* 微信要求授权框必须落在 tap / 支付 success 的同步栈里,因此这里不能先 `await`。
*/
-export function requestSubscriptionMessage(scene: SubscriptionMessageScene): Promise {
+export function requestSubscriptionBundle(scenes: SubscriptionMessageScene[]): Promise {
if (!isMpWeixin()) {
return Promise.resolve([])
}
@@ -177,15 +190,28 @@ export function requestSubscriptionMessage(scene: SubscriptionMessageScene): Pro
return Promise.reject(new Error('订阅消息模板尚未初始化,请重新进入页面后重试'))
}
- const templates = getTemplatesByScene(config, scene)
+ // 按场景收集模板,去重 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 scene', stringifyDebugPayload({ scene, config, debugContext: getSubscribeDebugContext() }))
+ 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] requestSubscribeMessage:start', stringifyDebugPayload({ scene, templateIds, templates, debugContext }))
+ console.log('[subscribe] requestSubscriptionBundle:start', stringifyDebugPayload({ scenes, templateIds, templates, debugContext }))
return new Promise((resolve, reject) => {
uni.requestSubscribeMessage({
@@ -193,22 +219,78 @@ export function requestSubscriptionMessage(scene: SubscriptionMessageScene): Pro
success: (res) => {
const response = res as RequestSubscribeMessageSuccess
const requests = normalizeSubscribeResults(templates, response)
- console.log('[subscribe] requestSubscribeMessage:success', stringifyDebugPayload({ scene, response, templateIds, debugContext }))
- console.log('[subscribe] requestSubscribeMessage:normalized', stringifyDebugPayload({ scene, result: response, requests, templateIds, debugContext }))
+ 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] requestSubscribeMessage:fail', stringifyDebugPayload({ scene, error: err, templateIds, debugContext }))
- reject(buildSubscribeError(err as RequestSubscribeMessageFail, scene, templateIds))
+ 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 requestSubscriptionMessage(SubscriptionMessageScene.BOOKING_CREATED)
+ 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 {
diff --git a/packages/server/.env b/packages/server/.env
index e26e48e..9555365 100644
--- a/packages/server/.env
+++ b/packages/server/.env
@@ -21,6 +21,8 @@ API_BASE_URL=https://focus.richarjiang.com/
PORT=3000
WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED=antYfc85gvwImFZ9kM4UiqMOywJxbqFVgKHLH3NikII
+WX_SUBSCRIBE_TEMPLATE_BOOKING_CANCELLED=5YX4ml6XwC0NegITxqsUXBPJzwKeOiM4VRZCaTiCgFM
+WX_SUBSCRIBE_TEMPLATE_CLASS_REMINDER=CHwcRtAu-Bo6kYt-cmHMhBI4gDZdfoTarYqZotWQEi0
WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW=QJaTOSq_QpyL_spdNRTUfbkmeWDDi5iDAYZyXrFAPc8
# COS upload
diff --git a/packages/server/.env.example b/packages/server/.env.example
index cb37d09..9cb118b 100644
--- a/packages/server/.env.example
+++ b/packages/server/.env.example
@@ -14,3 +14,6 @@ COS_UPLOAD_DURATION_SECONDS=1800
# WeChat subscribe message for class review reminders (24h after completion)
WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW=
+WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED=
+WX_SUBSCRIBE_TEMPLATE_BOOKING_CANCELLED=5YX4ml6XwC0NegITxqsUXBPJzwKeOiM4VRZCaTiCgFM
+WX_SUBSCRIBE_TEMPLATE_CLASS_REMINDER=CHwcRtAu-Bo6kYt-cmHMhBI4gDZdfoTarYqZotWQEi0
diff --git a/packages/server/prisma/migrations/20260910160000_add_class_reminder_fields/migration.sql b/packages/server/prisma/migrations/20260910160000_add_class_reminder_fields/migration.sql
new file mode 100644
index 0000000..52fe335
--- /dev/null
+++ b/packages/server/prisma/migrations/20260910160000_add_class_reminder_fields/migration.sql
@@ -0,0 +1,4 @@
+-- AlterTable
+ALTER TABLE `bookings` ADD COLUMN `class_reminder_claimed_at` DATETIME(3) NULL,
+ ADD COLUMN `class_reminder_due_at` DATETIME(3) NULL,
+ ADD COLUMN `class_reminder_sent_at` DATETIME(3) NULL;
diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma
index f5d6800..087a961 100644
--- a/packages/server/prisma/schema.prisma
+++ b/packages/server/prisma/schema.prisma
@@ -220,6 +220,9 @@ model Booking {
reviewReminderDueAt DateTime? @map("review_reminder_due_at")
reviewReminderClaimedAt DateTime? @map("review_reminder_claimed_at")
reviewReminderSentAt DateTime? @map("review_reminder_sent_at")
+ classReminderDueAt DateTime? @map("class_reminder_due_at")
+ classReminderClaimedAt DateTime? @map("class_reminder_claimed_at")
+ classReminderSentAt DateTime? @map("class_reminder_sent_at")
statusHistory BookingStatusHistory[]
@@unique([userId, timeSlotId])
diff --git a/packages/server/src/booking/__tests__/booking.service.spec.ts b/packages/server/src/booking/__tests__/booking.service.spec.ts
index 14b186f..1e0ccda 100644
--- a/packages/server/src/booking/__tests__/booking.service.spec.ts
+++ b/packages/server/src/booking/__tests__/booking.service.spec.ts
@@ -170,7 +170,12 @@ describe('BookingService', () => {
let service: BookingService
let prisma: jest.Mocked
let studioService: jest.Mocked
- let subscriptionMessageService: { sendBookingConfirmedMessage: jest.Mock; sendAdminBookingCreatedMessage: jest.Mock }
+ let subscriptionMessageService: {
+ sendBookingConfirmedMessage: jest.Mock
+ sendAdminBookingCreatedMessage: jest.Mock
+ sendBookingCancelledMessage: jest.Mock
+ sendClassReminderMessage: jest.Mock
+ }
let inviteService: { recordQualifiedTrialBooking: jest.Mock }
beforeEach(async () => {
@@ -223,6 +228,8 @@ describe('BookingService', () => {
useValue: {
sendBookingConfirmedMessage: jest.fn(),
sendAdminBookingCreatedMessage: jest.fn(),
+ sendBookingCancelledMessage: jest.fn(),
+ sendClassReminderMessage: jest.fn(),
},
},
{
@@ -1083,6 +1090,35 @@ describe('BookingService', () => {
}),
)
})
+
+ it('triggers sendBookingCancelledMessage when a booking is cancelled', async () => {
+ const futureDate = new Date(Date.now() + 86400000 * 2)
+ const futureSlot = { ...mockOpenSlot, date: futureDate, startTime: '14:00', endTime: '15:00' }
+ const ownBooking = {
+ ...mockConfirmedBooking,
+ userId: MOCK_USER_ID,
+ timeSlot: futureSlot,
+ membership: mockActiveMembership,
+ }
+ ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(ownBooking)
+ ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ id: MOCK_USER_ID, openid: 'test-user-openid' } as any)
+ const tx = buildTxMock()
+ tx.booking.update.mockResolvedValue({ ...ownBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() })
+ tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
+ tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 })
+ ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
+
+ await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
+
+ expect(subscriptionMessageService.sendBookingCancelledMessage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ openid: 'test-user-openid',
+ userId: MOCK_USER_ID,
+ bookingId: MOCK_BOOKING_ID,
+ courseName: 'Test Studio',
+ }),
+ )
+ })
})
// ─── getMyBookings ────────────────────────────────────────────────────────
diff --git a/packages/server/src/booking/booking.service.ts b/packages/server/src/booking/booking.service.ts
index fdae2e8..23056e5 100644
--- a/packages/server/src/booking/booking.service.ts
+++ b/packages/server/src/booking/booking.service.ts
@@ -531,6 +531,7 @@ export class BookingService {
},
})
})
+ await this.trySendBookingCancelledSubscriptionMessage(booking)
return { booking: { ...booking, status: BookingStatus.CANCELLED }, refunded }
}
@@ -617,6 +618,8 @@ export class BookingService {
return cancelled
})
+ await this.trySendBookingCancelledSubscriptionMessage(booking)
+
return { booking: { ...updatedBooking }, refunded }
}
@@ -963,6 +966,39 @@ export class BookingService {
}
}
+ private async trySendBookingCancelledSubscriptionMessage(
+ booking: {
+ id: string
+ userId: string
+ timeSlot: { date: Date; startTime: string; endTime: string }
+ },
+ ): Promise {
+ try {
+ const user = await this.prisma.user.findUnique({
+ where: { id: booking.userId },
+ select: { openid: true },
+ })
+ if (!user?.openid) {
+ return
+ }
+
+ const studio = await this.studioService.getInfo()
+ const dateLabel = this.formatLocalDate(booking.timeSlot.date)
+ const courseTime = `${booking.timeSlot.startTime.slice(0, 5)}-${booking.timeSlot.endTime.slice(0, 5)}`
+
+ await this.subscriptionMessageService.sendBookingCancelledMessage({
+ openid: user.openid,
+ userId: booking.userId,
+ bookingId: booking.id,
+ bookingDate: dateLabel,
+ courseTime,
+ courseName: studio.name || '普拉提课程',
+ })
+ } catch (error) {
+ console.error('Send booking cancelled subscription message failed:', error)
+ }
+ }
+
private async trySendAdminBookingCreatedSubscriptionMessages(
booking: BookingWithRelations,
): Promise {
diff --git a/packages/server/src/scheduler/__tests__/class-reminder.service.spec.ts b/packages/server/src/scheduler/__tests__/class-reminder.service.spec.ts
new file mode 100644
index 0000000..4aa0a2b
--- /dev/null
+++ b/packages/server/src/scheduler/__tests__/class-reminder.service.spec.ts
@@ -0,0 +1,122 @@
+import { ClassReminderService } from '../class-reminder.service'
+import { PrismaService } from '../../prisma/prisma.service'
+import { SubscriptionMessageService } from '../../user/subscription-message.service'
+import { ConfigService } from '@nestjs/config'
+import { Logger } from '@nestjs/common'
+import { BookingStatus } from '@mp-pilates/shared'
+
+describe('ClassReminderService', () => {
+ const db = {
+ booking: { findMany: jest.fn(), updateMany: jest.fn(), update: jest.fn() },
+ studioConfig: { findFirst: jest.fn() },
+ }
+ const messages = {
+ getClassReminderTemplateId: jest.fn(),
+ sendClassReminderMessage: jest.fn(),
+ }
+ const config = { get: jest.fn() }
+ let service: ClassReminderService
+
+ beforeEach(() => {
+ jest.resetAllMocks()
+ messages.getClassReminderTemplateId.mockReturnValue('reminder-template-id')
+ db.studioConfig.findFirst.mockResolvedValue({ name: 'FocusCore Studio' })
+ service = new ClassReminderService(
+ db as unknown as PrismaService,
+ messages as unknown as SubscriptionMessageService,
+ config as unknown as ConfigService,
+ )
+ jest.spyOn(Logger.prototype, 'error').mockImplementation(() => {})
+ jest.spyOn(Logger.prototype, 'log').mockImplementation(() => {})
+ })
+
+ afterEach(() => jest.restoreAllMocks())
+
+ it('skips run when class reminder template is not configured', async () => {
+ messages.getClassReminderTemplateId.mockReturnValue('')
+ await service.run()
+ expect(db.booking.findMany).not.toHaveBeenCalled()
+ })
+
+ it('scans and sends reminders for bookings starting in ~1 hour (e.g. 60 mins)', async () => {
+ // Current time fixed or calculated
+ const now = new Date()
+ const targetDate = new Date(now.getTime() + 60 * 60 * 1000)
+ // Convert targetDate to China time string representation
+ // China is UTC+8
+ const chinaDate = new Date(targetDate.getTime() + 8 * 3600 * 1000)
+ const dateStr = chinaDate.toISOString().slice(0, 10)
+ const hours = String(chinaDate.getUTCHours()).padStart(2, '0')
+ const minutes = String(chinaDate.getUTCMinutes()).padStart(2, '0')
+ const startTime = `${hours}:${minutes}:00`
+
+ db.booking.findMany.mockResolvedValue([
+ {
+ id: 'booking-reminder-1',
+ userId: 'user-1',
+ status: BookingStatus.CONFIRMED,
+ user: { openid: 'openid-user-1' },
+ timeSlot: {
+ date: new Date(`${dateStr}T00:00:00.000Z`),
+ startTime,
+ endTime: '12:00:00',
+ },
+ },
+ ])
+ db.booking.updateMany.mockResolvedValue({ count: 1 })
+ messages.sendClassReminderMessage.mockResolvedValue(true)
+
+ await service.run()
+
+ expect(db.booking.updateMany).toHaveBeenCalledWith({
+ where: {
+ id: 'booking-reminder-1',
+ status: BookingStatus.CONFIRMED,
+ classReminderClaimedAt: null,
+ },
+ data: { classReminderClaimedAt: expect.any(Date) },
+ })
+
+ expect(messages.sendClassReminderMessage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ openid: 'openid-user-1',
+ userId: 'user-1',
+ bookingId: 'booking-reminder-1',
+ courseName: 'FocusCore Studio',
+ }),
+ )
+
+ expect(db.booking.update).toHaveBeenCalledWith({
+ where: { id: 'booking-reminder-1' },
+ data: { classReminderSentAt: expect.any(Date) },
+ })
+ })
+
+ it('skips bookings that are not in the 50-70 minutes window (e.g. starting in 2 hours)', async () => {
+ const now = new Date()
+ const targetDate = new Date(now.getTime() + 120 * 60 * 1000)
+ const chinaDate = new Date(targetDate.getTime() + 8 * 3600 * 1000)
+ const dateStr = chinaDate.toISOString().slice(0, 10)
+ const hours = String(chinaDate.getUTCHours()).padStart(2, '0')
+ const minutes = String(chinaDate.getUTCMinutes()).padStart(2, '0')
+
+ db.booking.findMany.mockResolvedValue([
+ {
+ id: 'booking-far',
+ userId: 'user-far',
+ status: BookingStatus.CONFIRMED,
+ user: { openid: 'openid-far' },
+ timeSlot: {
+ date: new Date(`${dateStr}T00:00:00.000Z`),
+ startTime: `${hours}:${minutes}:00`,
+ endTime: '20:00:00',
+ },
+ },
+ ])
+
+ await service.run()
+
+ expect(db.booking.updateMany).not.toHaveBeenCalled()
+ expect(messages.sendClassReminderMessage).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/server/src/scheduler/class-reminder.service.ts b/packages/server/src/scheduler/class-reminder.service.ts
new file mode 100644
index 0000000..acdb71f
--- /dev/null
+++ b/packages/server/src/scheduler/class-reminder.service.ts
@@ -0,0 +1,107 @@
+import { Injectable, Logger } from '@nestjs/common'
+import { Cron } from '@nestjs/schedule'
+import { ConfigService } from '@nestjs/config'
+import { BookingStatus } from '@mp-pilates/shared'
+import { PrismaService } from '../prisma/prisma.service'
+import { SubscriptionMessageService } from '../user/subscription-message.service'
+
+@Injectable()
+export class ClassReminderService {
+ private readonly logger = new Logger(ClassReminderService.name)
+
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly messages: SubscriptionMessageService,
+ private readonly config: ConfigService,
+ ) {}
+
+ /**
+ * Run every 5 minutes to scan for upcoming confirmed classes starting in ~1 hour (50-70 mins).
+ */
+ @Cron('*/5 * * * *')
+ async run(): Promise {
+ const templateId = this.messages.getClassReminderTemplateId()
+ if (!templateId) {
+ return
+ }
+
+ const now = new Date()
+ const nowMs = now.getTime()
+
+ // Restrict date range to around today to avoid full table scan
+ const yesterday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 1))
+ const inTwoDays = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 2))
+
+ const studio = await this.prisma.studioConfig.findFirst({ select: { name: true } })
+ const courseName = studio?.name || '普拉提课程'
+
+ const rows = await this.prisma.booking.findMany({
+ where: {
+ status: BookingStatus.CONFIRMED,
+ classReminderClaimedAt: null,
+ classReminderSentAt: null,
+ timeSlot: {
+ date: {
+ gte: yesterday,
+ lte: inTwoDays,
+ },
+ },
+ },
+ include: {
+ user: { select: { openid: true } },
+ timeSlot: { select: { date: true, startTime: true, endTime: true } },
+ },
+ take: 100,
+ })
+
+ for (const row of rows) {
+ try {
+ const dateStr = row.timeSlot.date.toISOString().slice(0, 10)
+ const startTime = row.timeSlot.startTime.slice(0, 5)
+ const classStartMs = new Date(`${dateStr}T${startTime}:00+08:00`).getTime()
+ const diffMs = classStartMs - nowMs
+
+ // Target: starting in 50 to 70 minutes (around 1 hour ahead)
+ if (diffMs < 50 * 60 * 1000 || diffMs > 70 * 60 * 1000) {
+ continue
+ }
+
+ // Atomically claim the booking to prevent race conditions
+ const claimed = await this.prisma.booking.updateMany({
+ where: {
+ id: row.id,
+ status: BookingStatus.CONFIRMED,
+ classReminderClaimedAt: null,
+ },
+ data: {
+ classReminderClaimedAt: new Date(),
+ },
+ })
+
+ if (!claimed.count) {
+ continue
+ }
+
+ const classTime = `${dateStr} ${startTime}`
+ const sent = await this.messages.sendClassReminderMessage({
+ openid: row.user.openid,
+ userId: row.userId,
+ bookingId: row.id,
+ courseName,
+ classTime,
+ tips: '课程将于1小时后开始,请准时出席',
+ })
+
+ if (sent) {
+ await this.prisma.booking.update({
+ where: { id: row.id },
+ data: { classReminderSentAt: new Date() },
+ })
+ this.logger.log(`Class reminder sent successfully for booking ${row.id}`)
+ }
+ } catch (error) {
+ this.logger.error(`Failed to process class reminder for booking ${row.id}`, error)
+ }
+ }
+ }
+}
diff --git a/packages/server/src/scheduler/scheduler.module.ts b/packages/server/src/scheduler/scheduler.module.ts
index 8c6c37c..d23d053 100644
--- a/packages/server/src/scheduler/scheduler.module.ts
+++ b/packages/server/src/scheduler/scheduler.module.ts
@@ -1,6 +1,7 @@
import { UserModule } from '../user/user.module'
import { ConfigModule } from '@nestjs/config'
import { ReviewReminderService } from './review-reminder.service'
+import { ClassReminderService } from './class-reminder.service'
import { Module } from '@nestjs/common'
import { ScheduleModule } from '@nestjs/schedule'
import { TimeSlotModule } from '../time-slot/time-slot.module'
@@ -12,6 +13,6 @@ import { SchedulerService } from './scheduler.service'
UserModule, ConfigModule,
TimeSlotModule,
],
- providers: [SchedulerService, ReviewReminderService],
+ providers: [SchedulerService, ReviewReminderService, ClassReminderService],
})
export class SchedulerModule {}
diff --git a/packages/server/src/user/__tests__/subscription-message.service.spec.ts b/packages/server/src/user/__tests__/subscription-message.service.spec.ts
index 18c0652..65fa76d 100644
--- a/packages/server/src/user/__tests__/subscription-message.service.spec.ts
+++ b/packages/server/src/user/__tests__/subscription-message.service.spec.ts
@@ -1,4 +1,8 @@
-import { buildClassReviewSubscribeData } from '../subscription-message.service'
+import {
+ buildClassReviewSubscribeData,
+ buildBookingCancelledSubscribeData,
+ buildClassReminderSubscribeData,
+} from '../subscription-message.service'
describe('Class review subscribe payload', () => {
it('fills thing1 course, thing2 coach, time3 class time and thing4 tip', () => {
@@ -25,3 +29,51 @@ describe('Class review subscribe payload', () => {
expect(data.time3.value).toBe('2026年01月02日 09:30')
})
})
+
+describe('Booking cancelled subscribe payload', () => {
+ it('formats date11, date1 and thing2 correctly', () => {
+ const data = buildBookingCancelledSubscribeData({
+ bookingDate: '2026-09-10',
+ courseTime: '10:00-11:00',
+ courseName: '普拉提一对一私教体验课',
+ })
+ expect(data).toEqual({
+ date11: { value: '2026-09-10' },
+ date1: { value: '10:00-11:00' },
+ thing2: { value: '普拉提一对一私教体验课' },
+ })
+ })
+
+ it('trims courseName to 20 characters', () => {
+ const data = buildBookingCancelledSubscribeData({
+ bookingDate: '2026-09-10',
+ courseTime: '10:00-11:00',
+ courseName: '这是一个超过二十个汉字的普拉提非常长非常长非常长的课程名称',
+ })
+ expect(data.thing2.value).toHaveLength(20)
+ })
+})
+
+describe('Class reminder subscribe payload', () => {
+ it('formats thing1, time2 and thing5 correctly', () => {
+ const data = buildClassReminderSubscribeData({
+ courseName: '普拉提核心床小班课',
+ classTime: '2026-09-10 15:00',
+ tips: '课程将于1小时后开始,请准时出席',
+ })
+ expect(data).toEqual({
+ thing1: { value: '普拉提核心床小班课' },
+ time2: { value: '2026-09-10 15:00' },
+ thing5: { value: '课程将于1小时后开始,请准时出席' },
+ })
+ })
+
+ it('falls back to default tips and trims fields to 20 characters', () => {
+ const data = buildClassReminderSubscribeData({
+ courseName: '超过二十个字的超长课程名称请务必进行截断处理测试',
+ classTime: '14:00',
+ })
+ expect(data.thing1.value).toHaveLength(20)
+ expect(data.thing5.value).toBe('课程即将于1小时后开始,请准时出席'.slice(0, 20))
+ })
+})
diff --git a/packages/server/src/user/__tests__/user.service.spec.ts b/packages/server/src/user/__tests__/user.service.spec.ts
index da0f4bf..e2c2e34 100644
--- a/packages/server/src/user/__tests__/user.service.spec.ts
+++ b/packages/server/src/user/__tests__/user.service.spec.ts
@@ -83,6 +83,8 @@ const mockPrisma = {
const mockConfigService = {
get: jest.fn((key: string, defaultValue = '') => {
if (key === 'WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED') return 'tmpl-booking-confirmed'
+ if (key === 'WX_SUBSCRIBE_TEMPLATE_BOOKING_CANCELLED') return '5YX4ml6XwC0NegITxqsUXBPJzwKeOiM4VRZCaTiCgFM'
+ if (key === 'WX_SUBSCRIBE_TEMPLATE_CLASS_REMINDER') return 'CHwcRtAu-Bo6kYt-cmHMhBI4gDZdfoTarYqZotWQEi0'
return defaultValue
}),
}
@@ -143,7 +145,19 @@ describe('UserService', () => {
{
templateId: 'tmpl-booking-confirmed',
scene: SubscriptionMessageScene.BOOKING_CREATED,
- description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送',
+ description: '预约成功后推送确认通知',
+ usageTarget: 'consent',
+ },
+ {
+ templateId: '5YX4ml6XwC0NegITxqsUXBPJzwKeOiM4VRZCaTiCgFM',
+ scene: SubscriptionMessageScene.BOOKING_CANCELLED,
+ description: '约课取消后推送取消通知',
+ usageTarget: 'consent',
+ },
+ {
+ templateId: 'CHwcRtAu-Bo6kYt-cmHMhBI4gDZdfoTarYqZotWQEi0',
+ scene: SubscriptionMessageScene.CLASS_REMINDER,
+ description: '开课前 1 小时推送上课提醒',
usageTarget: 'consent',
},
{
@@ -282,7 +296,7 @@ describe('UserService', () => {
expect(result.avatarUrl).toBe('https://example.com/new.png')
expect(result.activeMembershipCount).toBe(1)
expect(result.adminBookingSubscriptionCount).toBe(0)
- expect(result.subscriptionMessageTemplates.templates).toHaveLength(2)
+ expect(result.subscriptionMessageTemplates.templates).toHaveLength(4)
})
it('increments admin booking subscription count for admin users', async () => {
@@ -742,4 +756,31 @@ describe('UserService', () => {
expect(mockPrisma.membership.findFirst).not.toHaveBeenCalled()
})
})
+
+ describe('getUserSubscriptionQuotas', () => {
+ it('returns calculated quotas for consent templates', async () => {
+ mockPrisma.subscriptionMessageConsent.findMany.mockResolvedValue([
+ {
+ templateId: 'tmpl-booking-confirmed',
+ scene: SubscriptionMessageScene.BOOKING_CREATED,
+ acceptCount: 5,
+ sentCount: 2,
+ lastResult: 'accept',
+ },
+ ])
+
+ const res = await service.getUserSubscriptionQuotas('user-1')
+ expect(res.quotas).toHaveLength(3) // BOOKING_CREATED, BOOKING_CANCELLED, CLASS_REMINDER
+ const bookingQuota = res.quotas.find((q) => q.scene === SubscriptionMessageScene.BOOKING_CREATED)
+ expect(bookingQuota).toBeDefined()
+ expect(bookingQuota?.remainingQuota).toBe(3)
+ expect(bookingQuota?.acceptCount).toBe(5)
+ expect(bookingQuota?.sentCount).toBe(2)
+ expect(bookingQuota?.lastResult).toBe('accept')
+
+ const cancelledQuota = res.quotas.find((q) => q.scene === SubscriptionMessageScene.BOOKING_CANCELLED)
+ expect(cancelledQuota).toBeDefined()
+ expect(cancelledQuota?.remainingQuota).toBe(0)
+ })
+ })
})
diff --git a/packages/server/src/user/subscription-message.service.ts b/packages/server/src/user/subscription-message.service.ts
index 16bdd6b..06a5138 100644
--- a/packages/server/src/user/subscription-message.service.ts
+++ b/packages/server/src/user/subscription-message.service.ts
@@ -16,6 +16,24 @@ interface BookingConfirmedTemplatePayload {
readonly bookingEndTime: string
}
+export interface BookingCancelledTemplatePayload {
+ readonly openid: string
+ readonly userId?: string
+ readonly bookingId: string
+ readonly bookingDate: string
+ readonly courseTime: string
+ readonly courseName: string
+}
+
+export interface ClassReminderTemplatePayload {
+ readonly openid: string
+ readonly userId?: string
+ readonly bookingId: string
+ readonly courseName: string
+ readonly classTime: string
+ readonly tips?: string
+}
+
interface AdminBookingCreatedTemplatePayload {
readonly openid: string
readonly bookingId: string
@@ -56,6 +74,22 @@ export function buildClassReviewSubscribeData(input: { studioName: string | null
}
}
+export function buildBookingCancelledSubscribeData(input: { bookingDate: string; courseTime: string; courseName: string }) {
+ return {
+ date11: { value: input.bookingDate },
+ date1: { value: input.courseTime },
+ thing2: { value: (input.courseName || '普拉提私教').slice(0, 20) },
+ }
+}
+
+export function buildClassReminderSubscribeData(input: { courseName: string; classTime: string; tips?: string }) {
+ return {
+ thing1: { value: (input.courseName || '普拉提私教').slice(0, 20) },
+ time2: { value: input.classTime },
+ thing5: { value: (input.tips || '课程即将于1小时后开始,请准时出席').slice(0, 20) },
+ }
+}
+
@Injectable()
export class SubscriptionMessageService {
private readonly logger = new Logger(SubscriptionMessageService.name)
@@ -70,10 +104,154 @@ export class SubscriptionMessageService {
return this.configService.get('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', '')
}
+ getBookingCancelledTemplateId(): string {
+ return this.configService.get(
+ 'WX_SUBSCRIBE_TEMPLATE_BOOKING_CANCELLED',
+ '5YX4ml6XwC0NegITxqsUXBPJzwKeOiM4VRZCaTiCgFM',
+ )
+ }
+
+ getClassReminderTemplateId(): string {
+ return this.configService.get(
+ 'WX_SUBSCRIBE_TEMPLATE_CLASS_REMINDER',
+ 'CHwcRtAu-Bo6kYt-cmHMhBI4gDZdfoTarYqZotWQEi0',
+ )
+ }
+
async sendBookingConfirmedMessage(payload: BookingConfirmedTemplatePayload): Promise {
return this.sendConsentBasedBookingMessage(payload)
}
+ async sendBookingCancelledMessage(payload: BookingCancelledTemplatePayload): Promise {
+ const templateId = this.getBookingCancelledTemplateId()
+ if (!templateId) {
+ this.logger.warn('WX_SUBSCRIBE_TEMPLATE_BOOKING_CANCELLED is not configured, skip sending cancelled message')
+ return false
+ }
+
+ const consent = await this.prisma.subscriptionMessageConsent.findFirst({
+ where: {
+ user: payload.userId ? { id: payload.userId } : { openid: payload.openid },
+ templateId,
+ scene: SubscriptionMessageScene.BOOKING_CANCELLED,
+ acceptCount: { gt: 0 },
+ },
+ orderBy: [
+ { lastRequestedAt: 'desc' },
+ { updatedAt: 'desc' },
+ ],
+ })
+
+ if (!consent) {
+ this.logger.warn(`No subscription quota found for booking cancelled: ${stringifyDebugPayload({ openid: payload.openid, bookingId: payload.bookingId, templateId })}`)
+ return false
+ }
+
+ if (consent.sentCount >= consent.acceptCount) {
+ this.logger.warn(`Subscription quota exhausted for booking cancelled: ${stringifyDebugPayload({ consentId: consent.id, bookingId: payload.bookingId, sentCount: consent.sentCount, acceptCount: consent.acceptCount, templateId })}`)
+ return false
+ }
+
+ const claimed = await this.prisma.subscriptionMessageConsent.updateMany({
+ where: {
+ id: consent.id,
+ sentCount: consent.sentCount,
+ acceptCount: { gt: consent.sentCount },
+ },
+ data: {
+ sentCount: { increment: 1 },
+ lastSentAt: new Date(),
+ },
+ })
+ if (!claimed.count) {
+ return false
+ }
+
+ const data = buildBookingCancelledSubscribeData({
+ bookingDate: payload.bookingDate,
+ courseTime: payload.courseTime,
+ courseName: payload.courseName,
+ })
+
+ return this.postWechatSubscribeSend({
+ openid: payload.openid,
+ templateId,
+ page: `pages/booking/detail?id=${payload.bookingId}`,
+ data,
+ logContext: {
+ target: 'member',
+ bookingId: payload.bookingId,
+ consentId: consent.id,
+ scene: SubscriptionMessageScene.BOOKING_CANCELLED,
+ },
+ })
+ }
+
+ async sendClassReminderMessage(payload: ClassReminderTemplatePayload): Promise {
+ const templateId = this.getClassReminderTemplateId()
+ if (!templateId) {
+ this.logger.warn('WX_SUBSCRIBE_TEMPLATE_CLASS_REMINDER is not configured, skip sending reminder message')
+ return false
+ }
+
+ const consent = await this.prisma.subscriptionMessageConsent.findFirst({
+ where: {
+ user: payload.userId ? { id: payload.userId } : { openid: payload.openid },
+ templateId,
+ scene: SubscriptionMessageScene.CLASS_REMINDER,
+ acceptCount: { gt: 0 },
+ },
+ orderBy: [
+ { lastRequestedAt: 'desc' },
+ { updatedAt: 'desc' },
+ ],
+ })
+
+ if (!consent) {
+ this.logger.warn(`No subscription quota found for class reminder: ${stringifyDebugPayload({ openid: payload.openid, bookingId: payload.bookingId, templateId })}`)
+ return false
+ }
+
+ if (consent.sentCount >= consent.acceptCount) {
+ this.logger.warn(`Subscription quota exhausted for class reminder: ${stringifyDebugPayload({ consentId: consent.id, bookingId: payload.bookingId, sentCount: consent.sentCount, acceptCount: consent.acceptCount, templateId })}`)
+ return false
+ }
+
+ const claimed = await this.prisma.subscriptionMessageConsent.updateMany({
+ where: {
+ id: consent.id,
+ sentCount: consent.sentCount,
+ acceptCount: { gt: consent.sentCount },
+ },
+ data: {
+ sentCount: { increment: 1 },
+ lastSentAt: new Date(),
+ },
+ })
+ if (!claimed.count) {
+ return false
+ }
+
+ const data = buildClassReminderSubscribeData({
+ courseName: payload.courseName,
+ classTime: payload.classTime,
+ tips: payload.tips,
+ })
+
+ return this.postWechatSubscribeSend({
+ openid: payload.openid,
+ templateId,
+ page: `pages/booking/detail?id=${payload.bookingId}`,
+ data,
+ logContext: {
+ target: 'member',
+ bookingId: payload.bookingId,
+ consentId: consent.id,
+ scene: SubscriptionMessageScene.CLASS_REMINDER,
+ },
+ })
+ }
+
async sendAdminBookingCreatedMessage(payload: AdminBookingCreatedTemplatePayload): Promise {
const templateId = this.getBookingConfirmedTemplateId()
if (!templateId) {
@@ -182,28 +360,22 @@ export class SubscriptionMessageService {
return true
}
- private async sendWechatSubscribeMessage(params: {
+ private async postWechatSubscribeSend(params: {
openid: string
- bookingId: string
templateId: string
- payload: BookingConfirmedTemplatePayload | AdminBookingCreatedTemplatePayload
+ page: string
+ data: Record
logContext: Record
}): Promise {
const accessToken = await this.getAccessToken()
- const page = `/pages/booking/detail?id=${params.bookingId}`
const requestBody = {
touser: params.openid,
template_id: params.templateId,
- page,
- data: {
- thing1: { value: params.payload.bookingContent.slice(0, 20) },
- time2: { value: params.payload.bookingTime.slice(0, 20) },
- thing25: { value: params.payload.courseName.slice(0, 20) },
- time35: { value: params.payload.bookingEndTime.slice(0, 20) },
- },
+ page: params.page,
+ data: params.data,
}
- this.logger.log(`WeChat subscribe send request: ${stringifyDebugPayload({ bookingId: params.bookingId, templateId: params.templateId, requestBody, ...params.logContext })}`)
+ this.logger.log(`WeChat subscribe send request: ${stringifyDebugPayload({ templateId: params.templateId, requestBody, ...params.logContext })}`)
const response = await fetch(
`https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${accessToken}`,
@@ -213,25 +385,49 @@ export class SubscriptionMessageService {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
+ signal: AbortSignal.timeout(15000),
},
)
if (!response.ok) {
const responseText = await response.text()
- this.logger.error(`WeChat subscribe send http error: ${stringifyDebugPayload({ status: response.status, statusText: response.statusText, body: responseText, bookingId: params.bookingId, templateId: params.templateId, requestBody, ...params.logContext })}`)
- throw new InternalServerErrorException('调用微信订阅消息接口失败')
+ this.logger.error(`WeChat subscribe send http error: ${stringifyDebugPayload({ status: response.status, statusText: response.statusText, body: responseText, templateId: params.templateId, requestBody, ...params.logContext })}`)
+ return false
}
const result = (await response.json()) as WechatSubscribeSendResponse
if (result.errcode && result.errcode !== 0) {
- this.logger.warn(`WeChat subscribe send failed: ${stringifyDebugPayload({ bookingId: params.bookingId, templateId: params.templateId, requestBody, response: result, ...params.logContext })}`)
+ this.logger.warn(`WeChat subscribe send failed: ${stringifyDebugPayload({ templateId: params.templateId, requestBody, response: result, ...params.logContext })}`)
return false
}
- this.logger.log(`WeChat subscribe send success: ${stringifyDebugPayload({ bookingId: params.bookingId, templateId: params.templateId, response: result, ...params.logContext })}`)
+ this.logger.log(`WeChat subscribe send success: ${stringifyDebugPayload({ templateId: params.templateId, response: result, ...params.logContext })}`)
return true
}
+ private async sendWechatSubscribeMessage(params: {
+ openid: string
+ bookingId: string
+ templateId: string
+ payload: BookingConfirmedTemplatePayload | AdminBookingCreatedTemplatePayload
+ logContext: Record
+ }): Promise {
+ const data = {
+ thing1: { value: params.payload.bookingContent.slice(0, 20) },
+ time2: { value: params.payload.bookingTime.slice(0, 20) },
+ thing25: { value: params.payload.courseName.slice(0, 20) },
+ time35: { value: params.payload.bookingEndTime.slice(0, 20) },
+ }
+
+ return this.postWechatSubscribeSend({
+ openid: params.openid,
+ templateId: params.templateId,
+ page: `/pages/booking/detail?id=${params.bookingId}`,
+ data,
+ logContext: { bookingId: params.bookingId, ...params.logContext },
+ })
+ }
+
async sendReviewReminder(userId: string, openid: string, bookingId: string): Promise {
const templateId = this.configService.get('WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW', '')
if (!templateId) return false
@@ -247,15 +443,20 @@ export class SubscriptionMessageService {
// Reserve quota before external I/O. Ambiguous network outcomes must not cause duplicate sends.
const claimed = await this.prisma.subscriptionMessageConsent.updateMany({ where: { id: consent.id, sentCount: consent.sentCount, acceptCount: { gt: consent.sentCount } }, data: { sentCount: { increment: 1 }, lastSentAt: new Date() } })
if (!claimed.count) return false
- const token = await this.getAccessToken()
- const response = await fetch(`https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${token}`, {
- method: 'POST', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ touser: openid, template_id: templateId, page: `pages/booking/detail?id=${bookingId}`, data }),
- signal: AbortSignal.timeout(15000),
+
+ return this.postWechatSubscribeSend({
+ openid,
+ templateId,
+ page: `pages/booking/detail?id=${bookingId}`,
+ data,
+ logContext: {
+ target: 'member',
+ userId,
+ bookingId,
+ consentId: consent.id,
+ scene: SubscriptionMessageScene.CLASS_REVIEW,
+ },
})
- if (!response.ok) throw new Error('评价提醒发送结果未知')
- const result = await response.json() as WechatSubscribeSendResponse
- return !result.errcode
}
private async getAccessToken(): Promise {
diff --git a/packages/server/src/user/user.controller.ts b/packages/server/src/user/user.controller.ts
index 71812f6..48a3dd1 100644
--- a/packages/server/src/user/user.controller.ts
+++ b/packages/server/src/user/user.controller.ts
@@ -51,6 +51,11 @@ export class UserController {
return this.userService.getSubscriptionMessageTemplates()
}
+ @Get('user/subscription-messages/quotas')
+ getUserSubscriptionQuotas(@CurrentUser('sub') userId: string) {
+ return this.userService.getUserSubscriptionQuotas(userId)
+ }
+
@Post('user/subscription-messages/report')
reportSubscriptionMessageRequests(
@CurrentUser('sub') userId: string,
diff --git a/packages/server/src/user/user.service.ts b/packages/server/src/user/user.service.ts
index 4a5884a..bad23d7 100644
--- a/packages/server/src/user/user.service.ts
+++ b/packages/server/src/user/user.service.ts
@@ -15,6 +15,8 @@ import type {
SubscriptionMessageRequestResult,
SubscriptionMessageTemplate,
SubscriptionMessageTemplateConfig,
+ SubscriptionQuotaItem,
+ SubscriptionQuotasResponse,
AdminMemberSummary,
AdminMemberDetail,
MembershipWithCardType,
@@ -100,7 +102,25 @@ export class UserService {
{
templateId: this.configService.get('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
scene: SubscriptionMessageScene.BOOKING_CREATED,
- description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送',
+ description: '预约成功后推送确认通知',
+ usageTarget: 'consent' as const,
+ },
+ {
+ templateId: this.configService.get(
+ 'WX_SUBSCRIBE_TEMPLATE_BOOKING_CANCELLED',
+ '5YX4ml6XwC0NegITxqsUXBPJzwKeOiM4VRZCaTiCgFM',
+ ),
+ scene: SubscriptionMessageScene.BOOKING_CANCELLED,
+ description: '约课取消后推送取消通知',
+ usageTarget: 'consent' as const,
+ },
+ {
+ templateId: this.configService.get(
+ 'WX_SUBSCRIBE_TEMPLATE_CLASS_REMINDER',
+ 'CHwcRtAu-Bo6kYt-cmHMhBI4gDZdfoTarYqZotWQEi0',
+ ),
+ scene: SubscriptionMessageScene.CLASS_REMINDER,
+ description: '开课前 1 小时推送上课提醒',
usageTarget: 'consent' as const,
},
{
@@ -260,6 +280,48 @@ export class UserService {
}))
}
+ async getUserSubscriptionQuotas(userId: string): Promise {
+ const config = this.buildSubscriptionTemplateConfig()
+ const consentTemplates = config.templates.filter((item) => item.usageTarget !== 'counter')
+
+ if (consentTemplates.length === 0) {
+ return { quotas: [] }
+ }
+
+ const consents = await this.prisma.subscriptionMessageConsent.findMany({
+ where: {
+ userId,
+ OR: consentTemplates.map((t) => ({
+ templateId: t.templateId,
+ scene: t.scene,
+ })),
+ },
+ })
+
+ const consentMap = new Map()
+ for (const consent of consents) {
+ consentMap.set(`${consent.templateId}_${consent.scene}`, consent)
+ }
+
+ const quotas: SubscriptionQuotaItem[] = consentTemplates.map((tpl) => {
+ const record = consentMap.get(`${tpl.templateId}_${tpl.scene}`)
+ const acceptCount = record?.acceptCount ?? 0
+ const sentCount = record?.sentCount ?? 0
+ const remainingQuota = Math.max(0, acceptCount - sentCount)
+ return {
+ scene: tpl.scene,
+ templateId: tpl.templateId,
+ description: tpl.description,
+ remainingQuota,
+ acceptCount,
+ sentCount,
+ lastResult: (record?.lastResult as SubscriptionMessageRequestResult) ?? null,
+ }
+ })
+
+ return { quotas }
+ }
+
async grantAdminBookingSubscriptionCount(userId: string): Promise {
const user = await this.prisma.user.findUnique({
where: { id: userId },
diff --git a/packages/shared/src/enums.ts b/packages/shared/src/enums.ts
index a61ae77..8239e4d 100644
--- a/packages/shared/src/enums.ts
+++ b/packages/shared/src/enums.ts
@@ -58,5 +58,7 @@ export enum SubscriptionMessageScene {
CLASS_REVIEW = 'CLASS_REVIEW',
ORDER_PAID = 'ORDER_PAID',
BOOKING_CREATED = 'BOOKING_CREATED',
+ BOOKING_CANCELLED = 'BOOKING_CANCELLED',
+ CLASS_REMINDER = 'CLASS_REMINDER',
ADMIN_BOOKING_CREATED = 'ADMIN_BOOKING_CREATED',
}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 983221b..0c2761b 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -111,6 +111,8 @@ export type {
SubscriptionMessageTemplateConfig,
ReportSubscriptionMessageRequestDto,
SubscriptionMessageConsentSummary,
+ SubscriptionQuotaItem,
+ SubscriptionQuotasResponse,
} from './types/index'
export * from './types/member-care'
diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts
index 351be93..dcaef8e 100644
--- a/packages/shared/src/types/index.ts
+++ b/packages/shared/src/types/index.ts
@@ -19,6 +19,8 @@ export type {
SubscriptionMessageTemplateConfig,
ReportSubscriptionMessageRequestDto,
SubscriptionMessageConsentSummary,
+ SubscriptionQuotaItem,
+ SubscriptionQuotasResponse,
} from './subscription'
export type { CardType, CreateCardTypeDto, UpdateCardTypeDto } from './card-type'
export type { Membership, MembershipWithCardType } from './membership'
diff --git a/packages/shared/src/types/subscription.ts b/packages/shared/src/types/subscription.ts
index 462f794..dfc35f3 100644
--- a/packages/shared/src/types/subscription.ts
+++ b/packages/shared/src/types/subscription.ts
@@ -40,3 +40,17 @@ export interface SubscriptionMessageConsentSummary {
readonly createdAt: string
readonly updatedAt: string
}
+
+export interface SubscriptionQuotaItem {
+ readonly scene: SubscriptionMessageScene
+ readonly templateId: string
+ readonly description: string
+ readonly remainingQuota: number
+ readonly acceptCount: number
+ readonly sentCount: number
+ readonly lastResult: SubscriptionMessageRequestResult | null
+}
+
+export interface SubscriptionQuotasResponse {
+ readonly quotas: SubscriptionQuotaItem[]
+}