feat: 支持登录、个人信息存储

This commit is contained in:
richarjiang
2026-04-05 13:38:12 +08:00
parent 46368b8c89
commit 9ab78555cb
24 changed files with 3560 additions and 20 deletions

View File

@@ -3,6 +3,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppConfigModule } from './config/config.module';
import { WechatGameModule } from './modules/wechat-game/wechat-game.module';
import { AuthModule } from './modules/auth/auth.module';
@Module({
imports: [
@@ -24,6 +25,7 @@ import { WechatGameModule } from './modules/wechat-game/wechat-game.module';
}),
}),
WechatGameModule,
AuthModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,13 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { JwtPayload } from '../guards/jwt-auth.guard';
/**
* 从请求中提取当前登录用户信息的装饰器
* 使用方式: @CurrentUser() user: JwtPayload
*/
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): JwtPayload => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);

View File

@@ -0,0 +1,45 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
export interface JwtPayload {
sub: string; // user id
openid: string;
}
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractToken(request);
if (!token) {
throw new UnauthorizedException('未提供访问令牌');
}
try {
const payload = await this.jwtService.verifyAsync<JwtPayload>(token);
// 将用户信息挂载到 request 上
(request as any).user = payload;
} catch {
throw new UnauthorizedException('访问令牌无效或已过期');
}
return true;
}
private extractToken(request: Request): string | null {
const authorization = request.headers.authorization;
if (!authorization) return null;
const [type, token] = authorization.split(' ');
return type === 'Bearer' ? token : null;
}
}

View File

@@ -33,6 +33,15 @@ class EnvironmentVariables {
@IsString()
DB_DATABASE: string = 'meme_mind';
@IsString()
WX_APPID: string = '';
@IsString()
WX_SECRET: string = '';
@IsString()
JWT_SECRET: string = 'default_jwt_secret_change_me';
}
export function validateEnvironment(

View File

@@ -33,6 +33,7 @@ async function bootstrap() {
.setTitle('MemeMind Server API')
.setDescription('微信小游戏 MemeMind 服务端 API 文档')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);

View File

@@ -0,0 +1,110 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { WxLoginRequestDto, WxLoginResponseDto } from './dto/wx-login.dto';
import {
ConsumePointRequestDto,
EarnPointRequestDto,
GameDataResponseDto,
UserAssetsResponseDto,
} from './dto/user-assets.dto';
import { ApiResponseDto } from '../../common/dto/api-response.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import type { JwtPayload } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('用户认证与资产')
@Controller('v1')
export class AuthController {
constructor(private readonly authService: AuthService) {}
// ==================== 公开接口 ====================
@Post('auth/wx-login')
@ApiOperation({
summary: '微信登录',
description: '使用微信 wx.login 返回的 code 换取 JWT 令牌',
})
@ApiResponse({ status: 200, description: '登录成功' })
@ApiResponse({ status: 401, description: '微信登录失败' })
async wxLogin(
@Body() dto: WxLoginRequestDto,
): Promise<ApiResponseDto<WxLoginResponseDto>> {
const data = await this.authService.wxLogin(dto.code);
return ApiResponseDto.success(data);
}
// ==================== 需要鉴权的接口 ====================
@Get('user/assets')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: '获取用户积分',
description: '获取当前登录用户的积分信息',
})
@ApiResponse({ status: 200, description: '成功' })
@ApiResponse({ status: 401, description: '未授权' })
async getUserAssets(
@CurrentUser() user: JwtPayload,
): Promise<ApiResponseDto<UserAssetsResponseDto>> {
const data = await this.authService.getUserAssets(user.sub);
return ApiResponseDto.success(data);
}
@Post('user/assets/consume')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: '消耗积分',
description: '消耗 1 积分(用于解锁提示)',
})
@ApiResponse({ status: 200, description: '消耗成功' })
@ApiResponse({ status: 400, description: '积分不足' })
@ApiResponse({ status: 401, description: '未授权' })
async consumePoint(
@CurrentUser() user: JwtPayload,
@Body() dto: ConsumePointRequestDto,
): Promise<ApiResponseDto<UserAssetsResponseDto>> {
const data = await this.authService.consumePoint(user.sub, dto);
return ApiResponseDto.success(data);
}
@Post('user/assets/earn')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: '获得积分',
description: '通关获得 1 积分(同一关卡不重复奖励)',
})
@ApiResponse({ status: 200, description: '获得成功' })
@ApiResponse({ status: 401, description: '未授权' })
async earnPoint(
@CurrentUser() user: JwtPayload,
@Body() dto: EarnPointRequestDto,
): Promise<ApiResponseDto<UserAssetsResponseDto>> {
const data = await this.authService.earnPoint(user.sub, dto);
return ApiResponseDto.success(data);
}
@Get('user/game-data')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: '获取游戏数据',
description: '获取用户积分和通关进度Loading 页面使用)',
})
@ApiResponse({ status: 200, description: '成功' })
@ApiResponse({ status: 401, description: '未授权' })
async getGameData(
@CurrentUser() user: JwtPayload,
): Promise<ApiResponseDto<GameDataResponseDto>> {
const data = await this.authService.getGameData(user.sub);
return ApiResponseDto.success(data);
}
}

View File

@@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { User } from './entities/user.entity';
import { UserLevelProgress } from './entities/user-level-progress.entity';
import { UserRepository } from './repositories/user.repository';
import { UserLevelProgressRepository } from './repositories/user-level-progress.repository';
@Module({
imports: [
TypeOrmModule.forFeature([User, UserLevelProgress]),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: { expiresIn: '7d' },
}),
}),
],
controllers: [AuthController],
providers: [AuthService, UserRepository, UserLevelProgressRepository],
exports: [JwtModule, AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,215 @@
import {
Injectable,
Logger,
UnauthorizedException,
BadRequestException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import axios from 'axios';
import { UserRepository } from './repositories/user.repository';
import { UserLevelProgressRepository } from './repositories/user-level-progress.repository';
import { WxLoginResponseDto, UserInfoDto } from './dto/wx-login.dto';
import {
UserAssetsResponseDto,
ConsumePointRequestDto,
EarnPointRequestDto,
GameDataResponseDto,
} from './dto/user-assets.dto';
import { JwtPayload } from '../../common/guards/jwt-auth.guard';
interface WxSessionResponse {
openid?: string;
session_key?: string;
errcode?: number;
errmsg?: string;
}
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
private readonly wxAppId: string;
private readonly wxSecret: string;
constructor(
private readonly configService: ConfigService,
private readonly jwtService: JwtService,
private readonly userRepository: UserRepository,
private readonly userLevelProgressRepository: UserLevelProgressRepository,
) {
this.wxAppId = this.configService.get<string>('WX_APPID', '');
this.wxSecret = this.configService.get<string>('WX_SECRET', '');
}
/**
* 微信登录code 换取 openid创建或查找用户签发 JWT
*/
async wxLogin(code: string): Promise<WxLoginResponseDto> {
// 1. 调用微信接口换取 openid
const wxSession = await this.getWxSession(code);
if (!wxSession.openid) {
this.logger.error(
`微信登录失败: errcode=${wxSession.errcode}, errmsg=${wxSession.errmsg}`,
);
throw new UnauthorizedException('微信登录失败,请重试');
}
// 2. 查找或创建用户
let user = await this.userRepository.findByOpenid(wxSession.openid);
if (!user) {
user = this.userRepository.create({
openid: wxSession.openid,
sessionKey: wxSession.session_key ?? null,
points: 10, // 新用户默认 10 积分
});
user = await this.userRepository.save(user);
this.logger.log(`新用户注册: ${user.id}`);
} else {
// 更新 session_key
if (wxSession.session_key) {
user.sessionKey = wxSession.session_key;
user = await this.userRepository.save(user);
}
}
// 3. 签发 JWT
const payload: JwtPayload = {
sub: user.id,
openid: user.openid,
};
const token = await this.jwtService.signAsync(payload);
// 4. 构造响应
const userInfo: UserInfoDto = {
id: user.id,
nickname: user.nickname,
points: user.points,
};
return { token, user: userInfo };
}
/**
* 获取用户积分
*/
async getUserAssets(userId: string): Promise<UserAssetsResponseDto> {
const user = await this.findUserOrThrow(userId);
return { points: user.points };
}
/**
* 消耗积分(解锁提示)
*/
async consumePoint(
userId: string,
dto: ConsumePointRequestDto,
): Promise<UserAssetsResponseDto> {
const user = await this.findUserOrThrow(userId);
if (user.points <= 0) {
throw new BadRequestException('积分不足,无法消耗');
}
user.points -= 1;
await this.userRepository.save(user);
this.logger.log(
`用户 ${userId} 消耗 1 积分(${dto.reason}),剩余: ${user.points}`,
);
return { points: user.points };
}
/**
* 获得积分(通关奖励)
*/
async earnPoint(
userId: string,
dto: EarnPointRequestDto,
): Promise<UserAssetsResponseDto> {
const user = await this.findUserOrThrow(userId);
// 检查是否已经领取过该关卡的通关奖励(防重复)
const existing = await this.userLevelProgressRepository.findByUserAndLevel(
userId,
dto.levelId,
);
if (existing) {
this.logger.warn(
`用户 ${userId} 已完成关卡 ${dto.levelId},不重复奖励`,
);
return { points: user.points };
}
// 记录通关进度
const progress = this.userLevelProgressRepository.create({
userId,
levelId: dto.levelId,
});
await this.userLevelProgressRepository.save(progress);
// 增加积分
user.points += 1;
await this.userRepository.save(user);
this.logger.log(
`用户 ${userId} 通关 ${dto.levelId},获得 1 积分,当前: ${user.points}`,
);
return { points: user.points };
}
/**
* 获取用户游戏数据Loading 页面复合接口)
*/
async getGameData(userId: string): Promise<GameDataResponseDto> {
const [user, progressList] = await Promise.all([
this.findUserOrThrow(userId),
this.userLevelProgressRepository.findByUserId(userId),
]);
const completedLevelIds = progressList.map((p) => p.levelId);
return {
user: {
id: user.id,
points: user.points,
},
completedLevelIds,
};
}
/**
* 调用微信 jscode2session 接口
*/
private async getWxSession(code: string): Promise<WxSessionResponse> {
const url = 'https://api.weixin.qq.com/sns/jscode2session';
const params = {
appid: this.wxAppId,
secret: this.wxSecret,
js_code: code,
grant_type: 'authorization_code',
};
try {
const response = await axios.get<WxSessionResponse>(url, { params });
return response.data;
} catch (error) {
this.logger.error('调用微信 jscode2session 失败:', error);
throw new UnauthorizedException('微信服务调用失败,请重试');
}
}
/**
* 查找用户,不存在则抛异常
*/
private async findUserOrThrow(userId: string) {
const user = await this.userRepository.findById(userId);
if (!user) {
throw new UnauthorizedException('用户不存在');
}
return user;
}
}

View File

@@ -0,0 +1,48 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class UserAssetsResponseDto {
@ApiProperty({ description: '积分' })
points: number;
}
export class ConsumePointRequestDto {
@ApiProperty({ description: '消耗原因', enum: ['hint_unlock'] })
@IsString()
@IsNotEmpty()
@IsIn(['hint_unlock'])
reason: 'hint_unlock';
@ApiProperty({ description: '关卡 ID', required: false })
@IsString()
@IsOptional()
levelId?: string;
@ApiProperty({ description: '提示索引2 或 3', required: false })
@IsOptional()
hintIndex?: number;
}
export class EarnPointRequestDto {
@ApiProperty({ description: '获取原因', enum: ['level_complete'] })
@IsString()
@IsNotEmpty()
@IsIn(['level_complete'])
reason: 'level_complete';
@ApiProperty({ description: '关卡 ID' })
@IsString()
@IsNotEmpty()
levelId: string;
}
export class GameDataResponseDto {
@ApiProperty({ description: '用户信息' })
user: {
id: string;
points: number;
};
@ApiProperty({ description: '已完成的关卡 ID 列表' })
completedLevelIds: string[];
}

View File

@@ -0,0 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class WxLoginRequestDto {
@ApiProperty({ description: '微信 wx.login 返回的 code' })
@IsString()
@IsNotEmpty()
code: string;
}
export class UserInfoDto {
@ApiProperty({ description: '用户 ID' })
id: string;
@ApiProperty({ description: '用户昵称', nullable: true })
nickname: string | null;
@ApiProperty({ description: '积分' })
points: number;
}
export class WxLoginResponseDto {
@ApiProperty({ description: 'JWT 访问令牌' })
token: string;
@ApiProperty({ description: '用户信息' })
user: UserInfoDto;
}

View File

@@ -0,0 +1,30 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from './user.entity';
@Entity('wx_user_level_progress')
@Index('idx_user_level', ['userId', 'levelId'], { unique: true })
export class UserLevelProgress {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 191, name: 'user_id' })
userId: string;
@Column({ type: 'varchar', length: 191, name: 'level_id' })
levelId: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user: User;
@CreateDateColumn({ name: 'completed_at' })
completedAt: Date;
}

View File

@@ -0,0 +1,37 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
@Entity('wx_users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index('idx_user_openid', { unique: true })
@Column({ type: 'varchar', length: 128 })
openid: string;
@Column({ type: 'varchar', length: 255, name: 'session_key', nullable: true })
sessionKey: string | null;
@Column({ type: 'varchar', length: 100, nullable: true })
nickname: string | null;
@Column({ type: 'text', name: 'avatar_url', nullable: true })
avatarUrl: string | null;
/** 积分(默认 10 */
@Column({ type: 'int', default: 10 })
points: number;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -0,0 +1,11 @@
import { UserLevelProgress } from '../entities/user-level-progress.entity';
export interface IUserLevelProgressRepository {
findByUserId(userId: string): Promise<UserLevelProgress[]>;
findByUserAndLevel(
userId: string,
levelId: string,
): Promise<UserLevelProgress | null>;
create(data: Partial<UserLevelProgress>): UserLevelProgress;
save(progress: UserLevelProgress): Promise<UserLevelProgress>;
}

View File

@@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserLevelProgress } from '../entities/user-level-progress.entity';
import { IUserLevelProgressRepository } from './user-level-progress.repository.interface';
@Injectable()
export class UserLevelProgressRepository
implements IUserLevelProgressRepository
{
constructor(
@InjectRepository(UserLevelProgress)
private readonly repository: Repository<UserLevelProgress>,
) {}
async findByUserId(userId: string): Promise<UserLevelProgress[]> {
return this.repository.find({ where: { userId } });
}
async findByUserAndLevel(
userId: string,
levelId: string,
): Promise<UserLevelProgress | null> {
return this.repository.findOne({ where: { userId, levelId } });
}
create(data: Partial<UserLevelProgress>): UserLevelProgress {
return this.repository.create(data);
}
async save(progress: UserLevelProgress): Promise<UserLevelProgress> {
return this.repository.save(progress);
}
}

View File

@@ -0,0 +1,8 @@
import { User } from '../entities/user.entity';
export interface IUserRepository {
findById(id: string): Promise<User | null>;
findByOpenid(openid: string): Promise<User | null>;
create(data: Partial<User>): User;
save(user: User): Promise<User>;
}

View File

@@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../entities/user.entity';
import { IUserRepository } from './user.repository.interface';
@Injectable()
export class UserRepository implements IUserRepository {
constructor(
@InjectRepository(User)
private readonly repository: Repository<User>,
) {}
async findById(id: string): Promise<User | null> {
return this.repository.findOne({ where: { id } });
}
async findByOpenid(openid: string): Promise<User | null> {
return this.repository.findOne({ where: { openid } });
}
create(data: Partial<User>): User {
return this.repository.create(data);
}
async save(user: User): Promise<User> {
return this.repository.save(user);
}
}