feat(challenges): 支持公开访问挑战列表与详情接口

- 在 GET /challenges、GET /challenges/:id、GET /challenges/:id/rankings 添加 @Public() 装饰器,允许未登录用户访问
- 将 userId 改为可选参数,未登录时仍可返回基础数据
- 列表接口过滤掉 UPCOMING 状态挑战,仅展示进行中/已结束
- 返回 DTO 新增 unit 字段,用于前端展示进度单位
- 鉴权守卫优化:公开接口若携带 token 仍尝试解析并注入 user,方便后续业务逻辑
This commit is contained in:
richarjiang
2025-09-30 16:43:46 +08:00
parent 87c3cbfac9
commit 999fc7f793
6 changed files with 64 additions and 33 deletions

View File

@@ -9,17 +9,19 @@ import { ChallengeDetailDto } from './dto/challenge-detail.dto';
import { ChallengeListItemDto } from './dto/challenge-list.dto';
import { ChallengeProgressDto } from './dto/challenge-progress.dto';
import { ChallengeRankingListDto, GetChallengeRankingQueryDto } from './dto/challenge-ranking.dto';
import { Public } from 'src/common/decorators/public.decorator';
@Controller('challenges')
@UseGuards(JwtAuthGuard)
export class ChallengesController {
constructor(private readonly challengesService: ChallengesService) { }
@Get()
@Public()
@UseGuards(JwtAuthGuard)
async getChallenges(
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<ChallengeListItemDto[]>> {
const data = await this.challengesService.getChallengesForUser(user.sub);
const data = await this.challengesService.getChallengesForUser(user?.sub);
return {
code: ResponseCode.SUCCESS,
message: '获取挑战列表成功',
@@ -28,11 +30,12 @@ export class ChallengesController {
}
@Get(':id')
@Public()
async getChallengeDetail(
@Param('id') id: string,
@CurrentUser() user: AccessTokenPayload,
): Promise<BaseResponseDto<ChallengeDetailDto>> {
const data = await this.challengesService.getChallengeDetail(user.sub, id);
const data = await this.challengesService.getChallengeDetail(id, user?.sub);
return {
code: ResponseCode.SUCCESS,
message: '获取挑战详情成功',
@@ -41,6 +44,7 @@ export class ChallengesController {
}
@Get(':id/rankings')
@Public()
async getChallengeRankings(
@Param('id') id: string,
@Query() query: GetChallengeRankingQueryDto,
@@ -49,7 +53,7 @@ export class ChallengesController {
const data = await this.challengesService.getChallengeRankings(id, {
page: query.page,
pageSize: query.pageSize,
userId: user.sub,
userId: user?.sub,
});
return {
code: ResponseCode.SUCCESS,
@@ -59,6 +63,7 @@ export class ChallengesController {
}
@Post(':id/join')
@UseGuards(JwtAuthGuard)
async joinChallenge(
@Param('id') id: string,
@CurrentUser() user: AccessTokenPayload,
@@ -72,6 +77,7 @@ export class ChallengesController {
}
@Post(':id/leave')
@UseGuards(JwtAuthGuard)
async leaveChallenge(
@Param('id') id: string,
@CurrentUser() user: AccessTokenPayload,
@@ -85,6 +91,7 @@ export class ChallengesController {
}
@Post(':id/progress')
@UseGuards(JwtAuthGuard)
async reportProgress(
@Param('id') id: string,
@Body() dto: UpdateChallengeProgressDto,

View File

@@ -28,7 +28,7 @@ export class ChallengesService {
private readonly progressReportModel: typeof ChallengeProgressReport,
) { }
async getChallengesForUser(userId: string): Promise<ChallengeListItemDto[]> {
async getChallengesForUser(userId?: string): Promise<ChallengeListItemDto[]> {
const challenges = await this.challengeModel.findAll({
order: [['startAt', 'ASC']],
});
@@ -50,6 +50,7 @@ export class ChallengesService {
challenge,
status: this.computeStatus(challenge.startAt, challenge.endAt),
}))
.filter(({ status }) => status !== ChallengeStatus.UPCOMING)
.sort((a, b) => {
const priorityDiff = statusPriority[a.status] - statusPriority[b.status];
if (priorityDiff !== 0) {
@@ -81,19 +82,22 @@ export class ChallengesService {
}
}
const userParticipations = await this.participantModel.findAll({
where: {
challengeId: challengeIds,
userId,
status: {
[Op.ne]: ChallengeParticipantStatus.LEFT,
},
},
});
const participationMap = new Map<string, ChallengeParticipant>();
for (const participation of userParticipations) {
participationMap.set(participation.challengeId, participation);
if (userId) {
const userParticipations = await this.participantModel.findAll({
where: {
challengeId: challengeIds,
userId,
status: {
[Op.ne]: ChallengeParticipantStatus.LEFT,
},
},
});
for (const participation of userParticipations) {
participationMap.set(participation.challengeId, participation);
}
}
return challengesWithStatus.map(({ challenge, status }) => {
@@ -111,6 +115,7 @@ export class ChallengesService {
durationLabel: challenge.durationLabel,
requirementLabel: challenge.requirementLabel,
status,
unit: challenge.progressUnit,
startAt: challenge.startAt,
endAt: challenge.endAt,
participantsCount: participantsCountMap.get(challenge.id) ?? 0,
@@ -126,7 +131,7 @@ export class ChallengesService {
});
}
async getChallengeDetail(userId: string, challengeId: string): Promise<ChallengeDetailDto> {
async getChallengeDetail(challengeId: string, userId?: string,): Promise<ChallengeDetailDto> {
const challenge = await this.challengeModel.findByPk(challengeId);
if (!challenge) {
@@ -147,15 +152,17 @@ export class ChallengesService {
status: ChallengeParticipantStatus.ACTIVE,
},
}),
this.participantModel.findOne({
where: {
challengeId,
userId,
status: {
[Op.ne]: ChallengeParticipantStatus.LEFT,
userId
? this.participantModel.findOne({
where: {
challengeId,
userId,
status: {
[Op.ne]: ChallengeParticipantStatus.LEFT,
},
},
},
}),
})
: null,
]);
this.winstonLogger.info('end get detail', {
@@ -205,6 +212,7 @@ export class ChallengesService {
progress,
rankings,
userRank,
unit: challenge.progressUnit,
type: challenge.type,
};
}

View File

@@ -19,4 +19,5 @@ export interface ChallengeDetailDto {
rankings: RankingItemDto[];
userRank?: number;
type: ChallengeType;
unit: string;
}

View File

@@ -20,6 +20,7 @@ export interface ChallengeListItemDto {
progress?: ChallengeProgressDto;
isJoined: boolean;
type: ChallengeType;
unit: string;
}
export interface ChallengeListResponseDto {

View File

@@ -17,21 +17,35 @@ export class JwtAuthGuard implements CanActivate {
context.getClass(),
]);
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
this.logger.log(`authHeader: ${authHeader}`);
this.logger.log(`authHeader: ${authHeader}, isPublic: ${isPublic}`);
const token = this.appleAuthService.extractTokenFromHeader(authHeader);
if (isPublic) {
// 公开接口如果有 token也可以尝试获取用户信息
if (token) {
try {
const payload = this.appleAuthService.verifyAccessToken(token);
this.logger.log(`鉴权成功: ${JSON.stringify(payload)}, token: ${token}`);
// 将用户信息添加到请求对象中
request.user = payload;
} catch (error) {
this.logger.error(`鉴权失败: ${error.message}, token: ${token}`);
}
}
return true;
}
if (!authHeader) {
throw new UnauthorizedException('缺少授权头');
}
try {
const token = this.appleAuthService.extractTokenFromHeader(authHeader);
const payload = this.appleAuthService.verifyAccessToken(token);
this.logger.log(`鉴权成功: ${JSON.stringify(payload)}, token: ${token}`);

View File

@@ -219,7 +219,7 @@ export class AppleAuthService {
*/
extractTokenFromHeader(authHeader: string): string {
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new UnauthorizedException('无效的授权头格式');
return ''
}
return authHeader.substring(7);