feat: 生成活动接口

This commit is contained in:
richarjiang
2025-08-21 14:28:15 +08:00
parent 94e1b124df
commit 73f53ac5e4
8 changed files with 413 additions and 2 deletions

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"kiroAgent.configureMCP": "Enabled"
}

View File

@@ -0,0 +1,36 @@
-- 创建用户活跃记录表
CREATE TABLE IF NOT EXISTS `t_user_activities` (
`id` int NOT NULL AUTO_INCREMENT,
`userId` varchar(255) NOT NULL COMMENT '用户ID',
`activityType` tinyint NOT NULL COMMENT '活跃类型1-登录2-训练3-饮食记录4-体重记录5-资料更新6-打卡',
`activityDate` date NOT NULL COMMENT '活跃日期 YYYY-MM-DD',
`level` tinyint NOT NULL DEFAULT 1 COMMENT '活跃等级0-无活跃1-低活跃2-中活跃3-高活跃',
`remark` text COMMENT '备注信息',
`createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_user_activity_date_type` (`userId`, `activityDate`, `activityType`),
KEY `idx_user_activity_date` (`userId`, `activityDate`),
KEY `idx_activity_date` (`activityDate`),
-- 添加枚举约束
CONSTRAINT `chk_activity_type` CHECK (`activityType` IN (1, 2, 3, 4, 5, 6)),
CONSTRAINT `chk_activity_level` CHECK (`level` IN (0, 1, 2, 3))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户活跃记录表';
-- 创建索引以优化查询性能
CREATE INDEX IF NOT EXISTS `idx_user_activity_level` ON `user_activities` (`userId`, `activityDate`, `level`);
-- 枚举值说明
-- activityType 枚举值:
-- 1: 登录 (LOGIN)
-- 2: 训练 (WORKOUT)
-- 3: 饮食记录 (DIET_RECORD)
-- 4: 体重记录 (WEIGHT_RECORD)
-- 5: 资料更新 (PROFILE_UPDATE)
-- 6: 打卡 (CHECKIN)
-- level 枚举值:
-- 0: 无活跃 (NONE)
-- 1: 低活跃 (LOW)
-- 2: 中活跃 (MEDIUM)
-- 3: 高活跃 (HIGH)

View 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[];
}

View 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;
}

View 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);
}
}
}

View File

@@ -29,6 +29,7 @@ import { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietR
import { GuestLoginDto, GuestLoginResponseDto, RefreshGuestTokenDto, RefreshGuestTokenResponseDto } from './dto/guest-login.dto'; import { GuestLoginDto, GuestLoginResponseDto, RefreshGuestTokenDto, RefreshGuestTokenResponseDto } from './dto/guest-login.dto';
import { AppStoreServerNotificationDto, ProcessNotificationResponseDto } from './dto/app-store-notification.dto'; import { AppStoreServerNotificationDto, ProcessNotificationResponseDto } from './dto/app-store-notification.dto';
import { RestorePurchaseDto, RestorePurchaseResponseDto } from './dto/restore-purchase.dto'; import { RestorePurchaseDto, RestorePurchaseResponseDto } from './dto/restore-purchase.dto';
import { GetUserActivityHistoryResponseDto } from './dto/user-activity.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';
import { AccessTokenPayload } from './services/apple-auth.service'; 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);
}
} }

View File

@@ -7,6 +7,8 @@ import { UserProfile } from "./models/user-profile.model";
import { UserWeightHistory } from "./models/user-weight-history.model"; import { UserWeightHistory } from "./models/user-weight-history.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";
import { UserActivity } from "./models/user-activity.model";
import { UserActivityService } from "./services/user-activity.service";
import { EncryptionService } from "../common/encryption.service"; import { EncryptionService } from "../common/encryption.service";
import { AppleAuthService } from "./services/apple-auth.service"; import { AppleAuthService } from "./services/apple-auth.service";
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@@ -28,6 +30,7 @@ import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
UserProfile, UserProfile,
UserWeightHistory, UserWeightHistory,
UserDietHistory, UserDietHistory,
UserActivity,
]), ]),
forwardRef(() => ActivityLogsModule), forwardRef(() => ActivityLogsModule),
JwtModule.register({ JwtModule.register({
@@ -36,7 +39,7 @@ import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
}), }),
], ],
controllers: [UsersController], controllers: [UsersController],
providers: [UsersService, ApplePurchaseService, EncryptionService, AppleAuthService, CosService], providers: [UsersService, ApplePurchaseService, EncryptionService, AppleAuthService, CosService, UserActivityService],
exports: [UsersService, AppleAuthService], exports: [UsersService, AppleAuthService],
}) })
export class UsersModule { } export class UsersModule { }

View File

@@ -33,6 +33,8 @@ import { BlockedTransaction, BlockReason } from './models/blocked-transaction.mo
import { UserWeightHistory, WeightUpdateSource } from './models/user-weight-history.model'; import { UserWeightHistory, WeightUpdateSource } from './models/user-weight-history.model';
import { UserDietHistory, DietRecordSource, MealType } from './models/user-diet-history.model'; import { UserDietHistory, DietRecordSource, MealType } from './models/user-diet-history.model';
import { ActivityLogsService } from '../activity-logs/activity-logs.service'; 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 { ActivityActionType, ActivityEntityType } from '../activity-logs/models/activity-log.model';
import { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietRecordResponseDto, DietHistoryResponseDto, NutritionSummaryDto } from './dto/diet-record.dto'; import { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietRecordResponseDto, DietHistoryResponseDto, NutritionSummaryDto } from './dto/diet-record.dto';
@@ -64,6 +66,7 @@ export class UsersService {
@InjectConnection() @InjectConnection()
private sequelize: Sequelize, private sequelize: Sequelize,
private readonly activityLogsService: ActivityLogsService, private readonly activityLogsService: ActivityLogsService,
private readonly userActivityService: UserActivityService,
) { } ) { }
async getProfile(user: AccessTokenPayload): Promise<UserResponseDto> { async getProfile(user: AccessTokenPayload): Promise<UserResponseDto> {
@@ -94,6 +97,9 @@ export class UsersService {
where: { userId: existingUser.id }, where: { userId: existingUser.id },
defaults: { userId: existingUser.id }, defaults: { userId: existingUser.id },
}); });
// 检查并记录今日登录活跃
await this.userActivityService.checkAndRecordTodayLogin(existingUser.id);
const returnData = { const returnData = {
...existingUser.toJSON(), ...existingUser.toJSON(),
maxUsageCount: DEFAULT_FREE_USAGE_COUNT, maxUsageCount: DEFAULT_FREE_USAGE_COUNT,
@@ -2334,4 +2340,26 @@ export class UsersService {
this.logger.error(`关联 RevenueCat 用户失败: ${error instanceof Error ? error.message : '未知错误'}`); 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: [],
};
}
}
} }