feat: 支持登录、个人信息存储
This commit is contained in:
110
src/modules/auth/auth.controller.ts
Normal file
110
src/modules/auth/auth.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
28
src/modules/auth/auth.module.ts
Normal file
28
src/modules/auth/auth.module.ts
Normal 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 {}
|
||||
215
src/modules/auth/auth.service.ts
Normal file
215
src/modules/auth/auth.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
48
src/modules/auth/dto/user-assets.dto.ts
Normal file
48
src/modules/auth/dto/user-assets.dto.ts
Normal 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[];
|
||||
}
|
||||
28
src/modules/auth/dto/wx-login.dto.ts
Normal file
28
src/modules/auth/dto/wx-login.dto.ts
Normal 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;
|
||||
}
|
||||
30
src/modules/auth/entities/user-level-progress.entity.ts
Normal file
30
src/modules/auth/entities/user-level-progress.entity.ts
Normal 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;
|
||||
}
|
||||
37
src/modules/auth/entities/user.entity.ts
Normal file
37
src/modules/auth/entities/user.entity.ts
Normal 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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
29
src/modules/auth/repositories/user.repository.ts
Normal file
29
src/modules/auth/repositories/user.repository.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user