feat(users): 新增围度分析报表接口
- 添加 dayjs 依赖用于日期处理 - 新增 GetBodyMeasurementAnalysisDto 和 GetBodyMeasurementAnalysisResponseDto - 支持按周、月、年三种时间范围统计围度变化趋势 - 实现最近数据点匹配算法,返回各围度类型最接近时间点的测量值
This commit is contained in:
@@ -42,6 +42,7 @@
|
|||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"cos-nodejs-sdk-v5": "^2.14.7",
|
"cos-nodejs-sdk-v5": "^2.14.7",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
|
"dayjs": "^1.11.18",
|
||||||
"fs": "^0.0.1-security",
|
"fs": "^0.0.1-security",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"jwks-rsa": "^3.2.0",
|
"jwks-rsa": "^3.2.0",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsNumber, IsOptional, IsEnum, Min } from 'class-validator';
|
import { IsNumber, IsOptional, IsEnum, Min, IsIn } from 'class-validator';
|
||||||
import { ResponseCode } from 'src/base.dto';
|
import { ResponseCode } from 'src/base.dto';
|
||||||
import { BodyMeasurementType } from '../models/user-body-measurement-history.model';
|
import { BodyMeasurementType } from '../models/user-body-measurement-history.model';
|
||||||
|
|
||||||
@@ -74,4 +74,62 @@ export class GetBodyMeasurementHistoryResponseDto {
|
|||||||
source: string;
|
source: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}>;
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetBodyMeasurementAnalysisDto {
|
||||||
|
@IsIn(['week', 'month', 'year'], { message: '时间范围必须是 week、month 或 year' })
|
||||||
|
@ApiProperty({
|
||||||
|
description: '时间范围',
|
||||||
|
example: 'week',
|
||||||
|
enum: ['week', 'month', 'year']
|
||||||
|
})
|
||||||
|
period: 'week' | 'month' | 'year';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BodyMeasurementAnalysisDataPoint {
|
||||||
|
@ApiProperty({ description: '时间标签', example: '2024-01-07' })
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '胸围数据', example: 90.5, required: false })
|
||||||
|
chestCircumference?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '腰围数据', example: 70.5, required: false })
|
||||||
|
waistCircumference?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '上臀围数据', example: 95.0, required: false })
|
||||||
|
upperHipCircumference?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '臂围数据', example: 28.5, required: false })
|
||||||
|
armCircumference?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '大腿围数据', example: 55.0, required: false })
|
||||||
|
thighCircumference?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '小腿围数据', example: 35.0, required: false })
|
||||||
|
calfCircumference?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetBodyMeasurementAnalysisResponseDto {
|
||||||
|
@ApiProperty({ description: '状态码', example: ResponseCode.SUCCESS })
|
||||||
|
code: ResponseCode;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '消息', example: 'success' })
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '围度分析数据',
|
||||||
|
type: [BodyMeasurementAnalysisDataPoint],
|
||||||
|
example: [
|
||||||
|
{
|
||||||
|
label: '2024-01-01',
|
||||||
|
chestCircumference: 90.5,
|
||||||
|
waistCircumference: 70.5,
|
||||||
|
upperHipCircumference: 95.0,
|
||||||
|
armCircumference: 28.5,
|
||||||
|
thighCircumference: 55.0,
|
||||||
|
calfCircumference: 35.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
data: BodyMeasurementAnalysisDataPoint[];
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ import { AppStoreServerNotificationDto, ProcessNotificationResponseDto } from '.
|
|||||||
import { RestorePurchaseDto, RestorePurchaseResponseDto } from './dto/restore-purchase.dto';
|
import { RestorePurchaseDto, RestorePurchaseResponseDto } from './dto/restore-purchase.dto';
|
||||||
import { GetUserActivityHistoryResponseDto } from './dto/user-activity.dto';
|
import { GetUserActivityHistoryResponseDto } from './dto/user-activity.dto';
|
||||||
import { UpdateWeightRecordDto, WeightRecordResponseDto, DeleteWeightRecordResponseDto } from './dto/weight-record.dto';
|
import { UpdateWeightRecordDto, WeightRecordResponseDto, DeleteWeightRecordResponseDto } from './dto/weight-record.dto';
|
||||||
import { UpdateBodyMeasurementDto, UpdateBodyMeasurementResponseDto, GetBodyMeasurementHistoryResponseDto } from './dto/body-measurement.dto';
|
import { UpdateBodyMeasurementDto, UpdateBodyMeasurementResponseDto, GetBodyMeasurementHistoryResponseDto, GetBodyMeasurementAnalysisDto, GetBodyMeasurementAnalysisResponseDto } from './dto/body-measurement.dto';
|
||||||
|
|
||||||
import { Public } from '../common/decorators/public.decorator';
|
import { Public } from '../common/decorators/public.decorator';
|
||||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||||
@@ -378,4 +378,26 @@ export class UsersController {
|
|||||||
return this.usersService.getBodyMeasurementHistory(user.sub, measurementType as any);
|
return this.usersService.getBodyMeasurementHistory(user.sub, measurementType as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户围度分析报表
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Get('body-measurements/analysis')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: '获取用户围度分析报表' })
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'period',
|
||||||
|
required: true,
|
||||||
|
description: '时间范围 (week: 按周, month: 按月, year: 按年)',
|
||||||
|
enum: ['week', 'month', 'year']
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: '成功获取围度分析报表', type: GetBodyMeasurementAnalysisResponseDto })
|
||||||
|
async getBodyMeasurementAnalysis(
|
||||||
|
@CurrentUser() user: AccessTokenPayload,
|
||||||
|
@Query('period') period: 'week' | 'month' | 'year',
|
||||||
|
): Promise<GetBodyMeasurementAnalysisResponseDto> {
|
||||||
|
this.logger.log(`获取用户围度分析报表 - 用户ID: ${user.sub}, 时间范围: ${period}`);
|
||||||
|
return this.usersService.getBodyMeasurementAnalysis(user.sub, period);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import { UpdateUserDto, UpdateUserResponseDto } from './dto/update-user.dto';
|
|||||||
import { UserPurchase, PurchaseType, PurchaseStatus, PurchasePlatform } from './models/user-purchase.model';
|
import { UserPurchase, PurchaseType, PurchaseStatus, PurchasePlatform } from './models/user-purchase.model';
|
||||||
import { ApplePurchaseService } from './services/apple-purchase.service';
|
import { ApplePurchaseService } from './services/apple-purchase.service';
|
||||||
import * as dayjs from 'dayjs';
|
import * as dayjs from 'dayjs';
|
||||||
|
import * as isoWeek from 'dayjs/plugin/isoWeek';
|
||||||
|
|
||||||
|
dayjs.extend(isoWeek);
|
||||||
import { AccessTokenPayload, AppleAuthService, AppleTokenPayload } from './services/apple-auth.service';
|
import { AccessTokenPayload, AppleAuthService, AppleTokenPayload } from './services/apple-auth.service';
|
||||||
import { AppleLoginDto, AppleLoginResponseDto, RefreshTokenDto, RefreshTokenResponseDto } from './dto/apple-login.dto';
|
import { AppleLoginDto, AppleLoginResponseDto, RefreshTokenDto, RefreshTokenResponseDto } from './dto/apple-login.dto';
|
||||||
import { DeleteAccountDto, DeleteAccountResponseDto } from './dto/delete-account.dto';
|
import { DeleteAccountDto, DeleteAccountResponseDto } from './dto/delete-account.dto';
|
||||||
@@ -2386,4 +2389,143 @@ export class UsersService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户围度分析报表
|
||||||
|
*/
|
||||||
|
async getBodyMeasurementAnalysis(userId: string, period: 'week' | 'month' | 'year'): Promise<any> {
|
||||||
|
try {
|
||||||
|
const now = dayjs();
|
||||||
|
let startDate: Date;
|
||||||
|
let dataPoints: Date[] = [];
|
||||||
|
|
||||||
|
// 根据时间范围计算起始日期和数据点
|
||||||
|
switch (period) {
|
||||||
|
case 'week':
|
||||||
|
// 获取本周7天,按中国习惯从周一开始
|
||||||
|
const startOfWeek = now.startOf('isoWeek'); // ISO周从周一开始
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const date = startOfWeek.add(i, 'day');
|
||||||
|
dataPoints.push(date.toDate());
|
||||||
|
}
|
||||||
|
startDate = startOfWeek.toDate();
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
// 获取本月的4周,以周日结束
|
||||||
|
const startOfMonth = now.startOf('month');
|
||||||
|
|
||||||
|
// 本月1日作为起始日期
|
||||||
|
startDate = startOfMonth.toDate();
|
||||||
|
|
||||||
|
// 生成4个数据点,每个代表一周,以该周的周日为准
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
// 每周从本月1日开始算第i周,取该周的周日
|
||||||
|
const weekStart = startOfMonth.add(i * 7, 'day');
|
||||||
|
const weekEnd = weekStart.endOf('week'); // dayjs默认周日结束
|
||||||
|
dataPoints.push(weekEnd.toDate());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
// 获取今年1月到12月,每月取最后一天
|
||||||
|
const startOfYear = now.startOf('year'); // 今年1月1日
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const date = startOfYear.add(i, 'month').endOf('month');
|
||||||
|
dataPoints.push(date.toDate());
|
||||||
|
}
|
||||||
|
startDate = startOfYear.toDate();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取时间范围内的所有围度数据
|
||||||
|
const measurements = await this.userBodyMeasurementHistoryModel.findAll({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
createdAt: {
|
||||||
|
[Op.gte]: startDate,
|
||||||
|
[Op.lte]: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
order: [['createdAt', 'ASC']],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 初始化结果数组
|
||||||
|
const analysisData = dataPoints.map(date => {
|
||||||
|
const label = this.formatDateLabel(date, period);
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
chestCircumference: null,
|
||||||
|
waistCircumference: null,
|
||||||
|
upperHipCircumference: null,
|
||||||
|
armCircumference: null,
|
||||||
|
thighCircumference: null,
|
||||||
|
calfCircumference: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 为每个数据点找到最接近的围度数据
|
||||||
|
analysisData.forEach((point, index) => {
|
||||||
|
const targetDate = dataPoints[index];
|
||||||
|
|
||||||
|
// 为每种围度类型找到最接近目标日期的值
|
||||||
|
Object.values(BodyMeasurementType).forEach(measurementType => {
|
||||||
|
const relevantMeasurements = measurements.filter(m =>
|
||||||
|
m.measurementType === measurementType &&
|
||||||
|
new Date(m.createdAt) <= targetDate
|
||||||
|
);
|
||||||
|
|
||||||
|
if (relevantMeasurements.length > 0) {
|
||||||
|
// 取最接近目标日期的最新记录
|
||||||
|
const closestMeasurement = relevantMeasurements
|
||||||
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0];
|
||||||
|
|
||||||
|
// 将数据库字段映射到响应字段
|
||||||
|
const fieldMapping = {
|
||||||
|
[BodyMeasurementType.ChestCircumference]: 'chestCircumference',
|
||||||
|
[BodyMeasurementType.WaistCircumference]: 'waistCircumference',
|
||||||
|
[BodyMeasurementType.UpperHipCircumference]: 'upperHipCircumference',
|
||||||
|
[BodyMeasurementType.ArmCircumference]: 'armCircumference',
|
||||||
|
[BodyMeasurementType.ThighCircumference]: 'thighCircumference',
|
||||||
|
[BodyMeasurementType.CalfCircumference]: 'calfCircumference',
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldName = fieldMapping[measurementType];
|
||||||
|
if (fieldName) {
|
||||||
|
point[fieldName] = closestMeasurement.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: ResponseCode.SUCCESS,
|
||||||
|
message: 'success',
|
||||||
|
data: analysisData,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`获取围度分析报表失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||||
|
return {
|
||||||
|
code: ResponseCode.ERROR,
|
||||||
|
message: `获取围度分析报表失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||||||
|
data: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化日期标签
|
||||||
|
*/
|
||||||
|
private formatDateLabel(date: Date, period: 'week' | 'month' | 'year'): string {
|
||||||
|
const dayjsDate = dayjs(date);
|
||||||
|
|
||||||
|
switch (period) {
|
||||||
|
case 'week':
|
||||||
|
return dayjsDate.format('YYYY-MM-DD');
|
||||||
|
case 'month':
|
||||||
|
return dayjsDate.format('YYYY-MM-DD');
|
||||||
|
case 'year':
|
||||||
|
return dayjsDate.format('YYYY-MM');
|
||||||
|
default:
|
||||||
|
return dayjsDate.format('YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user