feat: 支持管理员消息推送
This commit is contained in:
@@ -66,15 +66,16 @@ enum FlashSaleOrderStatus {
|
||||
// ===== Models =====
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
openid String @unique
|
||||
unionid String?
|
||||
phone String?
|
||||
nickname String @default("")
|
||||
avatarUrl String? @map("avatar_url")
|
||||
role UserRole @default(MEMBER)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
id String @id @default(uuid())
|
||||
openid String @unique
|
||||
unionid String?
|
||||
phone String?
|
||||
nickname String @default("")
|
||||
avatarUrl String? @map("avatar_url")
|
||||
role UserRole @default(MEMBER)
|
||||
adminBookingSubscriptionCount Int @default(0) @map("admin_booking_subscription_count")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
memberships Membership[]
|
||||
bookings Booking[]
|
||||
|
||||
@@ -98,6 +98,7 @@ async function main() {
|
||||
openid: 'admin_test_openid',
|
||||
nickname: '教练',
|
||||
role: UserRole.ADMIN,
|
||||
adminBookingSubscriptionCount: 0,
|
||||
},
|
||||
})
|
||||
console.log(' ✅ Admin user created')
|
||||
|
||||
@@ -80,6 +80,7 @@ export class AuthService {
|
||||
...(unionid !== undefined && { unionid }),
|
||||
nickname: nickname || generateDefaultNickname(this.randomFn),
|
||||
...(avatarUrl && { avatarUrl }),
|
||||
adminBookingSubscriptionCount: 0,
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common'
|
||||
import { BookingStatus, CardTypeCategory, MembershipStatus, TimeSlotStatus } from '@mp-pilates/shared'
|
||||
import { BookingStatus, CardTypeCategory, MembershipStatus, TimeSlotStatus, UserRole } from '@mp-pilates/shared'
|
||||
import { BookingService } from '../booking.service'
|
||||
import { PrismaService } from '../../prisma/prisma.service'
|
||||
import { MembershipService } from '../../membership/membership.service'
|
||||
@@ -152,7 +152,7 @@ describe('BookingService', () => {
|
||||
let service: BookingService
|
||||
let prisma: jest.Mocked<PrismaService>
|
||||
let studioService: jest.Mocked<StudioService>
|
||||
let subscriptionMessageService: { sendBookingConfirmedMessage: jest.Mock }
|
||||
let subscriptionMessageService: { sendBookingConfirmedMessage: jest.Mock; sendAdminBookingCreatedMessage: jest.Mock }
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
@@ -179,6 +179,7 @@ describe('BookingService', () => {
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -200,6 +201,7 @@ describe('BookingService', () => {
|
||||
provide: SubscriptionMessageService,
|
||||
useValue: {
|
||||
sendBookingConfirmedMessage: jest.fn(),
|
||||
sendAdminBookingCreatedMessage: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -284,6 +286,7 @@ describe('BookingService', () => {
|
||||
timeSlot: mockOpenSlot,
|
||||
membership: mockActiveMembership,
|
||||
})
|
||||
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
|
||||
|
||||
const result = await service.createBooking(MOCK_USER_ID, dto)
|
||||
|
||||
@@ -323,6 +326,7 @@ describe('BookingService', () => {
|
||||
timeSlot: nearFullSlot,
|
||||
membership: mockActiveMembership,
|
||||
})
|
||||
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
|
||||
|
||||
await service.createBooking(MOCK_USER_ID, dto)
|
||||
|
||||
@@ -353,6 +357,7 @@ describe('BookingService', () => {
|
||||
timeSlot: mockOpenSlot,
|
||||
membership: mockDurationMembership,
|
||||
})
|
||||
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
|
||||
|
||||
await service.createBooking(MOCK_USER_ID, durationDto)
|
||||
|
||||
@@ -379,12 +384,61 @@ describe('BookingService', () => {
|
||||
timeSlot: mockOpenSlot,
|
||||
membership: mockMembershipNoTimes,
|
||||
})
|
||||
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
|
||||
|
||||
await service.createBooking(MOCK_USER_ID, dto)
|
||||
|
||||
expect(tx.membership.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends admin booking created subscription message to admins with remaining count', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
tx.booking.findFirst.mockResolvedValue(null)
|
||||
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
|
||||
tx.booking.create.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
})
|
||||
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
timeSlot: mockOpenSlot,
|
||||
membership: mockActiveMembership,
|
||||
})
|
||||
;(prisma.user.findMany as jest.Mock).mockResolvedValue([
|
||||
{ openid: 'admin-openid-1' },
|
||||
])
|
||||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ nickname: 'Alice', phone: '13800000000' })
|
||||
studioService.getInfo.mockResolvedValue({
|
||||
...mockStudioConfig,
|
||||
name: 'FocusCore Pilates',
|
||||
})
|
||||
subscriptionMessageService.sendAdminBookingCreatedMessage.mockResolvedValue(true)
|
||||
|
||||
await service.createBooking(MOCK_USER_ID, dto)
|
||||
|
||||
expect(prisma.user.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
role: UserRole.ADMIN,
|
||||
adminBookingSubscriptionCount: { gt: 0 },
|
||||
},
|
||||
select: {
|
||||
openid: true,
|
||||
},
|
||||
})
|
||||
expect(subscriptionMessageService.sendAdminBookingCreatedMessage).toHaveBeenCalledWith({
|
||||
openid: 'admin-openid-1',
|
||||
bookingId: MOCK_BOOKING_ID,
|
||||
bookingContent: 'Alice已预约',
|
||||
bookingTime: '2099-12-31 09:00',
|
||||
courseName: 'FocusCore Pilates',
|
||||
bookingPeriod: '2099-12-31 09:00~10:00',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws BadRequestException when slot is FULL', async () => {
|
||||
const fullDto = { timeSlotId: mockFullSlot.id, membershipId: MOCK_MEMBERSHIP_ID }
|
||||
|
||||
|
||||
@@ -140,7 +140,9 @@ export class BookingService {
|
||||
})
|
||||
|
||||
// Re-fetch with relations after transaction
|
||||
return this.fetchBookingWithRelations(booking.id)
|
||||
const bookingWithRelations = await this.fetchBookingWithRelations(booking.id)
|
||||
await this.trySendAdminBookingCreatedSubscriptionMessages(bookingWithRelations)
|
||||
return bookingWithRelations
|
||||
}
|
||||
|
||||
// ─── Confirm Booking (Admin) ─────────────────────────────────────────────
|
||||
@@ -600,4 +602,63 @@ export class BookingService {
|
||||
console.error('Send booking confirmed subscription message failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private async trySendAdminBookingCreatedSubscriptionMessages(
|
||||
booking: BookingWithRelations,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const admins = await this.prisma.user.findMany({
|
||||
where: {
|
||||
role: 'ADMIN',
|
||||
adminBookingSubscriptionCount: { gt: 0 },
|
||||
},
|
||||
select: {
|
||||
openid: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (admins.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const student = await this.prisma.user.findUnique({
|
||||
where: { id: booking.userId },
|
||||
select: { nickname: true, phone: true },
|
||||
})
|
||||
const studio = await this.studioService.getInfo()
|
||||
const bookingDate = booking.timeSlot.date
|
||||
const dateLabel = `${bookingDate.getFullYear()}-${String(bookingDate.getMonth() + 1).padStart(2, '0')}-${String(bookingDate.getDate()).padStart(2, '0')}`
|
||||
const periodLabel = `${booking.timeSlot.startTime.slice(0, 5)}~${booking.timeSlot.endTime.slice(0, 5)}`
|
||||
|
||||
const studentLabel = this.buildAdminBookingStudentLabel(student)
|
||||
await Promise.allSettled(
|
||||
admins
|
||||
.filter((admin) => admin.openid)
|
||||
.map((admin) => this.subscriptionMessageService.sendAdminBookingCreatedMessage({
|
||||
openid: admin.openid,
|
||||
bookingId: booking.id,
|
||||
bookingContent: `${studentLabel}已预约`.slice(0, 20),
|
||||
bookingTime: `${dateLabel} ${booking.timeSlot.startTime.slice(0, 5)}`,
|
||||
courseName: studio.name || '普拉提课程',
|
||||
bookingPeriod: `${dateLabel} ${periodLabel}`,
|
||||
})),
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Send admin booking created subscription message failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private buildAdminBookingStudentLabel(student: { nickname: string; phone: string | null } | null): string {
|
||||
const nickname = (student?.nickname || '').trim()
|
||||
if (nickname) {
|
||||
return nickname.slice(0, 8)
|
||||
}
|
||||
|
||||
const phone = student?.phone || ''
|
||||
if (phone.length >= 4) {
|
||||
return `尾号${phone.slice(-4)}`
|
||||
}
|
||||
|
||||
return '学员'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,14 +84,26 @@ describe('TimeSlotService', () => {
|
||||
expect(result[0].myBookingId).toBeNull()
|
||||
})
|
||||
|
||||
it('marks isBookedByMe=true and sets myBookingId when user has a CONFIRMED booking', async () => {
|
||||
const slot = makeSlot({ bookings: [{ id: 'booking-42' }] })
|
||||
it('marks isBookedByMe=true and sets my booking info when user has a CONFIRMED booking', async () => {
|
||||
const slot = makeSlot({ bookings: [{ id: 'booking-42', status: BookingStatus.CONFIRMED }] })
|
||||
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([slot])
|
||||
|
||||
const result = await service.getAvailableSlots('2026-04-07', 'user-1')
|
||||
|
||||
expect(result[0].isBookedByMe).toBe(true)
|
||||
expect(result[0].myBookingId).toBe('booking-42')
|
||||
expect(result[0].myBookingStatus).toBe(BookingStatus.CONFIRMED)
|
||||
})
|
||||
|
||||
it('marks pending confirmation booking as already booked by current user', async () => {
|
||||
const slot = makeSlot({ bookings: [{ id: 'booking-99', status: BookingStatus.PENDING_CONFIRMATION }] })
|
||||
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([slot])
|
||||
|
||||
const result = await service.getAvailableSlots('2026-04-07', 'user-1')
|
||||
|
||||
expect(result[0].isBookedByMe).toBe(true)
|
||||
expect(result[0].myBookingId).toBe('booking-99')
|
||||
expect(result[0].myBookingStatus).toBe(BookingStatus.PENDING_CONFIRMATION)
|
||||
})
|
||||
|
||||
it('excludes CLOSED slots from query', async () => {
|
||||
@@ -134,11 +146,12 @@ describe('TimeSlotService', () => {
|
||||
|
||||
expect(result[0].isBookedByMe).toBe(false)
|
||||
expect(result[0].myBookingId).toBeNull()
|
||||
expect(result[0].myBookingStatus).toBeNull()
|
||||
})
|
||||
|
||||
it('maps multiple slots correctly', async () => {
|
||||
const slots = [
|
||||
makeSlot({ id: 'slot-1', startTime: '09:00', bookings: [{ id: 'bk-1' }] }),
|
||||
makeSlot({ id: 'slot-1', startTime: '09:00', bookings: [{ id: 'bk-1', status: BookingStatus.CONFIRMED }] }),
|
||||
makeSlot({ id: 'slot-2', startTime: '10:00', bookings: [] }),
|
||||
]
|
||||
mockPrisma.timeSlot.findMany.mockResolvedValueOnce(slots)
|
||||
@@ -148,8 +161,10 @@ describe('TimeSlotService', () => {
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].isBookedByMe).toBe(true)
|
||||
expect(result[0].myBookingId).toBe('bk-1')
|
||||
expect(result[0].myBookingStatus).toBe(BookingStatus.CONFIRMED)
|
||||
expect(result[1].isBookedByMe).toBe(false)
|
||||
expect(result[1].myBookingId).toBeNull()
|
||||
expect(result[1].myBookingStatus).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -164,7 +179,20 @@ describe('TimeSlotService', () => {
|
||||
|
||||
const result = await service.getSlotById('slot-1')
|
||||
|
||||
expect(result).toEqual(slot)
|
||||
expect(result).toMatchObject({
|
||||
id: slot.id,
|
||||
date: '2026-04-07',
|
||||
startTime: slot.startTime,
|
||||
endTime: slot.endTime,
|
||||
capacity: slot.capacity,
|
||||
bookedCount: slot.bookedCount,
|
||||
status: slot.status,
|
||||
source: slot.source,
|
||||
templateId: slot.templateId,
|
||||
isBookedByMe: false,
|
||||
myBookingId: null,
|
||||
myBookingStatus: null,
|
||||
})
|
||||
expect(mockPrisma.timeSlot.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'slot-1' } }),
|
||||
)
|
||||
|
||||
@@ -32,7 +32,7 @@ export class TimeSlotService {
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
},
|
||||
myBooking: { id: string } | null,
|
||||
myBooking: { id: string; status: string } | null,
|
||||
): TimeSlotWithBookingStatus {
|
||||
return {
|
||||
id: slot.id,
|
||||
@@ -48,6 +48,7 @@ export class TimeSlotService {
|
||||
updatedAt: slot.updatedAt.toISOString(),
|
||||
isBookedByMe: myBooking !== null,
|
||||
myBookingId: myBooking?.id ?? null,
|
||||
myBookingStatus: (myBooking?.status as BookingStatus | undefined) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,9 +72,9 @@ export class TimeSlotService {
|
||||
? {
|
||||
where: {
|
||||
userId,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
|
||||
},
|
||||
select: { id: true },
|
||||
select: { id: true, status: true },
|
||||
}
|
||||
: false,
|
||||
},
|
||||
@@ -97,9 +98,9 @@ export class TimeSlotService {
|
||||
? {
|
||||
where: {
|
||||
userId,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
|
||||
},
|
||||
select: { id: true },
|
||||
select: { id: true, status: true },
|
||||
}
|
||||
: false,
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@ const makeUser = (overrides: Record<string, unknown> = {}) => ({
|
||||
nickname: 'Alice',
|
||||
avatarUrl: 'https://example.com/avatar.png',
|
||||
role: UserRole.MEMBER,
|
||||
adminBookingSubscriptionCount: 0,
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2024-01-01T00:00:00Z'),
|
||||
_count: { memberships: 2 },
|
||||
@@ -119,12 +120,20 @@ describe('UserService', () => {
|
||||
avatarUrl: 'https://example.com/avatar.png',
|
||||
role: UserRole.MEMBER,
|
||||
activeMembershipCount: 3,
|
||||
adminBookingSubscriptionCount: 0,
|
||||
subscriptionMessageTemplates: {
|
||||
templates: [
|
||||
{
|
||||
templateId: 'tmpl-booking-confirmed',
|
||||
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
||||
description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送',
|
||||
usageTarget: 'consent',
|
||||
},
|
||||
{
|
||||
templateId: 'tmpl-booking-confirmed',
|
||||
scene: SubscriptionMessageScene.ADMIN_BOOKING_CREATED,
|
||||
description: '管理员主动增加预约提醒次数,用于接收学员新预约通知',
|
||||
usageTarget: 'counter',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -255,7 +264,38 @@ describe('UserService', () => {
|
||||
expect(result.nickname).toBe('Bob')
|
||||
expect(result.avatarUrl).toBe('https://example.com/new.png')
|
||||
expect(result.activeMembershipCount).toBe(1)
|
||||
expect(result.subscriptionMessageTemplates.templates).toHaveLength(1)
|
||||
expect(result.adminBookingSubscriptionCount).toBe(0)
|
||||
expect(result.subscriptionMessageTemplates.templates).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('increments admin booking subscription count for admin users', async () => {
|
||||
mockPrisma.user.findUnique.mockResolvedValue(makeUser({
|
||||
role: UserRole.ADMIN,
|
||||
adminBookingSubscriptionCount: 2,
|
||||
}))
|
||||
mockPrisma.user.update.mockResolvedValue(makeUser({
|
||||
role: UserRole.ADMIN,
|
||||
adminBookingSubscriptionCount: 3,
|
||||
}))
|
||||
|
||||
const result = await service.grantAdminBookingSubscriptionCount('user-1')
|
||||
|
||||
expect(mockPrisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: 'user-1' },
|
||||
data: {
|
||||
adminBookingSubscriptionCount: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
memberships: { where: { status: MembershipStatus.ACTIVE } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(result.adminBookingSubscriptionCount).toBe(3)
|
||||
})
|
||||
|
||||
it('only includes provided fields in the update payload', async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Logger,
|
||||
} from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import { SubscriptionMessageScene } from '@mp-pilates/shared'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
|
||||
interface BookingConfirmedTemplatePayload {
|
||||
@@ -15,6 +16,15 @@ interface BookingConfirmedTemplatePayload {
|
||||
readonly bookingPeriod: string
|
||||
}
|
||||
|
||||
interface AdminBookingCreatedTemplatePayload {
|
||||
readonly openid: string
|
||||
readonly bookingId: string
|
||||
readonly bookingContent: string
|
||||
readonly bookingTime: string
|
||||
readonly courseName: string
|
||||
readonly bookingPeriod: string
|
||||
}
|
||||
|
||||
interface WechatAccessTokenResponse {
|
||||
access_token?: string
|
||||
expires_in?: number
|
||||
@@ -50,6 +60,60 @@ export class SubscriptionMessageService {
|
||||
}
|
||||
|
||||
async sendBookingConfirmedMessage(payload: BookingConfirmedTemplatePayload): Promise<boolean> {
|
||||
return this.sendConsentBasedBookingMessage(payload)
|
||||
}
|
||||
|
||||
async sendAdminBookingCreatedMessage(payload: AdminBookingCreatedTemplatePayload): Promise<boolean> {
|
||||
const templateId = this.getBookingConfirmedTemplateId()
|
||||
if (!templateId) {
|
||||
this.logger.warn('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED is not configured, skip sending admin subscription message')
|
||||
return false
|
||||
}
|
||||
|
||||
const adminUser = await this.prisma.user.findUnique({
|
||||
where: { openid: payload.openid },
|
||||
select: { id: true, adminBookingSubscriptionCount: true },
|
||||
})
|
||||
|
||||
if (!adminUser) {
|
||||
this.logger.warn(`Admin user not found for subscription send: ${stringifyDebugPayload({ openid: payload.openid, bookingId: payload.bookingId, templateId })}`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (adminUser.adminBookingSubscriptionCount <= 0) {
|
||||
this.logger.warn(`Admin subscription quota exhausted: ${stringifyDebugPayload({ userId: adminUser.id, bookingId: payload.bookingId, remainingCount: adminUser.adminBookingSubscriptionCount, templateId })}`)
|
||||
return false
|
||||
}
|
||||
|
||||
const sent = await this.sendWechatSubscribeMessage({
|
||||
openid: payload.openid,
|
||||
bookingId: payload.bookingId,
|
||||
templateId,
|
||||
payload,
|
||||
logContext: {
|
||||
target: 'admin',
|
||||
userId: adminUser.id,
|
||||
scene: SubscriptionMessageScene.ADMIN_BOOKING_CREATED,
|
||||
},
|
||||
})
|
||||
|
||||
if (!sent) {
|
||||
return false
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: adminUser.id },
|
||||
data: {
|
||||
adminBookingSubscriptionCount: {
|
||||
decrement: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private async sendConsentBasedBookingMessage(payload: BookingConfirmedTemplatePayload): Promise<boolean> {
|
||||
const templateId = this.getBookingConfirmedTemplateId()
|
||||
if (!templateId) {
|
||||
this.logger.warn('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED is not configured, skip sending subscription message')
|
||||
@@ -60,7 +124,7 @@ export class SubscriptionMessageService {
|
||||
where: {
|
||||
user: { openid: payload.openid },
|
||||
templateId,
|
||||
scene: 'BOOKING_CREATED',
|
||||
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
||||
acceptCount: { gt: 0 },
|
||||
totalRequestCount: { gt: 0 },
|
||||
},
|
||||
@@ -71,7 +135,7 @@ export class SubscriptionMessageService {
|
||||
})
|
||||
|
||||
if (!consent) {
|
||||
this.logger.warn(`No subscription quota found: ${stringifyDebugPayload({ openid: payload.openid, bookingId: payload.bookingId, templateId, scene: 'BOOKING_CREATED' })}`)
|
||||
this.logger.warn(`No subscription quota found: ${stringifyDebugPayload({ openid: payload.openid, bookingId: payload.bookingId, templateId, scene: SubscriptionMessageScene.BOOKING_CREATED })}`)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -80,21 +144,55 @@ export class SubscriptionMessageService {
|
||||
return false
|
||||
}
|
||||
|
||||
const sent = await this.sendWechatSubscribeMessage({
|
||||
openid: payload.openid,
|
||||
bookingId: payload.bookingId,
|
||||
templateId,
|
||||
payload,
|
||||
logContext: {
|
||||
target: 'member',
|
||||
consentId: consent.id,
|
||||
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
||||
},
|
||||
})
|
||||
|
||||
if (!sent) {
|
||||
return false
|
||||
}
|
||||
|
||||
await this.prisma.subscriptionMessageConsent.update({
|
||||
where: { id: consent.id },
|
||||
data: {
|
||||
sentCount: { increment: 1 },
|
||||
lastSentAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private async sendWechatSubscribeMessage(params: {
|
||||
openid: string
|
||||
bookingId: string
|
||||
templateId: string
|
||||
payload: BookingConfirmedTemplatePayload | AdminBookingCreatedTemplatePayload
|
||||
logContext: Record<string, unknown>
|
||||
}): Promise<boolean> {
|
||||
const accessToken = await this.getAccessToken()
|
||||
const page = `/pages/booking/detail?id=${payload.bookingId}`
|
||||
const page = `/pages/booking/detail?id=${params.bookingId}`
|
||||
const requestBody = {
|
||||
touser: payload.openid,
|
||||
template_id: templateId,
|
||||
touser: params.openid,
|
||||
template_id: params.templateId,
|
||||
page,
|
||||
data: {
|
||||
thing1: { value: payload.bookingContent.slice(0, 20) },
|
||||
time2: { value: payload.bookingTime.slice(0, 20) },
|
||||
thing25: { value: payload.courseName.slice(0, 20) },
|
||||
time35: { value: payload.bookingPeriod.slice(0, 20) },
|
||||
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.bookingPeriod.slice(0, 20) },
|
||||
},
|
||||
}
|
||||
|
||||
this.logger.log(`WeChat subscribe send request: ${stringifyDebugPayload({ bookingId: payload.bookingId, templateId, requestBody, consentId: consent.id })}`)
|
||||
this.logger.log(`WeChat subscribe send request: ${stringifyDebugPayload({ bookingId: params.bookingId, templateId: params.templateId, requestBody, ...params.logContext })}`)
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${accessToken}`,
|
||||
@@ -109,26 +207,17 @@ export class SubscriptionMessageService {
|
||||
|
||||
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: payload.bookingId, templateId, requestBody })}`)
|
||||
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('调用微信订阅消息接口失败')
|
||||
}
|
||||
|
||||
const result = (await response.json()) as WechatSubscribeSendResponse
|
||||
if (result.errcode && result.errcode !== 0) {
|
||||
this.logger.warn(`WeChat subscribe send failed: ${stringifyDebugPayload({ bookingId: payload.bookingId, templateId, requestBody, response: result, consentId: consent.id })}`)
|
||||
this.logger.warn(`WeChat subscribe send failed: ${stringifyDebugPayload({ bookingId: params.bookingId, templateId: params.templateId, requestBody, response: result, ...params.logContext })}`)
|
||||
return false
|
||||
}
|
||||
|
||||
this.logger.log(`WeChat subscribe send success: ${stringifyDebugPayload({ bookingId: payload.bookingId, templateId, response: result, consentId: consent.id })}`)
|
||||
|
||||
await this.prisma.subscriptionMessageConsent.update({
|
||||
where: { id: consent.id },
|
||||
data: {
|
||||
sentCount: { increment: 1 },
|
||||
lastSentAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
this.logger.log(`WeChat subscribe send success: ${stringifyDebugPayload({ bookingId: params.bookingId, templateId: params.templateId, response: result, ...params.logContext })}`)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,11 @@ export class UserController {
|
||||
return this.userService.reportSubscriptionMessageRequests(userId, dto.requests)
|
||||
}
|
||||
|
||||
@Post('user/subscription-messages/admin-booking-count')
|
||||
increaseAdminBookingSubscriptionCount(@CurrentUser('sub') userId: string) {
|
||||
return this.userService.grantAdminBookingSubscriptionCount(userId)
|
||||
}
|
||||
|
||||
// ─── Admin: Member Management ─────────────────────────────────────────────
|
||||
|
||||
@Get('admin/members')
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
SubscriptionMessageConsentSummary,
|
||||
SubscriptionMessageRequestItem,
|
||||
SubscriptionMessageRequestResult,
|
||||
SubscriptionMessageTemplate,
|
||||
SubscriptionMessageTemplateConfig,
|
||||
} from '@mp-pilates/shared'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
@@ -20,6 +21,7 @@ import { PrismaService } from '../prisma/prisma.service'
|
||||
import { UpdateUserMembershipDto } from './dto/update-user-membership.dto'
|
||||
|
||||
const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory))
|
||||
const ADMIN_BOOKING_SUBSCRIPTION_INCREMENT = 1
|
||||
|
||||
type SubscriptionMessageConsentDelegate = PrismaService['subscriptionMessageConsent']
|
||||
type SubscriptionMessageConsentRecord = Awaited<ReturnType<SubscriptionMessageConsentDelegate['findMany']>>[number]
|
||||
@@ -32,14 +34,46 @@ export class UserService {
|
||||
) {}
|
||||
|
||||
private buildSubscriptionTemplateConfig(): SubscriptionMessageTemplateConfig {
|
||||
const templates = [
|
||||
{
|
||||
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
|
||||
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
||||
description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送',
|
||||
usageTarget: 'consent' as const,
|
||||
},
|
||||
{
|
||||
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
|
||||
scene: SubscriptionMessageScene.ADMIN_BOOKING_CREATED,
|
||||
description: '管理员主动增加预约提醒次数,用于接收学员新预约通知',
|
||||
usageTarget: 'counter' as const,
|
||||
},
|
||||
] satisfies SubscriptionMessageTemplate[]
|
||||
|
||||
return {
|
||||
templates: [
|
||||
{
|
||||
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
|
||||
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
||||
description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送',
|
||||
},
|
||||
].filter((item) => item.templateId),
|
||||
templates: templates.filter((item) => item.templateId),
|
||||
}
|
||||
}
|
||||
|
||||
private mapProfile(user: {
|
||||
id: string
|
||||
phone: string | null
|
||||
nickname: string
|
||||
avatarUrl: string | null
|
||||
role: string
|
||||
adminBookingSubscriptionCount: number
|
||||
createdAt: Date
|
||||
_count: { memberships: number }
|
||||
}): UserProfileResponse {
|
||||
return {
|
||||
id: user.id,
|
||||
phone: user.phone,
|
||||
nickname: user.nickname,
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: user.role as UserRole,
|
||||
activeMembershipCount: user._count.memberships,
|
||||
adminBookingSubscriptionCount: user.adminBookingSubscriptionCount,
|
||||
subscriptionMessageTemplates: this.buildSubscriptionTemplateConfig(),
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,16 +95,7 @@ export class UserService {
|
||||
throw new NotFoundException('User not found')
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
phone: user.phone,
|
||||
nickname: user.nickname,
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: user.role as UserRole,
|
||||
activeMembershipCount: user._count.memberships,
|
||||
subscriptionMessageTemplates: this.buildSubscriptionTemplateConfig(),
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
}
|
||||
return this.mapProfile(user)
|
||||
}
|
||||
|
||||
async updateProfile(
|
||||
@@ -94,16 +119,7 @@ export class UserService {
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
phone: updated.phone,
|
||||
nickname: updated.nickname,
|
||||
avatarUrl: updated.avatarUrl,
|
||||
role: updated.role as UserRole,
|
||||
activeMembershipCount: updated._count.memberships,
|
||||
subscriptionMessageTemplates: this.buildSubscriptionTemplateConfig(),
|
||||
createdAt: updated.createdAt.toISOString(),
|
||||
}
|
||||
return this.mapProfile(updated)
|
||||
}
|
||||
|
||||
getSubscriptionMessageTemplates(): SubscriptionMessageTemplateConfig {
|
||||
@@ -181,6 +197,49 @@ export class UserService {
|
||||
}))
|
||||
}
|
||||
|
||||
async grantAdminBookingSubscriptionCount(userId: string): Promise<UserProfileResponse> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
memberships: {
|
||||
where: { status: MembershipStatus.ACTIVE },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found')
|
||||
}
|
||||
|
||||
if (user.role !== UserRole.ADMIN) {
|
||||
return this.mapProfile(user)
|
||||
}
|
||||
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
adminBookingSubscriptionCount: {
|
||||
increment: ADMIN_BOOKING_SUBSCRIPTION_INCREMENT,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
memberships: {
|
||||
where: { status: MembershipStatus.ACTIVE },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return this.mapProfile(updated)
|
||||
}
|
||||
|
||||
async getStats(userId: string): Promise<UserStatsResponse> {
|
||||
const completedBookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
|
||||
Reference in New Issue
Block a user