fix: 修复创建分享接口报错

This commit is contained in:
richarjiang
2026-05-09 16:25:36 +08:00
parent 9185df3567
commit 8443f8844d
8 changed files with 182 additions and 123 deletions

View File

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

View File

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

View File

@@ -17,17 +17,21 @@ export class ShareLevelProgressRepository {
private readonly repository: Repository<ShareLevelProgress>,
) {}
async findByParticipantId(
async findByShareConfigAndParticipant(
shareConfigId: string,
participantId: string,
): Promise<ShareLevelProgress[]> {
return this.repository.find({ where: { participantId } });
return this.repository.find({ where: { shareConfigId, participantId } });
}
async findByParticipantAndLevel(
async findByShareConfigParticipantAndLevel(
shareConfigId: string,
participantId: string,
levelId: string,
): Promise<ShareLevelProgress | null> {
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<ShareChallengeRankingRow>();
}

View File

@@ -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<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);
): Promise<boolean> {
const count = await this.repository.count({
where: { shareConfigId, participantId },
});
return count > 0;
}
}

View File

@@ -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<ShareLevelProgress> = {
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<ShareLevelProgress> = {
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<ShareLevelProgress> = {
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,

View File

@@ -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,