feat(share): 分享挑战关卡进度记录功能
- 新增 Level.timeLimit 字段支持关卡时间限制 - 新增 ShareLevelProgress 实体记录单关通关进度 - 新增 ShareLevelProgressRepository - 新增 DTO: ReportLevelProgressDto, ReportLevelProgressResponseDto - 新增 POST /v1/share/progress 接口用于上报进度 - 支持仅首次通关有效判断 - 支持时间限制内通关判断 - 不可变模式更新进度记录 - 数据库迁移脚本 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
20
src/database/migrations/002_add_share_level_progress.sql
Normal file
20
src/database/migrations/002_add_share_level_progress.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- Level 表增加 time_limit 字段
|
||||
ALTER TABLE levels
|
||||
ADD COLUMN time_limit INT DEFAULT NULL COMMENT '通关时间限制(秒),NULL 表示无限制'
|
||||
AFTER sort_order;
|
||||
|
||||
-- 新建 share_level_progress 表
|
||||
CREATE TABLE IF NOT EXISTS share_level_progress (
|
||||
id CHAR(36) PRIMARY KEY,
|
||||
participant_id CHAR(36) NOT NULL COMMENT '关联 share_participants.id',
|
||||
level_id CHAR(191) NOT NULL COMMENT '关卡ID',
|
||||
passed TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否通过',
|
||||
time_spent INT NOT NULL DEFAULT 0 COMMENT '通关时间(秒)',
|
||||
completed_at DATETIME DEFAULT NULL COMMENT '通关时间戳',
|
||||
|
||||
UNIQUE KEY uq_participant_level (participant_id, level_id),
|
||||
INDEX idx_slp_participant_id (participant_id),
|
||||
INDEX idx_slp_level_id (level_id),
|
||||
|
||||
FOREIGN KEY (participant_id) REFERENCES share_participants(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
23
src/modules/share/dto/report-level-progress.dto.ts
Normal file
23
src/modules/share/dto/report-level-progress.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
|
||||
|
||||
export class ReportLevelProgressDto {
|
||||
@ApiProperty({ description: '分享码' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
shareCode!: string;
|
||||
|
||||
@ApiProperty({ description: '关卡 ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
levelId!: string;
|
||||
|
||||
@ApiProperty({ description: '是否通过' })
|
||||
@IsBoolean()
|
||||
passed!: boolean;
|
||||
|
||||
@ApiProperty({ description: '通关时间(秒)' })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
timeSpent!: number;
|
||||
}
|
||||
12
src/modules/share/dto/share-level-progress-response.dto.ts
Normal file
12
src/modules/share/dto/share-level-progress-response.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ReportLevelProgressResponseDto {
|
||||
@ApiProperty({ description: '是否通过' })
|
||||
passed!: boolean;
|
||||
|
||||
@ApiProperty({ description: '该关卡时间限制(秒),null 表示无限制' })
|
||||
timeLimit!: number | null;
|
||||
|
||||
@ApiProperty({ description: '是否在时间限制内通过' })
|
||||
withinTimeLimit!: boolean;
|
||||
}
|
||||
44
src/modules/share/entities/share-level-progress.entity.ts
Normal file
44
src/modules/share/entities/share-level-progress.entity.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
import { ShareParticipant } from './share-participant.entity';
|
||||
import { Level } from '../../wechat-game/entities/level.entity';
|
||||
|
||||
@Entity('share_level_progress')
|
||||
@Unique('uq_participant_level', ['participantId', 'levelId'])
|
||||
export class ShareLevelProgress {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Index('idx_slp_participant_id')
|
||||
@Column({ type: 'char', length: 36, name: 'participant_id' })
|
||||
participantId!: string;
|
||||
|
||||
@ManyToOne(() => ShareParticipant)
|
||||
@JoinColumn({ name: 'participant_id' })
|
||||
participant!: ShareParticipant;
|
||||
|
||||
@Index('idx_slp_level_id')
|
||||
@Column({ type: 'char', length: 191, name: 'level_id' })
|
||||
levelId!: string;
|
||||
|
||||
@ManyToOne(() => Level)
|
||||
@JoinColumn({ name: 'level_id' })
|
||||
level!: Level;
|
||||
|
||||
@Column({ type: 'tinyint', width: 1, default: 0 })
|
||||
passed!: boolean;
|
||||
|
||||
@Column({ type: 'int', default: 0, name: 'time_spent' })
|
||||
timeSpent!: number;
|
||||
|
||||
@CreateDateColumn({ name: 'completed_at' })
|
||||
completedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ShareLevelProgress } from '../entities/share-level-progress.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ShareLevelProgressRepository {
|
||||
constructor(
|
||||
@InjectRepository(ShareLevelProgress)
|
||||
private readonly repository: Repository<ShareLevelProgress>,
|
||||
) {}
|
||||
|
||||
async findByParticipantId(participantId: string): Promise<ShareLevelProgress[]> {
|
||||
return this.repository.find({ where: { participantId } });
|
||||
}
|
||||
|
||||
async findByParticipantAndLevel(
|
||||
participantId: string,
|
||||
levelId: string,
|
||||
): Promise<ShareLevelProgress | null> {
|
||||
return this.repository.findOne({ where: { participantId, levelId } });
|
||||
}
|
||||
|
||||
create(data: Partial<ShareLevelProgress>): ShareLevelProgress {
|
||||
return this.repository.create(data);
|
||||
}
|
||||
|
||||
async save(progress: ShareLevelProgress): Promise<ShareLevelProgress> {
|
||||
return this.repository.save(progress);
|
||||
}
|
||||
}
|
||||
@@ -27,4 +27,19 @@ export class ShareParticipantRepository {
|
||||
async countByShareConfigId(shareConfigId: string): Promise<number> {
|
||||
return this.repository.count({ where: { shareConfigId } });
|
||||
}
|
||||
|
||||
async findByShareConfigAndParticipant(
|
||||
shareConfigId: string,
|
||||
participantId: string,
|
||||
): Promise<ShareParticipant | null> {
|
||||
return this.repository.findOne({ where: { shareConfigId, participantId } });
|
||||
}
|
||||
|
||||
create(data: Partial<ShareParticipant>): ShareParticipant {
|
||||
return this.repository.create(data);
|
||||
}
|
||||
|
||||
async save(participant: ShareParticipant): Promise<ShareParticipant> {
|
||||
return this.repository.save(participant);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ 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';
|
||||
import { ReportLevelProgressDto } from './dto/report-level-progress.dto';
|
||||
import { ReportLevelProgressResponseDto } from './dto/share-level-progress-response.dto';
|
||||
|
||||
@ApiTags('分享挑战')
|
||||
@Controller('v1/share')
|
||||
@@ -54,4 +56,21 @@ export class ShareController {
|
||||
const data = await this.shareService.joinShare(user.sub, code);
|
||||
return ApiResponseDto.success(data);
|
||||
}
|
||||
|
||||
@Post('progress')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '上报单关进度',
|
||||
description: '用户在分享挑战中通关后上报进度,仅首次通关(passed=true)有效',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '成功' })
|
||||
@ApiResponse({ status: 404, description: '分享或关卡不存在' })
|
||||
async reportLevelProgress(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: ReportLevelProgressDto,
|
||||
): Promise<ApiResponseDto<ReportLevelProgressResponseDto>> {
|
||||
const data = await this.shareService.reportLevelProgress(user.sub, dto);
|
||||
return ApiResponseDto.success(data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,18 +4,29 @@ 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 { ShareLevelProgress } from './entities/share-level-progress.entity';
|
||||
import { ShareConfigRepository } from './repositories/share-config.repository';
|
||||
import { ShareParticipantRepository } from './repositories/share-participant.repository';
|
||||
import { ShareLevelProgressRepository } from './repositories/share-level-progress.repository';
|
||||
import { WechatGameModule } from '../wechat-game/wechat-game.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ShareConfig, ShareParticipant]),
|
||||
TypeOrmModule.forFeature([
|
||||
ShareConfig,
|
||||
ShareParticipant,
|
||||
ShareLevelProgress,
|
||||
]),
|
||||
WechatGameModule,
|
||||
AuthModule,
|
||||
],
|
||||
controllers: [ShareController],
|
||||
providers: [ShareService, ShareConfigRepository, ShareParticipantRepository],
|
||||
providers: [
|
||||
ShareService,
|
||||
ShareConfigRepository,
|
||||
ShareParticipantRepository,
|
||||
ShareLevelProgressRepository,
|
||||
],
|
||||
})
|
||||
export class ShareModule {}
|
||||
|
||||
@@ -6,13 +6,16 @@ import {
|
||||
import { nanoid } from 'nanoid';
|
||||
import { ShareConfigRepository } from './repositories/share-config.repository';
|
||||
import { ShareParticipantRepository } from './repositories/share-participant.repository';
|
||||
import { ShareLevelProgressRepository } from './repositories/share-level-progress.repository';
|
||||
import { LevelRepository } from '../wechat-game/repositories/level.repository';
|
||||
import { CreateShareDto } from './dto/create-share.dto';
|
||||
import { ReportLevelProgressDto } from './dto/report-level-progress.dto';
|
||||
import {
|
||||
CreateShareResponseDto,
|
||||
JoinShareResponseDto,
|
||||
ShareLevelDto,
|
||||
} from './dto/share-response.dto';
|
||||
import { ReportLevelProgressResponseDto } from './dto/share-level-progress-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ShareService {
|
||||
@@ -20,6 +23,7 @@ export class ShareService {
|
||||
private readonly shareConfigRepository: ShareConfigRepository,
|
||||
private readonly shareParticipantRepository: ShareParticipantRepository,
|
||||
private readonly levelRepository: LevelRepository,
|
||||
private readonly shareLevelProgressRepository: ShareLevelProgressRepository,
|
||||
) {}
|
||||
|
||||
async createShare(
|
||||
@@ -105,4 +109,97 @@ export class ShareService {
|
||||
levels,
|
||||
};
|
||||
}
|
||||
|
||||
async reportLevelProgress(
|
||||
userId: string,
|
||||
dto: ReportLevelProgressDto,
|
||||
): Promise<ReportLevelProgressResponseDto> {
|
||||
// 1. 查找分享配置
|
||||
const config = await this.shareConfigRepository.findByShareCode(
|
||||
dto.shareCode,
|
||||
);
|
||||
if (!config) {
|
||||
throw new NotFoundException('分享不存在或已过期');
|
||||
}
|
||||
|
||||
// 2. 查找或创建 ShareParticipant
|
||||
let participant =
|
||||
await this.shareParticipantRepository.findByShareConfigAndParticipant(
|
||||
config.id,
|
||||
userId,
|
||||
);
|
||||
if (!participant) {
|
||||
participant = await this.shareParticipantRepository.create({
|
||||
shareConfigId: config.id,
|
||||
participantId: userId,
|
||||
});
|
||||
participant = await this.shareParticipantRepository.save(participant);
|
||||
}
|
||||
|
||||
// 3. 如果 passed=true,检查是否已有通关记录
|
||||
if (dto.passed) {
|
||||
const existing =
|
||||
await this.shareLevelProgressRepository.findByParticipantAndLevel(
|
||||
participant.id,
|
||||
dto.levelId,
|
||||
);
|
||||
if (existing?.passed) {
|
||||
const existingLevel = await this.levelRepository.findById(dto.levelId);
|
||||
if (!existingLevel) {
|
||||
throw new NotFoundException('关卡不存在');
|
||||
}
|
||||
const wasWithinTimeLimit =
|
||||
existingLevel.timeLimit === null ||
|
||||
existing.timeSpent <= existingLevel.timeLimit;
|
||||
return {
|
||||
passed: true,
|
||||
timeLimit: existingLevel.timeLimit,
|
||||
withinTimeLimit: wasWithinTimeLimit,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 查找关卡获取时间限制
|
||||
const level = await this.levelRepository.findById(dto.levelId);
|
||||
if (!level) {
|
||||
throw new NotFoundException('关卡不存在');
|
||||
}
|
||||
|
||||
// 5. 判断是否在时间限制内通过
|
||||
const withinTimeLimit = dto.passed
|
||||
? level.timeLimit === null || dto.timeSpent <= level.timeLimit
|
||||
: false;
|
||||
|
||||
// 6. 创建或更新进度
|
||||
let progress =
|
||||
await this.shareLevelProgressRepository.findByParticipantAndLevel(
|
||||
participant.id,
|
||||
dto.levelId,
|
||||
);
|
||||
|
||||
if (progress) {
|
||||
progress = this.shareLevelProgressRepository.create({
|
||||
...progress,
|
||||
passed: dto.passed,
|
||||
timeSpent: dto.timeSpent,
|
||||
completedAt: dto.passed ? new Date() : progress.completedAt,
|
||||
});
|
||||
} else {
|
||||
progress = this.shareLevelProgressRepository.create({
|
||||
participantId: participant.id,
|
||||
levelId: dto.levelId,
|
||||
passed: dto.passed,
|
||||
timeSpent: dto.timeSpent,
|
||||
completedAt: dto.passed ? new Date() : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
await this.shareLevelProgressRepository.save(progress);
|
||||
|
||||
return {
|
||||
passed: dto.passed,
|
||||
timeLimit: level.timeLimit,
|
||||
withinTimeLimit,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ export class Level {
|
||||
@Column({ type: 'int', name: 'sort_order', default: 0 })
|
||||
sortOrder!: number;
|
||||
|
||||
@Column({ type: 'int', name: 'time_limit', nullable: true, default: null })
|
||||
timeLimit!: number | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user