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 { Injectable, NotFoundException, BadRequestException, ConflictException, Inject } from '@nestjs/common';
|
||||||
import { InjectModel } from '@nestjs/sequelize';
|
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 { ChallengeParticipant, ChallengeParticipantStatus } from './models/challenge-participant.model';
|
||||||
import { ChallengeProgressReport } from './models/challenge-progress-report.model';
|
import { ChallengeProgressReport } from './models/challenge-progress-report.model';
|
||||||
import { UpdateChallengeProgressDto } from './dto/update-challenge-progress.dto';
|
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 { User } from '../users/models/user.model';
|
||||||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||||
import { Logger as WinstonLogger } from 'winston';
|
import { Logger as WinstonLogger } from 'winston';
|
||||||
|
import { BadgeService } from '../users/services/badge.service';
|
||||||
|
import { BadgeSource } from '../users/models/user-badge.model';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ChallengesService {
|
export class ChallengesService {
|
||||||
@@ -26,6 +28,7 @@ export class ChallengesService {
|
|||||||
private readonly participantModel: typeof ChallengeParticipant,
|
private readonly participantModel: typeof ChallengeParticipant,
|
||||||
@InjectModel(ChallengeProgressReport)
|
@InjectModel(ChallengeProgressReport)
|
||||||
private readonly progressReportModel: typeof ChallengeProgressReport,
|
private readonly progressReportModel: typeof ChallengeProgressReport,
|
||||||
|
private readonly badgeService: BadgeService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async getChallengesForUser(userId?: string): Promise<ChallengeListItemDto[]> {
|
async getChallengesForUser(userId?: string): Promise<ChallengeListItemDto[]> {
|
||||||
@@ -432,10 +435,70 @@ export class ChallengesService {
|
|||||||
if (report.reportedValue >= reportCompletedValue && !dayjs(participant.lastProgressAt).isSame(dayjs(), 'd')) {
|
if (report.reportedValue >= reportCompletedValue && !dayjs(participant.lastProgressAt).isSame(dayjs(), 'd')) {
|
||||||
participant.progressValue++
|
participant.progressValue++
|
||||||
participant.lastProgressAt = now;
|
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) {
|
if (participant.progressValue >= (participant.challenge?.minimumCheckInDays || 0) && participant.status !== ChallengeParticipantStatus.COMPLETED) {
|
||||||
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();
|
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 { GetUserActivityHistoryResponseDto } from './dto/user-activity.dto';
|
||||||
import { UpdateWeightRecordDto, WeightRecordResponseDto, DeleteWeightRecordResponseDto } from './dto/weight-record.dto';
|
import { UpdateWeightRecordDto, WeightRecordResponseDto, DeleteWeightRecordResponseDto } from './dto/weight-record.dto';
|
||||||
import { UpdateBodyMeasurementDto, UpdateBodyMeasurementResponseDto, GetBodyMeasurementHistoryResponseDto, GetBodyMeasurementAnalysisDto, GetBodyMeasurementAnalysisResponseDto } from './dto/body-measurement.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 { Public } from '../common/decorators/public.decorator';
|
||||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||||
@@ -393,4 +394,53 @@ export class UsersController {
|
|||||||
return this.usersService.getBodyMeasurementAnalysis(user.sub, period);
|
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 { UserProfile } from "./models/user-profile.model";
|
||||||
import { UserWeightHistory } from "./models/user-weight-history.model";
|
import { UserWeightHistory } from "./models/user-weight-history.model";
|
||||||
import { UserBodyMeasurementHistory } from "./models/user-body-measurement-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 { UserDietHistory } from "./models/user-diet-history.model";
|
||||||
import { ApplePurchaseService } from "./services/apple-purchase.service";
|
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 { PurchaseRestoreLog } from "./models/purchase-restore-log.model";
|
||||||
import { RevenueCatEvent } from "./models/revenue-cat-event.model";
|
import { RevenueCatEvent } from "./models/revenue-cat-event.model";
|
||||||
import { CosService } from './cos.service';
|
import { CosService } from './cos.service';
|
||||||
|
import { BadgeService } from './services/badge.service';
|
||||||
import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
|
import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -33,6 +36,8 @@ import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
|
|||||||
UserProfile,
|
UserProfile,
|
||||||
UserWeightHistory,
|
UserWeightHistory,
|
||||||
UserBodyMeasurementHistory,
|
UserBodyMeasurementHistory,
|
||||||
|
BadgeConfig,
|
||||||
|
UserBadge,
|
||||||
|
|
||||||
UserDietHistory,
|
UserDietHistory,
|
||||||
UserActivity,
|
UserActivity,
|
||||||
@@ -44,7 +49,7 @@ import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [UsersController],
|
controllers: [UsersController],
|
||||||
providers: [UsersService, ApplePurchaseService, EncryptionService, AppleAuthService, CosService, UserActivityService],
|
providers: [UsersService, ApplePurchaseService, EncryptionService, AppleAuthService, CosService, UserActivityService, BadgeService],
|
||||||
exports: [UsersService, AppleAuthService, UserActivityService],
|
exports: [UsersService, AppleAuthService, UserActivityService, BadgeService],
|
||||||
})
|
})
|
||||||
export class UsersModule { }
|
export class UsersModule { }
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { UserActivityService } from './services/user-activity.service';
|
|||||||
|
|
||||||
import { GetUserActivityHistoryResponseDto } from './dto/user-activity.dto';
|
import { GetUserActivityHistoryResponseDto } from './dto/user-activity.dto';
|
||||||
import { ActivityActionType, ActivityEntityType } from '../activity-logs/models/activity-log.model';
|
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 sequelize: Sequelize,
|
||||||
private readonly activityLogsService: ActivityLogsService,
|
private readonly activityLogsService: ActivityLogsService,
|
||||||
private readonly userActivityService: UserActivityService,
|
private readonly userActivityService: UserActivityService,
|
||||||
|
private readonly badgeService: BadgeService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async getProfile(user: AccessTokenPayload): Promise<UserResponseDto> {
|
async getProfile(user: AccessTokenPayload): Promise<UserResponseDto> {
|
||||||
@@ -2561,4 +2563,85 @@ export class UsersService {
|
|||||||
return Math.floor(Date.now() / 1000) % 100000;
|
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