feat(server): add auth, user, and studio modules

Auth: WeChat login, JWT, roles guard (24 tests passing)
User: profile CRUD, training stats with month/total calculations
Studio: config management with auto-default creation
This commit is contained in:
richarjiang
2026-04-02 12:12:18 +08:00
parent e653580155
commit a1a91f96d8
23 changed files with 1284 additions and 0 deletions

View File

@@ -0,0 +1,220 @@
import { Test, TestingModule } from '@nestjs/testing'
import { JwtService } from '@nestjs/jwt'
import { UnauthorizedException } from '@nestjs/common'
import { UserRole } from '@mp-pilates/shared'
import { AuthService } from '../auth.service'
import { WechatService } from '../wechat.service'
import { PrismaService } from '../../prisma/prisma.service'
// ─── Fixtures ────────────────────────────────────────────────────────────────
const OPENID = 'test_openid_123'
const SESSION_KEY = 'test_session_key'
const USER_ID = 'user-uuid-001'
const JWT_TOKEN = 'signed.jwt.token'
const mockUser = {
id: USER_ID,
openid: OPENID,
unionid: null,
phone: null,
nickname: '',
avatarUrl: null,
role: UserRole.MEMBER,
createdAt: new Date(),
updatedAt: new Date(),
}
// ─── Mocks ───────────────────────────────────────────────────────────────────
const mockPrismaService = {
user: {
findUnique: jest.fn(),
findUniqueOrThrow: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
}
const mockWechatService = {
code2Session: jest.fn(),
decryptData: jest.fn(),
}
const mockJwtService = {
sign: jest.fn(),
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('AuthService', () => {
let authService: AuthService
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{ provide: PrismaService, useValue: mockPrismaService },
{ provide: WechatService, useValue: mockWechatService },
{ provide: JwtService, useValue: mockJwtService },
],
}).compile()
authService = module.get<AuthService>(AuthService)
jest.clearAllMocks()
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
})
// ── login ──────────────────────────────────────────────────────────────────
describe('login', () => {
const loginCode = 'wx_login_code_abc'
beforeEach(() => {
mockWechatService.code2Session.mockResolvedValue({
openid: OPENID,
sessionKey: SESSION_KEY,
})
})
it('creates a new user when openid is not found', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null)
mockPrismaService.user.create.mockResolvedValue(mockUser)
const result = await authService.login(loginCode)
expect(mockWechatService.code2Session).toHaveBeenCalledWith(loginCode)
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
where: { openid: OPENID },
})
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
data: { openid: OPENID },
})
expect(result.user).toEqual(mockUser)
})
it('creates user with unionid when present', async () => {
const unionid = 'wx_union_id_xyz'
mockWechatService.code2Session.mockResolvedValue({
openid: OPENID,
sessionKey: SESSION_KEY,
unionid,
})
mockPrismaService.user.findUnique.mockResolvedValue(null)
mockPrismaService.user.create.mockResolvedValue({ ...mockUser, unionid })
await authService.login(loginCode)
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
data: { openid: OPENID, unionid },
})
})
it('returns existing user when openid already exists', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
const result = await authService.login(loginCode)
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
where: { openid: OPENID },
})
expect(mockPrismaService.user.create).not.toHaveBeenCalled()
expect(result.user).toEqual(mockUser)
})
it('returns a valid JWT token', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
const result = await authService.login(loginCode)
expect(mockJwtService.sign).toHaveBeenCalledWith({
sub: USER_ID,
role: UserRole.MEMBER,
})
expect(result.token).toBe(JWT_TOKEN)
})
it('returns both token and user in result', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
const result = await authService.login(loginCode)
expect(result).toEqual({
token: JWT_TOKEN,
user: mockUser,
})
})
})
// ── bindPhone ──────────────────────────────────────────────────────────────
describe('bindPhone', () => {
const encryptedData = 'encrypted_phone_data'
const iv = 'init_vector'
const phoneNumber = '+8613800138000'
beforeEach(async () => {
// Seed the in-memory session key store by running login first
mockWechatService.code2Session.mockResolvedValue({
openid: OPENID,
sessionKey: SESSION_KEY,
})
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
await authService.login('login_code')
})
it('updates phone number and returns the updated user', async () => {
const updatedUser = { ...mockUser, phone: phoneNumber }
mockWechatService.decryptData.mockReturnValue({
phoneNumber,
purePhoneNumber: '13800138000',
countryCode: '86',
})
mockPrismaService.user.update.mockResolvedValue(updatedUser)
const result = await authService.bindPhone(USER_ID, encryptedData, iv)
expect(mockWechatService.decryptData).toHaveBeenCalledWith(
SESSION_KEY,
encryptedData,
iv,
)
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
where: { id: USER_ID },
data: { phone: phoneNumber },
})
expect(result).toEqual(updatedUser)
})
it('throws UnauthorizedException when session key is not found', async () => {
const unknownUserId = 'unknown-user-id'
await expect(
authService.bindPhone(unknownUserId, encryptedData, iv),
).rejects.toThrow(UnauthorizedException)
})
it('does not mutate the original user object', async () => {
const originalUser = { ...mockUser }
const updatedUser = { ...mockUser, phone: phoneNumber }
mockWechatService.decryptData.mockReturnValue({
phoneNumber,
purePhoneNumber: '13800138000',
countryCode: '86',
})
mockPrismaService.user.update.mockResolvedValue(updatedUser)
const result = await authService.bindPhone(USER_ID, encryptedData, iv)
// Original mock user should be unchanged
expect(mockUser.phone).toBeNull()
// Result is a new object with the updated phone
expect(result.phone).toBe(phoneNumber)
expect(result).not.toBe(originalUser)
})
})
})

View File

@@ -0,0 +1,44 @@
import {
Controller,
Post,
Body,
UseGuards,
Request,
HttpCode,
HttpStatus,
} from '@nestjs/common'
import { AuthService } from './auth.service'
import { LoginDto } from './dto/login.dto'
import { BindPhoneDto } from './dto/bind-phone.dto'
import { JwtAuthGuard } from './jwt-auth.guard'
import { AuthenticatedUser } from './jwt.strategy'
import { User } from '@prisma/client'
interface AuthenticatedRequest {
user: AuthenticatedUser
}
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
@HttpCode(HttpStatus.OK)
async login(@Body() loginDto: LoginDto): Promise<{ token: string; user: User }> {
return this.authService.login(loginDto.code)
}
@Post('phone')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
async bindPhone(
@Request() req: AuthenticatedRequest,
@Body() bindPhoneDto: BindPhoneDto,
): Promise<User> {
return this.authService.bindPhone(
req.user.userId,
bindPhoneDto.encryptedData,
bindPhoneDto.iv,
)
}
}

View File

@@ -0,0 +1,28 @@
import { Module } from '@nestjs/common'
import { PassportModule } from '@nestjs/passport'
import { JwtModule } from '@nestjs/jwt'
import { ConfigModule, ConfigService } from '@nestjs/config'
import { AuthService } from './auth.service'
import { AuthController } from './auth.controller'
import { WechatService } from './wechat.service'
import { JwtStrategy } from './jwt.strategy'
import { JwtAuthGuard } from './jwt-auth.guard'
import { RolesGuard } from './roles.guard'
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.getOrThrow<string>('JWT_SECRET'),
signOptions: { expiresIn: '7d' },
}),
}),
],
controllers: [AuthController],
providers: [AuthService, WechatService, JwtStrategy, JwtAuthGuard, RolesGuard],
exports: [JwtStrategy, JwtAuthGuard, RolesGuard, AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,82 @@
import { Injectable, UnauthorizedException } from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import { User } from '@prisma/client'
import { UserRole } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { WechatService } from './wechat.service'
export interface LoginResult {
token: string
user: User
}
export interface JwtPayload {
sub: string
role: UserRole
}
/**
* In-memory session key store.
* TODO: Replace with Redis for production multi-instance deployments.
* Key: userId, Value: WeChat sessionKey
*/
const sessionKeyStore = new Map<string, string>()
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly wechatService: WechatService,
) {}
async login(code: string): Promise<LoginResult> {
const { openid, unionid, sessionKey } =
await this.wechatService.code2Session(code)
const existingUser = await this.prisma.user.findUnique({
where: { openid },
})
const user =
existingUser ??
(await this.prisma.user.create({
data: {
openid,
...(unionid !== undefined && { unionid }),
},
}))
sessionKeyStore.set(user.id, sessionKey)
const payload: JwtPayload = { sub: user.id, role: user.role as UserRole }
const token = this.jwtService.sign(payload)
return { token, user }
}
async bindPhone(
userId: string,
encryptedData: string,
iv: string,
): Promise<User> {
const sessionKey = sessionKeyStore.get(userId)
if (!sessionKey) {
throw new UnauthorizedException(
'Session expired. Please log in again to bind your phone number.',
)
}
const phoneInfo = this.wechatService.decryptData(
sessionKey,
encryptedData,
iv,
)
return this.prisma.user.update({
where: { id: userId },
data: { phone: phoneInfo.phoneNumber },
})
}
}

View File

@@ -0,0 +1,11 @@
import { IsString, IsNotEmpty } from 'class-validator'
export class BindPhoneDto {
@IsString()
@IsNotEmpty()
encryptedData!: string
@IsString()
@IsNotEmpty()
iv!: string
}

View File

@@ -0,0 +1,7 @@
import { IsString, IsNotEmpty } from 'class-validator'
export class LoginDto {
@IsString()
@IsNotEmpty()
code!: string
}

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common'
import { AuthGuard } from '@nestjs/passport'
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View File

@@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common'
import { PassportStrategy } from '@nestjs/passport'
import { ExtractJwt, Strategy } from 'passport-jwt'
import { ConfigService } from '@nestjs/config'
import { UserRole } from '@mp-pilates/shared'
import { JwtPayload } from './auth.service'
export interface AuthenticatedUser {
userId: string
role: UserRole
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.getOrThrow<string>('JWT_SECRET'),
})
}
validate(payload: JwtPayload): AuthenticatedUser {
return {
userId: payload.sub,
role: payload.role,
}
}
}

View File

@@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common'
import { UserRole } from '@mp-pilates/shared'
export const ROLES_KEY = 'roles'
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles)

View File

@@ -0,0 +1,33 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'
import { Reflector } from '@nestjs/core'
import { UserRole } from '@mp-pilates/shared'
import { ROLES_KEY } from './roles.decorator'
import { AuthenticatedUser } from './jwt.strategy'
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
)
if (!requiredRoles || requiredRoles.length === 0) {
return true
}
const request = context.switchToHttp().getRequest<{
user?: AuthenticatedUser
}>()
const user = request.user
if (!user) {
return false
}
return requiredRoles.includes(user.role)
}
}

View File

@@ -0,0 +1,107 @@
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import * as crypto from 'crypto'
export interface Code2SessionResult {
openid: string
sessionKey: string
unionid?: string
}
export interface WechatPhoneInfo {
phoneNumber: string
purePhoneNumber: string
countryCode: string
}
@Injectable()
export class WechatService {
private readonly appId: string
private readonly secret: string
constructor(private readonly configService: ConfigService) {
this.appId = this.configService.getOrThrow<string>('WX_APPID')
this.secret = this.configService.getOrThrow<string>('WX_SECRET')
}
async code2Session(code: string): Promise<Code2SessionResult> {
const url =
`https://api.weixin.qq.com/sns/jscode2session` +
`?appid=${this.appId}` +
`&secret=${this.secret}` +
`&js_code=${code}` +
`&grant_type=authorization_code`
const response = await fetch(url)
if (!response.ok) {
throw new HttpException(
'WeChat API request failed',
HttpStatus.BAD_GATEWAY,
)
}
const data = (await response.json()) as {
openid?: string
session_key?: string
unionid?: string
errcode?: number
errmsg?: string
}
if (data.errcode) {
throw new HttpException(
`WeChat login error: ${data.errmsg ?? data.errcode}`,
HttpStatus.UNAUTHORIZED,
)
}
if (!data.openid || !data.session_key) {
throw new HttpException(
'Invalid response from WeChat API',
HttpStatus.BAD_GATEWAY,
)
}
return {
openid: data.openid,
sessionKey: data.session_key,
...(data.unionid !== undefined && { unionid: data.unionid }),
}
}
decryptData(
sessionKey: string,
encryptedData: string,
iv: string,
): WechatPhoneInfo {
const sessionKeyBuffer = Buffer.from(sessionKey, 'base64')
const encryptedDataBuffer = Buffer.from(encryptedData, 'base64')
const ivBuffer = Buffer.from(iv, 'base64')
const decipher = crypto.createDecipheriv(
'aes-128-cbc',
sessionKeyBuffer,
ivBuffer,
)
decipher.setAutoPadding(true)
const decryptedBuffer = Buffer.concat([
decipher.update(encryptedDataBuffer),
decipher.final(),
])
const decryptedText = decryptedBuffer.toString('utf8')
const decryptedData = JSON.parse(decryptedText) as {
phoneNumber: string
purePhoneNumber: string
countryCode: string
}
return {
phoneNumber: decryptedData.phoneNumber,
purePhoneNumber: decryptedData.purePhoneNumber,
countryCode: decryptedData.countryCode,
}
}
}