新增打卡模块,包括打卡控制器、服务、模型及数据传输对象,更新应用模块以引入新模块
This commit is contained in:
@@ -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],
|
||||
|
||||
59
src/checkins/checkins.controller.ts
Normal file
59
src/checkins/checkins.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
15
src/checkins/checkins.module.ts
Normal file
15
src/checkins/checkins.module.ts
Normal 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 { }
|
||||
|
||||
|
||||
120
src/checkins/checkins.service.ts
Normal file
120
src/checkins/checkins.service.ts
Normal 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()) };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
136
src/checkins/dto/checkin.dto.ts
Normal file
136
src/checkins/dto/checkin.dto.ts
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
116
src/checkins/models/checkin.model.ts
Normal file
116
src/checkins/models/checkin.model.ts
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user