From 3e049d2c1dd8a2fbb589f845cb341d691d0462af Mon Sep 17 00:00:00 2001 From: richarjiang Date: Thu, 10 Sep 2026 16:20:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E6=B6=88=E6=81=AF=E4=BD=93=E7=B3=BB=E5=B9=B6?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=BA=A6=E8=AF=BE=E5=8F=96=E6=B6=88=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E4=B8=8E=E5=BC=80=E8=AF=BE=E5=89=8D1=E5=B0=8F?= =?UTF-8?q?=E6=97=B6=E6=8F=90=E9=86=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../src/components/BookingConfirmPopup.vue | 7 + packages/app/src/components/ProfileMenu.vue | 12 +- .../components/SubscriptionSettingsModal.vue | 376 ++++++++++++++++++ packages/app/src/pages/booking/detail.vue | 38 +- packages/app/src/pages/booking/index.vue | 7 + packages/app/src/pages/profile/bookings.vue | 7 + packages/app/src/pages/profile/index.vue | 6 + packages/app/src/utils/wechat-subscription.ts | 104 ++++- packages/server/.env | 2 + packages/server/.env.example | 3 + .../migration.sql | 4 + packages/server/prisma/schema.prisma | 3 + .../booking/__tests__/booking.service.spec.ts | 38 +- .../server/src/booking/booking.service.ts | 36 ++ .../__tests__/class-reminder.service.spec.ts | 122 ++++++ .../src/scheduler/class-reminder.service.ts | 107 +++++ .../server/src/scheduler/scheduler.module.ts | 3 +- .../subscription-message.service.spec.ts | 54 ++- .../src/user/__tests__/user.service.spec.ts | 45 ++- .../src/user/subscription-message.service.ts | 249 ++++++++++-- packages/server/src/user/user.controller.ts | 5 + packages/server/src/user/user.service.ts | 64 ++- packages/shared/src/enums.ts | 2 + packages/shared/src/index.ts | 2 + packages/shared/src/types/index.ts | 2 + packages/shared/src/types/subscription.ts | 14 + 26 files changed, 1268 insertions(+), 44 deletions(-) create mode 100644 packages/app/src/components/SubscriptionSettingsModal.vue create mode 100644 packages/server/prisma/migrations/20260910160000_add_class_reminder_fields/migration.sql create mode 100644 packages/server/src/scheduler/__tests__/class-reminder.service.spec.ts create mode 100644 packages/server/src/scheduler/class-reminder.service.ts 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 @@ + + + @@ -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(() => { @@ -64,6 +65,13 @@ const menuItems = computed(() => { path: '/pages/profile/info', requireAuth: true, }, + { + key: 'notifications', + type: 'item', + title: '消息提醒设置', + action: 'notifications', + requireAuth: true, + }, { key: 'sep1', type: 'separator', @@ -98,6 +106,8 @@ function handleTap(item: MenuItem) { } if (item.action === 'clear') { emit('clear-cache') + } else if (item.action === 'notifications') { + emit('open-notifications') } else if (item.path) { uni.navigateTo({ url: item.path }) } diff --git a/packages/app/src/components/SubscriptionSettingsModal.vue b/packages/app/src/components/SubscriptionSettingsModal.vue new file mode 100644 index 0000000..6c3e95a --- /dev/null +++ b/packages/app/src/components/SubscriptionSettingsModal.vue @@ -0,0 +1,376 @@ + + + + + diff --git a/packages/app/src/pages/booking/detail.vue b/packages/app/src/pages/booking/detail.vue index 2415467..8691f87 100644 --- a/packages/app/src/pages/booking/detail.vue +++ b/packages/app/src/pages/booking/detail.vue @@ -90,6 +90,9 @@ + + + @@ -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[] +}