feat(app): 新增个人中心课表视图

This commit is contained in:
richarjiang
2026-04-19 22:23:23 +08:00
parent 9575210b06
commit bd3d519b4f
17 changed files with 998 additions and 29 deletions

View File

@@ -1,7 +1,8 @@
import { Test, TestingModule } from '@nestjs/testing'
import { JwtService } from '@nestjs/jwt'
import { UnauthorizedException } from '@nestjs/common'
import { UserRole } from '@mp-pilates/shared'
import { MembershipStatus, UserRole } from '@mp-pilates/shared'
import { ConfigService } from '@nestjs/config'
import { AuthService, RANDOM_FN_TOKEN } from '../auth.service'
import { WechatService } from '../wechat.service'
import { PrismaService } from '../../prisma/prisma.service'
@@ -23,6 +24,7 @@ const mockUser = {
nickname: TEST_NICKNAME,
avatarUrl: null,
role: UserRole.MEMBER,
adminBookingSubscriptionCount: 0,
createdAt: new Date(),
updatedAt: new Date(),
}
@@ -30,6 +32,9 @@ const mockUser = {
// ─── Mocks ───────────────────────────────────────────────────────────────────
const mockPrismaService = {
membership: {
count: jest.fn(),
},
user: {
findUnique: jest.fn(),
findUniqueOrThrow: jest.fn(),
@@ -51,6 +56,10 @@ const mockInviteService = {
bindInviterToUser: jest.fn(),
}
const mockConfigService = {
get: jest.fn(),
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('AuthService', () => {
@@ -64,6 +73,7 @@ describe('AuthService', () => {
{ provide: WechatService, useValue: mockWechatService },
{ provide: JwtService, useValue: mockJwtService },
{ provide: InviteService, useValue: mockInviteService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: RANDOM_FN_TOKEN, useValue: () => 0 }, // deterministic nickname
],
}).compile()
@@ -72,6 +82,8 @@ describe('AuthService', () => {
jest.clearAllMocks()
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
mockPrismaService.membership.count.mockResolvedValue(0)
mockConfigService.get.mockReturnValue('tmpl-booking-confirmed')
})
// ── login ──────────────────────────────────────────────────────────────────
@@ -99,7 +111,17 @@ describe('AuthService', () => {
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
data: { openid: OPENID, nickname: TEST_NICKNAME, adminBookingSubscriptionCount: 0 },
})
expect(result.user).toEqual(mockUser)
expect(result.user).toEqual(expect.objectContaining({
id: mockUser.id,
phone: mockUser.phone,
nickname: mockUser.nickname,
avatarUrl: mockUser.avatarUrl,
role: mockUser.role,
activeMembershipCount: 0,
inviteShareEligible: false,
adminBookingSubscriptionCount: 0,
}))
expect(result.user.subscriptionMessageTemplates.templates).toHaveLength(2)
expect(result.isNewUser).toBe(true)
expect(mockInviteService.bindInviterToUser).toHaveBeenCalledWith(USER_ID, undefined)
})
@@ -139,7 +161,11 @@ describe('AuthService', () => {
where: { openid: OPENID },
})
expect(mockPrismaService.user.create).not.toHaveBeenCalled()
expect(result.user).toEqual(mockUser)
expect(result.user).toEqual(expect.objectContaining({
id: mockUser.id,
nickname: mockUser.nickname,
role: mockUser.role,
}))
expect(result.isNewUser).toBe(false)
})
@@ -160,11 +186,35 @@ describe('AuthService', () => {
const result = await authService.login(loginCode)
expect(result).toEqual({
expect(result).toEqual(expect.objectContaining({
token: JWT_TOKEN,
user: mockUser,
isNewUser: false,
}))
expect(result.user).toEqual(expect.objectContaining({
id: mockUser.id,
subscriptionMessageTemplates: {
templates: [
expect.objectContaining({ scene: 'BOOKING_CREATED' }),
expect.objectContaining({ scene: 'ADMIN_BOOKING_CREATED' }),
],
},
}))
})
it('includes active membership count and invite eligibility in login response', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
mockPrismaService.membership.count.mockResolvedValue(2)
const result = await authService.login(loginCode)
expect(mockPrismaService.membership.count).toHaveBeenCalledWith({
where: {
userId: USER_ID,
status: MembershipStatus.ACTIVE,
},
})
expect(result.user.activeMembershipCount).toBe(2)
expect(result.user.inviteShareEligible).toBe(true)
})
})

View File

@@ -1,12 +1,13 @@
import {
Controller,
Post,
Body,
UseGuards,
Request,
Controller,
HttpCode,
HttpStatus,
Post,
Request,
UseGuards,
} from '@nestjs/common'
import type { UserProfileResponse } from '@mp-pilates/shared'
import { AuthService } from './auth.service'
import { LoginDto } from './dto/login.dto'
import { BindPhoneDto } from './dto/bind-phone.dto'
@@ -24,7 +25,7 @@ export class AuthController {
@Post('login')
@HttpCode(HttpStatus.OK)
async login(@Body() loginDto: LoginDto): Promise<{ token: string; user: User; isNewUser: boolean }> {
async login(@Body() loginDto: LoginDto): Promise<{ token: string; user: UserProfileResponse; isNewUser: boolean }> {
return this.authService.login(
loginDto.code,
loginDto.nickname,

View File

@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'
import { PassportModule } from '@nestjs/passport'
import { JwtModule } from '@nestjs/jwt'
import { ConfigModule, ConfigService } from '@nestjs/config'
import { MembershipModule } from '../membership/membership.module'
import { AuthService, RANDOM_FN_TOKEN } from './auth.service'
import { AuthController } from './auth.controller'
import { WechatService } from './wechat.service'
@@ -14,6 +15,8 @@ import { InviteModule } from '../invite/invite.module'
imports: [
PassportModule,
InviteModule,
ConfigModule,
MembershipModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],

View File

@@ -1,14 +1,22 @@
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import { User } from '@prisma/client'
import { UserRole } from '@mp-pilates/shared'
import {
MembershipStatus,
SubscriptionMessageScene,
type SubscriptionMessageTemplate,
type SubscriptionMessageTemplateConfig,
type UserProfileResponse,
UserRole,
} from '@mp-pilates/shared'
import { ConfigService } from '@nestjs/config'
import { PrismaService } from '../prisma/prisma.service'
import { WechatService } from './wechat.service'
import { InviteService } from '../invite/invite.service'
export interface LoginResult {
token: string
user: User
user: UserProfileResponse
isNewUser: boolean
}
@@ -57,9 +65,53 @@ export class AuthService {
private readonly jwtService: JwtService,
private readonly wechatService: WechatService,
private readonly inviteService: InviteService,
private readonly configService: ConfigService,
@Inject(RANDOM_FN_TOKEN) private readonly randomFn: () => number = Math.random,
) {}
private buildSubscriptionTemplateConfig(): SubscriptionMessageTemplateConfig {
const templates = [
{
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
scene: SubscriptionMessageScene.BOOKING_CREATED,
description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送',
usageTarget: 'consent' as const,
},
{
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
scene: SubscriptionMessageScene.ADMIN_BOOKING_CREATED,
description: '管理员主动增加预约提醒次数,用于接收学员新预约通知',
usageTarget: 'counter' as const,
},
] satisfies SubscriptionMessageTemplate[]
return {
templates: templates.filter((item) => item.templateId),
}
}
private async mapLoginUser(user: User): Promise<UserProfileResponse> {
const activeMembershipCount = await this.prisma.membership.count({
where: {
userId: user.id,
status: MembershipStatus.ACTIVE,
},
})
return {
id: user.id,
phone: user.phone,
nickname: user.nickname,
avatarUrl: user.avatarUrl,
role: user.role as UserRole,
activeMembershipCount,
inviteShareEligible: activeMembershipCount > 0,
adminBookingSubscriptionCount: user.adminBookingSubscriptionCount,
subscriptionMessageTemplates: this.buildSubscriptionTemplateConfig(),
createdAt: user.createdAt.toISOString(),
}
}
async login(
code: string,
nickname?: string,
@@ -96,7 +148,7 @@ export class AuthService {
sessionKeyStore.set(updated.id, sessionKey)
const payload: JwtPayload = { sub: updated.id, role: updated.role as UserRole }
const token = this.jwtService.sign(payload)
return { token, user: updated, isNewUser: false }
return { token, user: await this.mapLoginUser(updated), isNewUser: false }
}
sessionKeyStore.set(user.id, sessionKey)
@@ -108,7 +160,7 @@ export class AuthService {
const payload: JwtPayload = { sub: user.id, role: user.role as UserRole }
const token = this.jwtService.sign(payload)
return { token, user, isNewUser }
return { token, user: await this.mapLoginUser(user), isNewUser }
}
async bindPhone(