feat: 优化微信订阅消息体系并新增约课取消通知与开课前1小时提醒
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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])
|
||||
|
||||
@@ -170,7 +170,12 @@ describe('BookingService', () => {
|
||||
let service: BookingService
|
||||
let prisma: jest.Mocked<PrismaService>
|
||||
let studioService: jest.Mocked<StudioService>
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
107
packages/server/src/scheduler/class-reminder.service.ts
Normal file
107
packages/server/src/scheduler/class-reminder.service.ts
Normal file
@@ -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<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', '')
|
||||
}
|
||||
|
||||
getBookingCancelledTemplateId(): string {
|
||||
return this.configService.get<string>(
|
||||
'WX_SUBSCRIBE_TEMPLATE_BOOKING_CANCELLED',
|
||||
'5YX4ml6XwC0NegITxqsUXBPJzwKeOiM4VRZCaTiCgFM',
|
||||
)
|
||||
}
|
||||
|
||||
getClassReminderTemplateId(): string {
|
||||
return this.configService.get<string>(
|
||||
'WX_SUBSCRIBE_TEMPLATE_CLASS_REMINDER',
|
||||
'CHwcRtAu-Bo6kYt-cmHMhBI4gDZdfoTarYqZotWQEi0',
|
||||
)
|
||||
}
|
||||
|
||||
async sendBookingConfirmedMessage(payload: BookingConfirmedTemplatePayload): Promise<boolean> {
|
||||
return this.sendConsentBasedBookingMessage(payload)
|
||||
}
|
||||
|
||||
async sendBookingCancelledMessage(payload: BookingCancelledTemplatePayload): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<string, { value: string }>
|
||||
logContext: Record<string, unknown>
|
||||
}): Promise<boolean> {
|
||||
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<string, unknown>
|
||||
}): Promise<boolean> {
|
||||
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<boolean> {
|
||||
const templateId = this.configService.get<string>('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<string> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
|
||||
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
||||
description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送',
|
||||
description: '预约成功后推送确认通知',
|
||||
usageTarget: 'consent' as const,
|
||||
},
|
||||
{
|
||||
templateId: this.configService.get<string>(
|
||||
'WX_SUBSCRIBE_TEMPLATE_BOOKING_CANCELLED',
|
||||
'5YX4ml6XwC0NegITxqsUXBPJzwKeOiM4VRZCaTiCgFM',
|
||||
),
|
||||
scene: SubscriptionMessageScene.BOOKING_CANCELLED,
|
||||
description: '约课取消后推送取消通知',
|
||||
usageTarget: 'consent' as const,
|
||||
},
|
||||
{
|
||||
templateId: this.configService.get<string>(
|
||||
'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<SubscriptionQuotasResponse> {
|
||||
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<string, SubscriptionMessageConsentRecord>()
|
||||
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<UserProfileResponse> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
|
||||
Reference in New Issue
Block a user