feat(badges): 添加用户勋章系统,支持睡眠挑战勋章自动授予
实现完整的用户勋章功能模块: - 新增 BadgeConfig 和 UserBadge 数据模型,支持勋章配置和用户勋章管理 - 新增 BadgeService 服务,提供勋章授予、查询、展示状态管理等核心功能 - 在挑战服务中集成勋章授予逻辑,完成首次睡眠打卡授予 goodSleep 勋章,完成睡眠挑战授予 sleepChallengeMonth 勋章 - 新增用户勋章相关接口:获取用户勋章列表、获取可用勋章列表、标记勋章已展示 - 支持勋章分类(睡眠、运动、饮食等)、排序、启用状态管理 - 支持勋章来源追踪(挑战、系统、手动授予)和元数据记录
This commit is contained in:
@@ -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<ChallengeListItemDto[]> {
|
||||
@@ -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();
|
||||
|
||||
157
src/users/dto/badge.dto.ts
Normal file
157
src/users/dto/badge.dto.ts
Normal file
@@ -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<string, any>;
|
||||
|
||||
@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<AvailableBadgeDto[]> {
|
||||
@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 };
|
||||
}
|
||||
90
src/users/models/badge-config.model.ts
Normal file
90
src/users/models/badge-config.model.ts
Normal file
@@ -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;
|
||||
}
|
||||
105
src/users/models/user-badge.model.ts
Normal file
105
src/users/models/user-badge.model.ts
Normal file
@@ -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<string, any>;
|
||||
|
||||
@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;
|
||||
}
|
||||
236
src/users/services/badge.service.ts
Normal file
236
src/users/services/badge.service.ts
Normal file
@@ -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<string, any>;
|
||||
},
|
||||
): Promise<UserBadge | null> {
|
||||
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<Array<{
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
category: string;
|
||||
awardedAt: Date;
|
||||
source: string;
|
||||
sourceId: string;
|
||||
metadata: Record<string, any>;
|
||||
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<Array<{
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
category: string;
|
||||
sortOrder: number;
|
||||
isAwarded: boolean;
|
||||
awardedAt?: Date;
|
||||
isShow?: boolean;
|
||||
}>> {
|
||||
// 获取所有启用的勋章配置
|
||||
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<boolean> {
|
||||
const count = await this.userBadgeModel.count({
|
||||
where: { userId, badgeCode },
|
||||
});
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户勋章数量
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
async getUserBadgeCount(userId: string): Promise<number> {
|
||||
return await this.userBadgeModel.count({
|
||||
where: { userId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记勋章已展示
|
||||
* @param userId 用户ID
|
||||
* @param badgeCode 勋章代码
|
||||
*/
|
||||
async markBadgeAsShown(userId: string, badgeCode: string): Promise<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<GetUserBadgesResponseDto> {
|
||||
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<GetAvailableBadgesResponseDto> {
|
||||
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<MarkBadgeShownResponseDto> {
|
||||
this.logger.log(`标记勋章已展示 - 用户ID: ${user.sub}, 勋章代码: ${markBadgeShownDto.badgeCode}`);
|
||||
return this.usersService.markBadgeAsShown(user.sub, markBadgeShownDto.badgeCode);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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 { }
|
||||
|
||||
@@ -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<UserResponseDto> {
|
||||
@@ -2561,4 +2563,85 @@ export class UsersService {
|
||||
return Math.floor(Date.now() / 1000) % 100000;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户勋章列表
|
||||
*/
|
||||
async getUserBadges(userId: string): Promise<any> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user