新增打卡模块,包括打卡控制器、服务、模型及数据传输对象,更新应用模块以引入新模块
This commit is contained in:
@@ -5,6 +5,7 @@ import { DatabaseModule } from "./database/database.module";
|
|||||||
import { UsersModule } from "./users/users.module";
|
import { UsersModule } from "./users/users.module";
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { LoggerModule } from './common/logger/logger.module';
|
import { LoggerModule } from './common/logger/logger.module';
|
||||||
|
import { CheckinsModule } from './checkins/checkins.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -15,6 +16,7 @@ import { LoggerModule } from './common/logger/logger.module';
|
|||||||
LoggerModule,
|
LoggerModule,
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
|
CheckinsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
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