feat: 支持关卡配置分享

This commit is contained in:
richarjiang
2026-04-06 17:32:20 +08:00
parent 9ab78555cb
commit 3a1b4d22bf
15 changed files with 418 additions and 3 deletions

View File

@@ -32,6 +32,7 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"mysql2": "^3.19.1", "mysql2": "^3.19.1",
"nanoid": "^3.3.11",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"typeorm": "^0.3.28" "typeorm": "^0.3.28"

10
pnpm-lock.yaml generated
View File

@@ -41,6 +41,9 @@ importers:
mysql2: mysql2:
specifier: ^3.19.1 specifier: ^3.19.1
version: 3.19.1(@types/node@22.19.15) version: 3.19.1(@types/node@22.19.15)
nanoid:
specifier: ^3.3.11
version: 3.3.11
reflect-metadata: reflect-metadata:
specifier: ^0.2.2 specifier: ^0.2.2
version: 0.2.2 version: 0.2.2
@@ -2492,6 +2495,11 @@ packages:
resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==}
engines: {node: '>=8.0.0'} 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: napi-postinstall@0.3.4:
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@@ -6047,6 +6055,8 @@ snapshots:
dependencies: dependencies:
lru.min: 1.1.4 lru.min: 1.1.4
nanoid@3.3.11: {}
napi-postinstall@0.3.4: {} napi-postinstall@0.3.4: {}
natural-compare@1.4.0: {} natural-compare@1.4.0: {}

View File

@@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AppConfigModule } from './config/config.module'; import { AppConfigModule } from './config/config.module';
import { WechatGameModule } from './modules/wechat-game/wechat-game.module'; import { WechatGameModule } from './modules/wechat-game/wechat-game.module';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { ShareModule } from './modules/share/share.module';
@Module({ @Module({
imports: [ imports: [
@@ -26,6 +27,7 @@ import { AuthModule } from './modules/auth/auth.module';
}), }),
WechatGameModule, WechatGameModule,
AuthModule, AuthModule,
ShareModule,
], ],
}) })
export class AppModule {} export class AppModule {}

View 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[];
}

View 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[];
}

View 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;
}

View 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;
}

View 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 } });
}
}

View File

@@ -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 } });
}
}

View 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);
}
}

View 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 {}

View 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,
};
}
}

View File

@@ -3,5 +3,6 @@ import { Level } from '../entities/level.entity';
export interface ILevelRepository { export interface ILevelRepository {
findAll(): Promise<Level[]>; findAll(): Promise<Level[]>;
findById(id: string): Promise<Level | null>; findById(id: string): Promise<Level | null>;
findByIds(ids: string[]): Promise<Level[]>;
findAllOrdered(): Promise<Level[]>; findAllOrdered(): Promise<Level[]>;
} }

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { In, Repository } from 'typeorm';
import { Level } from '../entities/level.entity'; import { Level } from '../entities/level.entity';
import { ILevelRepository } from './level.repository.interface'; import { ILevelRepository } from './level.repository.interface';
@@ -19,6 +19,11 @@ export class LevelRepository implements ILevelRepository {
return this.repository.findOne({ where: { id } }); 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[]> { async findAllOrdered(): Promise<Level[]> {
return this.repository.find({ return this.repository.find({
order: { sortOrder: 'ASC' }, order: { sortOrder: 'ASC' },

View File

@@ -11,6 +11,6 @@ import { LevelRepository } from './repositories/level.repository';
imports: [TypeOrmModule.forFeature([GameConfig, Level])], imports: [TypeOrmModule.forFeature([GameConfig, Level])],
controllers: [WechatGameController], controllers: [WechatGameController],
providers: [WechatGameService, GameConfigRepository, LevelRepository], providers: [WechatGameService, GameConfigRepository, LevelRepository],
exports: [WechatGameService], exports: [WechatGameService, LevelRepository],
}) })
export class WechatGameModule {} export class WechatGameModule {}