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, GetDailyStatusRangeQueryDto, DailyStatusItem } from './dto/checkin.dto'; import { ResponseCode } from '../base.dto'; import * as dayjs from 'dayjs'; import { Op } from 'sequelize'; import { ActivityLogsService } from '../activity-logs/activity-logs.service'; import { ActivityActionType, ActivityEntityType } from '../activity-logs/models/activity-log.model'; @Injectable() export class CheckinsService { private readonly logger = new Logger(CheckinsService.name); constructor( @InjectModel(Checkin) private readonly checkinModel: typeof Checkin, private readonly activityLogsService: ActivityLogsService, ) { } async create(dto: CreateCheckinDto, userId: string): Promise { // 检查是否已存在未删除的记录 const existingRecord = await this.checkinModel.findOne({ where: { userId, workoutId: dto.workoutId || null, planId: dto.planId || null, checkinDate: dto.checkinDate || null, deleted: false, }, }); // 存在则更新 if (existingRecord) { await this.update({ ...dto, id: existingRecord.id! }, userId); return { code: ResponseCode.SUCCESS, message: 'success', data: existingRecord.toJSON() }; } const record = await this.checkinModel.create({ 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, }); await this.activityLogsService.record({ userId, entityType: ActivityEntityType.CHECKIN, action: ActivityActionType.CREATE, entityId: record.id, changes: record.toJSON(), }); return { code: ResponseCode.SUCCESS, message: 'success', data: record.toJSON() }; } async update(dto: UpdateCheckinDto, userId: string): Promise { const record = await this.checkinModel.findOne({ where: { id: dto.id, deleted: false, }, }); if (!record) { throw new NotFoundException('打卡记录不存在'); } if (record.userId !== userId) { throw new ForbiddenException('无权操作该打卡记录'); } const changes: Record = {}; if (dto.workoutId !== undefined) { record.workoutId = dto.workoutId; changes.workoutId = dto.workoutId; } if (dto.planId !== undefined) { record.planId = dto.planId; changes.planId = dto.planId; } if (dto.title !== undefined) { record.title = dto.title; changes.title = dto.title; } if (dto.checkinDate !== undefined) { record.checkinDate = dto.checkinDate as any; changes.checkinDate = dto.checkinDate; } if (dto.startedAt !== undefined) { record.startedAt = dto.startedAt ? new Date(dto.startedAt) : null; changes.startedAt = dto.startedAt; } if (dto.notes !== undefined) { record.notes = dto.notes; changes.notes = dto.notes; } if (dto.metrics !== undefined) { record.metrics = dto.metrics as any; changes.metrics = dto.metrics; } if (dto.status !== undefined) { record.status = dto.status; changes.status = dto.status; } 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 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() }; } async complete(dto: CompleteCheckinDto, userId: string): Promise { const record = await this.checkinModel.findOne({ where: { id: dto.id, deleted: false, }, }); 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(); 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() }; } 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('无权操作该打卡记录'); } record.deleted = true; await record.save(); 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 } }; } 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, deleted: false, [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()) }; } // 按时间范围返回每天是否打卡(任一记录满足视为已打卡) async getDailyStatusRange(userId: string, query: GetDailyStatusRangeQueryDto): Promise> { const start = dayjs(query.startDate, 'YYYY-MM-DD'); const end = dayjs(query.endDate, 'YYYY-MM-DD'); if (!start.isValid() || !end.isValid() || end.isBefore(start)) { return { code: ResponseCode.ERROR, message: '无效日期范围', data: [] }; } // 查询范围内所有打卡(覆盖checkinDate与时间戳) const startTs = start.startOf('day').toDate(); const endTs = end.endOf('day').toDate(); const rows = await this.checkinModel.findAll({ where: { userId, deleted: false, [Op.or]: [ { checkinDate: { [Op.between]: [start.format('YYYY-MM-DD') as any, end.format('YYYY-MM-DD') as any], } as any, }, { [Op.or]: [ { startedAt: { [Op.between]: [startTs, endTs] } }, { completedAt: { [Op.between]: [startTs, endTs] } }, ], }, ], }, attributes: ['checkinDate', 'startedAt', 'completedAt'], }); const set = new Set(); for (const r of rows) { if (r.checkinDate) { set.add(r.checkinDate); } if (r.startedAt) { set.add(dayjs(r.startedAt).format('YYYY-MM-DD')); } if (r.completedAt) { set.add(dayjs(r.completedAt).format('YYYY-MM-DD')); } } const result: DailyStatusItem[] = []; let cur = start.clone(); while (!cur.isAfter(end)) { const d = cur.format('YYYY-MM-DD'); result.push({ date: d, checkedIn: set.has(d) }); cur = cur.add(1, 'day'); } return { code: ResponseCode.SUCCESS, message: 'success', data: result }; } }