diff --git a/src/challenges/challenges.service.ts b/src/challenges/challenges.service.ts index c5fe171..b46cbd2 100644 --- a/src/challenges/challenges.service.ts +++ b/src/challenges/challenges.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException, BadRequestException, ConflictException, Inject } from '@nestjs/common'; import { InjectModel } from '@nestjs/sequelize'; -import { Challenge, ChallengeStatus } from './models/challenge.model'; +import { Challenge, ChallengeStatus, ChallengeType } from './models/challenge.model'; import { ChallengeParticipant, ChallengeParticipantStatus } from './models/challenge-participant.model'; import { ChallengeProgressReport } from './models/challenge-progress-report.model'; import { UpdateChallengeProgressDto } from './dto/update-challenge-progress.dto'; @@ -13,6 +13,8 @@ import * as dayjs from 'dayjs'; import { User } from '../users/models/user.model'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { Logger as WinstonLogger } from 'winston'; +import { BadgeService } from '../users/services/badge.service'; +import { BadgeSource } from '../users/models/user-badge.model'; @Injectable() export class ChallengesService { @@ -26,6 +28,7 @@ export class ChallengesService { private readonly participantModel: typeof ChallengeParticipant, @InjectModel(ChallengeProgressReport) private readonly progressReportModel: typeof ChallengeProgressReport, + private readonly badgeService: BadgeService, ) { } async getChallengesForUser(userId?: string): Promise { @@ -432,10 +435,70 @@ export class ChallengesService { if (report.reportedValue >= reportCompletedValue && !dayjs(participant.lastProgressAt).isSame(dayjs(), 'd')) { participant.progressValue++ participant.lastProgressAt = now; + + // 🎖️ 检查是否为睡眠挑战且完成了第一次打卡,授予 goodSleep 勋章 + if (challenge.type === ChallengeType.SLEEP) { + try { + await this.badgeService.awardBadge(userId, 'goodSleep', { + source: BadgeSource.CHALLENGE, + sourceId: challengeId, + metadata: { + challengeName: challenge.title, + challengeType: challenge.type, + }, + }); + this.winstonLogger.info('授予睡眠挑战勋章成功', { + context: 'reportProgress', + userId, + challengeId, + badgeCode: 'goodSleep', + }); + } catch (error) { + // 勋章授予失败不应影响主流程,仅记录日志 + this.winstonLogger.error('授予睡眠挑战勋章失败', { + context: 'reportProgress', + userId, + challengeId, + error: error instanceof Error ? error.message : '未知错误', + }); + } + } } if (participant.progressValue >= (participant.challenge?.minimumCheckInDays || 0) && participant.status !== ChallengeParticipantStatus.COMPLETED) { participant.status = ChallengeParticipantStatus.COMPLETED; + + // 🎖️ 完成睡眠挑战时,授予 sleepChallengeMonth 勋章 + if (challenge.type === ChallengeType.SLEEP) { + try { + await this.badgeService.awardBadge(userId, 'sleepChallengeMonth', { + source: BadgeSource.CHALLENGE, + sourceId: challengeId, + metadata: { + challengeName: challenge.title, + challengeType: challenge.type, + completedDays: participant.progressValue, + completedAt: new Date(), + }, + }); + this.winstonLogger.info('授予睡眠挑战完成勋章成功', { + context: 'reportProgress', + userId, + challengeId, + badgeCode: 'sleepChallengeMonth', + completedDays: participant.progressValue, + }); + } catch (error) { + // 勋章授予失败不应影响主流程,仅记录日志 + this.winstonLogger.error('授予睡眠挑战完成勋章失败', { + context: 'reportProgress', + userId, + challengeId, + badgeCode: 'sleepChallengeMonth', + error: error instanceof Error ? error.message : '未知错误', + }); + } + } } await participant.save(); diff --git a/src/users/dto/badge.dto.ts b/src/users/dto/badge.dto.ts new file mode 100644 index 0000000..d5cca12 --- /dev/null +++ b/src/users/dto/badge.dto.ts @@ -0,0 +1,157 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsOptional, IsString, IsBoolean, IsDateString, IsNotEmpty } from 'class-validator'; +import { BaseResponseDto } from '../../base.dto'; + +// 勋章基本信息 +export class BadgeInfoDto { + @ApiProperty({ description: '勋章代码', example: 'goodSleep' }) + code: string; + + @ApiProperty({ description: '勋章名称', example: '好眠达人' }) + name: string; + + @ApiProperty({ description: '勋章描述', example: '完成首次睡眠挑战' }) + description: string; + + @ApiProperty({ description: '勋章图片URL' }) + imageUrl: string; + + @ApiProperty({ description: '勋章分类', example: 'sleep' }) + category: string; + + @ApiProperty({ description: '排序顺序', example: 1 }) + sortOrder: number; +} + +// 用户勋章信息 +export class UserBadgeDto { + @ApiProperty({ description: '记录ID', example: 1 }) + id: number; + + @ApiProperty({ description: '勋章代码', example: 'goodSleep' }) + code: string; + + @ApiProperty({ description: '勋章名称', example: '好眠达人' }) + name: string; + + @ApiProperty({ description: '勋章描述', example: '完成首次睡眠挑战' }) + description: string; + + @ApiProperty({ description: '勋章图片URL' }) + imageUrl: string; + + @ApiProperty({ description: '勋章分类', example: 'sleep' }) + category: string; + + @ApiProperty({ description: '获得时间' }) + awardedAt: Date; + + @ApiProperty({ description: '授予来源', example: 'challenge' }) + source: string; + + @ApiProperty({ description: '来源ID(如挑战ID)', required: false }) + sourceId?: string; + + @ApiProperty({ description: '元数据', required: false }) + metadata?: Record; + + @ApiProperty({ description: '是否已展示过', example: false }) + isShow: boolean; +} + +// 可用勋章信息(包含是否已获得) +export class AvailableBadgeDto extends BadgeInfoDto { + @ApiProperty({ description: '是否已获得', example: false }) + isAwarded: boolean; + + @ApiProperty({ description: '获得时间(如果已获得)', required: false }) + awardedAt?: Date; + + @ApiProperty({ description: '是否已展示过(如果已获得)', required: false }) + isShow?: boolean; +} + +// 获取用户勋章列表响应 +export class GetUserBadgesResponseDto implements BaseResponseDto<{ + badges: UserBadgeDto[]; + total: number; +}> { + @ApiProperty({ description: '响应状态码', example: 0 }) + code: number; + + @ApiProperty({ description: '响应消息', example: 'success' }) + message: string; + + @ApiProperty({ + description: '用户勋章数据', + example: { + badges: [ + { + id: 1, + code: 'goodSleep', + name: '好眠达人', + description: '完成首次睡眠挑战', + imageUrl: 'https://example.com/badge.png', + category: 'sleep', + awardedAt: '2025-01-14T07:00:00Z', + source: 'challenge', + sourceId: 'challenge-uuid', + }, + ], + total: 1, + }, + }) + data: { + badges: UserBadgeDto[]; + total: number; + }; +} + +// 获取所有可用勋章响应 +export class GetAvailableBadgesResponseDto implements BaseResponseDto { + @ApiProperty({ description: '响应状态码', example: 0 }) + code: number; + + @ApiProperty({ description: '响应消息', example: 'success' }) + message: string; + + @ApiProperty({ + description: '所有可用勋章列表', + example: [ + { + code: 'goodSleep', + name: '好眠达人', + description: '完成首次睡眠挑战', + imageUrl: 'https://example.com/badge.png', + category: 'sleep', + sortOrder: 1, + isAwarded: true, + awardedAt: '2025-01-14T07:00:00Z', + }, + ], + }) + data: AvailableBadgeDto[]; +} + +// 标记勋章已展示请求 +export class MarkBadgeShownDto { + @ApiProperty({ description: '勋章代码', example: 'goodSleep' }) + @IsString() + @IsNotEmpty() + badgeCode: string; +} + +// 标记勋章已展示响应 +export class MarkBadgeShownResponseDto implements BaseResponseDto<{ success: boolean }> { + @ApiProperty({ description: '响应状态码', example: 0 }) + code: number; + + @ApiProperty({ description: '响应消息', example: 'success' }) + message: string; + + @ApiProperty({ + description: '操作结果', + example: { success: true }, + }) + data: { success: boolean }; +} \ No newline at end of file diff --git a/src/users/models/badge-config.model.ts b/src/users/models/badge-config.model.ts new file mode 100644 index 0000000..c26af97 --- /dev/null +++ b/src/users/models/badge-config.model.ts @@ -0,0 +1,90 @@ +import { Column, Model, Table, DataType } from 'sequelize-typescript'; + +export enum BadgeCategory { + SLEEP = 'sleep', + EXERCISE = 'exercise', + DIET = 'diet', + WATER = 'water', + MOOD = 'mood', + WEIGHT = 'weight', + GENERAL = 'general', +} + +@Table({ + tableName: 't_badge_configs', + underscored: true, + timestamps: true, +}) +export class BadgeConfig extends Model { + @Column({ + type: DataType.CHAR(36), + defaultValue: DataType.UUIDV4, + primaryKey: true, + }) + declare id: string; + + @Column({ + type: DataType.STRING(64), + allowNull: false, + unique: true, + comment: '勋章唯一标识码', + }) + declare code: string; + + @Column({ + type: DataType.STRING(128), + allowNull: false, + comment: '勋章名称', + }) + declare name: string; + + @Column({ + type: DataType.TEXT, + allowNull: true, + comment: '勋章描述', + }) + declare description: string; + + @Column({ + type: DataType.STRING(512), + allowNull: false, + comment: '勋章图片URL', + }) + declare imageUrl: string; + + @Column({ + type: DataType.ENUM(...Object.values(BadgeCategory)), + allowNull: false, + defaultValue: BadgeCategory.GENERAL, + comment: '勋章分类', + }) + declare category: BadgeCategory; + + @Column({ + type: DataType.BOOLEAN, + allowNull: false, + defaultValue: true, + comment: '是否启用', + }) + declare isActive: boolean; + + @Column({ + type: DataType.INTEGER, + allowNull: false, + defaultValue: 0, + comment: '排序顺序', + }) + declare sortOrder: number; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + }) + declare createdAt: Date; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + }) + declare updatedAt: Date; +} \ No newline at end of file diff --git a/src/users/models/user-badge.model.ts b/src/users/models/user-badge.model.ts new file mode 100644 index 0000000..221cd43 --- /dev/null +++ b/src/users/models/user-badge.model.ts @@ -0,0 +1,105 @@ +import { Column, Model, Table, DataType, ForeignKey, BelongsTo } from 'sequelize-typescript'; +import { User } from './user.model'; +import { BadgeConfig } from './badge-config.model'; + +export enum BadgeSource { + CHALLENGE = 'challenge', + MANUAL = 'manual', + SYSTEM = 'system', +} + +@Table({ + tableName: 't_user_badges', + underscored: true, + timestamps: true, + indexes: [ + { + unique: true, + fields: ['user_id', 'badge_code'], + name: 'unique_user_badge', + }, + { + fields: ['user_id'], + }, + { + fields: ['badge_code'], + }, + { + fields: ['awarded_at'], + }, + ], +}) +export class UserBadge extends Model { + @Column({ + type: DataType.INTEGER, + primaryKey: true, + autoIncrement: true, + }) + declare id: number; + + @ForeignKey(() => User) + @Column({ + type: DataType.STRING, + allowNull: false, + comment: '用户ID', + }) + declare userId: string; + + @ForeignKey(() => BadgeConfig) + @Column({ + type: DataType.STRING(64), + allowNull: false, + comment: '勋章代码', + }) + declare badgeCode: string; + + @Column({ + type: DataType.DATE, + allowNull: false, + defaultValue: DataType.NOW, + comment: '获得时间', + }) + declare awardedAt: Date; + + @Column({ + type: DataType.ENUM(...Object.values(BadgeSource)), + allowNull: false, + defaultValue: BadgeSource.SYSTEM, + comment: '授予来源', + }) + declare source: BadgeSource; + + @Column({ + type: DataType.STRING(128), + allowNull: true, + comment: '来源ID(如挑战ID)', + }) + declare sourceId: string; + + @Column({ + type: DataType.JSON, + allowNull: true, + comment: '额外元数据', + }) + declare metadata: Record; + + @Column({ + type: DataType.BOOLEAN, + allowNull: false, + defaultValue: false, + comment: '是否已展示过(客户端展示勋章获得动画后设置为true)', + }) + declare isShow: boolean; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + }) + declare createdAt: Date; + + @BelongsTo(() => User) + declare user: User; + + @BelongsTo(() => BadgeConfig, 'badgeCode') + declare badge: BadgeConfig; +} \ No newline at end of file diff --git a/src/users/services/badge.service.ts b/src/users/services/badge.service.ts new file mode 100644 index 0000000..4854fcc --- /dev/null +++ b/src/users/services/badge.service.ts @@ -0,0 +1,236 @@ +import { Injectable, Logger, ConflictException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/sequelize'; +import { BadgeConfig } from '../models/badge-config.model'; +import { UserBadge, BadgeSource } from '../models/user-badge.model'; +import { Op } from 'sequelize'; + +@Injectable() +export class BadgeService { + private readonly logger = new Logger(BadgeService.name); + + constructor( + @InjectModel(BadgeConfig) + private readonly badgeConfigModel: typeof BadgeConfig, + @InjectModel(UserBadge) + private readonly userBadgeModel: typeof UserBadge, + ) {} + + /** + * 授予用户勋章 + * @param userId 用户ID + * @param badgeCode 勋章代码 + * @param options 额外选项 + */ + async awardBadge( + userId: string, + badgeCode: string, + options?: { + source?: BadgeSource; + sourceId?: string; + metadata?: Record; + }, + ): Promise { + try { + // 检查勋章配置是否存在且启用 + const badgeConfig = await this.badgeConfigModel.findOne({ + where: { code: badgeCode, isActive: true }, + }); + + if (!badgeConfig) { + this.logger.warn(`勋章配置不存在或未启用: ${badgeCode}`); + return null; + } + + // 检查用户是否已经拥有该勋章 + const existingBadge = await this.userBadgeModel.findOne({ + where: { userId, badgeCode }, + }); + + if (existingBadge) { + this.logger.log(`用户 ${userId} 已拥有勋章 ${badgeCode},跳过授予`); + return existingBadge; + } + + // 创建用户勋章记录 + const userBadge = await this.userBadgeModel.create({ + userId, + badgeCode, + awardedAt: new Date(), + source: options?.source || BadgeSource.SYSTEM, + sourceId: options?.sourceId, + metadata: options?.metadata, + }); + + this.logger.log(`成功授予用户 ${userId} 勋章 ${badgeCode}`); + return userBadge; + } catch (error) { + this.logger.error(`授予勋章失败: ${error instanceof Error ? error.message : '未知错误'}`); + // 如果是唯一约束冲突,说明已经拥有该勋章 + if (error.name === 'SequelizeUniqueConstraintError') { + return null; + } + throw error; + } + } + + /** + * 获取用户所有勋章 + * @param userId 用户ID + */ + async getUserBadges(userId: string): Promise; + isShow: boolean; + }>> { + const userBadges = await this.userBadgeModel.findAll({ + where: { userId }, + include: [ + { + model: BadgeConfig, + as: 'badge', + attributes: ['code', 'name', 'description', 'imageUrl', 'category'], + }, + ], + order: [['awardedAt', 'DESC']], + }); + + return userBadges.map((ub) => ({ + id: ub.id, + code: ub.badgeCode, + name: ub.badge?.name || '', + description: ub.badge?.description || '', + imageUrl: ub.badge?.imageUrl || '', + category: ub.badge?.category || '', + awardedAt: ub.awardedAt, + source: ub.source, + sourceId: ub.sourceId, + metadata: ub.metadata, + isShow: ub.isShow, + })); + } + + /** + * 获取所有可用勋章(包含用户是否已获得信息) + * @param userId 用户ID(可选) + */ + async getAvailableBadges(userId?: string): Promise> { + // 获取所有启用的勋章配置 + const badgeConfigs = await this.badgeConfigModel.findAll({ + where: { isActive: true }, + order: [['sortOrder', 'ASC'], ['createdAt', 'ASC']], + }); + + if (!userId) { + // 如果没有提供用户ID,返回所有勋章,标记为未获得 + return badgeConfigs.map((bc) => ({ + code: bc.code, + name: bc.name, + description: bc.description, + imageUrl: bc.imageUrl, + category: bc.category, + sortOrder: bc.sortOrder, + isAwarded: false, + isShow: false + })); + } + + // 获取用户已拥有的勋章 + const userBadges = await this.userBadgeModel.findAll({ + where: { + userId, + badgeCode: { + [Op.in]: badgeConfigs.map((bc) => bc.code), + }, + }, + }); + + const userBadgeMap = new Map( + userBadges.map((ub) => [ub.badgeCode, { awardedAt: ub.awardedAt, isShow: ub.isShow }]), + ); + + return badgeConfigs.map((bc) => { + const badgeInfo = userBadgeMap.get(bc.code); + return { + code: bc.code, + name: bc.name, + description: bc.description, + imageUrl: bc.imageUrl, + category: bc.category, + sortOrder: bc.sortOrder, + isAwarded: !!badgeInfo, + awardedAt: badgeInfo?.awardedAt || undefined, + isShow: badgeInfo?.isShow || false, + }; + }); + } + + /** + * 检查用户是否拥有指定勋章 + * @param userId 用户ID + * @param badgeCode 勋章代码 + */ + async hasBadge(userId: string, badgeCode: string): Promise { + const count = await this.userBadgeModel.count({ + where: { userId, badgeCode }, + }); + return count > 0; + } + + /** + * 获取用户勋章数量 + * @param userId 用户ID + */ + async getUserBadgeCount(userId: string): Promise { + return await this.userBadgeModel.count({ + where: { userId }, + }); + } + + /** + * 标记勋章已展示 + * @param userId 用户ID + * @param badgeCode 勋章代码 + */ + async markBadgeAsShown(userId: string, badgeCode: string): Promise { + try { + const userBadge = await this.userBadgeModel.findOne({ + where: { userId, badgeCode }, + }); + + if (!userBadge) { + this.logger.warn(`用户 ${userId} 不拥有勋章 ${badgeCode},无法标记为已展示`); + return false; + } + + if (userBadge.isShow) { + this.logger.log(`用户 ${userId} 的勋章 ${badgeCode} 已经标记为展示过,跳过更新`); + return true; + } + + await userBadge.update({ isShow: true }); + this.logger.log(`成功标记用户 ${userId} 的勋章 ${badgeCode} 为已展示`); + return true; + } catch (error) { + this.logger.error(`标记勋章已展示失败: ${error instanceof Error ? error.message : '未知错误'}`); + throw error; + } + } +} \ No newline at end of file diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index b9ed63c..8cc0654 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -35,6 +35,7 @@ import { RestorePurchaseDto, RestorePurchaseResponseDto } from './dto/restore-pu import { GetUserActivityHistoryResponseDto } from './dto/user-activity.dto'; import { UpdateWeightRecordDto, WeightRecordResponseDto, DeleteWeightRecordResponseDto } from './dto/weight-record.dto'; import { UpdateBodyMeasurementDto, UpdateBodyMeasurementResponseDto, GetBodyMeasurementHistoryResponseDto, GetBodyMeasurementAnalysisDto, GetBodyMeasurementAnalysisResponseDto } from './dto/body-measurement.dto'; +import { GetUserBadgesResponseDto, GetAvailableBadgesResponseDto, MarkBadgeShownDto, MarkBadgeShownResponseDto } from './dto/badge.dto'; import { Public } from '../common/decorators/public.decorator'; import { CurrentUser } from '../common/decorators/current-user.decorator'; @@ -393,4 +394,53 @@ export class UsersController { return this.usersService.getBodyMeasurementAnalysis(user.sub, period); } + // ==================== 勋章相关接口 ==================== + + /** + * 获取用户勋章列表 + */ + @UseGuards(JwtAuthGuard) + @Get('badges') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '获取用户勋章列表' }) + @ApiResponse({ status: 200, description: '成功获取用户勋章列表', type: GetUserBadgesResponseDto }) + async getUserBadges( + @CurrentUser() user: AccessTokenPayload, + ): Promise { + this.logger.log(`获取用户勋章列表 - 用户ID: ${user.sub}`); + return this.usersService.getUserBadges(user.sub); + } + + /** + * 获取所有可用勋章(包含用户是否已获得) + */ + @UseGuards(JwtAuthGuard) + @Get('badges/available') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '获取所有可用勋章' }) + @ApiResponse({ status: 200, description: '成功获取所有可用勋章', type: GetAvailableBadgesResponseDto }) + async getAvailableBadges( + @CurrentUser() user: AccessTokenPayload, + ): Promise { + this.logger.log(`获取可用勋章列表 - 用户ID: ${user.sub}`); + return this.usersService.getAvailableBadges(user.sub); + } + + /** + * 标记勋章已展示 + */ + @UseGuards(JwtAuthGuard) + @Post('badges/mark-shown') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '标记勋章已展示(客户端展示勋章动画后调用)' }) + @ApiBody({ type: MarkBadgeShownDto }) + @ApiResponse({ status: 200, description: '成功标记勋章已展示', type: MarkBadgeShownResponseDto }) + async markBadgeAsShown( + @Body() markBadgeShownDto: MarkBadgeShownDto, + @CurrentUser() user: AccessTokenPayload, + ): Promise { + this.logger.log(`标记勋章已展示 - 用户ID: ${user.sub}, 勋章代码: ${markBadgeShownDto.badgeCode}`); + return this.usersService.markBadgeAsShown(user.sub, markBadgeShownDto.badgeCode); + } + } diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 522899e..784ea75 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -6,6 +6,8 @@ import { User } from "./models/user.model"; import { UserProfile } from "./models/user-profile.model"; import { UserWeightHistory } from "./models/user-weight-history.model"; import { UserBodyMeasurementHistory } from "./models/user-body-measurement-history.model"; +import { BadgeConfig } from "./models/badge-config.model"; +import { UserBadge } from "./models/user-badge.model"; import { UserDietHistory } from "./models/user-diet-history.model"; import { ApplePurchaseService } from "./services/apple-purchase.service"; @@ -20,6 +22,7 @@ import { UserPurchase } from "./models/user-purchase.model"; import { PurchaseRestoreLog } from "./models/purchase-restore-log.model"; import { RevenueCatEvent } from "./models/revenue-cat-event.model"; import { CosService } from './cos.service'; +import { BadgeService } from './services/badge.service'; import { ActivityLogsModule } from '../activity-logs/activity-logs.module'; @Module({ @@ -33,6 +36,8 @@ import { ActivityLogsModule } from '../activity-logs/activity-logs.module'; UserProfile, UserWeightHistory, UserBodyMeasurementHistory, + BadgeConfig, + UserBadge, UserDietHistory, UserActivity, @@ -44,7 +49,7 @@ import { ActivityLogsModule } from '../activity-logs/activity-logs.module'; }), ], controllers: [UsersController], - providers: [UsersService, ApplePurchaseService, EncryptionService, AppleAuthService, CosService, UserActivityService], - exports: [UsersService, AppleAuthService, UserActivityService], + providers: [UsersService, ApplePurchaseService, EncryptionService, AppleAuthService, CosService, UserActivityService, BadgeService], + exports: [UsersService, AppleAuthService, UserActivityService, BadgeService], }) export class UsersModule { } diff --git a/src/users/users.service.ts b/src/users/users.service.ts index fb38db3..d2d42e5 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -41,6 +41,7 @@ import { UserActivityService } from './services/user-activity.service'; import { GetUserActivityHistoryResponseDto } from './dto/user-activity.dto'; import { ActivityActionType, ActivityEntityType } from '../activity-logs/models/activity-log.model'; +import { BadgeService } from './services/badge.service'; @@ -74,6 +75,7 @@ export class UsersService { private sequelize: Sequelize, private readonly activityLogsService: ActivityLogsService, private readonly userActivityService: UserActivityService, + private readonly badgeService: BadgeService, ) { } async getProfile(user: AccessTokenPayload): Promise { @@ -2561,4 +2563,85 @@ export class UsersService { return Math.floor(Date.now() / 1000) % 100000; } } + + /** + * 获取用户勋章列表 + */ + async getUserBadges(userId: string): Promise { + try { + const badges = await this.badgeService.getUserBadges(userId); + const total = badges.length; + + return { + code: ResponseCode.SUCCESS, + message: 'success', + data: { + badges, + total, + }, + }; + } catch (error) { + this.logger.error(`获取用户勋章列表失败: ${error instanceof Error ? error.message : '未知错误'}`); + return { + code: ResponseCode.ERROR, + message: `获取用户勋章列表失败: ${error instanceof Error ? error.message : '未知错误'}`, + data: { + badges: [], + total: 0, + }, + }; + } + } + + /** + * 获取所有可用勋章(包含用户是否已获得) + */ + async getAvailableBadges(userId?: string): Promise { + try { + const badges = await this.badgeService.getAvailableBadges(userId); + + return { + code: ResponseCode.SUCCESS, + message: 'success', + data: badges, + }; + } catch (error) { + this.logger.error(`获取可用勋章列表失败: ${error instanceof Error ? error.message : '未知错误'}`); + return { + code: ResponseCode.ERROR, + message: `获取可用勋章列表失败: ${error instanceof Error ? error.message : '未知错误'}`, + data: [], + }; + } + } + + /** + * 标记勋章已展示 + */ + async markBadgeAsShown(userId: string, badgeCode: string): Promise { + try { + const success = await this.badgeService.markBadgeAsShown(userId, badgeCode); + + if (!success) { + return { + code: ResponseCode.ERROR, + message: '勋章不存在或标记失败', + data: { success: false }, + }; + } + + return { + code: ResponseCode.SUCCESS, + message: 'success', + data: { success: true }, + }; + } catch (error) { + this.logger.error(`标记勋章已展示失败: ${error instanceof Error ? error.message : '未知错误'}`); + return { + code: ResponseCode.ERROR, + message: `标记勋章已展示失败: ${error instanceof Error ? error.message : '未知错误'}`, + data: { success: false }, + }; + } + } }