diff --git a/docs/api/share-challenge-api.md b/docs/api/share-challenge-api.md index 2f468c3..e3b8d57 100644 --- a/docs/api/share-challenge-api.md +++ b/docs/api/share-challenge-api.md @@ -422,12 +422,14 @@ Authorization: Bearer **业务逻辑说明**: -1. **首次通关记录**:只有首次 `passed=true` 才会记录通关时间 -2. **重复通关**:如果用户再次通关同一关卡(且之前已通过),返回之前记录的时间判断结果,不会覆盖 -3. **未通过**:可以多次上报 `passed=false`,更新通关时间记录 -4. **时间限制判断**: +1. **参与者登记**:非创建者首次上报进度时会自动写入 `share_participants`,后续重复上报已存在则忽略;创建者本人不会被登记为参与者。 +2. **首次通关记录**:只有首次 `passed=true` 才会记录通关时间 +3. **重复通关**:如果用户再次通关同一关卡(且之前已通过),返回之前记录的时间判断结果,不会覆盖 +4. **未通过**:可以多次上报 `passed=false`,更新通关时间记录 +5. **时间限制判断**: - 如果关卡 `timeLimit` 为 `null`,`withinTimeLimit` 始终为 `true` - 如果 `timeLimit` 不为 `null`,只有 `timeSpent <= timeLimit` 时 `withinTimeLimit` 才为 `true` +6. **跨挑战独立**:进度按 `(shareConfigId, participantId, levelId)` 唯一记录,同一用户在不同分享挑战中对同一关卡的进度互不影响。 **客户端调用场景**: diff --git a/src/database/migrations/005_share_participant_composite_pk.sql b/src/database/migrations/005_share_participant_composite_pk.sql new file mode 100644 index 0000000..40d76a7 --- /dev/null +++ b/src/database/migrations/005_share_participant_composite_pk.sql @@ -0,0 +1,68 @@ +-- Description: Align share_participants and share_level_progress with the +-- "share_participants is a pure association table without an id" model. +-- +-- Background: the production share_participants table has no `id` column — +-- its primary key is the (share_config_id, participant_id) pair. The earlier +-- ShareParticipant entity declared a generated `id` column, which broke any +-- query that referenced participant.id (e.g. COUNT(participant.id)) with +-- "Unknown column 'participant.id' in 'field list'". +-- +-- This migration: +-- 1. Promotes (share_config_id, participant_id) to the primary key on +-- share_participants and drops the now-redundant unique key. +-- 2. Replaces share_level_progress.participant_id (a pointer to +-- share_participants.id) with the natural pair +-- (share_config_id, participant_id) where participant_id stores the user +-- UUID directly. This way a single user's progress in different share +-- challenges no longer collides on the same level. +-- +-- IMPORTANT: step 2 backfills participant_id by joining the existing +-- share_participants rows. Run on a maintenance window if there is real data. + +-- 1. share_participants: drop redundant unique key, set composite PK. +ALTER TABLE share_participants + DROP INDEX uq_share_participant; + +ALTER TABLE share_participants + ADD PRIMARY KEY (share_config_id, participant_id); + +-- 2. share_level_progress: introduce share_config_id + remap participant_id. +ALTER TABLE share_level_progress + ADD COLUMN share_config_id VARCHAR(191) NULL AFTER id; + +-- Backfill share_config_id and rewrite participant_id from the user UUID. +-- (Old participant_id referenced share_participants.id; new schema stores the +-- user UUID directly.) +UPDATE share_level_progress slp +INNER JOIN share_participants sp + ON sp.id = slp.participant_id +SET + slp.share_config_id = sp.share_config_id, + slp.participant_id = sp.participant_id; + +ALTER TABLE share_level_progress + MODIFY COLUMN share_config_id VARCHAR(191) NOT NULL, + MODIFY COLUMN participant_id VARCHAR(191) NOT NULL; + +-- Drop the legacy FK / unique key that pointed at share_participants.id. +-- The FK was auto-named by MySQL when migration 002 ran. If the name below +-- differs in your environment, look it up: +-- SELECT constraint_name FROM information_schema.referential_constraints +-- WHERE table_name = 'share_level_progress'; +ALTER TABLE share_level_progress + DROP FOREIGN KEY share_level_progress_ibfk_1; + +ALTER TABLE share_level_progress + DROP INDEX uq_participant_level; + +ALTER TABLE share_level_progress + ADD UNIQUE KEY uq_share_participant_level + (share_config_id, participant_id, level_id); + +CREATE INDEX idx_slp_share_config_id + ON share_level_progress (share_config_id); + +ALTER TABLE share_level_progress + ADD CONSTRAINT fk_slp_share_config + FOREIGN KEY (share_config_id) REFERENCES share_configs(id) + ON DELETE CASCADE; diff --git a/src/modules/share/entities/share-level-progress.entity.ts b/src/modules/share/entities/share-level-progress.entity.ts index 9bab889..60982bc 100644 --- a/src/modules/share/entities/share-level-progress.entity.ts +++ b/src/modules/share/entities/share-level-progress.entity.ts @@ -7,22 +7,38 @@ import { Index, Unique, } from 'typeorm'; -import { ShareParticipant } from './share-participant.entity'; +import { ShareConfig } from './share-config.entity'; import { Level } from '../../wechat-game/entities/level.entity'; +/** + * 分享挑战内的单关进度。 + * + * (share_config_id, participant_id, level_id) 三元组保证: + * 同一用户在不同分享挑战中对同一关的记录互不干扰。 + * + * participant_id 直接存储 wx_users.id(用户 UUID),不再引用 share_participants。 + */ @Entity('share_level_progress') -@Unique('uq_participant_level', ['participantId', 'levelId']) +@Unique('uq_share_participant_level', [ + 'shareConfigId', + 'participantId', + 'levelId', +]) export class ShareLevelProgress { @PrimaryGeneratedColumn('uuid') id!: string; - @Index('idx_slp_participant_id') - @Column({ type: 'char', length: 36, name: 'participant_id' }) - participantId!: string; + @Index('idx_slp_share_config_id') + @Column({ type: 'varchar', length: 191, name: 'share_config_id' }) + shareConfigId!: string; - @ManyToOne(() => ShareParticipant) - @JoinColumn({ name: 'participant_id' }) - participant!: ShareParticipant; + @ManyToOne(() => ShareConfig) + @JoinColumn({ name: 'share_config_id' }) + shareConfig!: ShareConfig; + + @Index('idx_slp_participant_id') + @Column({ type: 'varchar', length: 191, name: 'participant_id' }) + participantId!: string; @Index('idx_slp_level_id') @Column({ type: 'char', length: 191, name: 'level_id' }) diff --git a/src/modules/share/entities/share-participant.entity.ts b/src/modules/share/entities/share-participant.entity.ts index fd84de2..c06449b 100644 --- a/src/modules/share/entities/share-participant.entity.ts +++ b/src/modules/share/entities/share-participant.entity.ts @@ -1,33 +1,34 @@ import { Entity, - PrimaryGeneratedColumn, - Column, + PrimaryColumn, CreateDateColumn, ManyToOne, JoinColumn, Index, - Unique, } from 'typeorm'; import { User } from '../../auth/entities/user.entity'; import { ShareConfig } from './share-config.entity'; +/** + * 分享挑战参与者关联表。 + * + * 该表为纯关联表,没有独立主键 id: + * - 主键由 (share_config_id, participant_id) 组成 + * - participant_id 直接存储 wx_users.id(用户 UUID) + */ @Entity('share_participants') -@Unique('uq_share_participant', ['shareConfigId', 'participantId']) export class ShareParticipant { - @PrimaryGeneratedColumn('uuid') - id!: string; - + @PrimaryColumn({ type: 'varchar', length: 191, name: 'share_config_id' }) @Index('idx_share_config_id') - @Column({ type: 'varchar', length: 191, name: 'share_config_id' }) shareConfigId!: string; + @PrimaryColumn({ type: 'varchar', length: 191, name: 'participant_id' }) + participantId!: 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; diff --git a/src/modules/share/repositories/share-level-progress.repository.ts b/src/modules/share/repositories/share-level-progress.repository.ts index 46a710a..bff7b80 100644 --- a/src/modules/share/repositories/share-level-progress.repository.ts +++ b/src/modules/share/repositories/share-level-progress.repository.ts @@ -17,17 +17,21 @@ export class ShareLevelProgressRepository { private readonly repository: Repository, ) {} - async findByParticipantId( + async findByShareConfigAndParticipant( + shareConfigId: string, participantId: string, ): Promise { - return this.repository.find({ where: { participantId } }); + return this.repository.find({ where: { shareConfigId, participantId } }); } - async findByParticipantAndLevel( + async findByShareConfigParticipantAndLevel( + shareConfigId: string, participantId: string, levelId: string, ): Promise { - return this.repository.findOne({ where: { participantId, levelId } }); + return this.repository.findOne({ + where: { shareConfigId, participantId, levelId }, + }); } async summarizeByShareConfigIds( @@ -39,17 +43,16 @@ export class ShareLevelProgressRepository { return this.repository .createQueryBuilder('progress') - .innerJoin('progress.participant', 'participant') - .select('participant.shareConfigId', 'shareConfigId') - .addSelect('participant.participantId', 'participantId') + .select('progress.shareConfigId', 'shareConfigId') + .addSelect('progress.participantId', 'participantId') .addSelect('SUM(progress.timeSpent)', 'totalTimeSpent') .addSelect('COUNT(DISTINCT progress.levelId)', 'passedLevelCount') - .where('participant.shareConfigId IN (:...shareConfigIds)', { + .where('progress.shareConfigId IN (:...shareConfigIds)', { shareConfigIds, }) .andWhere('progress.passed = :passed', { passed: true }) - .groupBy('participant.shareConfigId') - .addGroupBy('participant.participantId') + .groupBy('progress.shareConfigId') + .addGroupBy('progress.participantId') .getRawMany(); } diff --git a/src/modules/share/repositories/share-participant.repository.ts b/src/modules/share/repositories/share-participant.repository.ts index f7f0df1..d58de77 100644 --- a/src/modules/share/repositories/share-participant.repository.ts +++ b/src/modules/share/repositories/share-participant.repository.ts @@ -43,7 +43,7 @@ export class ShareParticipantRepository { const rows = await this.repository .createQueryBuilder('participant') .select('participant.shareConfigId', 'shareConfigId') - .addSelect('COUNT(participant.id)', 'participantCount') + .addSelect('COUNT(*)', 'participantCount') .where('participant.shareConfigId IN (:...shareConfigIds)', { shareConfigIds, }) @@ -55,18 +55,13 @@ export class ShareParticipantRepository { ); } - async findByShareConfigAndParticipant( + async existsByShareConfigAndParticipant( 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); + ): Promise { + const count = await this.repository.count({ + where: { shareConfigId, participantId }, + }); + return count > 0; } } diff --git a/src/modules/share/share.service.spec.ts b/src/modules/share/share.service.spec.ts index 1c32966..ef54fd3 100644 --- a/src/modules/share/share.service.spec.ts +++ b/src/modules/share/share.service.spec.ts @@ -7,7 +7,6 @@ import { ShareLevelProgressRepository } from './repositories/share-level-progres import { LevelRepository } from '../wechat-game/repositories/level.repository'; import { Level } from '../wechat-game/entities/level.entity'; import { ShareConfig } from './entities/share-config.entity'; -import { ShareParticipant } from './entities/share-participant.entity'; import { ShareLevelProgress } from './entities/share-level-progress.entity'; import { User } from '../auth/entities/user.entity'; @@ -49,15 +48,6 @@ describe('ShareService', () => { updatedAt: new Date('2026-01-01'), }; - const mockParticipant: ShareParticipant = { - id: 'participant-uuid-1', - shareConfigId: 'share-uuid-1', - participantId: 'user-uuid-2', - shareConfig: {} as ShareConfig, - participant: {} as User, - createdAt: new Date('2026-01-01'), - }; - const mockShareConfigRepository = { create: jest.fn(), findByShareCode: jest.fn(), @@ -68,13 +58,11 @@ describe('ShareService', () => { addParticipant: jest.fn(), countByShareConfigId: jest.fn(), countByShareConfigIds: jest.fn(), - findByShareConfigAndParticipant: jest.fn(), - create: jest.fn(), - save: jest.fn(), + existsByShareConfigAndParticipant: jest.fn(), }; const mockShareLevelProgressRepository = { - findByParticipantAndLevel: jest.fn(), + findByShareConfigParticipantAndLevel: jest.fn(), summarizeByShareConfigIds: jest.fn(), create: jest.fn(), save: jest.fn(), @@ -345,14 +333,15 @@ describe('ShareService', () => { mockShareConfig, ); mockLevelRepository.findById.mockResolvedValue(levelWithTimeLimit); - mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue( - mockParticipant, + mockShareParticipantRepository.addParticipant.mockResolvedValue( + undefined, ); - mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue( + mockShareLevelProgressRepository.findByShareConfigParticipantAndLevel.mockResolvedValue( null, ); const newProgress: Partial = { - participantId: 'participant-uuid-1', + shareConfigId: 'share-uuid-1', + participantId: 'user-uuid-2', levelId: 'level-1', passed: true, timeSpent: 30, @@ -368,6 +357,30 @@ describe('ShareService', () => { expect(result.passed).toBe(true); expect(result.timeLimit).toBe(60); expect(result.withinTimeLimit).toBe(true); + expect( + mockShareParticipantRepository.addParticipant, + ).toHaveBeenCalledWith('share-uuid-1', 'user-uuid-2'); + expect( + mockShareLevelProgressRepository.findByShareConfigParticipantAndLevel, + ).toHaveBeenCalledWith('share-uuid-1', 'user-uuid-2', 'level-1'); + }); + + it('should not register sharer themselves as participant when reporting progress', async () => { + mockShareConfigRepository.findByShareCode.mockResolvedValue( + mockShareConfig, + ); + mockLevelRepository.findById.mockResolvedValue(mockLevels[0]); + mockShareLevelProgressRepository.findByShareConfigParticipantAndLevel.mockResolvedValue( + null, + ); + mockShareLevelProgressRepository.create.mockReturnValue({} as any); + mockShareLevelProgressRepository.save.mockResolvedValue({} as any); + + await service.reportLevelProgress('user-uuid-1', reportDto); + + expect( + mockShareParticipantRepository.addParticipant, + ).not.toHaveBeenCalled(); }); it('should return existing result when already passed (idempotent)', async () => { @@ -376,13 +389,11 @@ describe('ShareService', () => { mockShareConfig, ); mockLevelRepository.findById.mockResolvedValue(levelWithTimeLimit); - mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue( - mockParticipant, - ); - mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue( + mockShareLevelProgressRepository.findByShareConfigParticipantAndLevel.mockResolvedValue( { id: 'progress-uuid-1', - participantId: 'participant-uuid-1', + shareConfigId: 'share-uuid-1', + participantId: 'user-uuid-2', levelId: 'level-1', passed: true, timeSpent: 25, @@ -406,14 +417,12 @@ describe('ShareService', () => { mockShareConfig, ); mockLevelRepository.findById.mockResolvedValue(levelWithTimeLimit); - mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue( - mockParticipant, - ); - mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue( + mockShareLevelProgressRepository.findByShareConfigParticipantAndLevel.mockResolvedValue( null, ); const newProgress: Partial = { - participantId: 'participant-uuid-1', + shareConfigId: 'share-uuid-1', + participantId: 'user-uuid-2', levelId: 'level-1', passed: true, timeSpent: 30, @@ -436,14 +445,12 @@ describe('ShareService', () => { mockShareConfig, ); mockLevelRepository.findById.mockResolvedValue(levelNoTimeLimit); - mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue( - mockParticipant, - ); - mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue( + mockShareLevelProgressRepository.findByShareConfigParticipantAndLevel.mockResolvedValue( null, ); const newProgress: Partial = { - participantId: 'participant-uuid-1', + shareConfigId: 'share-uuid-1', + participantId: 'user-uuid-2', levelId: 'level-2', passed: true, timeSpent: 999, @@ -461,32 +468,6 @@ describe('ShareService', () => { expect(result.withinTimeLimit).toBe(true); }); - it('should create participant if not exists when reporting progress', async () => { - mockShareConfigRepository.findByShareCode.mockResolvedValue( - mockShareConfig, - ); - mockLevelRepository.findById.mockResolvedValue(mockLevels[0]); - mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue( - null, - ); - const createdParticipant = { ...mockParticipant }; - mockShareParticipantRepository.create.mockReturnValue(createdParticipant); - mockShareParticipantRepository.save.mockResolvedValue(createdParticipant); - mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue( - null, - ); - mockShareLevelProgressRepository.create.mockReturnValue({} as any); - mockShareLevelProgressRepository.save.mockResolvedValue({} as any); - - await service.reportLevelProgress('user-uuid-2', reportDto); - - expect(mockShareParticipantRepository.create).toHaveBeenCalledWith({ - shareConfigId: 'share-uuid-1', - participantId: 'user-uuid-2', - }); - expect(mockShareParticipantRepository.save).toHaveBeenCalled(); - }); - it('should throw NotFoundException when share not found', async () => { mockShareConfigRepository.findByShareCode.mockResolvedValue(null); @@ -511,20 +492,19 @@ describe('ShareService', () => { mockShareConfig, ); mockLevelRepository.findById.mockResolvedValue(mockLevels[0]); - mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue( - mockParticipant, - ); const existingProgress = { id: 'progress-uuid-1', - participantId: 'participant-uuid-1', + shareConfigId: 'share-uuid-1', + participantId: 'user-uuid-2', levelId: 'level-1', passed: false, timeSpent: 15, completedAt: new Date('2026-01-01'), } as ShareLevelProgress; - mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue( + mockShareLevelProgressRepository.findByShareConfigParticipantAndLevel.mockResolvedValue( existingProgress, ); + mockShareLevelProgressRepository.create.mockReturnValue(existingProgress); mockShareLevelProgressRepository.save.mockResolvedValue({ ...existingProgress, passed: true, diff --git a/src/modules/share/share.service.ts b/src/modules/share/share.service.ts index 8618f7e..aaadb17 100644 --- a/src/modules/share/share.service.ts +++ b/src/modules/share/share.service.ts @@ -185,22 +185,15 @@ export class ShareService { throw new BadRequestException('该关卡不属于此分享挑战'); } - let participant = - await this.shareParticipantRepository.findByShareConfigAndParticipant( - config.id, - userId, - ); - if (!participant) { - participant = this.shareParticipantRepository.create({ - shareConfigId: config.id, - participantId: userId, - }); - participant = await this.shareParticipantRepository.save(participant); + // 自动登记参与者(创建者本人不计入)。已存在则忽略。 + if (userId !== config.sharerId) { + await this.shareParticipantRepository.addParticipant(config.id, userId); } const progress = - await this.shareLevelProgressRepository.findByParticipantAndLevel( - participant.id, + await this.shareLevelProgressRepository.findByShareConfigParticipantAndLevel( + config.id, + userId, dto.levelId, ); @@ -226,7 +219,8 @@ export class ShareService { completedAt: dto.passed ? new Date() : progress.completedAt, }) : this.shareLevelProgressRepository.create({ - participantId: participant.id, + shareConfigId: config.id, + participantId: userId, levelId: dto.levelId, passed: dto.passed, timeSpent: dto.timeSpent,