feat: 支持关卡配置分享
This commit is contained in:
@@ -32,6 +32,7 @@
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"mysql2": "^3.19.1",
|
||||
"nanoid": "^3.3.11",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.28"
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -41,6 +41,9 @@ importers:
|
||||
mysql2:
|
||||
specifier: ^3.19.1
|
||||
version: 3.19.1(@types/node@22.19.15)
|
||||
nanoid:
|
||||
specifier: ^3.3.11
|
||||
version: 3.3.11
|
||||
reflect-metadata:
|
||||
specifier: ^0.2.2
|
||||
version: 0.2.2
|
||||
@@ -2492,6 +2495,11 @@ packages:
|
||||
resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
nanoid@3.3.11:
|
||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
napi-postinstall@0.3.4:
|
||||
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
|
||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||
@@ -6047,6 +6055,8 @@ snapshots:
|
||||
dependencies:
|
||||
lru.min: 1.1.4
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
napi-postinstall@0.3.4: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
import { ShareModule } from './modules/share/share.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -26,6 +27,7 @@ import { AuthModule } from './modules/auth/auth.module';
|
||||
}),
|
||||
WechatGameModule,
|
||||
AuthModule,
|
||||
ShareModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
27
src/modules/share/dto/create-share.dto.ts
Normal file
27
src/modules/share/dto/create-share.dto.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
MaxLength,
|
||||
ArrayMinSize,
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateShareDto {
|
||||
@ApiProperty({ description: '分享标题', example: '我的挑战' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '标题不能为空' })
|
||||
@MaxLength(100, { message: '标题不能超过100个字符' })
|
||||
title: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '6个关卡ID',
|
||||
example: ['id1', 'id2', 'id3', 'id4', 'id5', 'id6'],
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMinSize(6, { message: '需要恰好6个关卡' })
|
||||
@ArrayMaxSize(6, { message: '需要恰好6个关卡' })
|
||||
@IsString({ each: true })
|
||||
levelIds: string[];
|
||||
}
|
||||
49
src/modules/share/dto/share-response.dto.ts
Normal file
49
src/modules/share/dto/share-response.dto.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CreateShareResponseDto {
|
||||
@ApiProperty({ description: '分享码' })
|
||||
shareCode: string;
|
||||
|
||||
@ApiProperty({ description: '分享标题' })
|
||||
title: string;
|
||||
|
||||
@ApiProperty({ description: '关卡数量' })
|
||||
levelCount: number;
|
||||
}
|
||||
|
||||
export class ShareLevelDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
level: number;
|
||||
|
||||
@ApiProperty()
|
||||
imageUrl: string;
|
||||
|
||||
@ApiProperty()
|
||||
answer: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
hint1: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
hint2: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
hint3: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export class JoinShareResponseDto {
|
||||
@ApiProperty({ description: '分享码' })
|
||||
shareCode: string;
|
||||
|
||||
@ApiProperty({ description: '分享标题' })
|
||||
title: string;
|
||||
|
||||
@ApiProperty({ description: '关卡列表', type: [ShareLevelDto] })
|
||||
levels: ShareLevelDto[];
|
||||
}
|
||||
46
src/modules/share/entities/share-config.entity.ts
Normal file
46
src/modules/share/entities/share-config.entity.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../auth/entities/user.entity';
|
||||
import { ShareParticipant } from './share-participant.entity';
|
||||
|
||||
@Entity('share_configs')
|
||||
export class ShareConfig {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index('idx_share_code', { unique: true })
|
||||
@Column({ type: 'varchar', length: 8, name: 'share_code' })
|
||||
shareCode: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 191, name: 'sharer_id' })
|
||||
sharerId: string;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'sharer_id' })
|
||||
sharer: User;
|
||||
|
||||
/** 有序 JSON 数组,存储 6 个关卡 ID */
|
||||
@Column({ type: 'json', name: 'level_ids' })
|
||||
levelIds: string[];
|
||||
|
||||
@OneToMany(() => ShareParticipant, (p) => p.shareConfig)
|
||||
participants: ShareParticipant[];
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
37
src/modules/share/entities/share-participant.entity.ts
Normal file
37
src/modules/share/entities/share-participant.entity.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../auth/entities/user.entity';
|
||||
import { ShareConfig } from './share-config.entity';
|
||||
|
||||
@Entity('share_participants')
|
||||
@Unique('uq_share_participant', ['shareConfigId', 'participantId'])
|
||||
export class ShareParticipant {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index('idx_share_config_id')
|
||||
@Column({ type: 'varchar', length: 191, name: 'share_config_id' })
|
||||
shareConfigId: string;
|
||||
|
||||
@ManyToOne(() => ShareConfig, (sc) => sc.participants)
|
||||
@JoinColumn({ name: 'share_config_id' })
|
||||
shareConfig: ShareConfig;
|
||||
|
||||
@Column({ type: 'varchar', length: 191, name: 'participant_id' })
|
||||
participantId: string;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'participant_id' })
|
||||
participant: User;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
}
|
||||
21
src/modules/share/repositories/share-config.repository.ts
Normal file
21
src/modules/share/repositories/share-config.repository.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ShareConfig } from '../entities/share-config.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ShareConfigRepository {
|
||||
constructor(
|
||||
@InjectRepository(ShareConfig)
|
||||
private readonly repository: Repository<ShareConfig>,
|
||||
) {}
|
||||
|
||||
async create(data: Partial<ShareConfig>): Promise<ShareConfig> {
|
||||
const entity = this.repository.create(data);
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
async findByShareCode(code: string): Promise<ShareConfig | null> {
|
||||
return this.repository.findOne({ where: { shareCode: code } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ShareParticipant } from '../entities/share-participant.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ShareParticipantRepository {
|
||||
constructor(
|
||||
@InjectRepository(ShareParticipant)
|
||||
private readonly repository: Repository<ShareParticipant>,
|
||||
) {}
|
||||
|
||||
/** 添加参与者(已存在则忽略) */
|
||||
async addParticipant(
|
||||
shareConfigId: string,
|
||||
participantId: string,
|
||||
): Promise<void> {
|
||||
await this.repository
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(ShareParticipant)
|
||||
.values({ shareConfigId, participantId })
|
||||
.orIgnore()
|
||||
.execute();
|
||||
}
|
||||
|
||||
async countByShareConfigId(shareConfigId: string): Promise<number> {
|
||||
return this.repository.count({ where: { shareConfigId } });
|
||||
}
|
||||
}
|
||||
54
src/modules/share/share.controller.ts
Normal file
54
src/modules/share/share.controller.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { ShareService } from './share.service';
|
||||
import { CreateShareDto } from './dto/create-share.dto';
|
||||
import {
|
||||
CreateShareResponseDto,
|
||||
JoinShareResponseDto,
|
||||
} from './dto/share-response.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/share')
|
||||
export class ShareController {
|
||||
constructor(private readonly shareService: ShareService) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '创建分享', description: '选择6关+标题,生成分享码' })
|
||||
@ApiResponse({ status: 201, description: '创建成功' })
|
||||
@ApiResponse({ status: 400, description: '参数错误' })
|
||||
async createShare(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: CreateShareDto,
|
||||
): Promise<ApiResponseDto<CreateShareResponseDto>> {
|
||||
const data = await this.shareService.createShare(user.sub, dto);
|
||||
return ApiResponseDto.success(data);
|
||||
}
|
||||
|
||||
@Post(':code/join')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '接受分享',
|
||||
description: '通过分享码加入挑战并获取关卡数据',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '成功' })
|
||||
@ApiResponse({ status: 404, description: '分享不存在' })
|
||||
async joinShare(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('code') code: string,
|
||||
): Promise<ApiResponseDto<JoinShareResponseDto>> {
|
||||
const data = await this.shareService.joinShare(user.sub, code);
|
||||
return ApiResponseDto.success(data);
|
||||
}
|
||||
}
|
||||
21
src/modules/share/share.module.ts
Normal file
21
src/modules/share/share.module.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ShareController } from './share.controller';
|
||||
import { ShareService } from './share.service';
|
||||
import { ShareConfig } from './entities/share-config.entity';
|
||||
import { ShareParticipant } from './entities/share-participant.entity';
|
||||
import { ShareConfigRepository } from './repositories/share-config.repository';
|
||||
import { ShareParticipantRepository } from './repositories/share-participant.repository';
|
||||
import { WechatGameModule } from '../wechat-game/wechat-game.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ShareConfig, ShareParticipant]),
|
||||
WechatGameModule,
|
||||
AuthModule,
|
||||
],
|
||||
controllers: [ShareController],
|
||||
providers: [ShareService, ShareConfigRepository, ShareParticipantRepository],
|
||||
})
|
||||
export class ShareModule {}
|
||||
111
src/modules/share/share.service.ts
Normal file
111
src/modules/share/share.service.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { ShareConfigRepository } from './repositories/share-config.repository';
|
||||
import { ShareParticipantRepository } from './repositories/share-participant.repository';
|
||||
import { LevelRepository } from '../wechat-game/repositories/level.repository';
|
||||
import { CreateShareDto } from './dto/create-share.dto';
|
||||
import {
|
||||
CreateShareResponseDto,
|
||||
JoinShareResponseDto,
|
||||
ShareLevelDto,
|
||||
} from './dto/share-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ShareService {
|
||||
constructor(
|
||||
private readonly shareConfigRepository: ShareConfigRepository,
|
||||
private readonly shareParticipantRepository: ShareParticipantRepository,
|
||||
private readonly levelRepository: LevelRepository,
|
||||
) {}
|
||||
|
||||
async createShare(
|
||||
userId: string,
|
||||
dto: CreateShareDto,
|
||||
): Promise<CreateShareResponseDto> {
|
||||
const uniqueIds = [...new Set(dto.levelIds)];
|
||||
if (uniqueIds.length !== 6) {
|
||||
throw new BadRequestException('关卡ID不能重复,需要恰好6个不同的关卡');
|
||||
}
|
||||
|
||||
// 单次查询验证所有关卡存在
|
||||
const levels = await this.levelRepository.findByIds(uniqueIds);
|
||||
if (levels.length !== uniqueIds.length) {
|
||||
const foundIds = new Set(levels.map((l) => l.id));
|
||||
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
||||
throw new NotFoundException(`以下关卡不存在: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
// 生成 8 位分享码(碰撞重试)
|
||||
let shareCode: string;
|
||||
let attempts = 0;
|
||||
do {
|
||||
shareCode = nanoid(8);
|
||||
const existing =
|
||||
await this.shareConfigRepository.findByShareCode(shareCode);
|
||||
if (!existing) break;
|
||||
attempts++;
|
||||
} while (attempts < 3);
|
||||
|
||||
if (attempts >= 3) {
|
||||
throw new BadRequestException('生成分享码失败,请重试');
|
||||
}
|
||||
|
||||
const config = await this.shareConfigRepository.create({
|
||||
shareCode,
|
||||
title: dto.title,
|
||||
sharerId: userId,
|
||||
levelIds: dto.levelIds,
|
||||
});
|
||||
|
||||
return {
|
||||
shareCode: config.shareCode,
|
||||
title: config.title,
|
||||
levelCount: config.levelIds.length,
|
||||
};
|
||||
}
|
||||
|
||||
async joinShare(
|
||||
userId: string,
|
||||
code: string,
|
||||
): Promise<JoinShareResponseDto> {
|
||||
const config = await this.shareConfigRepository.findByShareCode(code);
|
||||
if (!config) {
|
||||
throw new NotFoundException('分享不存在或已过期');
|
||||
}
|
||||
|
||||
if (userId !== config.sharerId) {
|
||||
await this.shareParticipantRepository.addParticipant(config.id, userId);
|
||||
}
|
||||
|
||||
// 单次查询获取所有关卡,再按 levelIds 顺序排列
|
||||
const allLevels = await this.levelRepository.findByIds(config.levelIds);
|
||||
const levelMap = new Map(allLevels.map((l) => [l.id, l]));
|
||||
|
||||
const levels: ShareLevelDto[] = config.levelIds.map((id, index) => {
|
||||
const level = levelMap.get(id);
|
||||
if (!level) {
|
||||
throw new NotFoundException(`关卡 ${id} 不存在`);
|
||||
}
|
||||
return {
|
||||
id: level.id,
|
||||
level: index + 1,
|
||||
imageUrl: level.imageUrl,
|
||||
answer: level.answer,
|
||||
hint1: level.hint1,
|
||||
hint2: level.hint2,
|
||||
hint3: level.hint3,
|
||||
sortOrder: level.sortOrder,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
shareCode: config.shareCode,
|
||||
title: config.title,
|
||||
levels,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,6 @@ import { Level } from '../entities/level.entity';
|
||||
export interface ILevelRepository {
|
||||
findAll(): Promise<Level[]>;
|
||||
findById(id: string): Promise<Level | null>;
|
||||
findByIds(ids: string[]): Promise<Level[]>;
|
||||
findAllOrdered(): Promise<Level[]>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { Level } from '../entities/level.entity';
|
||||
import { ILevelRepository } from './level.repository.interface';
|
||||
|
||||
@@ -19,6 +19,11 @@ export class LevelRepository implements ILevelRepository {
|
||||
return this.repository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async findByIds(ids: string[]): Promise<Level[]> {
|
||||
if (ids.length === 0) return [];
|
||||
return this.repository.find({ where: { id: In(ids) } });
|
||||
}
|
||||
|
||||
async findAllOrdered(): Promise<Level[]> {
|
||||
return this.repository.find({
|
||||
order: { sortOrder: 'ASC' },
|
||||
|
||||
@@ -11,6 +11,6 @@ import { LevelRepository } from './repositories/level.repository';
|
||||
imports: [TypeOrmModule.forFeature([GameConfig, Level])],
|
||||
controllers: [WechatGameController],
|
||||
providers: [WechatGameService, GameConfigRepository, LevelRepository],
|
||||
exports: [WechatGameService],
|
||||
exports: [WechatGameService, LevelRepository],
|
||||
})
|
||||
export class WechatGameModule {}
|
||||
|
||||
Reference in New Issue
Block a user