perf: 支持约课以及消息推送能力

This commit is contained in:
richarjiang
2026-04-12 21:44:44 +08:00
parent 9639f44698
commit c60821c5ff
28 changed files with 963 additions and 86 deletions

View File

@@ -19,3 +19,5 @@ API_BASE_URL=https://focus.richarjiang.com/
# Server
PORT=3000
WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED=antYfc85gvwImFZ9kM4UiqMOywJxbqFVgKHLH3NikII

View File

@@ -52,9 +52,9 @@
},
"jest": {
"moduleFileExtensions": [
"ts",
"js",
"json",
"ts"
"json"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
@@ -69,6 +69,7 @@
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"moduleNameMapper": {
"^@mp-pilates/shared$": "<rootDir>/../../shared/src/index.ts",
"^@mp-pilates/shared(.*)$": "<rootDir>/../../shared/src$1"
}
},

View File

@@ -80,10 +80,36 @@ model User {
bookings Booking[]
orders Order[]
flashSaleOrders FlashSaleOrder[]
subscriptionMessageConsents SubscriptionMessageConsent[]
@@map("users")
}
model SubscriptionMessageConsent {
id String @id @default(uuid())
userId String @map("user_id")
templateId String @map("template_id")
scene String
totalRequestCount Int @default(0) @map("total_request_count")
acceptCount Int @default(0) @map("accept_count")
rejectCount Int @default(0) @map("reject_count")
banCount Int @default(0) @map("ban_count")
filterCount Int @default(0) @map("filter_count")
sentCount Int @default(0) @map("sent_count")
lastResult String @map("last_result")
lastRequestedAt DateTime @default(now()) @map("last_requested_at")
lastSentAt DateTime? @map("last_sent_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
@@unique([userId, templateId, scene])
@@index([userId])
@@index([scene])
@@map("subscription_message_consents")
}
model CardType {
id String @id @default(uuid())
name String

View File

@@ -10,6 +10,7 @@ import { BookingService } from '../booking.service'
import { PrismaService } from '../../prisma/prisma.service'
import { MembershipService } from '../../membership/membership.service'
import { StudioService } from '../../studio/studio.service'
import { SubscriptionMessageService } from '../../user/subscription-message.service'
// ─── Fixtures ──────────────────────────────────────────────────────────────
@@ -138,6 +139,9 @@ function buildTxMock(overrides: Record<string, unknown> = {}) {
findUnique: jest.fn(),
update: jest.fn(),
},
bookingStatusHistory: {
create: jest.fn(),
},
...overrides,
}
}
@@ -148,6 +152,7 @@ describe('BookingService', () => {
let service: BookingService
let prisma: jest.Mocked<PrismaService>
let studioService: jest.Mocked<StudioService>
let subscriptionMessageService: { sendBookingConfirmedMessage: jest.Mock }
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -172,6 +177,9 @@ describe('BookingService', () => {
findUnique: jest.fn(),
update: jest.fn(),
},
user: {
findUnique: jest.fn(),
},
},
},
{
@@ -188,35 +196,91 @@ describe('BookingService', () => {
getInfo: jest.fn(),
},
},
{
provide: SubscriptionMessageService,
useValue: {
sendBookingConfirmedMessage: jest.fn(),
},
},
],
}).compile()
service = module.get<BookingService>(BookingService)
prisma = module.get(PrismaService) as jest.Mocked<PrismaService>
studioService = module.get(StudioService) as jest.Mocked<StudioService>
subscriptionMessageService = module.get(SubscriptionMessageService)
})
afterEach(() => jest.clearAllMocks())
describe('confirmBooking', () => {
it('sends booking confirmed subscription message after admin confirmation', async () => {
const tx = buildTxMock({
bookingStatusHistory: { create: jest.fn() },
})
tx.booking.findUnique.mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.PENDING_CONFIRMATION,
timeSlot: mockOpenSlot,
membership: mockActiveMembership,
})
tx.booking.update.mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.CONFIRMED,
confirmedAt: new Date('2099-12-30T00:00:00Z'),
})
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1, status: TimeSlotStatus.OPEN })
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 4 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.CONFIRMED,
confirmedAt: new Date('2099-12-30T00:00:00Z'),
timeSlot: mockOpenSlot,
membership: mockActiveMembership,
})
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ openid: 'openid-001' })
studioService.getInfo.mockResolvedValue({
...mockStudioConfig,
name: 'FocusCore Pilates',
})
subscriptionMessageService.sendBookingConfirmedMessage.mockResolvedValue(true)
await service.confirmBooking(MOCK_BOOKING_ID, 'admin-001')
expect(subscriptionMessageService.sendBookingConfirmedMessage).toHaveBeenCalledWith({
openid: 'openid-001',
bookingId: MOCK_BOOKING_ID,
bookingContent: '预约已确认',
bookingTime: '2099-12-31 09:00',
courseName: 'FocusCore Pilates',
bookingPeriod: '2099-12-31 09:00~10:00',
})
})
})
// ─── createBooking ────────────────────────────────────────────────────────
describe('createBooking', () => {
const dto = { timeSlotId: MOCK_SLOT_ID, membershipId: MOCK_MEMBERSHIP_ID }
it('creates booking, increments bookedCount, and deducts membership (TIMES card)', async () => {
it('creates booking in pending confirmation status', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(null) // no duplicate
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.booking.create.mockResolvedValue(mockConfirmedBooking)
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 4 })
tx.booking.create.mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.PENDING_CONFIRMATION,
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
// Mock the re-fetch after transaction
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.PENDING_CONFIRMATION,
timeSlot: mockOpenSlot,
membership: mockActiveMembership,
})
@@ -229,55 +293,45 @@ describe('BookingService', () => {
userId: MOCK_USER_ID,
timeSlotId: MOCK_SLOT_ID,
membershipId: MOCK_MEMBERSHIP_ID,
status: BookingStatus.CONFIRMED,
status: BookingStatus.PENDING_CONFIRMATION,
}),
}),
)
// bookedCount incremented from 0 → 1, still OPEN (capacity 5)
expect(tx.timeSlot.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ bookedCount: 1, status: TimeSlotStatus.OPEN }),
}),
)
// membership deducted from 5 → 4
expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
remainingTimes: 4,
status: MembershipStatus.ACTIVE,
}),
}),
)
expect(tx.timeSlot.update).not.toHaveBeenCalled()
expect(tx.membership.update).not.toHaveBeenCalled()
expect(result).toBeDefined()
})
it('sets slot to FULL when bookedCount reaches capacity', async () => {
it('records booking status history when user creates a booking', async () => {
const nearFullSlot = { ...mockOpenSlot, bookedCount: 4, capacity: 5 }
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(nearFullSlot)
tx.booking.findFirst.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.booking.create.mockResolvedValue(mockConfirmedBooking)
tx.timeSlot.update.mockResolvedValue({ ...nearFullSlot, bookedCount: 5, status: TimeSlotStatus.FULL })
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 4 })
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,
timeSlot: { ...nearFullSlot, status: TimeSlotStatus.FULL },
status: BookingStatus.PENDING_CONFIRMATION,
timeSlot: nearFullSlot,
membership: mockActiveMembership,
})
await service.createBooking(MOCK_USER_ID, dto)
// bookedCount 4+1 = 5 = capacity → FULL
expect(tx.timeSlot.update).toHaveBeenCalledWith(
expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ bookedCount: 5, status: TimeSlotStatus.FULL }),
data: expect.objectContaining({
toStatus: BookingStatus.PENDING_CONFIRMATION,
operatorId: MOCK_USER_ID,
}),
}),
)
})
@@ -306,34 +360,29 @@ describe('BookingService', () => {
expect(tx.membership.update).not.toHaveBeenCalled()
})
it('marks membership as USED_UP when remainingTimes hits 0', async () => {
const lastTimeMembership = { ...mockActiveMembership, remainingTimes: 1 }
it('allows time-based membership with zero remaining times and leaves deduction to admin confirmation', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(lastTimeMembership)
tx.booking.create.mockResolvedValue(mockConfirmedBooking)
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
tx.membership.update.mockResolvedValue({ ...lastTimeMembership, remainingTimes: 0, status: MembershipStatus.USED_UP })
tx.membership.findUnique.mockResolvedValue(mockMembershipNoTimes)
tx.booking.create.mockResolvedValue({
...mockConfirmedBooking,
membershipId: mockMembershipNoTimes.id,
status: BookingStatus.PENDING_CONFIRMATION,
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
membershipId: mockMembershipNoTimes.id,
status: BookingStatus.PENDING_CONFIRMATION,
timeSlot: mockOpenSlot,
membership: { ...lastTimeMembership, remainingTimes: 0, status: MembershipStatus.USED_UP },
membership: mockMembershipNoTimes,
})
await service.createBooking(MOCK_USER_ID, dto)
expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
remainingTimes: 0,
status: MembershipStatus.USED_UP,
}),
}),
)
expect(tx.membership.update).not.toHaveBeenCalled()
})
it('throws BadRequestException when slot is FULL', async () => {
@@ -374,20 +423,6 @@ describe('BookingService', () => {
)
})
it('throws BadRequestException when TIMES membership has 0 remaining', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockMembershipNoTimes)
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.createBooking(MOCK_USER_ID, dto)).rejects.toThrow(
BadRequestException,
)
expect(tx.booking.create).not.toHaveBeenCalled()
})
it('throws NotFoundException when timeSlot does not exist', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(null)
@@ -662,7 +697,7 @@ describe('BookingService', () => {
expect.objectContaining({
where: expect.objectContaining({
userId: MOCK_USER_ID,
status: BookingStatus.CONFIRMED,
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
}),
orderBy: [
{ timeSlot: { date: 'asc' } },

View File

@@ -3,9 +3,10 @@ import { BookingController } from './booking.controller'
import { BookingService } from './booking.service'
import { MembershipModule } from '../membership/membership.module'
import { StudioModule } from '../studio/studio.module'
import { UserModule } from '../user/user.module'
@Module({
imports: [MembershipModule, StudioModule],
imports: [MembershipModule, StudioModule, UserModule],
controllers: [BookingController],
providers: [BookingService],
exports: [BookingService],

View File

@@ -10,6 +10,7 @@ import { BookingStatus, CardTypeCategory, MembershipStatus, TimeSlotStatus } fro
import { PrismaService } from '../prisma/prisma.service'
import { MembershipService } from '../membership/membership.service'
import { StudioService } from '../studio/studio.service'
import { SubscriptionMessageService } from '../user/subscription-message.service'
import { CreateBookingDto } from './dto/create-booking.dto'
// ─── Types ─────────────────────────────────────────────────────────────────
@@ -48,6 +49,7 @@ export class BookingService {
private readonly prisma: PrismaService,
private readonly membershipService: MembershipService,
private readonly studioService: StudioService,
private readonly subscriptionMessageService: SubscriptionMessageService,
) {}
// ─── Create Booking ──────────────────────────────────────────────────────
@@ -235,7 +237,9 @@ export class BookingService {
return updated
})
return this.fetchBookingWithRelations(booking.id)
const confirmedBooking = await this.fetchBookingWithRelations(booking.id)
await this.trySendBookingConfirmedSubscriptionMessage(confirmedBooking)
return confirmedBooking
}
// ─── Complete / NoShow Booking (Admin) ──────────────────────────────────
@@ -566,4 +570,34 @@ export class BookingService {
return { ...booking } as BookingWithRelations
}
private async trySendBookingConfirmedSubscriptionMessage(
booking: BookingWithRelations,
): 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 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)}`
await this.subscriptionMessageService.sendBookingConfirmedMessage({
openid: user.openid,
bookingId: booking.id,
bookingContent: '预约已确认',
bookingTime: `${dateLabel} ${booking.timeSlot.startTime.slice(0, 5)}`,
courseName: studio.name || '普拉提课程',
bookingPeriod: `${dateLabel} ${periodLabel}`,
})
} catch (error) {
console.error('Send booking confirmed subscription message failed:', error)
}
}
}

View File

@@ -2,7 +2,13 @@ import { Test, TestingModule } from '@nestjs/testing'
import { NotFoundException } from '@nestjs/common'
import { UserService } from '../user.service'
import { PrismaService } from '../../prisma/prisma.service'
import { MembershipStatus, BookingStatus, UserRole } from '@mp-pilates/shared'
import {
MembershipStatus,
BookingStatus,
UserRole,
SubscriptionMessageScene,
} from '@mp-pilates/shared'
import { ConfigService } from '@nestjs/config'
// ---------------------------------------------------------------------------
// Helpers
@@ -48,11 +54,22 @@ const mockPrisma = {
findUnique: jest.fn(),
update: jest.fn(),
},
subscriptionMessageConsent: {
upsert: jest.fn(),
findMany: jest.fn(),
},
booking: {
findMany: jest.fn(),
},
}
const mockConfigService = {
get: jest.fn((key: string, defaultValue = '') => {
if (key === 'WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED') return 'tmpl-booking-confirmed'
return defaultValue
}),
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -65,6 +82,7 @@ describe('UserService', () => {
providers: [
UserService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile()
@@ -101,6 +119,15 @@ describe('UserService', () => {
avatarUrl: 'https://example.com/avatar.png',
role: UserRole.MEMBER,
activeMembershipCount: 3,
subscriptionMessageTemplates: {
templates: [
{
templateId: 'tmpl-booking-confirmed',
scene: SubscriptionMessageScene.BOOKING_CREATED,
description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送',
},
],
},
createdAt: new Date('2024-01-01T00:00:00Z').toISOString(),
})
})
@@ -112,6 +139,89 @@ describe('UserService', () => {
})
})
describe('reportSubscriptionMessageRequests', () => {
it('aggregates and returns subscription consent stats', async () => {
mockPrisma.subscriptionMessageConsent.upsert.mockResolvedValue(undefined)
mockPrisma.subscriptionMessageConsent.findMany.mockResolvedValue([
{
userId: 'user-1',
templateId: 'tmpl-booking-confirmed',
scene: SubscriptionMessageScene.BOOKING_CREATED,
totalRequestCount: 2,
acceptCount: 1,
rejectCount: 1,
banCount: 0,
filterCount: 0,
sentCount: 0,
lastResult: 'reject',
lastRequestedAt: new Date('2024-01-03T00:00:00Z'),
lastSentAt: null,
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-03T00:00:00Z'),
},
])
const result = await service.reportSubscriptionMessageRequests('user-1', [
{
templateId: 'tmpl-booking-confirmed',
scene: SubscriptionMessageScene.BOOKING_CREATED,
result: 'reject',
},
])
expect(mockPrisma.subscriptionMessageConsent.upsert).toHaveBeenCalledWith({
where: {
userId_templateId_scene: {
userId: 'user-1',
templateId: 'tmpl-booking-confirmed',
scene: SubscriptionMessageScene.BOOKING_CREATED,
},
},
create: {
userId: 'user-1',
templateId: 'tmpl-booking-confirmed',
scene: SubscriptionMessageScene.BOOKING_CREATED,
totalRequestCount: 1,
acceptCount: 0,
rejectCount: 1,
banCount: 0,
filterCount: 0,
sentCount: 0,
lastResult: 'reject',
lastRequestedAt: expect.any(Date),
},
update: {
totalRequestCount: { increment: 1 },
acceptCount: { increment: 0 },
rejectCount: { increment: 1 },
banCount: { increment: 0 },
filterCount: { increment: 0 },
lastResult: 'reject',
lastRequestedAt: expect.any(Date),
},
})
expect(result).toEqual([
{
userId: 'user-1',
templateId: 'tmpl-booking-confirmed',
scene: SubscriptionMessageScene.BOOKING_CREATED,
totalRequestCount: 2,
acceptCount: 1,
rejectCount: 1,
banCount: 0,
filterCount: 0,
sentCount: 0,
lastResult: 'reject',
lastRequestedAt: '2024-01-03T00:00:00.000Z',
lastSentAt: null,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-03T00:00:00.000Z',
},
])
})
})
// -------------------------------------------------------------------------
// updateProfile
// -------------------------------------------------------------------------
@@ -145,6 +255,7 @@ 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)
})
it('only includes provided fields in the update payload', async () => {

View File

@@ -0,0 +1,34 @@
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsEnum,
IsIn,
IsString,
ValidateNested,
} from 'class-validator'
import { Type } from 'class-transformer'
import {
SubscriptionMessageScene,
SUBSCRIPTION_MESSAGE_REQUEST_RESULTS,
} from '@mp-pilates/shared'
export class SubscriptionMessageRequestItemDto {
@IsString()
readonly templateId!: string
@IsEnum(SubscriptionMessageScene)
readonly scene!: SubscriptionMessageScene
@IsIn(SUBSCRIPTION_MESSAGE_REQUEST_RESULTS)
readonly result!: (typeof SUBSCRIPTION_MESSAGE_REQUEST_RESULTS)[number]
}
export class ReportSubscriptionMessageRequestDto {
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(10)
@ValidateNested({ each: true })
@Type(() => SubscriptionMessageRequestItemDto)
readonly requests!: SubscriptionMessageRequestItemDto[]
}

View File

@@ -0,0 +1,168 @@
import {
Injectable,
InternalServerErrorException,
Logger,
} from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { PrismaService } from '../prisma/prisma.service'
interface BookingConfirmedTemplatePayload {
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
errcode?: number
errmsg?: string
}
interface WechatSubscribeSendResponse {
errcode?: number
errmsg?: string
}
function stringifyDebugPayload(payload: unknown): string {
try {
return JSON.stringify(payload)
} catch {
return String(payload)
}
}
@Injectable()
export class SubscriptionMessageService {
private readonly logger = new Logger(SubscriptionMessageService.name)
private accessTokenCache: { token: string; expireAt: number } | null = null
constructor(
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
) {}
getBookingConfirmedTemplateId(): string {
return this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', '')
}
async sendBookingConfirmedMessage(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')
return false
}
const consent = await this.prisma.subscriptionMessageConsent.findFirst({
where: {
user: { openid: payload.openid },
templateId,
scene: 'BOOKING_CREATED',
acceptCount: { gt: 0 },
totalRequestCount: { gt: 0 },
},
orderBy: [
{ lastRequestedAt: 'desc' },
{ updatedAt: 'desc' },
],
})
if (!consent) {
this.logger.warn(`No subscription quota found: ${stringifyDebugPayload({ openid: payload.openid, bookingId: payload.bookingId, templateId, scene: 'BOOKING_CREATED' })}`)
return false
}
if (consent.sentCount >= consent.acceptCount) {
this.logger.warn(`Subscription quota exhausted: ${stringifyDebugPayload({ consentId: consent.id, bookingId: payload.bookingId, sentCount: consent.sentCount, acceptCount: consent.acceptCount, templateId })}`)
return false
}
const accessToken = await this.getAccessToken()
const page = `/pages/booking/detail?id=${payload.bookingId}`
const requestBody = {
touser: payload.openid,
template_id: 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) },
},
}
this.logger.log(`WeChat subscribe send request: ${stringifyDebugPayload({ bookingId: payload.bookingId, templateId, requestBody, consentId: consent.id })}`)
const response = await fetch(
`https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${accessToken}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
},
)
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 })}`)
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 })}`)
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(),
},
})
return true
}
private async getAccessToken(): Promise<string> {
const now = Date.now()
if (this.accessTokenCache && this.accessTokenCache.expireAt > now) {
return this.accessTokenCache.token
}
const appId = this.configService.getOrThrow<string>('WX_APPID')
const secret = this.configService.getOrThrow<string>('WX_SECRET')
const response = await fetch(
`https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appId}&secret=${secret}`,
)
if (!response.ok) {
const responseText = await response.text()
this.logger.error(`WeChat access_token http error: ${stringifyDebugPayload({ status: response.status, statusText: response.statusText, body: responseText })}`)
throw new InternalServerErrorException('获取微信 access_token 失败')
}
const data = (await response.json()) as WechatAccessTokenResponse
if (!data.access_token || !data.expires_in) {
this.logger.error(`WeChat access_token invalid response: ${stringifyDebugPayload(data)}`)
throw new InternalServerErrorException(data.errmsg || '微信 access_token 返回异常')
}
this.logger.log(`WeChat access_token refreshed: ${stringifyDebugPayload({ expiresIn: data.expires_in })}`)
this.accessTokenCache = {
token: data.access_token,
expireAt: now + Math.max(data.expires_in - 300, 60) * 1000,
}
return data.access_token
}
}

View File

@@ -6,6 +6,7 @@ import {
Body,
Param,
Query,
Post,
UseGuards,
} from '@nestjs/common'
import { UserRole, CardTypeCategory } from '@mp-pilates/shared'
@@ -16,6 +17,7 @@ import { CurrentUser } from '../common/decorators/current-user.decorator'
import { UserService } from './user.service'
import { UpdateProfileDto } from './dto/update-profile.dto'
import { UpdateUserMembershipDto } from './dto/update-user-membership.dto'
import { ReportSubscriptionMessageRequestDto } from './dto/report-subscription-message.dto'
const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory))
@@ -42,6 +44,19 @@ export class UserController {
return this.userService.getStats(userId)
}
@Get('user/subscription-messages/templates')
getSubscriptionMessageTemplates() {
return this.userService.getSubscriptionMessageTemplates()
}
@Post('user/subscription-messages/report')
reportSubscriptionMessageRequests(
@CurrentUser('sub') userId: string,
@Body() dto: ReportSubscriptionMessageRequestDto,
) {
return this.userService.reportSubscriptionMessageRequests(userId, dto.requests)
}
// ─── Admin: Member Management ─────────────────────────────────────────────
@Get('admin/members')

View File

@@ -1,12 +1,14 @@
import { Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { AuthModule } from '../auth/auth.module'
import { UserController } from './user.controller'
import { UserService } from './user.service'
import { SubscriptionMessageService } from './subscription-message.service'
@Module({
imports: [AuthModule],
imports: [AuthModule, ConfigModule],
controllers: [UserController],
providers: [UserService],
exports: [UserService],
providers: [UserService, SubscriptionMessageService],
exports: [UserService, SubscriptionMessageService],
})
export class UserModule {}

View File

@@ -1,14 +1,47 @@
import { Injectable, NotFoundException } from '@nestjs/common'
import { MembershipStatus, BookingStatus, UserRole, CardTypeCategory } from '@mp-pilates/shared'
import type { PaginatedData, UserProfileResponse, UserStatsResponse } from '@mp-pilates/shared'
import {
MembershipStatus,
BookingStatus,
UserRole,
CardTypeCategory,
SubscriptionMessageScene,
} from '@mp-pilates/shared'
import type {
PaginatedData,
UserProfileResponse,
UserStatsResponse,
SubscriptionMessageConsentSummary,
SubscriptionMessageRequestItem,
SubscriptionMessageRequestResult,
SubscriptionMessageTemplateConfig,
} from '@mp-pilates/shared'
import { ConfigService } from '@nestjs/config'
import { PrismaService } from '../prisma/prisma.service'
import { UpdateUserMembershipDto } from './dto/update-user-membership.dto'
const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory))
type SubscriptionMessageConsentDelegate = PrismaService['subscriptionMessageConsent']
type SubscriptionMessageConsentRecord = Awaited<ReturnType<SubscriptionMessageConsentDelegate['findMany']>>[number]
@Injectable()
export class UserService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly configService: ConfigService,
) {}
private buildSubscriptionTemplateConfig(): SubscriptionMessageTemplateConfig {
return {
templates: [
{
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
scene: SubscriptionMessageScene.BOOKING_CREATED,
description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送',
},
].filter((item) => item.templateId),
}
}
async getProfile(userId: string): Promise<UserProfileResponse> {
const user = await this.prisma.user.findUnique({
@@ -35,6 +68,7 @@ export class UserService {
avatarUrl: user.avatarUrl,
role: user.role as UserRole,
activeMembershipCount: user._count.memberships,
subscriptionMessageTemplates: this.buildSubscriptionTemplateConfig(),
createdAt: user.createdAt.toISOString(),
}
}
@@ -67,10 +101,86 @@ export class UserService {
avatarUrl: updated.avatarUrl,
role: updated.role as UserRole,
activeMembershipCount: updated._count.memberships,
subscriptionMessageTemplates: this.buildSubscriptionTemplateConfig(),
createdAt: updated.createdAt.toISOString(),
}
}
getSubscriptionMessageTemplates(): SubscriptionMessageTemplateConfig {
return this.buildSubscriptionTemplateConfig()
}
async reportSubscriptionMessageRequests(
userId: string,
requests: readonly SubscriptionMessageRequestItem[],
): Promise<SubscriptionMessageConsentSummary[]> {
if (requests.length === 0) {
return []
}
await Promise.all(
requests.map((item) => this.prisma.subscriptionMessageConsent.upsert({
where: {
userId_templateId_scene: {
userId,
templateId: item.templateId,
scene: item.scene,
},
},
create: {
userId,
templateId: item.templateId,
scene: item.scene,
totalRequestCount: 1,
acceptCount: item.result === 'accept' ? 1 : 0,
rejectCount: item.result === 'reject' ? 1 : 0,
banCount: item.result === 'ban' ? 1 : 0,
filterCount: item.result === 'filter' ? 1 : 0,
sentCount: 0,
lastResult: item.result,
lastRequestedAt: new Date(),
},
update: {
totalRequestCount: { increment: 1 },
acceptCount: { increment: item.result === 'accept' ? 1 : 0 },
rejectCount: { increment: item.result === 'reject' ? 1 : 0 },
banCount: { increment: item.result === 'ban' ? 1 : 0 },
filterCount: { increment: item.result === 'filter' ? 1 : 0 },
lastResult: item.result,
lastRequestedAt: new Date(),
},
})),
)
const summaries = await this.prisma.subscriptionMessageConsent.findMany({
where: {
userId,
OR: requests.map((item) => ({
templateId: item.templateId,
scene: item.scene,
})),
},
orderBy: { updatedAt: 'desc' },
})
return summaries.map((item: SubscriptionMessageConsentRecord) => ({
userId: item.userId,
templateId: item.templateId,
scene: item.scene as SubscriptionMessageScene,
totalRequestCount: item.totalRequestCount,
acceptCount: item.acceptCount,
rejectCount: item.rejectCount,
banCount: item.banCount,
filterCount: item.filterCount,
sentCount: item.sentCount,
lastResult: item.lastResult as SubscriptionMessageRequestResult,
lastRequestedAt: item.lastRequestedAt.toISOString(),
lastSentAt: item.lastSentAt?.toISOString() ?? null,
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
}))
}
async getStats(userId: string): Promise<UserStatsResponse> {
const completedBookings = await this.prisma.booking.findMany({
where: {