feat: 生成活动接口
This commit is contained in:
78
src/users/dto/user-activity.dto.ts
Normal file
78
src/users/dto/user-activity.dto.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, IsOptional, IsInt, Min, Max, IsDateString, IsEnum } from 'class-validator';
|
||||
import { ResponseCode } from 'src/base.dto';
|
||||
import { ActivityType, ActivityLevel } from '../models/user-activity.model';
|
||||
|
||||
export class CreateUserActivityDto {
|
||||
@ApiProperty({
|
||||
description: '活跃类型',
|
||||
enum: ActivityType,
|
||||
example: ActivityType.LOGIN
|
||||
})
|
||||
@IsEnum(ActivityType)
|
||||
activityType!: ActivityType;
|
||||
|
||||
@ApiProperty({ description: '活跃日期 YYYY-MM-DD', example: '2024-01-15' })
|
||||
@IsDateString()
|
||||
activityDate!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '活跃等级',
|
||||
enum: ActivityLevel,
|
||||
example: ActivityLevel.LOW,
|
||||
minimum: 0,
|
||||
maximum: 3
|
||||
})
|
||||
@IsEnum(ActivityLevel)
|
||||
level!: ActivityLevel;
|
||||
|
||||
@ApiProperty({ description: '备注', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export class UserActivityDto {
|
||||
@ApiProperty({ description: '活跃记录ID' })
|
||||
id!: number;
|
||||
|
||||
@ApiProperty({ description: '用户ID' })
|
||||
userId!: string;
|
||||
|
||||
@ApiProperty({ description: '活跃类型', enum: ActivityType })
|
||||
activityType!: ActivityType;
|
||||
|
||||
@ApiProperty({ description: '活跃日期' })
|
||||
activityDate!: string;
|
||||
|
||||
@ApiProperty({ description: '活跃等级', enum: ActivityLevel })
|
||||
level!: ActivityLevel;
|
||||
|
||||
@ApiProperty({ description: '备注', required: false })
|
||||
remark?: string;
|
||||
|
||||
@ApiProperty({ description: '创建时间' })
|
||||
createdAt!: Date;
|
||||
|
||||
@ApiProperty({ description: '更新时间' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
|
||||
export class UserActivitySummaryDto {
|
||||
@ApiProperty({ description: '日期 YYYY-MM-DD' })
|
||||
date!: string;
|
||||
|
||||
@ApiProperty({ description: '活跃等级', enum: ActivityLevel })
|
||||
level!: ActivityLevel;
|
||||
}
|
||||
|
||||
export class GetUserActivityHistoryResponseDto {
|
||||
@ApiProperty({ description: '响应码' })
|
||||
code!: ResponseCode;
|
||||
|
||||
@ApiProperty({ description: '响应消息' })
|
||||
message!: string;
|
||||
|
||||
@ApiProperty({ description: '用户活跃历史数据', type: [UserActivitySummaryDto] })
|
||||
data!: UserActivitySummaryDto[];
|
||||
}
|
||||
108
src/users/models/user-activity.model.ts
Normal file
108
src/users/models/user-activity.model.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { DataTypes, Model, Optional } from 'sequelize';
|
||||
import { Column, Table, Model as SequelizeModel, DataType, ForeignKey, BelongsTo } from 'sequelize-typescript';
|
||||
import { User } from './user.model';
|
||||
|
||||
export enum ActivityType {
|
||||
LOGIN = 1, // 登录
|
||||
WORKOUT = 2, // 训练
|
||||
DIET_RECORD = 3, // 饮食记录
|
||||
WEIGHT_RECORD = 4, // 体重记录
|
||||
PROFILE_UPDATE = 5, // 资料更新
|
||||
CHECKIN = 6, // 打卡
|
||||
}
|
||||
|
||||
// 活跃类型显示名称映射
|
||||
export const ActivityTypeNames: Record<ActivityType, string> = {
|
||||
[ActivityType.LOGIN]: '登录',
|
||||
[ActivityType.WORKOUT]: '训练',
|
||||
[ActivityType.DIET_RECORD]: '饮食记录',
|
||||
[ActivityType.WEIGHT_RECORD]: '体重记录',
|
||||
[ActivityType.PROFILE_UPDATE]: '资料更新',
|
||||
[ActivityType.CHECKIN]: '打卡',
|
||||
};
|
||||
|
||||
// 获取活跃类型显示名称的辅助函数
|
||||
export function getActivityTypeName(activityType: ActivityType): string {
|
||||
return ActivityTypeNames[activityType] || '未知';
|
||||
}
|
||||
|
||||
export enum ActivityLevel {
|
||||
NONE = 0, // 无活跃
|
||||
LOW = 1, // 低活跃
|
||||
MEDIUM = 2, // 中活跃
|
||||
HIGH = 3, // 高活跃
|
||||
}
|
||||
|
||||
export interface UserActivityAttributes {
|
||||
id: number;
|
||||
userId: string;
|
||||
activityType: ActivityType;
|
||||
activityDate: string; // YYYY-MM-DD 格式
|
||||
level: ActivityLevel; // 活跃等级
|
||||
remark?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface UserActivityCreationAttributes extends Optional<UserActivityAttributes, 'id' | 'createdAt' | 'updatedAt' | 'remark'> { }
|
||||
|
||||
@Table({
|
||||
tableName: 'user_activities',
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{
|
||||
unique: true,
|
||||
fields: ['userId', 'activityDate', 'activityType']
|
||||
},
|
||||
{
|
||||
fields: ['userId', 'activityDate']
|
||||
}
|
||||
]
|
||||
})
|
||||
export class UserActivity extends SequelizeModel<UserActivityAttributes, UserActivityCreationAttributes> {
|
||||
@Column({
|
||||
type: DataType.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
})
|
||||
declare id: number;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: false,
|
||||
})
|
||||
userId!: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.TINYINT,
|
||||
allowNull: false,
|
||||
comment: '活跃类型:1-登录,2-训练,3-饮食记录,4-体重记录,5-资料更新,6-打卡',
|
||||
})
|
||||
activityType!: ActivityType;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATEONLY,
|
||||
allowNull: false,
|
||||
comment: '活跃日期 YYYY-MM-DD',
|
||||
})
|
||||
activityDate!: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.TINYINT,
|
||||
allowNull: false,
|
||||
defaultValue: ActivityLevel.LOW,
|
||||
comment: '活跃等级:0-无活跃,1-低活跃,2-中活跃,3-高活跃',
|
||||
})
|
||||
level!: ActivityLevel;
|
||||
|
||||
@Column({
|
||||
type: DataType.TEXT,
|
||||
allowNull: true,
|
||||
comment: '备注信息',
|
||||
})
|
||||
remark?: string;
|
||||
|
||||
@BelongsTo(() => User)
|
||||
user!: User;
|
||||
}
|
||||
137
src/users/services/user-activity.service.ts
Normal file
137
src/users/services/user-activity.service.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/sequelize';
|
||||
import { UserActivity, ActivityType, ActivityLevel } from '../models/user-activity.model';
|
||||
import { CreateUserActivityDto, UserActivitySummaryDto } from '../dto/user-activity.dto';
|
||||
import { Op } from 'sequelize';
|
||||
import * as dayjs from 'dayjs';
|
||||
|
||||
@Injectable()
|
||||
export class UserActivityService {
|
||||
private readonly logger = new Logger(UserActivityService.name);
|
||||
|
||||
constructor(
|
||||
@InjectModel(UserActivity)
|
||||
private userActivityModel: typeof UserActivity,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* 记录用户活跃
|
||||
*/
|
||||
async recordActivity(userId: string, activityDto: CreateUserActivityDto): Promise<UserActivity> {
|
||||
try {
|
||||
// 使用 upsert 避免重复记录同一天同一类型的活跃
|
||||
const [activity, created] = await this.userActivityModel.upsert({
|
||||
userId,
|
||||
...activityDto,
|
||||
});
|
||||
|
||||
this.logger.log(`记录用户活跃 - 用户: ${userId}, 类型: ${activityDto.activityType}, 日期: ${activityDto.activityDate}, 是否新建: ${created}`);
|
||||
return activity;
|
||||
} catch (error) {
|
||||
this.logger.error(`记录用户活跃失败: ${error.message}`, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查今日是否有登录记录,如果没有则创建
|
||||
*/
|
||||
async checkAndRecordTodayLogin(userId: string): Promise<void> {
|
||||
const today = dayjs().format('YYYY-MM-DD'); // YYYY-MM-DD 格式
|
||||
|
||||
try {
|
||||
const existingLogin = await this.userActivityModel.findOne({
|
||||
where: {
|
||||
userId,
|
||||
activityDate: today,
|
||||
activityType: ActivityType.LOGIN,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingLogin) {
|
||||
await this.recordActivity(userId, {
|
||||
activityType: ActivityType.LOGIN,
|
||||
activityDate: today,
|
||||
level: ActivityLevel.LOW, // 登录默认为低活跃
|
||||
remark: '用户拉取profile接口自动记录',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`检查并记录今日登录失败: ${error.message}`, error.stack);
|
||||
// 不抛出错误,避免影响主要业务流程
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户最近六个月的活跃情况
|
||||
*/
|
||||
async getUserActivityHistory(userId: string): Promise<UserActivitySummaryDto[]> {
|
||||
const startDate = dayjs().subtract(6, 'month').format('YYYY-MM-DD');
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
|
||||
try {
|
||||
// 获取用户在指定时间范围内的活跃记录,按日期分组,取每日最高活跃等级
|
||||
const activities = await this.userActivityModel.findAll({
|
||||
where: {
|
||||
userId,
|
||||
activityDate: {
|
||||
[Op.gte]: startDate,
|
||||
[Op.lte]: today,
|
||||
},
|
||||
},
|
||||
attributes: [
|
||||
'activityDate',
|
||||
[this.userActivityModel.sequelize!.fn('MAX', this.userActivityModel.sequelize!.col('level')), 'maxLevel'],
|
||||
],
|
||||
group: ['activityDate'],
|
||||
order: [['activityDate', 'ASC']],
|
||||
raw: true,
|
||||
});
|
||||
|
||||
// 生成完整的日期范围(最近6个月的每一天)
|
||||
const result: UserActivitySummaryDto[] = [];
|
||||
let cursor = dayjs(startDate, 'YYYY-MM-DD');
|
||||
const end = dayjs(today, 'YYYY-MM-DD');
|
||||
|
||||
while (!cursor.isAfter(end)) {
|
||||
const dateStr = cursor.format('YYYY-MM-DD');
|
||||
const activity = activities.find((a: any) => a.activityDate === dateStr);
|
||||
|
||||
result.push({
|
||||
date: dateStr,
|
||||
level: activity ? parseInt((activity as any).maxLevel) : ActivityLevel.NONE, // 无活跃
|
||||
});
|
||||
|
||||
cursor = cursor.add(1, 'day');
|
||||
}
|
||||
|
||||
this.logger.log(`获取用户活跃历史 - 用户: ${userId}, 记录数: ${result.length}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(`获取用户活跃历史失败: ${error.message}`, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户某日的活跃等级(如果新等级更高)
|
||||
*/
|
||||
async updateActivityLevel(userId: string, activityDate: string, activityType: ActivityType, newLevel: ActivityLevel): Promise<void> {
|
||||
try {
|
||||
const activity = await this.userActivityModel.findOne({
|
||||
where: {
|
||||
userId,
|
||||
activityDate,
|
||||
activityType,
|
||||
},
|
||||
});
|
||||
|
||||
if (activity && activity.level < newLevel) {
|
||||
await activity.update({ level: newLevel });
|
||||
this.logger.log(`更新用户活跃等级 - 用户: ${userId}, 日期: ${activityDate}, 类型: ${activityType}, 新等级: ${newLevel}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`更新用户活跃等级失败: ${error.message}`, error.stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietR
|
||||
import { GuestLoginDto, GuestLoginResponseDto, RefreshGuestTokenDto, RefreshGuestTokenResponseDto } from './dto/guest-login.dto';
|
||||
import { AppStoreServerNotificationDto, ProcessNotificationResponseDto } from './dto/app-store-notification.dto';
|
||||
import { RestorePurchaseDto, RestorePurchaseResponseDto } from './dto/restore-purchase.dto';
|
||||
import { GetUserActivityHistoryResponseDto } from './dto/user-activity.dto';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import { AccessTokenPayload } from './services/apple-auth.service';
|
||||
@@ -317,4 +318,21 @@ export class UsersController {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
// ==================== 用户活跃记录相关接口 ====================
|
||||
|
||||
/**
|
||||
* 获取用户最近六个月的活跃情况
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('activity-history')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '获取用户最近六个月的活跃情况' })
|
||||
@ApiResponse({ status: 200, description: '成功获取用户活跃历史', type: GetUserActivityHistoryResponseDto })
|
||||
async getUserActivityHistory(
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
): Promise<GetUserActivityHistoryResponseDto> {
|
||||
this.logger.log(`获取用户活跃历史 - 用户ID: ${user.sub}`);
|
||||
return this.usersService.getUserActivityHistory(user.sub);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { UserProfile } from "./models/user-profile.model";
|
||||
import { UserWeightHistory } from "./models/user-weight-history.model";
|
||||
import { UserDietHistory } from "./models/user-diet-history.model";
|
||||
import { ApplePurchaseService } from "./services/apple-purchase.service";
|
||||
import { UserActivity } from "./models/user-activity.model";
|
||||
import { UserActivityService } from "./services/user-activity.service";
|
||||
import { EncryptionService } from "../common/encryption.service";
|
||||
import { AppleAuthService } from "./services/apple-auth.service";
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
@@ -28,6 +30,7 @@ import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
|
||||
UserProfile,
|
||||
UserWeightHistory,
|
||||
UserDietHistory,
|
||||
UserActivity,
|
||||
]),
|
||||
forwardRef(() => ActivityLogsModule),
|
||||
JwtModule.register({
|
||||
@@ -36,7 +39,7 @@ import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
|
||||
}),
|
||||
],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService, ApplePurchaseService, EncryptionService, AppleAuthService, CosService],
|
||||
providers: [UsersService, ApplePurchaseService, EncryptionService, AppleAuthService, CosService, UserActivityService],
|
||||
exports: [UsersService, AppleAuthService],
|
||||
})
|
||||
export class UsersModule { }
|
||||
|
||||
@@ -33,6 +33,8 @@ import { BlockedTransaction, BlockReason } from './models/blocked-transaction.mo
|
||||
import { UserWeightHistory, WeightUpdateSource } from './models/user-weight-history.model';
|
||||
import { UserDietHistory, DietRecordSource, MealType } from './models/user-diet-history.model';
|
||||
import { ActivityLogsService } from '../activity-logs/activity-logs.service';
|
||||
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 { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietRecordResponseDto, DietHistoryResponseDto, NutritionSummaryDto } from './dto/diet-record.dto';
|
||||
|
||||
@@ -64,6 +66,7 @@ export class UsersService {
|
||||
@InjectConnection()
|
||||
private sequelize: Sequelize,
|
||||
private readonly activityLogsService: ActivityLogsService,
|
||||
private readonly userActivityService: UserActivityService,
|
||||
) { }
|
||||
|
||||
async getProfile(user: AccessTokenPayload): Promise<UserResponseDto> {
|
||||
@@ -94,6 +97,9 @@ export class UsersService {
|
||||
where: { userId: existingUser.id },
|
||||
defaults: { userId: existingUser.id },
|
||||
});
|
||||
// 检查并记录今日登录活跃
|
||||
await this.userActivityService.checkAndRecordTodayLogin(existingUser.id);
|
||||
|
||||
const returnData = {
|
||||
...existingUser.toJSON(),
|
||||
maxUsageCount: DEFAULT_FREE_USAGE_COUNT,
|
||||
@@ -2334,4 +2340,26 @@ export class UsersService {
|
||||
this.logger.error(`关联 RevenueCat 用户失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户最近六个月的活跃情况
|
||||
*/
|
||||
async getUserActivityHistory(userId: string): Promise<GetUserActivityHistoryResponseDto> {
|
||||
try {
|
||||
const activityHistory = await this.userActivityService.getUserActivityHistory(userId);
|
||||
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: 'success',
|
||||
data: activityHistory,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`获取用户活跃历史失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
return {
|
||||
code: ResponseCode.ERROR,
|
||||
message: `获取用户活跃历史失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user