From 866143d3ad98f09e229a5c9e4ed784f5ef06318e Mon Sep 17 00:00:00 2001 From: richarjiang Date: Wed, 13 Aug 2025 19:16:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=89=93=E5=8D=A1=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=EF=BC=8C=E5=8C=85=E6=8B=AC=E6=89=93=E5=8D=A1=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E5=99=A8=E3=80=81=E6=9C=8D=E5=8A=A1=E3=80=81=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=8F=8A=E6=95=B0=E6=8D=AE=E4=BC=A0=E8=BE=93=E5=AF=B9?= =?UTF-8?q?=E8=B1=A1=EF=BC=8C=E6=9B=B4=E6=96=B0=E5=BA=94=E7=94=A8=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E4=BB=A5=E5=BC=95=E5=85=A5=E6=96=B0=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.module.ts | 2 + src/checkins/checkins.controller.ts | 59 ++++++++++++ src/checkins/checkins.module.ts | 15 +++ src/checkins/checkins.service.ts | 120 +++++++++++++++++++++++ src/checkins/dto/checkin.dto.ts | 136 +++++++++++++++++++++++++++ src/checkins/models/checkin.model.ts | 116 +++++++++++++++++++++++ 6 files changed, 448 insertions(+) create mode 100644 src/checkins/checkins.controller.ts create mode 100644 src/checkins/checkins.module.ts create mode 100644 src/checkins/checkins.service.ts create mode 100644 src/checkins/dto/checkin.dto.ts create mode 100644 src/checkins/models/checkin.model.ts diff --git a/src/app.module.ts b/src/app.module.ts index fbd9315..c7a7c30 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -5,6 +5,7 @@ import { DatabaseModule } from "./database/database.module"; import { UsersModule } from "./users/users.module"; import { ConfigModule } from '@nestjs/config'; import { LoggerModule } from './common/logger/logger.module'; +import { CheckinsModule } from './checkins/checkins.module'; @Module({ imports: [ @@ -15,6 +16,7 @@ import { LoggerModule } from './common/logger/logger.module'; LoggerModule, DatabaseModule, UsersModule, + CheckinsModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/checkins/checkins.controller.ts b/src/checkins/checkins.controller.ts new file mode 100644 index 0000000..c42dbb5 --- /dev/null +++ b/src/checkins/checkins.controller.ts @@ -0,0 +1,59 @@ +import { Body, Controller, HttpCode, HttpStatus, Post, Put, Delete, UseGuards, Get, Query } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { CheckinsService } from './checkins.service'; +import { CreateCheckinDto, UpdateCheckinDto, CompleteCheckinDto, RemoveCheckinDto, CheckinResponseDto, GetDailyCheckinsQueryDto } from './dto/checkin.dto'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { AccessTokenPayload } from '../users/services/apple-auth.service'; + +@ApiTags('checkins') +@Controller('checkins') +@UseGuards(JwtAuthGuard) +export class CheckinsController { + constructor(private readonly checkinsService: CheckinsService) { } + + @Post('create') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '创建打卡' }) + @ApiBody({ type: CreateCheckinDto }) + @ApiResponse({ type: CheckinResponseDto }) + async create(@Body() dto: CreateCheckinDto, @CurrentUser() user: AccessTokenPayload): Promise { + return this.checkinsService.create({ ...dto, userId: user.sub }); + } + + @Put('update') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '更新打卡' }) + @ApiBody({ type: UpdateCheckinDto }) + @ApiResponse({ type: CheckinResponseDto }) + async update(@Body() dto: UpdateCheckinDto, @CurrentUser() user: AccessTokenPayload): Promise { + return this.checkinsService.update(dto, user.sub); + } + + @Post('complete') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '完成打卡' }) + @ApiBody({ type: CompleteCheckinDto }) + @ApiResponse({ type: CheckinResponseDto }) + async complete(@Body() dto: CompleteCheckinDto, @CurrentUser() user: AccessTokenPayload): Promise { + return this.checkinsService.complete(dto, user.sub); + } + + @Delete('remove') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '删除打卡' }) + @ApiBody({ type: RemoveCheckinDto }) + @ApiResponse({ type: CheckinResponseDto }) + async remove(@Body() dto: RemoveCheckinDto, @CurrentUser() user: AccessTokenPayload): Promise { + return this.checkinsService.remove(dto, user.sub); + } + + @Get('daily') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '按天获取打卡列表' }) + async getDaily(@Query() query: GetDailyCheckinsQueryDto, @CurrentUser() user: AccessTokenPayload): Promise { + return this.checkinsService.getDaily(user.sub, query.date); + } +} + + diff --git a/src/checkins/checkins.module.ts b/src/checkins/checkins.module.ts new file mode 100644 index 0000000..9670c2f --- /dev/null +++ b/src/checkins/checkins.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { CheckinsService } from './checkins.service'; +import { CheckinsController } from './checkins.controller'; +import { Checkin } from './models/checkin.model'; +import { UsersModule } from '../users/users.module'; + +@Module({ + imports: [SequelizeModule.forFeature([Checkin]), UsersModule], + providers: [CheckinsService], + controllers: [CheckinsController], +}) +export class CheckinsModule { } + + diff --git a/src/checkins/checkins.service.ts b/src/checkins/checkins.service.ts new file mode 100644 index 0000000..28a39aa --- /dev/null +++ b/src/checkins/checkins.service.ts @@ -0,0 +1,120 @@ +import { Injectable, NotFoundException, Logger, ForbiddenException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/sequelize'; +import { Checkin, CheckinStatus } from './models/checkin.model'; +import { CreateCheckinDto, UpdateCheckinDto, CompleteCheckinDto, RemoveCheckinDto, CheckinResponseDto } from './dto/checkin.dto'; +import { ResponseCode } from '../base.dto'; +import * as dayjs from 'dayjs'; +import { Op } from 'sequelize'; + +@Injectable() +export class CheckinsService { + private readonly logger = new Logger(CheckinsService.name); + + constructor( + @InjectModel(Checkin) + private readonly checkinModel: typeof Checkin, + ) { } + + async create(dto: CreateCheckinDto): Promise { + const record = await this.checkinModel.create({ + userId: dto.userId, + workoutId: dto.workoutId || null, + planId: dto.planId || null, + title: dto.title || null, + checkinDate: dto.checkinDate || null, + startedAt: dto.startedAt ? new Date(dto.startedAt) : null, + notes: dto.notes || null, + metrics: dto.metrics || null, + status: CheckinStatus.PENDING, + }); + + return { code: ResponseCode.SUCCESS, message: 'success', data: record.toJSON() }; + } + + async update(dto: UpdateCheckinDto, userId: string): Promise { + const record = await this.checkinModel.findByPk(dto.id); + if (!record) { + throw new NotFoundException('打卡记录不存在'); + } + if (record.userId !== userId) { + throw new ForbiddenException('无权操作该打卡记录'); + } + + if (dto.workoutId !== undefined) record.workoutId = dto.workoutId; + if (dto.planId !== undefined) record.planId = dto.planId; + if (dto.title !== undefined) record.title = dto.title; + if (dto.checkinDate !== undefined) record.checkinDate = dto.checkinDate as any; + if (dto.startedAt !== undefined) record.startedAt = dto.startedAt ? new Date(dto.startedAt) : null; + if (dto.notes !== undefined) record.notes = dto.notes; + if (dto.metrics !== undefined) record.metrics = dto.metrics as any; + if (dto.status !== undefined) record.status = dto.status; + if (dto.completedAt !== undefined) record.completedAt = dto.completedAt ? new Date(dto.completedAt) : null; + if (dto.durationSeconds !== undefined) record.durationSeconds = dto.durationSeconds; + + await record.save(); + return { code: ResponseCode.SUCCESS, message: 'success', data: record.toJSON() }; + } + + async complete(dto: CompleteCheckinDto, userId: string): Promise { + const record = await this.checkinModel.findByPk(dto.id); + if (!record) { + throw new NotFoundException('打卡记录不存在'); + } + if (record.userId !== userId) { + throw new ForbiddenException('无权操作该打卡记录'); + } + + record.status = CheckinStatus.COMPLETED; + record.completedAt = dto.completedAt ? new Date(dto.completedAt) : new Date(); + if (dto.durationSeconds !== undefined) record.durationSeconds = dto.durationSeconds; + if (dto.notes !== undefined) record.notes = dto.notes; + if (dto.metrics !== undefined) record.metrics = dto.metrics as any; + + await record.save(); + return { code: ResponseCode.SUCCESS, message: 'success', data: record.toJSON() }; + } + + async remove(dto: RemoveCheckinDto, userId: string): Promise { + const record = await this.checkinModel.findByPk(dto.id); + if (!record) { + throw new NotFoundException('打卡记录不存在'); + } + if (record.userId !== userId) { + throw new ForbiddenException('无权操作该打卡记录'); + } + await record.destroy(); + return { code: ResponseCode.SUCCESS, message: 'success', data: { id: dto.id } }; + } + + async getDaily(userId: string, date?: string): Promise { + const target = date ? dayjs(date) : dayjs(); + if (!target.isValid()) { + return { code: ResponseCode.ERROR, message: '无效日期', data: [] }; + } + const start = target.startOf('day').toDate(); + const end = target.endOf('day').toDate(); + + // 覆盖两类数据: + // 1) checkinDate == YYYY-MM-DD(精确日) + // 2) startedAt/ completedAt 落在该日范围内(更鲁棒) + const rows = await this.checkinModel.findAll({ + where: { + userId, + [Op.or]: [ + { checkinDate: target.format('YYYY-MM-DD') as any }, + { + [Op.or]: [ + { startedAt: { [Op.between]: [start, end] } }, + { completedAt: { [Op.between]: [start, end] } }, + ], + }, + ], + }, + order: [['createdAt', 'ASC']], + }); + + return { code: ResponseCode.SUCCESS, message: 'success', data: rows.map(r => r.toJSON()) }; + } +} + + diff --git a/src/checkins/dto/checkin.dto.ts b/src/checkins/dto/checkin.dto.ts new file mode 100644 index 0000000..8d4555a --- /dev/null +++ b/src/checkins/dto/checkin.dto.ts @@ -0,0 +1,136 @@ +import { ApiProperty, PartialType } from '@nestjs/swagger'; +import { IsString, IsOptional, IsEnum, IsDateString, IsInt, Min, IsObject } from 'class-validator'; +import { CheckinStatus } from '../models/checkin.model'; +import { ResponseCode } from '../../base.dto'; + +export class CreateCheckinDto { + // 由后端从登录态注入 + @IsOptional() + @IsString() + userId?: string; + + @ApiProperty({ description: '训练/课程ID', required: false }) + @IsOptional() + @IsString() + workoutId?: string; + + @ApiProperty({ description: '计划ID', required: false }) + @IsOptional() + @IsString() + planId?: string; + + @ApiProperty({ description: '标题', required: false }) + @IsOptional() + @IsString() + title?: string; + + @ApiProperty({ description: '打卡日期(YYYY-MM-DD)', required: false }) + @IsOptional() + @IsDateString() + checkinDate?: string; + + @ApiProperty({ description: '开始时间ISO', required: false }) + @IsOptional() + @IsDateString() + startedAt?: string; + + @ApiProperty({ description: '备注', required: false }) + @IsOptional() + @IsString() + notes?: string; + + @ApiProperty({ description: '扩展指标', required: false }) + @IsOptional() + @IsObject() + metrics?: Record; +} + +export class UpdateCheckinDto extends PartialType(CreateCheckinDto) { + @ApiProperty({ description: '打卡ID' }) + @IsString() + id: string; + + // 由后端从登录态注入,不对外暴露文档 + @IsOptional() + @IsString() + userId?: string; + + @ApiProperty({ enum: CheckinStatus, required: false }) + @IsOptional() + @IsEnum(CheckinStatus) + status?: CheckinStatus; + + @ApiProperty({ description: '完成时间ISO', required: false }) + @IsOptional() + @IsDateString() + completedAt?: string; + + @ApiProperty({ description: '用时秒', required: false }) + @IsOptional() + @IsInt() + @Min(0) + durationSeconds?: number; +} + +export class CompleteCheckinDto { + @ApiProperty({ description: '打卡ID' }) + @IsString() + id: string; + + // 由后端从登录态注入 + @IsOptional() + @IsString() + userId?: string; + + @ApiProperty({ description: '完成时间ISO', required: false }) + @IsOptional() + @IsDateString() + completedAt?: string; + + @ApiProperty({ description: '用时秒', required: false }) + @IsOptional() + @IsInt() + @Min(0) + durationSeconds?: number; + + @ApiProperty({ description: '备注', required: false }) + @IsOptional() + @IsString() + notes?: string; + + @ApiProperty({ description: '扩展指标', required: false }) + @IsOptional() + @IsObject() + metrics?: Record; +} + +export class RemoveCheckinDto { + @ApiProperty({ description: '打卡ID' }) + @IsString() + id: string; + + // 由后端从登录态注入 + @IsOptional() + @IsString() + userId?: string; +} + +export class CheckinResponseDto { + @ApiProperty({ description: '状态码', example: ResponseCode.SUCCESS }) + code: ResponseCode; + + @ApiProperty({ description: '消息', example: 'success' }) + message: string; + + @ApiProperty({ description: '数据' }) + data: T; +} + +export class GetDailyCheckinsQueryDto { + @ApiProperty({ description: '日期(YYYY-MM-DD),不传则默认今天', required: false, example: '2025-01-01' }) + @IsOptional() + @IsString() + date?: string; +} + + diff --git a/src/checkins/models/checkin.model.ts b/src/checkins/models/checkin.model.ts new file mode 100644 index 0000000..bd91310 --- /dev/null +++ b/src/checkins/models/checkin.model.ts @@ -0,0 +1,116 @@ +import { Column, Model, Table, DataType, ForeignKey, BelongsTo, Index } from 'sequelize-typescript'; +import { User } from '../../users/models/user.model'; + +export enum CheckinStatus { + PENDING = 'PENDING', + COMPLETED = 'COMPLETED', +} + +@Table({ + tableName: 't_checkins', + underscored: true, +}) +export class Checkin extends Model { + @Column({ + type: DataType.UUID, + defaultValue: DataType.UUIDV4, + primaryKey: true, + }) + declare id: string; + + @ForeignKey(() => User) + @Column({ + type: DataType.STRING, + allowNull: false, + comment: '用户ID', + }) + declare userId: string; + + @BelongsTo(() => User) + declare user: User; + + @Column({ + type: DataType.STRING, + allowNull: true, + comment: '训练/课程ID(可选)', + }) + declare workoutId: string | null; + + @Column({ + type: DataType.STRING, + allowNull: true, + comment: '计划ID(可选)', + }) + declare planId: string | null; + + @Column({ + type: DataType.STRING, + allowNull: true, + comment: '标题(可选)', + }) + declare title: string | null; + + @Column({ + type: DataType.DATEONLY, + allowNull: true, + comment: '打卡日期(仅日期,便于统计)', + }) + declare checkinDate: string | null; // YYYY-MM-DD + + @Column({ + type: DataType.DATE, + allowNull: true, + comment: '开始时间', + }) + declare startedAt: Date | null; + + @Column({ + type: DataType.DATE, + allowNull: true, + comment: '完成时间', + }) + declare completedAt: Date | null; + + @Column({ + type: DataType.INTEGER, + allowNull: true, + comment: '用时(秒)', + }) + declare durationSeconds: number | null; + + @Column({ + type: DataType.ENUM(...Object.values(CheckinStatus)), + allowNull: false, + defaultValue: CheckinStatus.PENDING, + comment: '状态', + }) + declare status: CheckinStatus; + + @Column({ + type: DataType.TEXT, + allowNull: true, + comment: '备注', + }) + declare notes: string | null; + + @Column({ + type: DataType.JSON, + allowNull: true, + comment: '扩展指标(卡路里、心率等)', + }) + declare metrics: Record | null; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + }) + declare createdAt: Date; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + }) + declare updatedAt: Date; +} + +