Files
plates-server/src/users/users.controller.ts
richarjiang 7b4d7c4459 feat(badges): 添加用户勋章系统,支持睡眠挑战勋章自动授予
实现完整的用户勋章功能模块:
- 新增 BadgeConfig 和 UserBadge 数据模型,支持勋章配置和用户勋章管理
- 新增 BadgeService 服务,提供勋章授予、查询、展示状态管理等核心功能
- 在挑战服务中集成勋章授予逻辑,完成首次睡眠打卡授予 goodSleep 勋章,完成睡眠挑战授予 sleepChallengeMonth 勋章
- 新增用户勋章相关接口:获取用户勋章列表、获取可用勋章列表、标记勋章已展示
- 支持勋章分类(睡眠、运动、饮食等)、排序、启用状态管理
- 支持勋章来源追踪(挑战、系统、手动授予)和元数据记录
2025-11-14 17:08:02 +08:00

447 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
Controller,
Get,
Post,
Body,
Param,
HttpCode,
HttpStatus,
Put,
Delete,
Query,
Logger,
UseGuards,
Inject,
Req,
NotFoundException,
UseInterceptors,
UploadedFile,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Request } from 'express';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger as WinstonLogger } from 'winston';
import { UsersService } from './users.service';
import { UploadImageResponseDto } from './dto/upload-image.dto';
import { CreateUserDto } from './dto/create-user.dto';
import { UserResponseDto } from './dto/user-response.dto';
import { ApiOperation, ApiBody, ApiResponse, ApiTags, ApiQuery } from '@nestjs/swagger';
import { UpdateUserDto, UpdateUserResponseDto } from './dto/update-user.dto';
import { AppleLoginDto, AppleLoginResponseDto, RefreshTokenDto, RefreshTokenResponseDto } from './dto/apple-login.dto';
import { DeleteAccountDto, DeleteAccountResponseDto } from './dto/delete-account.dto';
import { GuestLoginDto, GuestLoginResponseDto, RefreshGuestTokenDto, RefreshGuestTokenResponseDto } from './dto/guest-login.dto';
import { AppStoreServerNotificationDto, ProcessNotificationResponseDto } from './dto/app-store-notification.dto';
import { RestorePurchaseDto, RestorePurchaseResponseDto } from './dto/restore-purchase.dto';
import { GetUserActivityHistoryResponseDto } from './dto/user-activity.dto';
import { UpdateWeightRecordDto, WeightRecordResponseDto, DeleteWeightRecordResponseDto } from './dto/weight-record.dto';
import { UpdateBodyMeasurementDto, UpdateBodyMeasurementResponseDto, GetBodyMeasurementHistoryResponseDto, GetBodyMeasurementAnalysisDto, GetBodyMeasurementAnalysisResponseDto } from './dto/body-measurement.dto';
import { GetUserBadgesResponseDto, GetAvailableBadgesResponseDto, MarkBadgeShownDto, MarkBadgeShownResponseDto } from './dto/badge.dto';
import { Public } from '../common/decorators/public.decorator';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { AccessTokenPayload } from './services/apple-auth.service';
import { JwtAuthGuard } from 'src/common/guards/jwt-auth.guard';
import { ResponseCode } from 'src/base.dto';
import { CosService } from './cos.service';
@ApiTags('users')
@Controller('users')
export class UsersController {
private readonly logger = new Logger(UsersController.name);
constructor(
private readonly usersService: UsersService,
@Inject(WINSTON_MODULE_PROVIDER) private readonly winstonLogger: WinstonLogger,
private readonly cosService: CosService,
) { }
@UseGuards(JwtAuthGuard)
@Get('/info')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取用户信息' })
@ApiBody({ type: CreateUserDto })
@ApiResponse({ type: UserResponseDto })
async getProfile(@CurrentUser() user: AccessTokenPayload): Promise<UserResponseDto> {
this.logger.log(`get profile: ${JSON.stringify(user)}`);
return this.usersService.getProfile(user);
}
// 获取历史体重记录
@UseGuards(JwtAuthGuard)
@Get('/weight-history')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取历史体重记录(按时间倒序)' })
@ApiQuery({ name: 'limit', required: false, description: '返回条数默认200最大1000' })
async getWeightHistory(
@CurrentUser() user: AccessTokenPayload,
) {
const data = await this.usersService.getWeightHistory(user.sub, {});
return { code: ResponseCode.SUCCESS, message: 'success', data };
}
// 更新体重记录
@UseGuards(JwtAuthGuard)
@Put('/weight-records/:id')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '更新体重记录' })
@ApiBody({ type: UpdateWeightRecordDto })
@ApiResponse({ status: 200, description: '成功更新体重记录', type: WeightRecordResponseDto })
async updateWeightRecord(
@Param('id') recordId: string,
@Body() updateDto: UpdateWeightRecordDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<WeightRecordResponseDto> {
this.logger.log(`更新体重记录 - 用户ID: ${user.sub}, 记录ID: ${recordId}`);
return this.usersService.updateWeightRecord(user.sub, parseInt(recordId), updateDto);
}
// 删除体重记录
@UseGuards(JwtAuthGuard)
@Delete('/weight-records/:id')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '删除体重记录' })
@ApiResponse({ status: 200, description: '成功删除体重记录', type: DeleteWeightRecordResponseDto })
async deleteWeightRecord(
@Param('id') recordId: string,
@CurrentUser() user: AccessTokenPayload,
): Promise<DeleteWeightRecordResponseDto> {
this.logger.log(`删除体重记录 - 用户ID: ${user.sub}, 记录ID: ${recordId}`);
const success = await this.usersService.deleteWeightRecord(user.sub, parseInt(recordId));
if (!success) {
throw new NotFoundException('体重记录不存在');
}
return { code: ResponseCode.SUCCESS, message: 'success' };
}
// 更新用户昵称、头像
@UseGuards(JwtAuthGuard)
@Put('update')
async updateUser(@Body() updateUserDto: UpdateUserDto, @CurrentUser() user: AccessTokenPayload): Promise<UpdateUserResponseDto> {
return this.usersService.updateUser(updateUserDto, user.sub);
}
// Apple 登录
@Public()
@Post('auth/apple/login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Apple 登录验证' })
@ApiBody({ type: AppleLoginDto })
@ApiResponse({ type: AppleLoginResponseDto })
async appleLogin(@Body() appleLoginDto: AppleLoginDto): Promise<AppleLoginResponseDto> {
return this.usersService.appleLogin(appleLoginDto);
}
// 刷新访问令牌
@Public()
@Post('auth/refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '刷新访问令牌' })
@ApiBody({ type: RefreshTokenDto })
@ApiResponse({ type: RefreshTokenResponseDto })
async refreshToken(@Body() refreshTokenDto: RefreshTokenDto): Promise<RefreshTokenResponseDto> {
return this.usersService.refreshToken(refreshTokenDto);
}
// 删除用户账号
@UseGuards(JwtAuthGuard)
@Post('delete-account')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '删除用户账号' })
@ApiBody({ type: DeleteAccountDto })
@ApiResponse({ type: DeleteAccountResponseDto })
async deleteAccount(@CurrentUser() user: AccessTokenPayload): Promise<DeleteAccountResponseDto> {
const deleteAccountDto = {
userId: user.sub,
};
return this.usersService.deleteAccount(deleteAccountDto);
}
// 游客登录
@Public()
@Post('auth/guest/login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '游客登录' })
@ApiBody({ type: GuestLoginDto })
@ApiResponse({ type: GuestLoginResponseDto })
async guestLogin(@Body() guestLoginDto: GuestLoginDto): Promise<GuestLoginResponseDto> {
return this.usersService.guestLogin(guestLoginDto);
}
// 刷新游客令牌
@Public()
@Post('auth/guest/refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '刷新游客访问令牌' })
@ApiBody({ type: RefreshGuestTokenDto })
@ApiResponse({ type: RefreshGuestTokenResponseDto })
async refreshGuestToken(@Body() refreshGuestTokenDto: RefreshGuestTokenDto): Promise<RefreshGuestTokenResponseDto> {
return this.usersService.refreshGuestToken(refreshGuestTokenDto);
}
// 获取COS上传临时密钥
@UseGuards(JwtAuthGuard)
@Get('cos/upload-token')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取COS上传临时密钥' })
async getCosUploadToken(@CurrentUser() user: AccessTokenPayload) {
try {
const data = await this.cosService.getUploadToken(user.sub);
return { code: ResponseCode.SUCCESS, message: 'success', data };
} catch (error) {
this.winstonLogger.error('获取COS上传临时密钥失败', {
context: 'UsersController',
userId: user?.sub,
error: (error as Error).message,
stack: (error as Error).stack,
});
return { code: ResponseCode.ERROR, message: (error as Error).message };
}
}
// 上传图片到COS
@UseGuards(JwtAuthGuard)
@Post('cos/upload-image')
@HttpCode(HttpStatus.OK)
@UseInterceptors(FileInterceptor('file'))
@ApiOperation({ summary: '上传图片到COS' })
@ApiResponse({
status: 200,
description: '图片上传成功',
type: UploadImageResponseDto,
})
@ApiResponse({
status: 400,
description: '上传失败:文件格式不支持或文件过大',
})
async uploadImageToCos(
@CurrentUser() user: AccessTokenPayload,
@UploadedFile() file: Express.Multer.File,
) {
try {
if (!file) {
return { code: ResponseCode.ERROR, message: '请选择要上传的图片文件' };
}
const data = await this.cosService.uploadImage(user.sub, file);
return data;
} catch (error) {
this.winstonLogger.error('上传图片失败', {
context: 'UsersController',
userId: user?.sub,
error: (error as Error).message,
stack: (error as Error).stack,
});
return { code: ResponseCode.ERROR, message: (error as Error).message };
}
}
// App Store 服务器通知接收接口
@Public()
@Post('app-store-notifications')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '接收App Store服务器通知' })
@ApiBody({ type: AppStoreServerNotificationDto })
@ApiResponse({ type: ProcessNotificationResponseDto })
async handleAppStoreNotification(@Body() notificationDto: AppStoreServerNotificationDto): Promise<ProcessNotificationResponseDto> {
this.logger.log(`收到App Store服务器通知`);
return this.usersService.processAppStoreNotification(notificationDto);
}
// RevenueCat Webhook
@Public()
@Post('revenuecat-webhook')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '接收 RevenueCat webhook' })
async handleRevenueCatWebhook(@Body() webhook) {
// 使用结构化日志记录webhook接收
this.winstonLogger.info('RevenueCat webhook received', {
context: 'UsersController',
eventType: webhook.event?.type,
eventId: webhook.event?.id,
appUserId: webhook.event?.app_user_id,
timestamp: new Date().toISOString(),
webhookData: {
apiVersion: webhook.api_version,
eventTimestamp: webhook.event?.event_timestamp_ms
}
});
try {
await this.usersService.handleRevenueCatWebhook(webhook);
this.winstonLogger.info('RevenueCat webhook processed successfully', {
context: 'UsersController',
eventType: webhook.event?.type,
eventId: webhook.event?.id,
appUserId: webhook.event?.app_user_id
});
return { code: ResponseCode.SUCCESS, message: 'success' };
} catch (error) {
this.winstonLogger.error('RevenueCat webhook processing failed', {
context: 'UsersController',
eventType: webhook.event?.type,
eventId: webhook.event?.id,
appUserId: webhook.event?.app_user_id,
error: error.message,
stack: error.stack
});
return { code: ResponseCode.ERROR, message: error.message };
}
}
// 恢复购买
@UseGuards(JwtAuthGuard)
@Post('restore-purchase')
async restorePurchase(
@Body() restorePurchaseDto: RestorePurchaseDto,
@CurrentUser() user: AccessTokenPayload,
@Req() request: Request
): Promise<RestorePurchaseResponseDto> {
const clientIp = request.ip || request.connection.remoteAddress || 'unknown';
const userAgent = request.get('User-Agent') || 'unknown';
this.logger.log(`恢复购买请求 - 用户ID: ${user.sub}, IP: ${clientIp}`);
// 记录安全相关信息
this.winstonLogger.info('Purchase restore request', {
context: 'UsersController',
userId: user.sub,
clientIp,
userAgent,
originalAppUserId: restorePurchaseDto.customerInfo?.originalAppUserId,
entitlementsCount: Object.keys(restorePurchaseDto.customerInfo?.activeEntitlements || {}).length,
nonSubTransactionsCount: restorePurchaseDto.customerInfo?.nonSubscriptionTransactions?.length || 0
});
return this.usersService.restorePurchase(restorePurchaseDto, user.sub, clientIp, userAgent);
}
// ==================== 用户活跃记录相关接口 ====================
/**
* 获取用户最近六个月的活跃情况
*/
@UseGuards(JwtAuthGuard)
@Get('activity-history')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取用户最近六个月的活跃情况' })
@ApiResponse({ status: 200, description: '成功获取用户活跃历史', type: GetUserActivityHistoryResponseDto })
async getUserActivityHistory(
@CurrentUser() user: AccessTokenPayload,
): Promise<GetUserActivityHistoryResponseDto> {
this.logger.log(`获取用户活跃历史 - 用户ID: ${user.sub}`);
return this.usersService.getUserActivityHistory(user.sub);
}
// ==================== 围度相关接口 ====================
/**
* 更新用户围度信息
*/
@UseGuards(JwtAuthGuard)
@Put('body-measurements')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '更新用户围度信息' })
@ApiBody({ type: UpdateBodyMeasurementDto })
@ApiResponse({ status: 200, description: '成功更新围度信息', type: UpdateBodyMeasurementResponseDto })
async updateBodyMeasurements(
@Body() updateBodyMeasurementDto: UpdateBodyMeasurementDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<UpdateBodyMeasurementResponseDto> {
this.logger.log(`更新用户围度信息 - 用户ID: ${user.sub}, 数据: ${JSON.stringify(updateBodyMeasurementDto)}`);
return this.usersService.updateBodyMeasurements(user.sub, updateBodyMeasurementDto);
}
/**
* 获取用户围度历史记录
*/
@UseGuards(JwtAuthGuard)
@Get('body-measurements/history')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取用户围度历史记录' })
@ApiQuery({ name: 'measurementType', required: false, description: '围度类型筛选' })
@ApiResponse({ status: 200, description: '成功获取围度历史记录', type: GetBodyMeasurementHistoryResponseDto })
async getBodyMeasurementHistory(
@CurrentUser() user: AccessTokenPayload,
@Query('measurementType') measurementType?: string,
): Promise<GetBodyMeasurementHistoryResponseDto> {
this.logger.log(`获取用户围度历史记录 - 用户ID: ${user.sub}, 围度类型: ${measurementType || '全部'}`);
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);
}
// ==================== 勋章相关接口 ====================
/**
* 获取用户勋章列表
*/
@UseGuards(JwtAuthGuard)
@Get('badges')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取用户勋章列表' })
@ApiResponse({ status: 200, description: '成功获取用户勋章列表', type: GetUserBadgesResponseDto })
async getUserBadges(
@CurrentUser() user: AccessTokenPayload,
): Promise<GetUserBadgesResponseDto> {
this.logger.log(`获取用户勋章列表 - 用户ID: ${user.sub}`);
return this.usersService.getUserBadges(user.sub);
}
/**
* 获取所有可用勋章(包含用户是否已获得)
*/
@UseGuards(JwtAuthGuard)
@Get('badges/available')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取所有可用勋章' })
@ApiResponse({ status: 200, description: '成功获取所有可用勋章', type: GetAvailableBadgesResponseDto })
async getAvailableBadges(
@CurrentUser() user: AccessTokenPayload,
): Promise<GetAvailableBadgesResponseDto> {
this.logger.log(`获取可用勋章列表 - 用户ID: ${user.sub}`);
return this.usersService.getAvailableBadges(user.sub);
}
/**
* 标记勋章已展示
*/
@UseGuards(JwtAuthGuard)
@Post('badges/mark-shown')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '标记勋章已展示(客户端展示勋章动画后调用)' })
@ApiBody({ type: MarkBadgeShownDto })
@ApiResponse({ status: 200, description: '成功标记勋章已展示', type: MarkBadgeShownResponseDto })
async markBadgeAsShown(
@Body() markBadgeShownDto: MarkBadgeShownDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<MarkBadgeShownResponseDto> {
this.logger.log(`标记勋章已展示 - 用户ID: ${user.sub}, 勋章代码: ${markBadgeShownDto.badgeCode}`);
return this.usersService.markBadgeAsShown(user.sub, markBadgeShownDto.badgeCode);
}
}