From 3a1b4d22bfee9cafee25465a2d6599542ff8d5db Mon Sep 17 00:00:00 2001 From: richarjiang Date: Mon, 6 Apr 2026 17:32:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=85=B3=E5=8D=A1?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=88=86=E4=BA=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 +- pnpm-lock.yaml | 10 ++ src/app.module.ts | 2 + src/modules/share/dto/create-share.dto.ts | 27 +++++ src/modules/share/dto/share-response.dto.ts | 49 ++++++++ .../share/entities/share-config.entity.ts | 46 ++++++++ .../entities/share-participant.entity.ts | 37 ++++++ .../repositories/share-config.repository.ts | 21 ++++ .../share-participant.repository.ts | 30 +++++ src/modules/share/share.controller.ts | 54 +++++++++ src/modules/share/share.module.ts | 21 ++++ src/modules/share/share.service.ts | 111 ++++++++++++++++++ .../level.repository.interface.ts | 1 + .../repositories/level.repository.ts | 7 +- src/modules/wechat-game/wechat-game.module.ts | 2 +- 15 files changed, 418 insertions(+), 3 deletions(-) create mode 100644 src/modules/share/dto/create-share.dto.ts create mode 100644 src/modules/share/dto/share-response.dto.ts create mode 100644 src/modules/share/entities/share-config.entity.ts create mode 100644 src/modules/share/entities/share-participant.entity.ts create mode 100644 src/modules/share/repositories/share-config.repository.ts create mode 100644 src/modules/share/repositories/share-participant.repository.ts create mode 100644 src/modules/share/share.controller.ts create mode 100644 src/modules/share/share.module.ts create mode 100644 src/modules/share/share.service.ts diff --git a/package.json b/package.json index 600a8ad..8dbc8fe 100644 --- a/package.json +++ b/package.json @@ -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" @@ -78,4 +79,4 @@ "coverageDirectory": "../coverage", "testEnvironment": "node" } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 343f66a..da84d8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/app.module.ts b/src/app.module.ts index cbc6129..37e158d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 {} diff --git a/src/modules/share/dto/create-share.dto.ts b/src/modules/share/dto/create-share.dto.ts new file mode 100644 index 0000000..c94be9d --- /dev/null +++ b/src/modules/share/dto/create-share.dto.ts @@ -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[]; +} diff --git a/src/modules/share/dto/share-response.dto.ts b/src/modules/share/dto/share-response.dto.ts new file mode 100644 index 0000000..02e0ce9 --- /dev/null +++ b/src/modules/share/dto/share-response.dto.ts @@ -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[]; +} diff --git a/src/modules/share/entities/share-config.entity.ts b/src/modules/share/entities/share-config.entity.ts new file mode 100644 index 0000000..2ab7338 --- /dev/null +++ b/src/modules/share/entities/share-config.entity.ts @@ -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; +} diff --git a/src/modules/share/entities/share-participant.entity.ts b/src/modules/share/entities/share-participant.entity.ts new file mode 100644 index 0000000..85e1d0b --- /dev/null +++ b/src/modules/share/entities/share-participant.entity.ts @@ -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; +} diff --git a/src/modules/share/repositories/share-config.repository.ts b/src/modules/share/repositories/share-config.repository.ts new file mode 100644 index 0000000..b72ccf8 --- /dev/null +++ b/src/modules/share/repositories/share-config.repository.ts @@ -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, + ) {} + + async create(data: Partial): Promise { + const entity = this.repository.create(data); + return this.repository.save(entity); + } + + async findByShareCode(code: string): Promise { + return this.repository.findOne({ where: { shareCode: code } }); + } +} diff --git a/src/modules/share/repositories/share-participant.repository.ts b/src/modules/share/repositories/share-participant.repository.ts new file mode 100644 index 0000000..835a4d4 --- /dev/null +++ b/src/modules/share/repositories/share-participant.repository.ts @@ -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, + ) {} + + /** 添加参与者(已存在则忽略) */ + async addParticipant( + shareConfigId: string, + participantId: string, + ): Promise { + await this.repository + .createQueryBuilder() + .insert() + .into(ShareParticipant) + .values({ shareConfigId, participantId }) + .orIgnore() + .execute(); + } + + async countByShareConfigId(shareConfigId: string): Promise { + return this.repository.count({ where: { shareConfigId } }); + } +} diff --git a/src/modules/share/share.controller.ts b/src/modules/share/share.controller.ts new file mode 100644 index 0000000..d0f8bfa --- /dev/null +++ b/src/modules/share/share.controller.ts @@ -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> { + 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> { + const data = await this.shareService.joinShare(user.sub, code); + return ApiResponseDto.success(data); + } +} diff --git a/src/modules/share/share.module.ts b/src/modules/share/share.module.ts new file mode 100644 index 0000000..fb35973 --- /dev/null +++ b/src/modules/share/share.module.ts @@ -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 {} diff --git a/src/modules/share/share.service.ts b/src/modules/share/share.service.ts new file mode 100644 index 0000000..79faca1 --- /dev/null +++ b/src/modules/share/share.service.ts @@ -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 { + 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 { + 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, + }; + } +} diff --git a/src/modules/wechat-game/repositories/level.repository.interface.ts b/src/modules/wechat-game/repositories/level.repository.interface.ts index 474298b..7a961ce 100644 --- a/src/modules/wechat-game/repositories/level.repository.interface.ts +++ b/src/modules/wechat-game/repositories/level.repository.interface.ts @@ -3,5 +3,6 @@ import { Level } from '../entities/level.entity'; export interface ILevelRepository { findAll(): Promise; findById(id: string): Promise; + findByIds(ids: string[]): Promise; findAllOrdered(): Promise; } diff --git a/src/modules/wechat-game/repositories/level.repository.ts b/src/modules/wechat-game/repositories/level.repository.ts index 1846aa4..e9f9fcd 100644 --- a/src/modules/wechat-game/repositories/level.repository.ts +++ b/src/modules/wechat-game/repositories/level.repository.ts @@ -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 { + if (ids.length === 0) return []; + return this.repository.find({ where: { id: In(ids) } }); + } + async findAllOrdered(): Promise { return this.repository.find({ order: { sortOrder: 'ASC' }, diff --git a/src/modules/wechat-game/wechat-game.module.ts b/src/modules/wechat-game/wechat-game.module.ts index 7865c1c..d3884d5 100644 --- a/src/modules/wechat-game/wechat-game.module.ts +++ b/src/modules/wechat-game/wechat-game.module.ts @@ -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 {}