From 73f53ac5e40cc1832de6418029cbef2bd5e39085 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Thu, 21 Aug 2025 14:28:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=94=9F=E6=88=90=E6=B4=BB=E5=8A=A8?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 3 + sql-scripts/user-activity-table-create.sql | 36 +++++ src/users/dto/user-activity.dto.ts | 78 +++++++++++ src/users/models/user-activity.model.ts | 108 +++++++++++++++ src/users/services/user-activity.service.ts | 137 ++++++++++++++++++++ src/users/users.controller.ts | 20 ++- src/users/users.module.ts | 5 +- src/users/users.service.ts | 28 ++++ 8 files changed, 413 insertions(+), 2 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 sql-scripts/user-activity-table-create.sql create mode 100644 src/users/dto/user-activity.dto.ts create mode 100644 src/users/models/user-activity.model.ts create mode 100644 src/users/services/user-activity.service.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3f836a2 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "kiroAgent.configureMCP": "Enabled" +} \ No newline at end of file diff --git a/sql-scripts/user-activity-table-create.sql b/sql-scripts/user-activity-table-create.sql new file mode 100644 index 0000000..7665773 --- /dev/null +++ b/sql-scripts/user-activity-table-create.sql @@ -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) diff --git a/src/users/dto/user-activity.dto.ts b/src/users/dto/user-activity.dto.ts new file mode 100644 index 0000000..1a6384a --- /dev/null +++ b/src/users/dto/user-activity.dto.ts @@ -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[]; +} \ No newline at end of file diff --git a/src/users/models/user-activity.model.ts b/src/users/models/user-activity.model.ts new file mode 100644 index 0000000..d9d7ad3 --- /dev/null +++ b/src/users/models/user-activity.model.ts @@ -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.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 { } + +@Table({ + tableName: 'user_activities', + timestamps: true, + indexes: [ + { + unique: true, + fields: ['userId', 'activityDate', 'activityType'] + }, + { + fields: ['userId', 'activityDate'] + } + ] +}) +export class UserActivity extends SequelizeModel { + @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; +} \ No newline at end of file diff --git a/src/users/services/user-activity.service.ts b/src/users/services/user-activity.service.ts new file mode 100644 index 0000000..971bf46 --- /dev/null +++ b/src/users/services/user-activity.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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); + } + } +} \ No newline at end of file diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index dbb8b04..7cc9b02 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -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 { } } -} \ No newline at end of file + // ==================== 用户活跃记录相关接口 ==================== + + /** + * 获取用户最近六个月的活跃情况 + */ + @UseGuards(JwtAuthGuard) + @Get('activity-history') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '获取用户最近六个月的活跃情况' }) + @ApiResponse({ status: 200, description: '成功获取用户活跃历史', type: GetUserActivityHistoryResponseDto }) + async getUserActivityHistory( + @CurrentUser() user: AccessTokenPayload, + ): Promise { + this.logger.log(`获取用户活跃历史 - 用户ID: ${user.sub}`); + return this.usersService.getUserActivityHistory(user.sub); + } + +} diff --git a/src/users/users.module.ts b/src/users/users.module.ts index f2cf03b..461992c 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -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 { } diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 76ef3fe..a14850d 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -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 { @@ -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 { + 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: [], + }; + } + } }