feat:新增活动日志模块,包括控制器、服务、模型及数据传输对象,更新应用模块以引入新模块,并在打卡和训练计划模块中集成活动日志记录功能。
This commit is contained in:
33
src/activity-logs/activity-logs.controller.ts
Normal file
33
src/activity-logs/activity-logs.controller.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { ActivityLogsService } from './activity-logs.service';
|
||||||
|
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||||
|
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||||
|
import { AccessTokenPayload } from '../users/services/apple-auth.service';
|
||||||
|
import { GetActivityLogsQueryDto, GetActivityLogsResponseDto } from './dto/activity-log.dto';
|
||||||
|
|
||||||
|
@ApiTags('activity-logs')
|
||||||
|
@Controller('activity-logs')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class ActivityLogsController {
|
||||||
|
constructor(private readonly service: ActivityLogsService) { }
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: '获取活动记录列表(分页)' })
|
||||||
|
@ApiQuery({ name: 'page', required: false })
|
||||||
|
@ApiQuery({ name: 'pageSize', required: false })
|
||||||
|
@ApiResponse({ type: GetActivityLogsResponseDto })
|
||||||
|
async list(@CurrentUser() user: AccessTokenPayload, @Query() query: GetActivityLogsQueryDto): Promise<GetActivityLogsResponseDto> {
|
||||||
|
const data = await this.service.list({
|
||||||
|
userId: user.sub,
|
||||||
|
page: query.page,
|
||||||
|
pageSize: query.pageSize,
|
||||||
|
entityType: query.entityType,
|
||||||
|
action: query.action,
|
||||||
|
entityId: query.entityId,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
16
src/activity-logs/activity-logs.module.ts
Normal file
16
src/activity-logs/activity-logs.module.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { SequelizeModule } from '@nestjs/sequelize';
|
||||||
|
import { ActivityLogsService } from './activity-logs.service';
|
||||||
|
import { ActivityLogsController } from './activity-logs.controller';
|
||||||
|
import { ActivityLog } from './models/activity-log.model';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [SequelizeModule.forFeature([ActivityLog]), forwardRef(() => UsersModule)],
|
||||||
|
providers: [ActivityLogsService],
|
||||||
|
controllers: [ActivityLogsController],
|
||||||
|
exports: [ActivityLogsService],
|
||||||
|
})
|
||||||
|
export class ActivityLogsModule { }
|
||||||
|
|
||||||
|
|
||||||
55
src/activity-logs/activity-logs.service.ts
Normal file
55
src/activity-logs/activity-logs.service.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectModel } from '@nestjs/sequelize';
|
||||||
|
import { ActivityActionType, ActivityEntityType, ActivityLog } from './models/activity-log.model';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ActivityLogsService {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(ActivityLog)
|
||||||
|
private readonly activityLogModel: typeof ActivityLog,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
async record(params: {
|
||||||
|
userId: string;
|
||||||
|
entityType: ActivityEntityType;
|
||||||
|
action: ActivityActionType;
|
||||||
|
entityId?: string | null;
|
||||||
|
changes?: Record<string, any> | null;
|
||||||
|
metadata?: Record<string, any> | null;
|
||||||
|
}): Promise<void> {
|
||||||
|
await this.activityLogModel.create({
|
||||||
|
userId: params.userId,
|
||||||
|
entityType: params.entityType,
|
||||||
|
action: params.action,
|
||||||
|
entityId: params.entityId ?? null,
|
||||||
|
changes: params.changes ?? null,
|
||||||
|
metadata: params.metadata ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(params: {
|
||||||
|
userId: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
entityType?: ActivityEntityType;
|
||||||
|
action?: ActivityActionType;
|
||||||
|
entityId?: string;
|
||||||
|
}) {
|
||||||
|
const page = params.page && params.page > 0 ? params.page : 1;
|
||||||
|
const pageSize = params.pageSize && params.pageSize > 0 ? Math.min(100, params.pageSize) : 20;
|
||||||
|
const where: any = { userId: params.userId };
|
||||||
|
if (params.entityType) where.entityType = params.entityType;
|
||||||
|
if (params.action) where.action = params.action;
|
||||||
|
if (params.entityId) where.entityId = params.entityId;
|
||||||
|
|
||||||
|
const { rows, count } = await this.activityLogModel.findAndCountAll({
|
||||||
|
where,
|
||||||
|
limit: pageSize,
|
||||||
|
offset: (page - 1) * pageSize,
|
||||||
|
order: [['created_at', 'DESC']],
|
||||||
|
});
|
||||||
|
return { total: count, items: rows.map(r => r.toJSON()) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
50
src/activity-logs/dto/activity-log.dto.ts
Normal file
50
src/activity-logs/dto/activity-log.dto.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
import { ActivityActionType, ActivityEntityType } from '../models/activity-log.model';
|
||||||
|
|
||||||
|
export class GetActivityLogsQueryDto {
|
||||||
|
@ApiProperty({ description: '分页页码(从1开始)', required: false, example: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '分页大小(1-100)', required: false, example: 20 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
pageSize?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ActivityEntityType, required: false, description: '按实体类型过滤' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ActivityEntityType)
|
||||||
|
entityType?: ActivityEntityType;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ActivityActionType, required: false, description: '按动作过滤' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ActivityActionType)
|
||||||
|
action?: ActivityActionType;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: '实体ID过滤' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
entityId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActivityLogItemDto {
|
||||||
|
@ApiProperty() id: string;
|
||||||
|
@ApiProperty({ enum: ActivityEntityType }) entityType: ActivityEntityType;
|
||||||
|
@ApiProperty({ enum: ActivityActionType }) action: ActivityActionType;
|
||||||
|
@ApiProperty({ required: false }) entityId?: string | null;
|
||||||
|
@ApiProperty({ required: false }) changes?: Record<string, any> | null;
|
||||||
|
@ApiProperty({ required: false }) metadata?: Record<string, any> | null;
|
||||||
|
@ApiProperty() createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetActivityLogsResponseDto {
|
||||||
|
@ApiProperty({ description: '总数' }) total: number;
|
||||||
|
@ApiProperty({ type: [ActivityLogItemDto] }) items: ActivityLogItemDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
67
src/activity-logs/models/activity-log.model.ts
Normal file
67
src/activity-logs/models/activity-log.model.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { BelongsTo, Column, DataType, ForeignKey, Index, Model, Table } from 'sequelize-typescript';
|
||||||
|
import { User } from '../../users/models/user.model';
|
||||||
|
|
||||||
|
export enum ActivityEntityType {
|
||||||
|
USER = 'USER',
|
||||||
|
USER_PROFILE = 'USER_PROFILE',
|
||||||
|
CHECKIN = 'CHECKIN',
|
||||||
|
TRAINING_PLAN = 'TRAINING_PLAN',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ActivityActionType {
|
||||||
|
CREATE = 'CREATE',
|
||||||
|
UPDATE = 'UPDATE',
|
||||||
|
DELETE = 'DELETE',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Table({
|
||||||
|
tableName: 't_activity_logs',
|
||||||
|
underscored: true,
|
||||||
|
})
|
||||||
|
export class ActivityLog extends Model {
|
||||||
|
@Column({
|
||||||
|
type: DataType.UUID,
|
||||||
|
primaryKey: true,
|
||||||
|
defaultValue: DataType.UUIDV4,
|
||||||
|
})
|
||||||
|
declare id: string;
|
||||||
|
|
||||||
|
@ForeignKey(() => User)
|
||||||
|
|
||||||
|
@Column({ type: DataType.STRING, allowNull: false, comment: '用户ID' })
|
||||||
|
declare userId: string;
|
||||||
|
|
||||||
|
@BelongsTo(() => User)
|
||||||
|
declare user?: User;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.ENUM('USER', 'USER_PROFILE', 'CHECKIN', 'TRAINING_PLAN'),
|
||||||
|
allowNull: false,
|
||||||
|
comment: '实体类型',
|
||||||
|
})
|
||||||
|
declare entityType: ActivityEntityType;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.ENUM('CREATE', 'UPDATE', 'DELETE'),
|
||||||
|
allowNull: false,
|
||||||
|
comment: '动作类型',
|
||||||
|
})
|
||||||
|
declare action: ActivityActionType;
|
||||||
|
|
||||||
|
@Column({ type: DataType.STRING, allowNull: true, comment: '实体ID' })
|
||||||
|
declare entityId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: DataType.JSON, allowNull: true, comment: '变更详情或前后快照' })
|
||||||
|
declare changes: Record<string, any> | null;
|
||||||
|
|
||||||
|
@Column({ type: DataType.JSON, allowNull: true, comment: '附加信息(来源、设备等)' })
|
||||||
|
declare metadata: Record<string, any> | null;
|
||||||
|
|
||||||
|
@Column({ type: DataType.DATE, defaultValue: DataType.NOW, comment: '发生时间' })
|
||||||
|
declare createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
|
||||||
|
declare updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ import { AiCoachModule } from './ai-coach/ai-coach.module';
|
|||||||
import { TrainingPlansModule } from './training-plans/training-plans.module';
|
import { TrainingPlansModule } from './training-plans/training-plans.module';
|
||||||
import { ArticlesModule } from './articles/articles.module';
|
import { ArticlesModule } from './articles/articles.module';
|
||||||
import { RecommendationsModule } from './recommendations/recommendations.module';
|
import { RecommendationsModule } from './recommendations/recommendations.module';
|
||||||
|
import { ActivityLogsModule } from './activity-logs/activity-logs.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -25,6 +26,7 @@ import { RecommendationsModule } from './recommendations/recommendations.module'
|
|||||||
TrainingPlansModule,
|
TrainingPlansModule,
|
||||||
ArticlesModule,
|
ArticlesModule,
|
||||||
RecommendationsModule,
|
RecommendationsModule,
|
||||||
|
ActivityLogsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { CheckinsService } from './checkins.service';
|
|||||||
import { CheckinsController } from './checkins.controller';
|
import { CheckinsController } from './checkins.controller';
|
||||||
import { Checkin } from './models/checkin.model';
|
import { Checkin } from './models/checkin.model';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
|
import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [SequelizeModule.forFeature([Checkin]), UsersModule],
|
imports: [SequelizeModule.forFeature([Checkin]), UsersModule, ActivityLogsModule],
|
||||||
providers: [CheckinsService],
|
providers: [CheckinsService],
|
||||||
controllers: [CheckinsController],
|
controllers: [CheckinsController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { CreateCheckinDto, UpdateCheckinDto, CompleteCheckinDto, RemoveCheckinDt
|
|||||||
import { ResponseCode } from '../base.dto';
|
import { ResponseCode } from '../base.dto';
|
||||||
import * as dayjs from 'dayjs';
|
import * as dayjs from 'dayjs';
|
||||||
import { Op } from 'sequelize';
|
import { Op } from 'sequelize';
|
||||||
|
import { ActivityLogsService } from '../activity-logs/activity-logs.service';
|
||||||
|
import { ActivityActionType, ActivityEntityType } from '../activity-logs/models/activity-log.model';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CheckinsService {
|
export class CheckinsService {
|
||||||
@@ -13,6 +15,7 @@ export class CheckinsService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectModel(Checkin)
|
@InjectModel(Checkin)
|
||||||
private readonly checkinModel: typeof Checkin,
|
private readonly checkinModel: typeof Checkin,
|
||||||
|
private readonly activityLogsService: ActivityLogsService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async create(dto: CreateCheckinDto): Promise<CheckinResponseDto> {
|
async create(dto: CreateCheckinDto): Promise<CheckinResponseDto> {
|
||||||
@@ -28,6 +31,14 @@ export class CheckinsService {
|
|||||||
status: CheckinStatus.PENDING,
|
status: CheckinStatus.PENDING,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.activityLogsService.record({
|
||||||
|
userId: record.userId,
|
||||||
|
entityType: ActivityEntityType.CHECKIN,
|
||||||
|
action: ActivityActionType.CREATE,
|
||||||
|
entityId: record.id,
|
||||||
|
changes: record.toJSON(),
|
||||||
|
});
|
||||||
|
|
||||||
return { code: ResponseCode.SUCCESS, message: 'success', data: record.toJSON() };
|
return { code: ResponseCode.SUCCESS, message: 'success', data: record.toJSON() };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,18 +51,26 @@ export class CheckinsService {
|
|||||||
throw new ForbiddenException('无权操作该打卡记录');
|
throw new ForbiddenException('无权操作该打卡记录');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.workoutId !== undefined) record.workoutId = dto.workoutId;
|
const changes: Record<string, any> = {};
|
||||||
if (dto.planId !== undefined) record.planId = dto.planId;
|
if (dto.workoutId !== undefined) { record.workoutId = dto.workoutId; changes.workoutId = dto.workoutId; }
|
||||||
if (dto.title !== undefined) record.title = dto.title;
|
if (dto.planId !== undefined) { record.planId = dto.planId; changes.planId = dto.planId; }
|
||||||
if (dto.checkinDate !== undefined) record.checkinDate = dto.checkinDate as any;
|
if (dto.title !== undefined) { record.title = dto.title; changes.title = dto.title; }
|
||||||
if (dto.startedAt !== undefined) record.startedAt = dto.startedAt ? new Date(dto.startedAt) : null;
|
if (dto.checkinDate !== undefined) { record.checkinDate = dto.checkinDate as any; changes.checkinDate = dto.checkinDate; }
|
||||||
if (dto.notes !== undefined) record.notes = dto.notes;
|
if (dto.startedAt !== undefined) { record.startedAt = dto.startedAt ? new Date(dto.startedAt) : null; changes.startedAt = dto.startedAt; }
|
||||||
if (dto.metrics !== undefined) record.metrics = dto.metrics as any;
|
if (dto.notes !== undefined) { record.notes = dto.notes; changes.notes = dto.notes; }
|
||||||
if (dto.status !== undefined) record.status = dto.status;
|
if (dto.metrics !== undefined) { record.metrics = dto.metrics as any; changes.metrics = dto.metrics; }
|
||||||
if (dto.completedAt !== undefined) record.completedAt = dto.completedAt ? new Date(dto.completedAt) : null;
|
if (dto.status !== undefined) { record.status = dto.status; changes.status = dto.status; }
|
||||||
if (dto.durationSeconds !== undefined) record.durationSeconds = dto.durationSeconds;
|
if (dto.completedAt !== undefined) { record.completedAt = dto.completedAt ? new Date(dto.completedAt) : null; changes.completedAt = dto.completedAt; }
|
||||||
|
if (dto.durationSeconds !== undefined) { record.durationSeconds = dto.durationSeconds; changes.durationSeconds = dto.durationSeconds; }
|
||||||
|
|
||||||
await record.save();
|
await record.save();
|
||||||
|
await this.activityLogsService.record({
|
||||||
|
userId: record.userId,
|
||||||
|
entityType: ActivityEntityType.CHECKIN,
|
||||||
|
action: ActivityActionType.UPDATE,
|
||||||
|
entityId: record.id,
|
||||||
|
changes,
|
||||||
|
});
|
||||||
return { code: ResponseCode.SUCCESS, message: 'success', data: record.toJSON() };
|
return { code: ResponseCode.SUCCESS, message: 'success', data: record.toJSON() };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +90,19 @@ export class CheckinsService {
|
|||||||
if (dto.metrics !== undefined) record.metrics = dto.metrics as any;
|
if (dto.metrics !== undefined) record.metrics = dto.metrics as any;
|
||||||
|
|
||||||
await record.save();
|
await record.save();
|
||||||
|
await this.activityLogsService.record({
|
||||||
|
userId: record.userId,
|
||||||
|
entityType: ActivityEntityType.CHECKIN,
|
||||||
|
action: ActivityActionType.UPDATE,
|
||||||
|
entityId: record.id,
|
||||||
|
changes: {
|
||||||
|
status: record.status,
|
||||||
|
completedAt: record.completedAt,
|
||||||
|
durationSeconds: record.durationSeconds,
|
||||||
|
notes: record.notes,
|
||||||
|
metrics: record.metrics,
|
||||||
|
},
|
||||||
|
});
|
||||||
return { code: ResponseCode.SUCCESS, message: 'success', data: record.toJSON() };
|
return { code: ResponseCode.SUCCESS, message: 'success', data: record.toJSON() };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +115,13 @@ export class CheckinsService {
|
|||||||
throw new ForbiddenException('无权操作该打卡记录');
|
throw new ForbiddenException('无权操作该打卡记录');
|
||||||
}
|
}
|
||||||
await record.destroy();
|
await record.destroy();
|
||||||
|
await this.activityLogsService.record({
|
||||||
|
userId: record.userId,
|
||||||
|
entityType: ActivityEntityType.CHECKIN,
|
||||||
|
action: ActivityActionType.DELETE,
|
||||||
|
entityId: record.id,
|
||||||
|
changes: null,
|
||||||
|
});
|
||||||
return { code: ResponseCode.SUCCESS, message: 'success', data: { id: dto.id } };
|
return { code: ResponseCode.SUCCESS, message: 'success', data: { id: dto.id } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ export enum RecommendationType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class GetRecommendationsQueryDto {
|
export class GetRecommendationsQueryDto {
|
||||||
@ApiProperty({ required: false, description: '数量,默认10' })
|
// @ApiProperty({ required: false, description: '数量,默认10' })
|
||||||
limit?: number = 10;
|
// limit?: number = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecommendationCard {
|
export interface RecommendationCard {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export class RecommendationsService {
|
|||||||
|
|
||||||
// 为你推荐:混合文章与每日打卡卡片
|
// 为你推荐:混合文章与每日打卡卡片
|
||||||
async list(query: GetRecommendationsQueryDto): Promise<GetRecommendationsResponseDto> {
|
async list(query: GetRecommendationsQueryDto): Promise<GetRecommendationsResponseDto> {
|
||||||
const limit = Math.min(50, Math.max(1, Number(query.limit || 10)));
|
const limit = 10
|
||||||
|
|
||||||
// 取最新文章若干
|
// 取最新文章若干
|
||||||
const articlesRes = await this.articlesService.query({ page: 1, pageSize: limit } as any);
|
const articlesRes = await this.articlesService.query({ page: 1, pageSize: limit } as any);
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import { TrainingPlansService } from './training-plans.service';
|
|||||||
import { TrainingPlansController } from './training-plans.controller';
|
import { TrainingPlansController } from './training-plans.controller';
|
||||||
import { TrainingPlan } from './models/training-plan.model';
|
import { TrainingPlan } from './models/training-plan.model';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
|
import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
UsersModule,
|
UsersModule,
|
||||||
|
ActivityLogsModule,
|
||||||
SequelizeModule.forFeature([TrainingPlan]),
|
SequelizeModule.forFeature([TrainingPlan]),
|
||||||
],
|
],
|
||||||
controllers: [TrainingPlansController],
|
controllers: [TrainingPlansController],
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
|||||||
import { InjectModel } from '@nestjs/sequelize';
|
import { InjectModel } from '@nestjs/sequelize';
|
||||||
import { TrainingPlan } from './models/training-plan.model';
|
import { TrainingPlan } from './models/training-plan.model';
|
||||||
import { CreateTrainingPlanDto } from './dto/training-plan.dto';
|
import { CreateTrainingPlanDto } from './dto/training-plan.dto';
|
||||||
|
import { ActivityLogsService } from '../activity-logs/activity-logs.service';
|
||||||
|
import { ActivityActionType, ActivityEntityType } from '../activity-logs/models/activity-log.model';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TrainingPlansService {
|
export class TrainingPlansService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectModel(TrainingPlan)
|
@InjectModel(TrainingPlan)
|
||||||
private trainingPlanModel: typeof TrainingPlan,
|
private trainingPlanModel: typeof TrainingPlan,
|
||||||
|
private readonly activityLogsService: ActivityLogsService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async create(userId: string, dto: CreateTrainingPlanDto) {
|
async create(userId: string, dto: CreateTrainingPlanDto) {
|
||||||
@@ -25,11 +28,27 @@ export class TrainingPlansService {
|
|||||||
startWeightKg: dto.startWeightKg ?? null,
|
startWeightKg: dto.startWeightKg ?? null,
|
||||||
preferredTimeOfDay: dto.preferredTimeOfDay ?? '',
|
preferredTimeOfDay: dto.preferredTimeOfDay ?? '',
|
||||||
});
|
});
|
||||||
|
await this.activityLogsService.record({
|
||||||
|
userId,
|
||||||
|
entityType: ActivityEntityType.TRAINING_PLAN,
|
||||||
|
action: ActivityActionType.CREATE,
|
||||||
|
entityId: plan.id,
|
||||||
|
changes: plan.toJSON(),
|
||||||
|
});
|
||||||
return plan.toJSON();
|
return plan.toJSON();
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(userId: string, id: string) {
|
async remove(userId: string, id: string) {
|
||||||
const count = await this.trainingPlanModel.destroy({ where: { id, userId } });
|
const count = await this.trainingPlanModel.destroy({ where: { id, userId } });
|
||||||
|
if (count > 0) {
|
||||||
|
await this.activityLogsService.record({
|
||||||
|
userId,
|
||||||
|
entityType: ActivityEntityType.TRAINING_PLAN,
|
||||||
|
action: ActivityActionType.DELETE,
|
||||||
|
entityId: id,
|
||||||
|
changes: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
return { success: count > 0 };
|
return { success: count > 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export class CosService {
|
|||||||
this.bucket = this.configService.get<string>('COS_BUCKET') || '';
|
this.bucket = this.configService.get<string>('COS_BUCKET') || '';
|
||||||
this.region = this.configService.get<string>('COS_REGION') || 'ap-guangzhou';
|
this.region = this.configService.get<string>('COS_REGION') || 'ap-guangzhou';
|
||||||
this.cdnDomain = this.configService.get<string>('COS_CDN_DOMAIN') || 'https://cdn.richarjiang.com';
|
this.cdnDomain = this.configService.get<string>('COS_CDN_DOMAIN') || 'https://cdn.richarjiang.com';
|
||||||
this.allowPrefix = this.configService.get<string>('COS_ALLOW_PREFIX') || 'tennis-uploads/*';
|
this.allowPrefix = this.configService.get<string>('COS_ALLOW_PREFIX') || 'uploads/*';
|
||||||
|
|
||||||
if (!this.secretId || !this.secretKey || !this.bucket) {
|
if (!this.secretId || !this.secretKey || !this.bucket) {
|
||||||
throw new Error('腾讯云COS配置缺失:TENCENT_SECRET_ID, TENCENT_SECRET_KEY, COS_BUCKET 是必需的');
|
throw new Error('腾讯云COS配置缺失:TENCENT_SECRET_ID, TENCENT_SECRET_KEY, COS_BUCKET 是必需的');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module, forwardRef } from "@nestjs/common";
|
||||||
import { SequelizeModule } from "@nestjs/sequelize";
|
import { SequelizeModule } from "@nestjs/sequelize";
|
||||||
import { UsersController } from "./users.controller";
|
import { UsersController } from "./users.controller";
|
||||||
import { UsersService } from "./users.service";
|
import { UsersService } from "./users.service";
|
||||||
@@ -14,6 +14,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 { ActivityLogsModule } from '../activity-logs/activity-logs.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -26,6 +27,7 @@ import { CosService } from './cos.service';
|
|||||||
UserProfile,
|
UserProfile,
|
||||||
UserWeightHistory,
|
UserWeightHistory,
|
||||||
]),
|
]),
|
||||||
|
forwardRef(() => ActivityLogsModule),
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_ACCESS_SECRET || 'your-access-token-secret-key',
|
secret: process.env.JWT_ACCESS_SECRET || 'your-access-token-secret-key',
|
||||||
signOptions: { expiresIn: '30d' },
|
signOptions: { expiresIn: '30d' },
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import { RestorePurchaseDto, RestorePurchaseResponseDto, RestoredPurchaseInfo, A
|
|||||||
import { PurchaseRestoreLog, RestoreStatus, RestoreSource } from './models/purchase-restore-log.model';
|
import { PurchaseRestoreLog, RestoreStatus, RestoreSource } from './models/purchase-restore-log.model';
|
||||||
import { BlockedTransaction, BlockReason } from './models/blocked-transaction.model';
|
import { BlockedTransaction, BlockReason } from './models/blocked-transaction.model';
|
||||||
import { UserWeightHistory, WeightUpdateSource } from './models/user-weight-history.model';
|
import { UserWeightHistory, WeightUpdateSource } from './models/user-weight-history.model';
|
||||||
|
import { ActivityLogsService } from '../activity-logs/activity-logs.service';
|
||||||
|
import { ActivityActionType, ActivityEntityType } from '../activity-logs/models/activity-log.model';
|
||||||
|
|
||||||
const DEFAULT_FREE_USAGE_COUNT = 10;
|
const DEFAULT_FREE_USAGE_COUNT = 10;
|
||||||
|
|
||||||
@@ -57,6 +59,7 @@ export class UsersService {
|
|||||||
private userWeightHistoryModel: typeof UserWeightHistory,
|
private userWeightHistoryModel: typeof UserWeightHistory,
|
||||||
@InjectConnection()
|
@InjectConnection()
|
||||||
private sequelize: Sequelize,
|
private sequelize: Sequelize,
|
||||||
|
private readonly activityLogsService: ActivityLogsService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async getProfile(user: AccessTokenPayload): Promise<UserResponseDto> {
|
async getProfile(user: AccessTokenPayload): Promise<UserResponseDto> {
|
||||||
@@ -134,17 +137,23 @@ export class UsersService {
|
|||||||
throw new NotFoundException(`ID为${userId}的用户不存在`);
|
throw new NotFoundException(`ID为${userId}的用户不存在`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const profileChanges: Record<string, any> = {};
|
||||||
|
const userChanges: Record<string, any> = {};
|
||||||
if (name) {
|
if (name) {
|
||||||
user.name = name;
|
user.name = name;
|
||||||
|
userChanges.name = name;
|
||||||
}
|
}
|
||||||
if (avatar) {
|
if (avatar) {
|
||||||
user.avatar = avatar;
|
user.avatar = avatar;
|
||||||
|
userChanges.avatar = avatar;
|
||||||
}
|
}
|
||||||
if (gender) {
|
if (gender) {
|
||||||
user.gender = gender;
|
user.gender = gender;
|
||||||
|
userChanges.gender = gender;
|
||||||
}
|
}
|
||||||
if (birthDate) {
|
if (birthDate) {
|
||||||
user.birthDate = dayjs(birthDate * 1000).startOf('day').toDate();
|
user.birthDate = dayjs(birthDate * 1000).startOf('day').toDate();
|
||||||
|
userChanges.birthDate = birthDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`updateUser user: ${JSON.stringify(user, null, 2)}`);
|
this.logger.log(`updateUser user: ${JSON.stringify(user, null, 2)}`);
|
||||||
@@ -157,9 +166,9 @@ export class UsersService {
|
|||||||
where: { userId },
|
where: { userId },
|
||||||
defaults: { userId },
|
defaults: { userId },
|
||||||
});
|
});
|
||||||
if (dailyStepsGoal !== undefined) profile.dailyStepsGoal = dailyStepsGoal as any;
|
if (dailyStepsGoal !== undefined) { profile.dailyStepsGoal = dailyStepsGoal as any; profileChanges.dailyStepsGoal = dailyStepsGoal; }
|
||||||
if (dailyCaloriesGoal !== undefined) profile.dailyCaloriesGoal = dailyCaloriesGoal as any;
|
if (dailyCaloriesGoal !== undefined) { profile.dailyCaloriesGoal = dailyCaloriesGoal as any; profileChanges.dailyCaloriesGoal = dailyCaloriesGoal; }
|
||||||
if (pilatesPurposes !== undefined) profile.pilatesPurposes = pilatesPurposes as any;
|
if (pilatesPurposes !== undefined) { profile.pilatesPurposes = pilatesPurposes as any; profileChanges.pilatesPurposes = pilatesPurposes; }
|
||||||
if (weight !== undefined) {
|
if (weight !== undefined) {
|
||||||
profile.weight = weight as any;
|
profile.weight = weight as any;
|
||||||
try {
|
try {
|
||||||
@@ -167,11 +176,32 @@ export class UsersService {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error(`记录体重历史失败: ${e instanceof Error ? e.message : String(e)}`);
|
this.logger.error(`记录体重历史失败: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
}
|
}
|
||||||
|
profileChanges.weight = weight;
|
||||||
}
|
}
|
||||||
if (height !== undefined) profile.height = height as any;
|
if (height !== undefined) { profile.height = height as any; profileChanges.height = height; }
|
||||||
await profile.save();
|
await profile.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 记录用户基础与扩展信息更新
|
||||||
|
if (Object.keys(userChanges).length > 0) {
|
||||||
|
await this.activityLogsService.record({
|
||||||
|
userId,
|
||||||
|
entityType: ActivityEntityType.USER,
|
||||||
|
action: ActivityActionType.UPDATE,
|
||||||
|
entityId: userId,
|
||||||
|
changes: userChanges,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (Object.keys(profileChanges).length > 0) {
|
||||||
|
await this.activityLogsService.record({
|
||||||
|
userId,
|
||||||
|
entityType: ActivityEntityType.USER_PROFILE,
|
||||||
|
action: ActivityActionType.UPDATE,
|
||||||
|
entityId: userId,
|
||||||
|
changes: profileChanges,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code: ResponseCode.SUCCESS,
|
code: ResponseCode.SUCCESS,
|
||||||
message: 'success',
|
message: 'success',
|
||||||
@@ -190,6 +220,14 @@ export class UsersService {
|
|||||||
await profile.save({ transaction: t });
|
await profile.save({ transaction: t });
|
||||||
await this.userWeightHistoryModel.create({ userId, weight, source: WeightUpdateSource.Vision }, { transaction: t });
|
await this.userWeightHistoryModel.create({ userId, weight, source: WeightUpdateSource.Vision }, { transaction: t });
|
||||||
await t.commit();
|
await t.commit();
|
||||||
|
await this.activityLogsService.record({
|
||||||
|
userId,
|
||||||
|
entityType: ActivityEntityType.USER_PROFILE,
|
||||||
|
action: ActivityActionType.UPDATE,
|
||||||
|
entityId: userId,
|
||||||
|
changes: { weight },
|
||||||
|
metadata: { source: 'vision' },
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await t.rollback();
|
await t.rollback();
|
||||||
this.logger.error(`addWeightByVision error: ${e instanceof Error ? e.message : String(e)}`);
|
this.logger.error(`addWeightByVision error: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
@@ -285,6 +323,16 @@ export class UsersService {
|
|||||||
} : undefined,
|
} : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 登录行为也可视为活动(可选,记录创建或登录行为)
|
||||||
|
await this.activityLogsService.record({
|
||||||
|
userId,
|
||||||
|
entityType: ActivityEntityType.USER,
|
||||||
|
action: ActivityActionType.UPDATE,
|
||||||
|
entityId: userId,
|
||||||
|
changes: { lastLogin: user.lastLogin },
|
||||||
|
metadata: { source: 'appleLogin' },
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code: ResponseCode.SUCCESS,
|
code: ResponseCode.SUCCESS,
|
||||||
message: 'success',
|
message: 'success',
|
||||||
@@ -387,6 +435,16 @@ export class UsersService {
|
|||||||
|
|
||||||
this.logger.log(`用户账号删除成功: ${userId}`);
|
this.logger.log(`用户账号删除成功: ${userId}`);
|
||||||
|
|
||||||
|
// 记录删除账户行为
|
||||||
|
await this.activityLogsService.record({
|
||||||
|
userId,
|
||||||
|
entityType: ActivityEntityType.USER,
|
||||||
|
action: ActivityActionType.DELETE,
|
||||||
|
entityId: userId,
|
||||||
|
changes: null,
|
||||||
|
metadata: { reason: 'deleteAccount' },
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code: ResponseCode.SUCCESS,
|
code: ResponseCode.SUCCESS,
|
||||||
message: '账号删除成功',
|
message: '账号删除成功',
|
||||||
@@ -476,6 +534,15 @@ export class UsersService {
|
|||||||
} : undefined,
|
} : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await this.activityLogsService.record({
|
||||||
|
userId: guestUserId,
|
||||||
|
entityType: ActivityEntityType.USER,
|
||||||
|
action: ActivityActionType.UPDATE,
|
||||||
|
entityId: guestUserId,
|
||||||
|
changes: { lastLogin: user.lastLogin },
|
||||||
|
metadata: { source: 'guestLogin' },
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code: ResponseCode.SUCCESS,
|
code: ResponseCode.SUCCESS,
|
||||||
message: 'success',
|
message: 'success',
|
||||||
|
|||||||
Reference in New Issue
Block a user