新增打卡模块,包括打卡控制器、服务、模型及数据传输对象,更新应用模块以引入新模块

This commit is contained in:
richarjiang
2025-08-13 19:16:41 +08:00
parent 3b1af29c63
commit 866143d3ad
6 changed files with 448 additions and 0 deletions

View File

@@ -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],

View File

@@ -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<CheckinResponseDto> {
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<CheckinResponseDto> {
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<CheckinResponseDto> {
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<CheckinResponseDto> {
return this.checkinsService.remove(dto, user.sub);
}
@Get('daily')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '按天获取打卡列表' })
async getDaily(@Query() query: GetDailyCheckinsQueryDto, @CurrentUser() user: AccessTokenPayload): Promise<CheckinResponseDto> {
return this.checkinsService.getDaily(user.sub, query.date);
}
}

View File

@@ -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 { }

View File

@@ -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<CheckinResponseDto> {
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<CheckinResponseDto> {
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<CheckinResponseDto> {
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<CheckinResponseDto> {
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<CheckinResponseDto> {
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()) };
}
}

View File

@@ -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<string, any>;
}
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<string, any>;
}
export class RemoveCheckinDto {
@ApiProperty({ description: '打卡ID' })
@IsString()
id: string;
// 由后端从登录态注入
@IsOptional()
@IsString()
userId?: string;
}
export class CheckinResponseDto<T = any> {
@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;
}

View File

@@ -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<string, any> | null;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare updatedAt: Date;
}