feat: 支持课后评价与成长档案

完成后即可评价并订阅提醒,馆主可记录体测、笔记与成长照片,首页展示匿名星级均分。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
richarjiang
2026-09-09 15:55:48 +08:00
parent c3e46f7ffa
commit 139882d7a1
50 changed files with 1307 additions and 15 deletions

View File

@@ -11,3 +11,6 @@ COS_REGION=ap-guangzhou
COS_PUBLIC_BASE_URL=https://plates-1251306435.cos.ap-guangzhou.myqcloud.com
COS_UPLOAD_PREFIX=mp/studio
COS_UPLOAD_DURATION_SECONDS=1800
# WeChat subscribe message for class review reminders (24h after completion)
WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW=

View File

@@ -0,0 +1,85 @@
-- AlterTable
ALTER TABLE `bookings` ADD COLUMN `review_reminder_claimed_at` DATETIME(3) NULL,
ADD COLUMN `review_reminder_due_at` DATETIME(3) NULL,
ADD COLUMN `review_reminder_sent_at` DATETIME(3) NULL;
-- CreateTable
CREATE TABLE `booking_reviews` (
`id` VARCHAR(191) NOT NULL,
`booking_id` VARCHAR(191) NOT NULL,
`rating` INTEGER NOT NULL,
`recommendation` INTEGER NULL,
`tags` JSON NOT NULL,
`comment` VARCHAR(200) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `booking_reviews_booking_id_key`(`booking_id`),
INDEX `booking_reviews_created_at_idx`(`created_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `body_metrics` (
`id` VARCHAR(191) NOT NULL,
`user_id` VARCHAR(191) NOT NULL,
`recorded_at` DATE NOT NULL,
`weight` DOUBLE NULL,
`body_fat` DOUBLE NULL,
`waist` DOUBLE NULL,
`hip` DOUBLE NULL,
`flexibility` DOUBLE NULL,
`remark` VARCHAR(200) NOT NULL DEFAULT '',
`operator_id` VARCHAR(191) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `body_metrics_user_id_recorded_at_idx`(`user_id`, `recorded_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `member_notes` (
`id` VARCHAR(191) NOT NULL,
`user_id` VARCHAR(191) NOT NULL,
`booking_id` VARCHAR(191) NULL,
`content` VARCHAR(1000) NOT NULL,
`shared` BOOLEAN NOT NULL DEFAULT false,
`operator_id` VARCHAR(191) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `member_notes_user_id_created_at_idx`(`user_id`, `created_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `progress_photos` (
`id` VARCHAR(191) NOT NULL,
`user_id` VARCHAR(191) NOT NULL,
`object_key` VARCHAR(191) NOT NULL,
`caption` VARCHAR(200) NOT NULL DEFAULT '',
`recorded_at` DATE NOT NULL,
`uploaded_at` DATETIME(3) NULL,
`consented_at` DATETIME(3) NULL,
`revoked_at` DATETIME(3) NULL,
`operator_id` VARCHAR(191) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `progress_photos_object_key_key`(`object_key`),
INDEX `progress_photos_user_id_recorded_at_idx`(`user_id`, `recorded_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `booking_reviews` ADD CONSTRAINT `booking_reviews_booking_id_fkey` FOREIGN KEY (`booking_id`) REFERENCES `bookings`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `body_metrics` ADD CONSTRAINT `body_metrics_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `member_notes` ADD CONSTRAINT `member_notes_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `member_notes` ADD CONSTRAINT `member_notes_booking_id_fkey` FOREIGN KEY (`booking_id`) REFERENCES `bookings`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `progress_photos` ADD CONSTRAINT `progress_photos_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -85,6 +85,9 @@ model User {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
bodyMetrics BodyMetric[]
memberNotes MemberNote[]
progressPhotos ProgressPhoto[]
lessonSupplements LessonSupplement[]
memberships Membership[]
bookings Booking[]
@@ -226,6 +229,11 @@ model Booking {
membership Membership @relation(fields: [membershipId], references: [id])
qualifiedInviteReferrals InviteReferral[]
review BookingReview?
memberNotes MemberNote[]
reviewReminderDueAt DateTime? @map("review_reminder_due_at")
reviewReminderClaimedAt DateTime? @map("review_reminder_claimed_at")
reviewReminderSentAt DateTime? @map("review_reminder_sent_at")
statusHistory BookingStatusHistory[]
@@unique([userId, timeSlotId])
@@ -402,3 +410,63 @@ model LessonSupplement {
@@index([userId, revokedAt, createdAt])
@@map("lesson_supplements")
}
model BookingReview {
id String @id @default(uuid())
bookingId String @unique @map("booking_id")
rating Int
recommendation Int?
tags Json
comment String @db.VarChar(200)
createdAt DateTime @default(now()) @map("created_at")
booking Booking @relation(fields: [bookingId], references: [id])
@@index([createdAt])
@@map("booking_reviews")
}
model BodyMetric {
id String @id @default(uuid())
userId String @map("user_id")
recordedAt DateTime @db.Date @map("recorded_at")
weight Float?
bodyFat Float? @map("body_fat")
waist Float?
hip Float?
flexibility Float?
remark String @default("") @db.VarChar(200)
operatorId String @map("operator_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
@@index([userId, recordedAt])
@@map("body_metrics")
}
model MemberNote {
id String @id @default(uuid())
userId String @map("user_id")
bookingId String? @map("booking_id")
content String @db.VarChar(1000)
shared Boolean @default(false)
operatorId String @map("operator_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
booking Booking? @relation(fields: [bookingId], references: [id])
@@index([userId, createdAt])
@@map("member_notes")
}
model ProgressPhoto {
id String @id @default(uuid())
userId String @map("user_id")
objectKey String @unique @map("object_key")
caption String @default("") @db.VarChar(200)
recordedAt DateTime @db.Date @map("recorded_at")
uploadedAt DateTime? @map("uploaded_at")
consentedAt DateTime? @map("consented_at")
revokedAt DateTime? @map("revoked_at")
operatorId String @map("operator_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
@@index([userId, recordedAt])
@@map("progress_photos")
}

View File

@@ -84,7 +84,7 @@ describe('AuthService', () => {
jest.clearAllMocks()
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
mockPrismaService.membership.count.mockResolvedValue(0)
mockConfigService.get.mockReturnValue('tmpl-booking-confirmed')
mockConfigService.get.mockImplementation((key: string) => key === 'WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED' ? 'tmpl-booking-confirmed' : '')
})
// ── login ──────────────────────────────────────────────────────────────────

View File

@@ -71,6 +71,7 @@ export class AuthService {
private buildSubscriptionTemplateConfig(): SubscriptionMessageTemplateConfig {
const templates = [
{ templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW', ''), scene: SubscriptionMessageScene.CLASS_REVIEW, description: '课程完成 24 小时后提醒评价', usageTarget: 'consent' as const },
{
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
scene: SubscriptionMessageScene.BOOKING_CREATED,

View File

@@ -408,6 +408,12 @@ describe('BookingService', () => {
await service.completeBooking(MOCK_BOOKING_ID, 'admin-001')
expect(tx.booking.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
status: BookingStatus.COMPLETED,
reviewReminderDueAt: expect.any(Date),
}),
}))
expect(inviteService.recordQualifiedTrialBooking).toHaveBeenCalledWith(MOCK_BOOKING_ID)
})
})

View File

@@ -0,0 +1,69 @@
import { ReviewService, summarizeReviews } from '../review.service'
import { PrismaService } from '../../prisma/prisma.service'
import { Prisma } from '@prisma/client'
import { CreateReviewDto } from '../dto/create-review.dto'
import { validate } from 'class-validator'
import 'reflect-metadata'
import { Reflector } from '@nestjs/core'
import { ExecutionContext } from '@nestjs/common'
import { GUARDS_METADATA } from '@nestjs/common/constants'
import { ReviewController, PublicReviewController } from '../review.controller'
import { JwtAuthGuard } from '../../auth/jwt-auth.guard'
import { RolesGuard } from '../../auth/roles.guard'
describe('Class reviews', () => {
const db = { booking: { findFirst: jest.fn() }, bookingReview: { create: jest.fn(), findMany: jest.fn(), count: jest.fn() } }
let service: ReviewService
beforeEach(() => { jest.resetAllMocks(); service = new ReviewService(db as unknown as PrismaService) })
const dto = { rating: 5, tags: ['氛围好'], comment: '很有收获' }
it('does not expose another member booking or review', async () => {
db.booking.findFirst.mockResolvedValue(null)
await expect(service.get('other', 'booking')).rejects.toThrow('预约不存在')
expect(db.booking.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'booking', userId: 'other' } }))
await expect(service.create('other', 'booking', dto)).rejects.toThrow('预约不存在')
expect(db.bookingReview.create).not.toHaveBeenCalled()
})
it.each(['CONFIRMED', 'NO_SHOW', 'CANCELLED', 'PENDING_CONFIRMATION'])('rejects %s booking', async status => {
db.booking.findFirst.mockResolvedValue({ status })
await expect(service.create('member', 'booking', dto)).rejects.toThrow('完成课程后才能评价')
})
it('allows older completed lessons, with database uniqueness protecting double submits', async () => {
db.booking.findFirst.mockResolvedValue({ status: 'COMPLETED', completedAt: new Date('2020-01-01') })
db.bookingReview.create.mockResolvedValueOnce({ id: 'review' }).mockRejectedValueOnce(new Prisma.PrismaClientKnownRequestError('duplicate', { code: 'P2002', clientVersion: '5' }))
await expect(service.create('member', 'booking', dto)).resolves.toEqual({ id: 'review' })
await expect(service.create('member', 'booking', dto)).rejects.toThrow('已经评价')
})
it.each([{ rating: 0 }, { rating: 6 }, { rating: 2.5 }, { tags: ['伪造标签'] }, { tags: ['氛围好', '氛围好'] }, { tags: ['动作到位','氛围好','强度合适','讲解清晰'] }, { recommendation: 11 }, { recommendation: -1 }, { comment: '长'.repeat(201) }])('rejects invalid payload %j', async patch => {
expect((await validate(Object.assign(new CreateReviewDto(), dto, patch))).length).toBeGreaterThan(0)
})
it('accepts optional recommendation including zero', async () => {
expect(await validate(Object.assign(new CreateReviewDto(), dto, { recommendation: 0 }))).toHaveLength(0)
})
it('keeps star average separate from NPS and handles zero samples', () => {
expect(summarizeReviews([])).toEqual({ count: 0, average: null, nps: null, npsCount: 0 })
expect(summarizeReviews([{ rating: 5, recommendation: 0 }, { rating: 1, recommendation: 9 }, { rating: 4, recommendation: 7 }, { rating: 4, recommendation: null }])).toEqual({ count: 4, average: 3.5, nps: 0, npsCount: 3 })
})
it('groups the six Chinese calendar months across year and UTC boundaries', async () => {
db.bookingReview.findMany.mockResolvedValue([{ rating: 5, recommendation: 10, createdAt: new Date('2025-12-31T16:00:00Z') }, { rating: 1, recommendation: 0, createdAt: new Date('2025-12-31T15:59:59Z') }])
const result = await service.trend('2026-01')
expect(result.map(r => r.month)).toEqual(['2025-08','2025-09','2025-10','2025-11','2025-12','2026-01'])
expect(result[4].average).toBe(1); expect(result[5].average).toBe(5)
expect(db.bookingReview.findMany.mock.calls[0][0].where.createdAt).toEqual({ gte: new Date('2025-07-31T16:00:00Z'), lt: new Date('2026-01-31T16:00:00Z') })
})
})
describe('Review authorization', () => {
it('requires authentication for member and admin review endpoints', () => {
expect(Reflect.getMetadata(GUARDS_METADATA, ReviewController)).toContain(JwtAuthGuard)
})
it('keeps the public summary unauthenticated', () => {
expect(Reflect.getMetadata(GUARDS_METADATA, PublicReviewController) || []).not.toContain(JwtAuthGuard)
})
it.each(['list', 'trend'] as const)('restricts %s to admins', method => {
const handler = ReviewController.prototype[method]
expect(Reflect.getMetadata(GUARDS_METADATA, handler)).toContain(RolesGuard)
const guard = new RolesGuard(new Reflector())
const context = (role: string) => ({ getHandler: () => handler, getClass: () => ReviewController, switchToHttp: () => ({ getRequest: () => ({ user: { role } }) }) }) as unknown as ExecutionContext
expect(guard.canActivate(context('MEMBER'))).toBe(false)
expect(guard.canActivate(context('ADMIN'))).toBe(true)
})
})

View File

@@ -1,3 +1,5 @@
import { ReviewController, PublicReviewController } from './review.controller'
import { ReviewService } from './review.service'
import { Module } from '@nestjs/common'
import { BookingController } from './booking.controller'
import { BookingService } from './booking.service'
@@ -8,8 +10,8 @@ import { InviteModule } from '../invite/invite.module'
@Module({
imports: [MembershipModule, StudioModule, UserModule, InviteModule],
controllers: [BookingController],
providers: [BookingService],
controllers: [BookingController, ReviewController, PublicReviewController],
providers: [BookingService, ReviewService],
exports: [BookingService],
})
export class BookingModule {}

View File

@@ -183,6 +183,7 @@ export class BookingService {
include: {
timeSlot: true,
membership: { include: { cardType: true } },
review: { select: { rating: true } },
},
})
@@ -455,6 +456,7 @@ export class BookingService {
}
if (toStatus === BookingStatus.COMPLETED) {
updateData.completedAt = new Date()
updateData.reviewReminderDueAt = new Date(Date.now() + 24 * 3600000)
}
const updated = await tx.booking.update({
@@ -493,6 +495,7 @@ export class BookingService {
include: {
timeSlot: true,
membership: { include: { cardType: true } },
review: { select: { rating: true } },
},
})
@@ -628,6 +631,7 @@ export class BookingService {
include: {
timeSlot: true,
membership: { include: { cardType: true } },
review: { select: { rating: true } },
user: { select: { id: true, nickname: true, phone: true } },
},
})
@@ -653,6 +657,7 @@ export class BookingService {
include: {
timeSlot: true,
membership: { include: { cardType: true } },
review: { select: { rating: true } },
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
@@ -714,6 +719,7 @@ export class BookingService {
include: {
timeSlot: true,
membership: { include: { cardType: true } },
review: { select: { rating: true } },
},
orderBy: [
{ timeSlot: { date: 'asc' } },
@@ -740,6 +746,7 @@ export class BookingService {
user: { select: { id: true, nickname: true, phone: true } },
timeSlot: true,
membership: { include: { cardType: true } },
review: { select: { rating: true } },
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
@@ -842,6 +849,7 @@ export class BookingService {
include: {
timeSlot: true,
membership: { include: { cardType: true } },
review: { select: { rating: true } },
},
})

View File

@@ -0,0 +1,8 @@
import { ArrayMaxSize, ArrayUnique, IsArray, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'
import { REVIEW_TAGS } from '@mp-pilates/shared'
export class CreateReviewDto {
@IsInt() @Min(1) @Max(5) rating!: number
@IsOptional() @IsInt() @Min(0) @Max(10) recommendation?: number
@IsArray() @ArrayMaxSize(3) @ArrayUnique() @IsIn(REVIEW_TAGS, { each: true }) tags!: string[]
@IsString() @MaxLength(200) comment!: string
}

View File

@@ -0,0 +1,27 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'
import { UserRole } from '@mp-pilates/shared'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { RolesGuard } from '../auth/roles.guard'
import { Roles } from '../auth/roles.decorator'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { ReviewService } from './review.service'
import { CreateReviewDto } from './dto/create-review.dto'
@Controller()
@UseGuards(JwtAuthGuard)
export class ReviewController {
constructor(private readonly service: ReviewService) {}
@Get('booking/:id/review')
get(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.get(userId, id) }
@Post('booking/:id/review')
create(@CurrentUser('sub') userId: string, @Param('id') id: string, @Body() dto: CreateReviewDto) { return this.service.create(userId, id, dto) }
@Get('admin/reviews') @UseGuards(RolesGuard) @Roles(UserRole.ADMIN)
list(@Query('userId') userId?: string, @Query('page') page?: string) { return this.service.list(userId, page ? Number(page) : 1) }
@Get('admin/reviews/trend') @UseGuards(RolesGuard) @Roles(UserRole.ADMIN)
trend(@Query('month') month?: string) { return this.service.trend(month) }
}
@Controller('reviews')
export class PublicReviewController {
constructor(private readonly service: ReviewService) {}
@Get('summary') summary() { return this.service.publicSummary() }
}

View File

@@ -0,0 +1,61 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
import { Prisma } from '@prisma/client'
import { PrismaService } from '../prisma/prisma.service'
import { CreateReviewDto } from './dto/create-review.dto'
export function summarizeReviews(rows: { rating: number; recommendation: number | null }[]) {
const recommendations = rows.filter(r => r.recommendation !== null)
return {
count: rows.length,
average: rows.length ? Math.round(rows.reduce((sum, r) => sum + r.rating, 0) / rows.length * 10) / 10 : null,
npsCount: recommendations.length,
nps: recommendations.length ? Math.round(100 * (recommendations.filter(r => r.recommendation! >= 9).length - recommendations.filter(r => r.recommendation! <= 6).length) / recommendations.length) : null,
}
}
@Injectable()
export class ReviewService {
constructor(private readonly prisma: PrismaService) {}
async get(userId: string, bookingId: string) {
const booking = await this.prisma.booking.findFirst({ where: { id: bookingId, userId }, include: { review: true } })
if (!booking) throw new NotFoundException('预约不存在')
return { review: booking.review, canReview: booking.status === 'COMPLETED' && !booking.review }
}
async create(userId: string, bookingId: string, dto: CreateReviewDto) {
const booking = await this.prisma.booking.findFirst({ where: { id: bookingId, userId } })
if (!booking) throw new NotFoundException('预约不存在')
if (booking.status !== 'COMPLETED') throw new BadRequestException('完成课程后才能评价')
try {
return await this.prisma.bookingReview.create({ data: { bookingId, rating: dto.rating, recommendation: dto.recommendation, tags: dto.tags, comment: dto.comment.trim() } })
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') throw new BadRequestException('这节课已经评价过了')
throw error
}
}
async publicSummary() {
const result = await this.prisma.bookingReview.aggregate({ _count: { id: true }, _avg: { rating: true } })
return { count: result._count.id, average: result._avg.rating === null ? null : Math.round(result._avg.rating * 10) / 10 }
}
async list(userId?: string, page = 1) {
if (!Number.isInteger(page) || page < 1 || page > 10000) throw new BadRequestException('页码无效')
const where = userId ? { booking: { userId } } : {}
const [data, total] = await Promise.all([
this.prisma.bookingReview.findMany({ where, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], skip: (page - 1) * 20, take: 20,
include: { booking: { select: { userId: true, user: { select: { nickname: true } }, timeSlot: { select: { date: true, startTime: true, endTime: true } } } } } }),
this.prisma.bookingReview.count({ where }),
])
return { data, total, page, limit: 20 }
}
async trend(month?: string) {
const current = month || new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 7)
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(current)) throw new BadRequestException('月份格式应为 YYYY-MM')
const end = new Date(current + '-01T00:00:00+08:00')
const months = Array.from({ length: 6 }, (_, i) => {
const date = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth() + 1 - (5 - i), 1))
return date.toISOString().slice(0, 7)
})
const from = new Date(months[0] + '-01T00:00:00+08:00')
const to = new Date(Date.UTC(Number(current.slice(0, 4)), Number(current.slice(5)), 1) - 8 * 3600000)
const rows = await this.prisma.bookingReview.findMany({ where: { createdAt: { gte: from, lt: to } }, select: { rating: true, recommendation: true, createdAt: true } })
return months.map(month => ({ month, ...summarizeReviews(rows.filter(r => new Date(r.createdAt.getTime() + 8 * 3600000).toISOString().startsWith(month))) }))
}
}

View File

@@ -0,0 +1,29 @@
import { ReviewReminderService } from '../review-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'
describe('Review reminder queue', () => {
const db = { booking: { findMany: jest.fn(), updateMany: jest.fn(), update: jest.fn() } }
const messages = { sendReviewReminder: jest.fn() }, config = { get: jest.fn() }
let service: ReviewReminderService
beforeEach(() => {
jest.resetAllMocks()
config.get.mockReturnValue('tmpl')
service = new ReviewReminderService(db as unknown as PrismaService, messages as unknown as SubscriptionMessageService, config as unknown as ConfigService)
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => {})
db.booking.findMany.mockResolvedValue([{ id: 'b', userId: 'u', user: { openid: 'o' } }])
})
afterEach(() => jest.restoreAllMocks())
it('does not consume queue when template is unconfigured', async () => { config.get.mockReturnValue(''); await service.run(); expect(db.booking.findMany).not.toHaveBeenCalled() })
it('queries only due, unreviewed completed lessons and atomically claims a booking', async () => {
db.booking.updateMany.mockResolvedValue({ count: 1 }); messages.sendReviewReminder.mockResolvedValue(true)
await service.run()
const where = db.booking.findMany.mock.calls[0][0].where
expect(where.status).toBe('COMPLETED'); expect(where.review).toBeNull(); expect(where.reviewReminderDueAt.lte).toBeInstanceOf(Date)
expect(db.booking.updateMany.mock.calls[0][0].where).toEqual({ id: 'b', status: 'COMPLETED', review: null, reviewReminderClaimedAt: null })
expect(db.booking.update).toHaveBeenCalledWith({ where: { id: 'b' }, data: { reviewReminderSentAt: expect.any(Date) } })
})
it('does not send if another worker claimed or member already reviewed', async () => { db.booking.updateMany.mockResolvedValue({ count: 0 }); await service.run(); expect(messages.sendReviewReminder).not.toHaveBeenCalled() })
it('does not mark a failed or unknown send as delivered or automatically release it', async () => { db.booking.updateMany.mockResolvedValue({ count: 1 }); messages.sendReviewReminder.mockRejectedValue(new Error('timeout')); await service.run(); expect(db.booking.update).not.toHaveBeenCalled(); expect(db.booking.updateMany).toHaveBeenCalledTimes(1) })
})

View File

@@ -1,3 +1,4 @@
import { FlashSaleService } from '../../flash-sale/flash-sale.service'
import { Test, TestingModule } from '@nestjs/testing'
import { Logger } from '@nestjs/common'
import { SchedulerService } from '../scheduler.service'
@@ -33,6 +34,7 @@ describe('SchedulerService', () => {
providers: [
SchedulerService,
{ provide: SlotGeneratorService, useValue: mockSlotGenerator },
{ provide: FlashSaleService, useValue: { expireUnpaidReservations: jest.fn() } },
],
}).compile()

View File

@@ -0,0 +1,25 @@
import { Injectable, Logger } from '@nestjs/common'
import { Cron } from '@nestjs/schedule'
import { ConfigService } from '@nestjs/config'
import { PrismaService } from '../prisma/prisma.service'
import { SubscriptionMessageService } from '../user/subscription-message.service'
@Injectable()
export class ReviewReminderService {
private readonly logger = new Logger(ReviewReminderService.name)
constructor(private readonly prisma: PrismaService, private readonly messages: SubscriptionMessageService, private readonly config: ConfigService) {}
@Cron('*/5 * * * *')
async run() {
const templateId = this.config.get('WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW')
if (!templateId) return
const rows = await this.prisma.booking.findMany({ where: { status: 'COMPLETED', review: null, reviewReminderDueAt: { lte: new Date(), gte: new Date(Date.now() - 7 * 86400000) }, reviewReminderClaimedAt: null }, include: { user: { select: { openid: true } } }, orderBy: { reviewReminderDueAt: 'asc' }, take: 100 })
for (const row of rows) {
try {
const claimed = await this.prisma.booking.updateMany({ where: { id: row.id, status: 'COMPLETED', review: null, reviewReminderClaimedAt: null }, data: { reviewReminderClaimedAt: new Date() } })
if (!claimed.count) continue
if (await this.messages.sendReviewReminder(row.userId, row.user.openid, row.id)) {
await this.prisma.booking.update({ where: { id: row.id }, data: { reviewReminderSentAt: new Date() } })
}
} catch (error) { this.logger.error(`评价提醒未发送或结果未知: ${row.id}`, error) }
}
}
}

View File

@@ -1,3 +1,6 @@
import { UserModule } from '../user/user.module'
import { ConfigModule } from '@nestjs/config'
import { ReviewReminderService } from './review-reminder.service'
import { Module } from '@nestjs/common'
import { ScheduleModule } from '@nestjs/schedule'
import { TimeSlotModule } from '../time-slot/time-slot.module'
@@ -7,9 +10,10 @@ import { SchedulerService } from './scheduler.service'
@Module({
imports: [
ScheduleModule.forRoot(),
UserModule, ConfigModule,
TimeSlotModule,
FlashSaleModule,
],
providers: [SchedulerService],
providers: [SchedulerService, ReviewReminderService],
})
export class SchedulerModule {}

View File

@@ -54,10 +54,11 @@ export class StudioUploadService {
}
}
private buildPostPolicy(params: {
protected buildPostPolicy(params: {
bucket: string
key: string
expiresAt: number
privateRead?: boolean
}): Record<string, string> {
const secretId = this.getRequiredConfig('COS_SECRET_ID')
const secretKey = this.getRequiredConfig('COS_SECRET_KEY')
@@ -66,6 +67,7 @@ export class StudioUploadService {
expiration: new Date(params.expiresAt * 1000).toISOString(),
conditions: [
{ bucket: params.bucket },
...(params.privateRead ? [{ 'x-cos-acl': 'private' }] : []),
['eq', '$key', params.key],
{ success_action_status: '200' },
{ 'q-sign-algorithm': 'sha1' },
@@ -87,6 +89,7 @@ export class StudioUploadService {
return {
key: params.key,
...(params.privateRead ? { 'x-cos-acl': 'private' } : {}),
policy: policyBase64,
success_action_status: '200',
'q-sign-algorithm': 'sha1',
@@ -121,7 +124,7 @@ export class StudioUploadService {
return `${startTime};${expiresAt}`
}
private resolveExtension(fileName: string, contentType?: string): string {
protected resolveExtension(fileName: string, contentType?: string): string {
const cleanedName = fileName.trim().toLowerCase()
const fileExtension = cleanedName.includes('.')
? cleanedName.split('.').pop() ?? ''
@@ -158,7 +161,7 @@ export class StudioUploadService {
.replace(/^\/+|\/+$/g, '')
}
private getRequiredConfig(key: string): string {
protected getRequiredConfig(key: string): string {
const value = this.configService.get<string>(key)?.trim()
if (!value) {

View File

@@ -217,7 +217,7 @@ describe('SlotGeneratorService', () => {
where: expect.objectContaining({
status: BookingStatus.CONFIRMED,
}),
data: { status: BookingStatus.COMPLETED },
data: { status: BookingStatus.COMPLETED, completedAt: expect.any(Date), reviewReminderDueAt: expect.any(Date) },
}),
)
})

View File

@@ -144,7 +144,7 @@ export class SlotGeneratorService {
date: { lt: today },
},
},
data: { status: BookingStatus.COMPLETED },
data: { status: BookingStatus.COMPLETED, completedAt: new Date(), reviewReminderDueAt: new Date(Date.now() + 24 * 3600000) },
})
this.logger.log(`Completed ${result.count} past bookings`)

View File

@@ -0,0 +1,102 @@
import { MemberProgressService } from '../member-progress.service'
import { PrismaService } from '../../prisma/prisma.service'
import { ProgressPhotoStorageService } from '../progress-photo-storage.service'
import { BodyMetricDto } from '../dto/member-progress.dto'
import { validate } from 'class-validator'
import 'reflect-metadata'
import { Reflector } from '@nestjs/core'
import { ExecutionContext } from '@nestjs/common'
import { GUARDS_METADATA } from '@nestjs/common/constants'
import { MemberProgressController, AdminMemberProgressController } from '../member-progress.controller'
import { JwtAuthGuard } from '../../auth/jwt-auth.guard'
import { RolesGuard } from '../../auth/roles.guard'
describe('Member progress permissions and measurements', () => {
const db = { user: { findUnique: jest.fn() }, booking: { count: jest.fn(), findFirst: jest.fn() }, lessonSupplement: { aggregate: jest.fn() }, bodyMetric: { findMany: jest.fn(), create: jest.fn(), deleteMany: jest.fn() }, memberNote: { findMany: jest.fn(), create: jest.fn(), deleteMany: jest.fn() }, progressPhoto: { findMany: jest.fn(), findFirst: jest.fn(), updateMany: jest.fn(), update: jest.fn(), create: jest.fn(), delete: jest.fn() } }
const storage = { signedUrl: jest.fn(), credential: jest.fn(), verify: jest.fn(), removeObject: jest.fn() }
let service: MemberProgressService
beforeEach(() => { jest.resetAllMocks(); service = new MemberProgressService(db as unknown as PrismaService, storage as unknown as ProgressPhotoStorageService); db.user.findUnique.mockResolvedValue({ nickname: '学员' }); db.booking.count.mockResolvedValue(8); db.lessonSupplement.aggregate.mockResolvedValue({ _sum: { quantity: 3 } }); db.bodyMetric.findMany.mockResolvedValue([]); db.memberNote.findMany.mockResolvedValue([]); db.progressPhoto.findMany.mockResolvedValue([]) })
it('filters private notes on the server, strips photo keys and counts only valid supplements', async () => {
const result = await service.archive('member')
expect(db.memberNote.findMany.mock.calls[0][0].where).toEqual({ userId: 'member', shared: true })
expect(db.progressPhoto.findMany.mock.calls[0][0].select.objectKey).toBeUndefined()
expect(db.lessonSupplement.aggregate.mock.calls[0][0].where).toEqual({ userId: 'member', revokedAt: null })
expect(result.completedCount).toBe(11); expect(result.milestones).toEqual([10])
await service.archive('member', true)
expect(db.memberNote.findMany.mock.calls[1][0].where).toEqual({ userId: 'member' })
})
it('rejects missing metrics, future and invalid dates; preserves negative flexibility', async () => {
await expect(service.addMetric('member', 'admin', { recordedAt: '2025-01-01' })).rejects.toThrow('至少填写')
await expect(service.addMetric('member', 'admin', { recordedAt: '2099-01-01', weight: 50 })).rejects.toThrow('日期')
await expect(service.addMetric('member', 'admin', { recordedAt: '2025-02-30', weight: 50 })).rejects.toThrow('日期')
await service.addMetric('member', 'admin', { recordedAt: '2025-01-01', flexibility: -3 })
expect(db.bodyMetric.create.mock.calls[0][0].data.flexibility).toBe(-3)
expect(db.bodyMetric.create.mock.calls[0][0].data.weight).toBeUndefined()
})
it('rejects non-finite, out of range and nonnumeric measurements', async () => {
for (const weight of [0, -10, 501, Infinity, '50']) expect((await validate(Object.assign(new BodyMetricDto(), { recordedAt: '2025-01-01', weight }))).length).toBeGreaterThan(0)
})
it('only annotates the target member completed booking', async () => {
db.booking.findFirst.mockResolvedValue(null)
await expect(service.addNote('member', 'admin', { content: 'note', shared: false, bookingId: 'foreign' })).rejects.toThrow('只能批注')
expect(db.booking.findFirst.mock.calls[0][0].where).toEqual({ id: 'foreign', userId: 'member', status: 'COMPLETED' })
expect(db.memberNote.create).not.toHaveBeenCalled()
})
it('never signs another member photo', async () => {
db.progressPhoto.findFirst.mockResolvedValue(null)
await expect(service.photoUrl('other', 'photo')).rejects.toThrow('照片不存在')
expect(storage.signedUrl).not.toHaveBeenCalled()
})
it('lets a member preview for informed consent but blocks admin until authorized', async () => {
db.progressPhoto.findFirst.mockResolvedValue({ objectKey: 'private/photo', consentedAt: null })
storage.signedUrl.mockReturnValue('short-lived')
await expect(service.photoUrl('member', 'photo', true)).rejects.toThrow('尚未授权')
await expect(service.photoUrl('member', 'photo')).resolves.toEqual({ url: 'short-lived', expiresIn: 60 })
})
it('consent and revocation are scoped to current member and uploaded photo', async () => {
db.progressPhoto.updateMany.mockResolvedValue({ count: 1 })
await service.consent('member', 'photo', false)
expect(db.progressPhoto.updateMany.mock.calls[0][0]).toEqual({ where: { id: 'photo', userId: 'member', uploadedAt: { not: null } }, data: { consentedAt: null, revokedAt: expect.any(Date) } })
})
it('requires confirmed storage upload before exposing a photo record', async () => {
db.progressPhoto.findFirst.mockResolvedValue({ objectKey: 'key' })
storage.verify.mockRejectedValue(new Error('not uploaded'))
await expect(service.finishUpload('member', 'photo')).rejects.toThrow('not uploaded')
expect(db.progressPhoto.update).not.toHaveBeenCalled()
})
it('deletes a photo from private storage and the database, scoped to the member', async () => {
db.progressPhoto.findFirst.mockResolvedValue({ id: 'photo', objectKey: 'progress/member/a.jpg' })
await service.remove('member', 'photos', 'photo')
expect(storage.removeObject).toHaveBeenCalledWith('progress/member/a.jpg')
expect(db.progressPhoto.delete).toHaveBeenCalledWith({ where: { id: 'photo' } })
})
it('does not delete the database row if storage deletion fails', async () => {
db.progressPhoto.findFirst.mockResolvedValue({ id: 'photo', objectKey: 'progress/member/a.jpg' })
storage.removeObject.mockRejectedValue(new Error('cos down'))
await expect(service.remove('member', 'photos', 'photo')).rejects.toThrow('cos down')
expect(db.progressPhoto.delete).not.toHaveBeenCalled()
})
it('does not delete another member metric, note or photo', async () => {
db.bodyMetric.deleteMany.mockResolvedValue({ count: 0 })
db.memberNote.deleteMany.mockResolvedValue({ count: 0 })
db.progressPhoto.findFirst.mockResolvedValue(null)
await expect(service.remove('other', 'metrics', 'm')).rejects.toThrow('记录不存在')
await expect(service.remove('other', 'notes', 'n')).rejects.toThrow('记录不存在')
await expect(service.remove('other', 'photos', 'p')).rejects.toThrow('照片不存在')
expect(storage.removeObject).not.toHaveBeenCalled()
})
})
describe('Member progress authorization', () => {
it('requires authentication on member and admin progress controllers', () => {
expect(Reflect.getMetadata(GUARDS_METADATA, MemberProgressController)).toContain(JwtAuthGuard)
expect(Reflect.getMetadata(GUARDS_METADATA, AdminMemberProgressController)).toContain(JwtAuthGuard)
expect(Reflect.getMetadata(GUARDS_METADATA, AdminMemberProgressController)).toContain(RolesGuard)
})
it('restricts admin progress reads to admins', () => {
const handler = AdminMemberProgressController.prototype.archive
const guard = new RolesGuard(new Reflector())
const context = (role: string) => ({ getHandler: () => handler, getClass: () => AdminMemberProgressController, switchToHttp: () => ({ getRequest: () => ({ user: { role } }) }) }) as unknown as ExecutionContext
expect(guard.canActivate(context('MEMBER'))).toBe(false)
expect(guard.canActivate(context('ADMIN'))).toBe(true)
})
})

View File

@@ -0,0 +1,59 @@
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { ProgressPhotoStorageService } from '../progress-photo-storage.service'
describe('Progress photo private storage', () => {
const config = { get: jest.fn() }
let service: ProgressPhotoStorageService
const originalFetch = global.fetch
const defaults = {
COS_BUCKET: 'plates-1251306435',
COS_REGION: 'ap-guangzhou',
COS_SECRET_ID: 'id',
COS_SECRET_KEY: 'key',
}
const values: Record<string, string> = { ...defaults }
beforeEach(() => {
jest.resetAllMocks()
Object.assign(values, defaults)
config.get.mockImplementation((key: string) => values[key] || '')
service = new ProgressPhotoStorageService(config as unknown as ConfigService)
})
afterEach(() => { global.fetch = originalFetch })
it('signs a private object under the member prefix in the shared studio bucket', () => {
const credential = service.credential('member-1', 'pose.PNG')
expect(credential.key).toMatch(/^progress\/member-1\/[0-9a-f-]+\.png$/)
expect(credential.uploadUrl).toBe('https://plates-1251306435.cos.ap-guangzhou.myqcloud.com')
expect(credential.formData['x-cos-acl']).toBe('private')
expect(credential.formData.key).toBe(credential.key)
})
it('treats missing or oversized objects and non-images as client errors after HEAD succeeds', async () => {
const fetchMock = jest.fn()
global.fetch = fetchMock as unknown as typeof fetch
fetchMock.mockResolvedValueOnce({ ok: true, headers: new Headers({ 'content-length': '0' }) })
await expect(service.verify('progress/member/a.jpg')).rejects.toBeInstanceOf(BadRequestException)
fetchMock.mockResolvedValueOnce({ ok: true, headers: new Headers({ 'content-length': String(11 * 1024 * 1024), 'content-type': 'image/jpeg' }) })
await expect(service.verify('progress/member/a.jpg')).rejects.toThrow('图片大小无效')
fetchMock.mockResolvedValueOnce({ ok: true, headers: new Headers({ 'content-length': '123', 'content-type': 'application/pdf' }) })
await expect(service.verify('progress/member/a.jpg')).rejects.toThrow('仅支持图片文件')
})
it('allows verify when COS omits content-length for an image object', async () => {
global.fetch = jest.fn().mockResolvedValue({ ok: true, headers: new Headers({ 'content-type': 'image/jpeg' }) }) as unknown as typeof fetch
await expect(service.verify('progress/member/a.jpg')).resolves.toBeUndefined()
})
it('treats a missing COS object as retryable and ignores 404 on delete', async () => {
const fetchMock = jest.fn()
global.fetch = fetchMock as unknown as typeof fetch
fetchMock.mockResolvedValueOnce({ ok: false, status: 404, headers: new Headers() })
await expect(service.verify('progress/member/a.jpg')).rejects.toBeInstanceOf(ServiceUnavailableException)
fetchMock.mockResolvedValueOnce({ ok: false, status: 404 })
await expect(service.removeObject('progress/member/a.jpg')).resolves.toBeUndefined()
fetchMock.mockResolvedValueOnce({ ok: false, status: 500 })
await expect(service.removeObject('progress/member/a.jpg')).rejects.toThrow('照片删除失败')
})
})

View File

@@ -0,0 +1,27 @@
import { buildClassReviewSubscribeData } from '../subscription-message.service'
describe('Class review subscribe payload', () => {
it('fills thing1 course, thing2 coach, time3 class time and thing4 tip', () => {
expect(buildClassReviewSubscribeData({
studioName: 'Focus Core 普拉提工作室',
date: new Date('2026-09-09T00:00:00.000Z'),
startTime: '10:00:00',
})).toEqual({
thing1: { value: 'Focus Core 普拉提工作室' },
thing2: { value: 'Iris' },
time3: { value: '2026年09月09日 10:00' },
thing4: { value: '欢迎留下这节课的感受' },
})
})
it('falls back to a short course name and trims thing fields to 20 characters', () => {
const data = buildClassReviewSubscribeData({
studioName: '超长工作室名称用来测试微信订阅消息字段截断是否生效',
date: new Date('2026-01-02T00:00:00.000Z'),
startTime: '09:30',
})
expect(data.thing1.value).toHaveLength(20)
expect(data.thing1.value).toBe('超长工作室名称用来测试微信订阅消息字段截断是否生效'.slice(0, 20))
expect(data.time3.value).toBe('2026年01月02日 09:30')
})
})

View File

@@ -0,0 +1,21 @@
import { IsBoolean, IsDateString, IsNumber, IsOptional, IsString, Max, MaxLength, Min, MinLength, Matches } from 'class-validator'
export class BodyMetricDto {
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/) recordedAt!: string
@IsOptional() @IsNumber() @Min(1) @Max(500) weight?: number
@IsOptional() @IsNumber() @Min(1) @Max(75) bodyFat?: number
@IsOptional() @IsNumber() @Min(10) @Max(300) waist?: number
@IsOptional() @IsNumber() @Min(10) @Max(300) hip?: number
@IsOptional() @IsNumber() @Min(-50) @Max(100) flexibility?: number
@IsOptional() @IsString() @MaxLength(200) remark?: string
}
export class MemberNoteDto {
@IsString() @MinLength(1) @MaxLength(1000) content!: string
@IsBoolean() shared!: boolean
@IsOptional() @IsString() bookingId?: string
}
export class ProgressPhotoDto {
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/) recordedAt!: string
@IsString() @MaxLength(200) caption!: string
@IsString() @MaxLength(100) fileName!: string
}
export class PhotoConsentDto { @IsBoolean() consent!: boolean }

View File

@@ -0,0 +1,27 @@
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common'
import { UserRole } from '@mp-pilates/shared'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { RolesGuard } from '../auth/roles.guard'
import { Roles } from '../auth/roles.decorator'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { MemberProgressService } from './member-progress.service'
import { BodyMetricDto, MemberNoteDto, PhotoConsentDto, ProgressPhotoDto } from './dto/member-progress.dto'
@Controller('user/progress') @UseGuards(JwtAuthGuard)
export class MemberProgressController {
constructor(private readonly service: MemberProgressService) {}
@Get() archive(@CurrentUser('sub') userId: string) { return this.service.archive(userId) }
@Post('photos/:id/consent') consent(@CurrentUser('sub') userId: string, @Param('id') id: string, @Body() dto: PhotoConsentDto) { return this.service.consent(userId, id, dto.consent) }
@Get('photos/:id/url') url(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.photoUrl(userId, id) }
@Delete('photos/:id') removePhoto(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.remove(userId, 'photos', id) }
}
@Controller('admin/members/:userId/progress') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN)
export class AdminMemberProgressController {
constructor(private readonly service: MemberProgressService) {}
@Get() archive(@Param('userId') userId: string) { return this.service.archive(userId, true) }
@Post('metrics') metric(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: BodyMetricDto) { return this.service.addMetric(userId, operatorId, dto) }
@Post('notes') note(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: MemberNoteDto) { return this.service.addNote(userId, operatorId, dto) }
@Delete(':kind/:id') remove(@Param('userId') userId: string, @Param('kind') kind: string, @Param('id') id: string) { return this.service.remove(userId, kind, id) }
@Post('photos/upload') upload(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: ProgressPhotoDto) { return this.service.upload(userId, operatorId, dto) }
@Post('photos/:id/complete') complete(@Param('userId') userId: string, @Param('id') id: string) { return this.service.finishUpload(userId, id) }
@Get('photos/:id/url') url(@Param('userId') userId: string, @Param('id') id: string) { return this.service.photoUrl(userId, id, true) }
}

View File

@@ -0,0 +1,87 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'
import { PrismaService } from '../prisma/prisma.service'
import { BodyMetricDto, MemberNoteDto, ProgressPhotoDto } from './dto/member-progress.dto'
import { ProgressPhotoStorageService } from './progress-photo-storage.service'
@Injectable()
export class MemberProgressService {
constructor(private readonly prisma: PrismaService, private readonly storage: ProgressPhotoStorageService) {}
private async member(userId: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { nickname: true } })
if (!user) throw new NotFoundException('学员不存在')
return user
}
private date(value: string) {
const date = new Date(value + 'T00:00:00.000Z')
if (!Number.isFinite(date.getTime()) || date.toISOString().slice(0, 10) !== value || value > new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10)) throw new BadRequestException('请选择有效的记录日期,不能晚于今天')
return date
}
async archive(userId: string, admin = false) {
const user = await this.member(userId)
const [count, supplements, metrics, notes, photos] = await Promise.all([
this.prisma.booking.count({ where: { userId, status: 'COMPLETED' } }),
this.prisma.lessonSupplement.aggregate({ where: { userId, revokedAt: null }, _sum: { quantity: true } }),
this.prisma.bodyMetric.findMany({ where: { userId }, orderBy: [{ recordedAt: 'desc' }, { createdAt: 'desc' }] }),
this.prisma.memberNote.findMany({ where: { userId, ...(admin ? {} : { shared: true }) }, orderBy: { createdAt: 'desc' }, include: { booking: { select: { timeSlot: { select: { date: true, startTime: true } } } } } }),
this.prisma.progressPhoto.findMany({ where: { userId, uploadedAt: { not: null } }, orderBy: { recordedAt: 'desc' }, select: { id: true, caption: true, recordedAt: true, consentedAt: true, revokedAt: true } }),
])
const completedCount = count + (supplements._sum.quantity || 0)
return { nickname: user.nickname, completedCount, milestones: [10, 30, 50].filter(n => completedCount >= n), metrics, notes, photos }
}
async addMetric(userId: string, operatorId: string, dto: BodyMetricDto) {
await this.member(userId)
if ([dto.weight, dto.bodyFat, dto.waist, dto.hip, dto.flexibility].every(v => v == null)) throw new BadRequestException('请至少填写一项体测数据')
return this.prisma.bodyMetric.create({ data: { ...dto, userId, operatorId, recordedAt: this.date(dto.recordedAt) } })
}
async addNote(userId: string, operatorId: string, dto: MemberNoteDto) {
await this.member(userId)
if (!dto.content.trim()) throw new BadRequestException('请填写笔记内容')
if (dto.bookingId && !await this.prisma.booking.findFirst({ where: { id: dto.bookingId, userId, status: 'COMPLETED' } })) throw new BadRequestException('只能批注该学员已完成的课程')
return this.prisma.memberNote.create({ data: { userId, operatorId, content: dto.content.trim(), shared: dto.shared, bookingId: dto.bookingId || null } })
}
async remove(userId: string, kind: string, id: string) {
if (kind === 'metrics') {
const result = await this.prisma.bodyMetric.deleteMany({ where: { id, userId } })
if (!result.count) throw new NotFoundException('记录不存在')
return { deleted: true }
}
if (kind === 'notes') {
const result = await this.prisma.memberNote.deleteMany({ where: { id, userId } })
if (!result.count) throw new NotFoundException('记录不存在')
return { deleted: true }
}
if (kind === 'photos') {
const photo = await this.prisma.progressPhoto.findFirst({ where: { id, userId } })
if (!photo) throw new NotFoundException('照片不存在')
await this.storage.removeObject(photo.objectKey)
await this.prisma.progressPhoto.delete({ where: { id } })
return { deleted: true }
}
throw new BadRequestException('记录类型无效')
}
async upload(userId: string, operatorId: string, dto: ProgressPhotoDto) {
await this.member(userId)
const recordedAt = this.date(dto.recordedAt)
const credential = this.storage.credential(userId, dto.fileName)
const photo = await this.prisma.progressPhoto.create({ data: { userId, operatorId, objectKey: credential.key, recordedAt, caption: dto.caption } })
return { ...credential, id: photo.id }
}
async finishUpload(userId: string, id: string) {
const photo = await this.prisma.progressPhoto.findFirst({ where: { id, userId } })
if (!photo) throw new NotFoundException('照片不存在')
await this.storage.verify(photo.objectKey)
await this.prisma.progressPhoto.update({ where: { id }, data: { uploadedAt: new Date() } })
return { uploaded: true }
}
async consent(userId: string, id: string, consent: boolean) {
const result = await this.prisma.progressPhoto.updateMany({ where: { id, userId, uploadedAt: { not: null } }, data: { consentedAt: consent ? new Date() : null, revokedAt: consent ? null : new Date() } })
if (!result.count) throw new NotFoundException('照片不存在')
return { consent }
}
async photoUrl(userId: string, id: string, admin = false) {
const photo = await this.prisma.progressPhoto.findFirst({ where: { id, userId, uploadedAt: { not: null } } })
if (!photo) throw new NotFoundException('照片不存在')
// Members can privately preview their own photo to make an informed consent decision.
if (admin && !photo.consentedAt) throw new ForbiddenException('学员尚未授权展示这张照片')
return { url: this.storage.signedUrl(photo.objectKey), expiresIn: 60 }
}
}

View File

@@ -0,0 +1,45 @@
import { BadRequestException, Injectable, ServiceUnavailableException } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { createHash, createHmac, randomUUID } from 'crypto'
import { StudioUploadService } from '../studio/studio-upload.service'
@Injectable()
export class ProgressPhotoStorageService extends StudioUploadService {
constructor(config: ConfigService) { super(config) }
private endpoint() {
const bucket = this.getRequiredConfig('COS_BUCKET')
const region = this.getRequiredConfig('COS_REGION')
return { bucket, host: `${bucket}.cos.${region}.myqcloud.com` }
}
credential(userId: string, fileName: string) {
const { bucket, host } = this.endpoint()
const extension = this.resolveExtension(fileName)
const key = `progress/${userId}/${randomUUID()}.${extension}`
const expiresAt = Math.floor(Date.now() / 1000) + 300
return { key, uploadUrl: `https://${host}`, expiresAt, formData: this.buildPostPolicy({ bucket, key, expiresAt, privateRead: true }) }
}
signedUrl(key: string, method = 'get') {
const { host } = this.endpoint()
const keyTime = `${Math.floor(Date.now() / 1000) - 5};${Math.floor(Date.now() / 1000) + 60}`
const signKey = createHmac('sha1', this.getRequiredConfig('COS_SECRET_KEY')).update(keyTime).digest('hex')
const http = `${method}\n/${key}\n\nhost=${encodeURIComponent(host)}\n`
const toSign = `sha1\n${keyTime}\n${createHash('sha1').update(http).digest('hex')}\n`
const signature = createHmac('sha1', signKey).update(toSign).digest('hex')
const query = new URLSearchParams({ 'q-sign-algorithm': 'sha1', 'q-ak': this.getRequiredConfig('COS_SECRET_ID'), 'q-sign-time': keyTime, 'q-key-time': keyTime, 'q-header-list': 'host', 'q-url-param-list': '', 'q-signature': signature })
return `https://${host}/${key}?${query}`
}
async verify(key: string) {
const response = await fetch(this.signedUrl(key, 'head'), { method: 'HEAD', signal: AbortSignal.timeout(10000) })
if (!response.ok) throw new ServiceUnavailableException('图片尚未上传成功,请重试')
const rawSize = response.headers.get('content-length')
if (rawSize != null && rawSize !== '') {
const size = Number(rawSize)
if (!Number.isFinite(size) || size <= 0 || size > 10 * 1024 * 1024) throw new BadRequestException('图片大小无效')
}
const type = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase()
if (type && !type.startsWith('image/')) throw new BadRequestException('仅支持图片文件')
}
async removeObject(key: string) {
const response = await fetch(this.signedUrl(key, 'delete'), { method: 'DELETE', signal: AbortSignal.timeout(10000) })
if (!response.ok && response.status !== 404) throw new ServiceUnavailableException('照片删除失败,请重试')
}
}

View File

@@ -45,6 +45,17 @@ function stringifyDebugPayload(payload: unknown): string {
}
}
export function buildClassReviewSubscribeData(input: { studioName: string | null; date: Date; startTime: string }) {
const calendar = input.date.toISOString().slice(0, 10)
const [year, month, day] = calendar.split('-')
return {
thing1: { value: (input.studioName || '普拉提私教').slice(0, 20) },
thing2: { value: 'Iris' },
time3: { value: `${year}${month}${day}${input.startTime.slice(0, 5)}` },
thing4: { value: '欢迎留下这节课的感受' },
}
}
@Injectable()
export class SubscriptionMessageService {
private readonly logger = new Logger(SubscriptionMessageService.name)
@@ -221,6 +232,32 @@ export class SubscriptionMessageService {
return true
}
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
const booking = await this.prisma.booking.findFirst({
where: { id: bookingId, userId, status: 'COMPLETED' },
include: { timeSlot: { select: { date: true, startTime: true } } },
})
if (!booking) return false
const studio = await this.prisma.studioConfig.findFirst({ select: { name: true } })
const data = buildClassReviewSubscribeData({ studioName: studio?.name ?? null, date: booking.timeSlot.date, startTime: booking.timeSlot.startTime })
const consent = await this.prisma.subscriptionMessageConsent.findUnique({ where: { userId_templateId_scene: { userId, templateId, scene: SubscriptionMessageScene.CLASS_REVIEW } } })
if (!consent || consent.sentCount >= consent.acceptCount) return false
// 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),
})
if (!response.ok) throw new Error('评价提醒发送结果未知')
const result = await response.json() as WechatSubscribeSendResponse
return !result.errcode
}
private async getAccessToken(): Promise<string> {
const now = Date.now()
if (this.accessTokenCache && this.accessTokenCache.expireAt > now) {

View File

@@ -1,3 +1,6 @@
import { MemberProgressController, AdminMemberProgressController } from './member-progress.controller'
import { MemberProgressService } from './member-progress.service'
import { ProgressPhotoStorageService } from './progress-photo-storage.service'
import { Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { AuthModule } from '../auth/auth.module'
@@ -10,8 +13,8 @@ import { LessonSupplementController } from './lesson-supplement.controller'
@Module({
imports: [AuthModule, ConfigModule],
controllers: [UserController, LessonSupplementController],
providers: [LessonSupplementService, UserService, SubscriptionMessageService],
controllers: [MemberProgressController, AdminMemberProgressController, UserController, LessonSupplementController],
providers: [MemberProgressService, ProgressPhotoStorageService, LessonSupplementService, UserService, SubscriptionMessageService],
exports: [UserService, SubscriptionMessageService],
})
export class UserModule {}

View File

@@ -96,6 +96,7 @@ export class UserService {
private buildSubscriptionTemplateConfig(): SubscriptionMessageTemplateConfig {
const templates = [
{ templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW', ''), scene: SubscriptionMessageScene.CLASS_REVIEW, description: '课程完成 24 小时后提醒评价', usageTarget: 'consent' as const },
{
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
scene: SubscriptionMessageScene.BOOKING_CREATED,