perf: 优化订阅刷新逻辑
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common'
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'
|
||||
import {
|
||||
MembershipStatus,
|
||||
BookingStatus,
|
||||
@@ -15,6 +15,10 @@ import type {
|
||||
SubscriptionMessageRequestResult,
|
||||
SubscriptionMessageTemplate,
|
||||
SubscriptionMessageTemplateConfig,
|
||||
AdminMemberSummary,
|
||||
AdminMemberDetail,
|
||||
MembershipWithCardType,
|
||||
UpdateAdminMemberProfileDto,
|
||||
} from '@mp-pilates/shared'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
@@ -23,6 +27,63 @@ import { UpdateUserMembershipDto } from './dto/update-user-membership.dto'
|
||||
const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory))
|
||||
const ADMIN_BOOKING_SUBSCRIPTION_INCREMENT = 1
|
||||
|
||||
function serializeMembership(membership: {
|
||||
id: string
|
||||
userId: string
|
||||
cardTypeId: string
|
||||
remainingTimes: number | null
|
||||
totalTimes: number | null
|
||||
startDate: Date
|
||||
expireDate: Date
|
||||
status: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
cardType: {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
totalTimes: number | null
|
||||
durationDays: number
|
||||
price: number | { toString(): string }
|
||||
originalPrice: number | { toString(): string } | null
|
||||
description: string | null
|
||||
coverUrl: string | null
|
||||
isActive: boolean
|
||||
sortOrder: number
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
}): MembershipWithCardType {
|
||||
const { cardType, ...rest } = membership
|
||||
return {
|
||||
id: rest.id,
|
||||
userId: rest.userId,
|
||||
cardTypeId: rest.cardTypeId,
|
||||
remainingTimes: rest.remainingTimes,
|
||||
totalTimes: rest.totalTimes,
|
||||
startDate: rest.startDate.toISOString(),
|
||||
expireDate: rest.expireDate.toISOString(),
|
||||
status: rest.status as MembershipStatus,
|
||||
createdAt: rest.createdAt.toISOString(),
|
||||
updatedAt: rest.updatedAt.toISOString(),
|
||||
cardType: {
|
||||
id: cardType.id,
|
||||
name: cardType.name,
|
||||
type: cardType.type as CardTypeCategory,
|
||||
totalTimes: cardType.totalTimes,
|
||||
durationDays: cardType.durationDays,
|
||||
price: Number(cardType.price),
|
||||
originalPrice: cardType.originalPrice == null ? null : Number(cardType.originalPrice),
|
||||
description: cardType.description,
|
||||
coverUrl: cardType.coverUrl,
|
||||
isActive: cardType.isActive,
|
||||
sortOrder: cardType.sortOrder,
|
||||
createdAt: cardType.createdAt.toISOString(),
|
||||
updatedAt: cardType.updatedAt.toISOString(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type SubscriptionMessageConsentDelegate = PrismaService['subscriptionMessageConsent']
|
||||
type SubscriptionMessageConsentRecord = Awaited<ReturnType<SubscriptionMessageConsentDelegate['findMany']>>[number]
|
||||
|
||||
@@ -298,16 +359,7 @@ export class UserService {
|
||||
limit: number,
|
||||
search?: string,
|
||||
cardType?: string,
|
||||
): Promise<PaginatedData<{
|
||||
userId: string
|
||||
openid: string
|
||||
nickname: string
|
||||
phone: string | null
|
||||
avatarUrl: string | null
|
||||
totalBookings: number
|
||||
completedBookings: number
|
||||
cancelledBookings: number
|
||||
}>> {
|
||||
): Promise<PaginatedData<AdminMemberSummary>> {
|
||||
const where: {
|
||||
OR?: Array<{ [key: string]: unknown }>
|
||||
memberships?: {
|
||||
@@ -348,6 +400,14 @@ export class UserService {
|
||||
nickname: true,
|
||||
phone: true,
|
||||
avatarUrl: true,
|
||||
createdAt: true,
|
||||
lastLoginAt: true,
|
||||
memberships: {
|
||||
where: { status: MembershipStatus.ACTIVE },
|
||||
include: { cardType: { select: { name: true, type: true } } },
|
||||
orderBy: { expireDate: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
bookings: true,
|
||||
@@ -383,12 +443,18 @@ export class UserService {
|
||||
|
||||
const items = users.map((u) => {
|
||||
const s = statsMap.get(u.id) ?? { total: 0, completed: 0, cancelled: 0 }
|
||||
const active = u.memberships[0]
|
||||
return {
|
||||
userId: u.id,
|
||||
openid: u.openid,
|
||||
nickname: u.nickname,
|
||||
phone: u.phone,
|
||||
avatarUrl: u.avatarUrl,
|
||||
createdAt: u.createdAt.toISOString(),
|
||||
lastLoginAt: u.lastLoginAt?.toISOString() ?? null,
|
||||
activeCard: active
|
||||
? { name: active.cardType.name, type: active.cardType.type as CardTypeCategory }
|
||||
: null,
|
||||
totalBookings: s.total,
|
||||
completedBookings: s.completed,
|
||||
cancelledBookings: s.cancelled,
|
||||
@@ -398,6 +464,109 @@ export class UserService {
|
||||
return { items, total, page, limit }
|
||||
}
|
||||
|
||||
async getMemberDetail(userId: string): Promise<AdminMemberDetail> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
memberships: {
|
||||
include: { cardType: true },
|
||||
orderBy: [{ createdAt: 'desc' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found')
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
today.setUTCHours(0, 0, 0, 0)
|
||||
|
||||
const [bookingStats, upcoming] = await Promise.all([
|
||||
this.prisma.booking.groupBy({
|
||||
by: ['status'],
|
||||
where: { userId },
|
||||
_count: { id: true },
|
||||
}),
|
||||
this.prisma.booking.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: {
|
||||
in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED],
|
||||
},
|
||||
timeSlot: { date: { gte: today } },
|
||||
},
|
||||
include: {
|
||||
timeSlot: true,
|
||||
membership: { include: { cardType: { select: { name: true } } } },
|
||||
},
|
||||
orderBy: [
|
||||
{ timeSlot: { date: 'asc' } },
|
||||
{ timeSlot: { startTime: 'asc' } },
|
||||
],
|
||||
}),
|
||||
])
|
||||
|
||||
const stats = { total: 0, completed: 0, cancelled: 0, noShow: 0 }
|
||||
for (const row of bookingStats) {
|
||||
stats.total += row._count.id
|
||||
if (row.status === BookingStatus.COMPLETED) stats.completed += row._count.id
|
||||
if (row.status === BookingStatus.CANCELLED) stats.cancelled += row._count.id
|
||||
if (row.status === BookingStatus.NO_SHOW) stats.noShow += row._count.id
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
userId: user.id,
|
||||
openid: user.openid,
|
||||
nickname: user.nickname,
|
||||
phone: user.phone,
|
||||
avatarUrl: user.avatarUrl,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
lastLoginAt: user.lastLoginAt?.toISOString() ?? null,
|
||||
},
|
||||
memberships: user.memberships.map((membership) => serializeMembership(membership)),
|
||||
stats: {
|
||||
totalBookings: stats.total,
|
||||
completedBookings: stats.completed,
|
||||
cancelledBookings: stats.cancelled,
|
||||
noShowBookings: stats.noShow,
|
||||
},
|
||||
upcomingBookings: upcoming.map((booking) => ({
|
||||
id: booking.id,
|
||||
status: booking.status as BookingStatus,
|
||||
date: booking.timeSlot.date.toISOString().slice(0, 10),
|
||||
startTime: booking.timeSlot.startTime,
|
||||
endTime: booking.timeSlot.endTime,
|
||||
cardName: booking.membership.cardType.name,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async updateMemberProfile(
|
||||
userId: string,
|
||||
dto: UpdateAdminMemberProfileDto,
|
||||
): Promise<AdminMemberDetail> {
|
||||
const existing = await this.prisma.user.findUnique({ where: { id: userId } })
|
||||
if (!existing) {
|
||||
throw new NotFoundException('User not found')
|
||||
}
|
||||
|
||||
const phone = dto.phone === undefined
|
||||
? undefined
|
||||
: (dto.phone?.trim() ? dto.phone.trim() : null)
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
...(dto.nickname !== undefined && { nickname: dto.nickname.trim() }),
|
||||
...(phone !== undefined && { phone }),
|
||||
},
|
||||
})
|
||||
|
||||
return this.getMemberDetail(userId)
|
||||
}
|
||||
|
||||
// ─── Membership management ────────────────────────────────────────────────
|
||||
|
||||
async getUserMembership(userId: string) {
|
||||
@@ -429,7 +598,15 @@ export class UserService {
|
||||
status,
|
||||
}
|
||||
|
||||
const existing = await this.prisma.membership.findFirst({ where: { userId } })
|
||||
const existing = dto.membershipId
|
||||
? await this.prisma.membership.findFirst({
|
||||
where: { id: dto.membershipId, userId },
|
||||
})
|
||||
: await this.prisma.membership.findFirst({ where: { userId } })
|
||||
|
||||
if (dto.membershipId && !existing) {
|
||||
throw new NotFoundException('Membership not found')
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
return this.prisma.membership.update({
|
||||
@@ -445,9 +622,20 @@ export class UserService {
|
||||
})
|
||||
}
|
||||
|
||||
async deleteUserMembership(userId: string): Promise<void> {
|
||||
await this.prisma.membership.updateMany({
|
||||
where: { userId },
|
||||
async deleteUserMembership(userId: string, membershipId: string): Promise<void> {
|
||||
if (!membershipId) {
|
||||
throw new BadRequestException('membershipId is required')
|
||||
}
|
||||
|
||||
const existing = await this.prisma.membership.findFirst({
|
||||
where: { id: membershipId, userId },
|
||||
})
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Membership not found')
|
||||
}
|
||||
|
||||
await this.prisma.membership.update({
|
||||
where: { id: existing.id },
|
||||
data: { status: MembershipStatus.EXPIRED },
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user