From 3d52cfe8437ee3de059891dce56ba150e64000d4 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Wed, 8 Apr 2026 11:46:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(share):=20=E5=88=86=E4=BA=AB=E6=8C=91?= =?UTF-8?q?=E6=88=98=E5=85=B3=E5=8D=A1=E8=BF=9B=E5=BA=A6=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Level.timeLimit 字段支持关卡时间限制 - 新增 ShareLevelProgress 实体记录单关通关进度 - 新增 ShareLevelProgressRepository - 新增 DTO: ReportLevelProgressDto, ReportLevelProgressResponseDto - 新增 POST /v1/share/progress 接口用于上报进度 - 支持仅首次通关有效判断 - 支持时间限制内通关判断 - 不可变模式更新进度记录 - 数据库迁移脚本 Co-Authored-By: Claude Opus 4.6 --- ...-07-share-level-progress-implementation.md | 509 ++++++++++++++++++ .../2026-04-07-share-level-progress-design.md | 174 ++++++ .../002_add_share_level_progress.sql | 20 + .../share/dto/report-level-progress.dto.ts | 23 + .../dto/share-level-progress-response.dto.ts | 12 + .../entities/share-level-progress.entity.ts | 44 ++ .../share-level-progress.repository.ts | 31 ++ .../share-participant.repository.ts | 15 + src/modules/share/share.controller.ts | 19 + src/modules/share/share.module.ts | 15 +- src/modules/share/share.service.ts | 97 ++++ .../wechat-game/entities/level.entity.ts | 3 + 12 files changed, 960 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-07-share-level-progress-implementation.md create mode 100644 docs/superpowers/specs/2026-04-07-share-level-progress-design.md create mode 100644 src/database/migrations/002_add_share_level_progress.sql create mode 100644 src/modules/share/dto/report-level-progress.dto.ts create mode 100644 src/modules/share/dto/share-level-progress-response.dto.ts create mode 100644 src/modules/share/entities/share-level-progress.entity.ts create mode 100644 src/modules/share/repositories/share-level-progress.repository.ts diff --git a/docs/superpowers/plans/2026-04-07-share-level-progress-implementation.md b/docs/superpowers/plans/2026-04-07-share-level-progress-implementation.md new file mode 100644 index 0000000..7a368b3 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-share-level-progress-implementation.md @@ -0,0 +1,509 @@ +# 分享挑战关卡进度记录功能实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现用户在分享挑战中的单关通关进度上报功能,支持记录通关时间、是否通过、时间限制判断。 + +**Architecture:** 在现有 Share 模块基础上,新增 ShareLevelProgress 实体和 Repository,通过 ShareService.reportLevelProgress 方法处理业务逻辑,在 ShareController 新增 POST /v1/share/progress 接口。 + +**Tech Stack:** NestJS, TypeORM, MySQL + +--- + +## 文件清单 + +| 操作 | 文件路径 | +|------|----------| +| 新增 | src/modules/share/entities/share-level-progress.entity.ts | +| 新增 | src/modules/share/repositories/share-level-progress.repository.ts | +| 新增 | src/modules/share/dto/report-level-progress.dto.ts | +| 新增 | src/modules/share/dto/share-level-progress-response.dto.ts | +| 修改 | src/modules/share/repositories/share-participant.repository.ts | +| 修改 | src/modules/share/share.service.ts | +| 修改 | src/modules/share/share.controller.ts | +| 修改 | src/modules/share/share.module.ts | +| 修改 | src/modules/wechat-game/entities/level.entity.ts | +| 修改 | src/database/migrations/*.sql | + +--- + +## Task 1: Level 实体增加 timeLimit 字段 + +**Files:** +- Modify: `src/modules/wechat-game/entities/level.entity.ts` + +- [ ] **Step 1: 修改 Level 实体** + +```typescript +// src/modules/wechat-game/entities/level.entity.ts +// 在 sortOrder 字段后添加 + +@Column({ type: 'int', name: 'time_limit', nullable: true, default: null }) +timeLimit!: number | null; +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/modules/wechat-game/entities/level.entity.ts +git commit -m "feat(level): add timeLimit field for level time restriction" +``` + +--- + +## Task 2: 创建 ShareLevelProgress 实体 + +**Files:** +- Create: `src/modules/share/entities/share-level-progress.entity.ts` + +- [ ] **Step 1: 创建实体文件** + +```typescript +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; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/modules/share/entities/share-level-progress.entity.ts +git commit -m "feat(share): add ShareLevelProgress entity" +``` + +--- + +## Task 3: 创建 ShareLevelProgressRepository + +**Files:** +- Create: `src/modules/share/repositories/share-level-progress.repository.ts` + +- [ ] **Step 1: 创建 Repository** + +```typescript +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, + ) {} + + async findByParticipantId(participantId: string): Promise { + return this.repository.find({ where: { participantId } }); + } + + async findByParticipantAndLevel( + participantId: string, + levelId: string, + ): Promise { + return this.repository.findOne({ where: { participantId, levelId } }); + } + + create(data: Partial): ShareLevelProgress { + return this.repository.create(data); + } + + async save(progress: ShareLevelProgress): Promise { + return this.repository.save(progress); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/modules/share/repositories/share-level-progress.repository.ts +git commit -m "feat(share): add ShareLevelProgressRepository" +``` + +--- + +## Task 4: 创建 DTO 文件 + +**Files:** +- Create: `src/modules/share/dto/report-level-progress.dto.ts` +- Create: `src/modules/share/dto/share-level-progress-response.dto.ts` + +- [ ] **Step 1: 创建 ReportLevelProgressDto** + +```typescript +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; +} +``` + +- [ ] **Step 2: 创建 ReportLevelProgressResponseDto** + +```typescript +import { ApiProperty } from '@nestjs/swagger'; + +export class ReportLevelProgressResponseDto { + @ApiProperty({ description: '是否通过' }) + passed!: boolean; + + @ApiProperty({ description: '该关卡时间限制(秒),null 表示无限制' }) + timeLimit!: number | null; + + @ApiProperty({ description: '是否在时间限制内通过' }) + withinTimeLimit!: boolean; +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/modules/share/dto/report-level-progress.dto.ts src/modules/share/dto/share-level-progress-response.dto.ts +git commit -m "feat(share): add DTOs for level progress reporting" +``` + +--- + +## Task 5: ShareParticipantRepository 补充方法 + +**Files:** +- Modify: `src/modules/share/repositories/share-participant.repository.ts` + +- [ ] **Step 1: 读取现有文件确认内容** + +```typescript +// src/modules/share/repositories/share-participant.repository.ts +// 在现有方法后添加 findByShareConfigAndParticipant 方法 +``` + +- [ ] **Step 2: 添加新方法** + +```typescript +async findByShareConfigAndParticipant( + shareConfigId: string, + participantId: string, +): Promise { + return this.repository.findOne({ where: { shareConfigId, participantId } }); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/modules/share/repositories/share-participant.repository.ts +git commit -m "feat(share): add findByShareConfigAndParticipant to ShareParticipantRepository" +``` + +--- + +## Task 6: ShareService 新增 reportLevelProgress 方法 + +**Files:** +- Modify: `src/modules/share/share.service.ts` + +- [ ] **Step 1: 读取现有文件确认 import 和 constructor** + +```typescript +// 需要新增的 imports +import { ShareLevelProgressRepository } from './repositories/share-level-progress.repository'; +import { ReportLevelProgressDto } from './dto/report-level-progress.dto'; +import { ReportLevelProgressResponseDto } from './dto/share-level-progress-response.dto'; +``` + +- [ ] **Step 2: 在 ShareService 中添加方法** + +```typescript +async reportLevelProgress( + userId: string, + dto: ReportLevelProgressDto, +): Promise { + // 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) { + return { + passed: true, + timeLimit: null, + withinTimeLimit: false, + }; + } + } + + // 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.passed = dto.passed; + progress.timeSpent = dto.timeSpent; + if (dto.passed) { + progress.completedAt = new Date(); + } + } else { + progress = this.shareLevelProgressRepository.create({ + participantId: participant.id, + levelId: dto.levelId, + passed: dto.passed, + timeSpent: dto.timeSpent, + completedAt: dto.passed ? new Date() : null, + }); + } + + await this.shareLevelProgressRepository.save(progress); + + return { + passed: dto.passed, + timeLimit: level.timeLimit, + withinTimeLimit, + }; +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/modules/share/share.service.ts +git commit -m "feat(share): add reportLevelProgress method" +``` + +--- + +## Task 7: ShareController 新增接口 + +**Files:** +- Modify: `src/modules/share/share.controller.ts` + +- [ ] **Step 1: 添加 import** + +```typescript +import { ReportLevelProgressDto } from './dto/report-level-progress.dto'; +import { ReportLevelProgressResponseDto } from './dto/share-level-progress-response.dto'; +``` + +- [ ] **Step 2: 添加 Controller 方法** + +```typescript +@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> { + const data = await this.shareService.reportLevelProgress(user.sub, dto); + return ApiResponseDto.success(data); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/modules/share/share.controller.ts +git commit -m "feat(share): add POST /v1/share/progress endpoint" +``` + +--- + +## Task 8: ShareModule 更新 + +**Files:** +- Modify: `src/modules/share/share.module.ts` + +- [ ] **Step 1: 修改 import 和 providers** + +```typescript +import { ShareLevelProgress } from './entities/share-level-progress.entity'; +import { ShareLevelProgressRepository } from './repositories/share-level-progress.repository'; + +// TypeOrmModule.forFeature 中添加 ShareLevelProgress +TypeOrmModule.forFeature([ShareConfig, ShareParticipant, ShareLevelProgress]), + +// providers 中添加 ShareLevelProgressRepository +providers: [ + ShareService, + ShareConfigRepository, + ShareParticipantRepository, + ShareLevelProgressRepository, +], +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/modules/share/share.module.ts +git commit -m "feat(share): register ShareLevelProgress in ShareModule" +``` + +--- + +## Task 9: 数据库迁移 SQL + +**Files:** +- Create: `src/database/migrations/002_add_share_level_progress.sql`(或按项目规范) + +- [ ] **Step 1: 创建迁移 SQL** + +```sql +-- 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; +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/database/migrations/002_add_share_level_progress.sql +git commit -m "chore: add migration for share level progress tables" +``` + +--- + +## Task 10: 编译验证 + +- [ ] **Step 1: 运行 TypeScript 编译检查** + +```bash +cd /Users/richard/Documents/code/xieyingeng/MemeMind-Server && npx tsc --noEmit +``` + +预期:无编译错误 + +- [ ] **Step 2: 如果有错误,修复后重新编译** + +- [ ] **Step 3: Commit(如果有代码修改)** + +--- + +## 自检清单 + +完成实现后,对照设计文档检查: + +- [ ] Level 实体有 `timeLimit` 字段 +- [ ] ShareLevelProgress 实体有 `participantId`, `levelId`, `passed`, `timeSpent`, `completedAt` 字段 +- [ ] ShareLevelProgress 有唯一索引 `(participantId, levelId)` +- [ ] ShareLevelProgressRepository 有 `findByParticipantId`, `findByParticipantAndLevel`, `create`, `save` 方法 +- [ ] ShareParticipantRepository 有 `findByShareConfigAndParticipant` 方法 +- [ ] ShareService.reportLevelProgress 实现了完整业务逻辑 +- [ ] Controller 接口为 `POST /v1/share/progress` +- [ ] DTO 包含 `shareCode`, `levelId`, `passed`, `timeSpent` +- [ ] 响应 DTO 包含 `passed`, `timeLimit`, `withinTimeLimit` +- [ ] ShareModule 注册了 ShareLevelProgress 实体和 Repository +- [ ] 数据库迁移 SQL 包含 Level 表修改和 share_level_progress 表创建 diff --git a/docs/superpowers/specs/2026-04-07-share-level-progress-design.md b/docs/superpowers/specs/2026-04-07-share-level-progress-design.md new file mode 100644 index 0000000..124d442 --- /dev/null +++ b/docs/superpowers/specs/2026-04-07-share-level-progress-design.md @@ -0,0 +1,174 @@ +# 分享挑战关卡进度记录功能设计 + +## 背景 + +现有好友挑战分享能力支持用户加入分享挑战并获取6个关卡的数据。需要在分享挑战场景下记录用户的单关通关进度,为后续的排行统计和规定时间内通关统计提供数据基础。 + +## 需求理解 + +1. **记录用户在分享挑战下的单关通关数据**(通关时间、是否通过) +2. **不包含积分**(积分已有独立体系) +3. **仅首次通关有效**(passed=true 时不可覆盖) +4. **每个关卡有独立时间限制**(存储在 Level 表的 `timeLimit` 字段) +5. **同一关卡在不同分享挑战中分别记录** + +## 数据库设计 + +### 1. Level 表扩展 + +```sql +ALTER TABLE levels ADD COLUMN time_limit INT DEFAULT NULL COMMENT '通关时间限制(秒),NULL 表示无限制'; +``` + +### 2. 新建 ShareLevelProgress 表 + +```sql +CREATE TABLE 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_participant_id (participant_id), + INDEX idx_level_id (level_id), + + FOREIGN KEY (participant_id) REFERENCES share_participants(id) ON DELETE CASCADE +); +``` + +### 3. ER 关系 + +``` +ShareConfig (1) ──< ShareParticipant (多) + │ + └─< ShareLevelProgress (多) >── Level (1) +``` + +## 实体设计 + +### ShareLevelProgress 实体 + +文件:`src/modules/share/entities/share-level-progress.entity.ts` + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | uuid | 主键 | +| participantId | varchar | 关联 ShareParticipant | +| levelId | varchar | 关卡ID | +| passed | boolean | 是否通过 | +| timeSpent | int | 通关时间(秒) | +| completedAt | datetime | 通关时间戳 | + +唯一索引:`uq_participant_level(participant_id, level_id)` + +## Repository 设计 + +### IShareLevelProgressRepository 接口 + +```typescript +interface IShareLevelProgressRepository { + findByParticipantId(participantId: string): Promise; + findByParticipantAndLevel(participantId: string, levelId: string): Promise; + create(data: Partial): ShareLevelProgress; + save(progress: ShareLevelProgress): Promise; +} +``` + +实现:`src/modules/share/repositories/share-level-progress.repository.ts` + +### ShareParticipantRepository 补充方法 + +```typescript +findByShareConfigAndParticipant( + shareConfigId: string, + participantId: string, +): Promise; +``` + +## DTO 设计 + +### ReportLevelProgressDto(请求) + +| 字段 | 类型 | 校验 | 说明 | +|------|------|------|------| +| shareCode | string | @IsString, @IsNotEmpty | 分享码 | +| levelId | string | @IsString, @IsNotEmpty | 关卡ID | +| passed | boolean | @IsBoolean | 是否通过 | +| timeSpent | number | @IsNumber, @Min(0) | 通关时间(秒) | + +### ReportLevelProgressResponseDto(响应) + +| 字段 | 类型 | 说明 | +|------|------|------| +| passed | boolean | 是否通过 | +| timeLimit | number \| null | 该关卡时间限制(秒),null 表示无限制 | +| withinTimeLimit | boolean | 是否在时间限制内通过(仅 passed=true 时有效) | + +## Service 层设计 + +### 新增方法:reportLevelProgress + +业务逻辑: + +1. 根据 shareCode 查找 ShareConfig +2. 查找或创建 ShareParticipant(分享者和参与者都需要) +3. 如果 passed=true,检查是否已有通关记录(存在则直接返回,不覆盖) +4. 查找关卡获取 timeLimit +5. 判断是否在时间限制内通过 +6. 创建或更新进度记录 + +### 核心规则 + +- `passed=true` 时仅首次有效,不可覆盖 +- `passed=false` 时允许重复上报(记录用户重玩) +- `withinTimeLimit = passed && (timeLimit === null || timeSpent <= timeLimit)` + +## Controller 设计 + +### 接口 + +``` +POST /v1/share/progress +Authorization: Bearer +``` + +```typescript +async reportLevelProgress( + @CurrentUser() user: JwtPayload, + @Body() dto: ReportLevelProgressDto, +): Promise> +``` + +## Module 变更 + +### ShareModule + +- 导入 `ShareLevelProgress` 实体 +- 注册 `ShareLevelProgressRepository` + +## 后续扩展 + +以下功能不在本次范围内,仅记录设计预留: + +1. **排行查询**:按分享挑战统计用户总通关时间排名 +2. **规定时间内通过关数统计**:统计某用户在挑战中的 withinTimeLimit 关数 +3. **历史记录**:支持查看用户的重玩记录 + +## 文件清单 + +| 操作 | 文件路径 | +|------|----------| +| 新增 | src/modules/share/entities/share-level-progress.entity.ts | +| 新增 | src/modules/share/repositories/share-level-progress.repository.interface.ts | +| 新增 | src/modules/share/repositories/share-level-progress.repository.ts | +| 新增 | src/modules/share/dto/report-level-progress.dto.ts | +| 新增 | src/modules/share/dto/share-level-progress-response.dto.ts | +| 修改 | src/modules/share/repositories/share-participant.repository.ts | +| 修改 | src/modules/share/share.service.ts | +| 修改 | src/modules/share/share.controller.ts | +| 修改 | src/modules/share/share.module.ts | +| 修改 | src/modules/wechat-game/entities/level.entity.ts | +| SQL | 数据库迁移脚本(Level 表 + ShareLevelProgress 表) | diff --git a/src/database/migrations/002_add_share_level_progress.sql b/src/database/migrations/002_add_share_level_progress.sql new file mode 100644 index 0000000..67da27c --- /dev/null +++ b/src/database/migrations/002_add_share_level_progress.sql @@ -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; diff --git a/src/modules/share/dto/report-level-progress.dto.ts b/src/modules/share/dto/report-level-progress.dto.ts new file mode 100644 index 0000000..6aa863f --- /dev/null +++ b/src/modules/share/dto/report-level-progress.dto.ts @@ -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; +} diff --git a/src/modules/share/dto/share-level-progress-response.dto.ts b/src/modules/share/dto/share-level-progress-response.dto.ts new file mode 100644 index 0000000..de373e5 --- /dev/null +++ b/src/modules/share/dto/share-level-progress-response.dto.ts @@ -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; +} diff --git a/src/modules/share/entities/share-level-progress.entity.ts b/src/modules/share/entities/share-level-progress.entity.ts new file mode 100644 index 0000000..e681dc1 --- /dev/null +++ b/src/modules/share/entities/share-level-progress.entity.ts @@ -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; +} diff --git a/src/modules/share/repositories/share-level-progress.repository.ts b/src/modules/share/repositories/share-level-progress.repository.ts new file mode 100644 index 0000000..63fcf25 --- /dev/null +++ b/src/modules/share/repositories/share-level-progress.repository.ts @@ -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, + ) {} + + async findByParticipantId(participantId: string): Promise { + return this.repository.find({ where: { participantId } }); + } + + async findByParticipantAndLevel( + participantId: string, + levelId: string, + ): Promise { + return this.repository.findOne({ where: { participantId, levelId } }); + } + + create(data: Partial): ShareLevelProgress { + return this.repository.create(data); + } + + async save(progress: ShareLevelProgress): Promise { + return this.repository.save(progress); + } +} diff --git a/src/modules/share/repositories/share-participant.repository.ts b/src/modules/share/repositories/share-participant.repository.ts index 835a4d4..b3b017d 100644 --- a/src/modules/share/repositories/share-participant.repository.ts +++ b/src/modules/share/repositories/share-participant.repository.ts @@ -27,4 +27,19 @@ export class ShareParticipantRepository { async countByShareConfigId(shareConfigId: string): Promise { return this.repository.count({ where: { shareConfigId } }); } + + async findByShareConfigAndParticipant( + shareConfigId: string, + participantId: string, + ): Promise { + return this.repository.findOne({ where: { shareConfigId, participantId } }); + } + + create(data: Partial): ShareParticipant { + return this.repository.create(data); + } + + async save(participant: ShareParticipant): Promise { + return this.repository.save(participant); + } } diff --git a/src/modules/share/share.controller.ts b/src/modules/share/share.controller.ts index 5215753..33b4497 100644 --- a/src/modules/share/share.controller.ts +++ b/src/modules/share/share.controller.ts @@ -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> { + const data = await this.shareService.reportLevelProgress(user.sub, dto); + return ApiResponseDto.success(data); + } } diff --git a/src/modules/share/share.module.ts b/src/modules/share/share.module.ts index fb35973..eda143c 100644 --- a/src/modules/share/share.module.ts +++ b/src/modules/share/share.module.ts @@ -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 {} diff --git a/src/modules/share/share.service.ts b/src/modules/share/share.service.ts index 17e2980..c810184 100644 --- a/src/modules/share/share.service.ts +++ b/src/modules/share/share.service.ts @@ -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 { + // 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, + }; + } } diff --git a/src/modules/wechat-game/entities/level.entity.ts b/src/modules/wechat-game/entities/level.entity.ts index 83beca5..5b089b9 100644 --- a/src/modules/wechat-game/entities/level.entity.ts +++ b/src/modules/wechat-game/entities/level.entity.ts @@ -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;