Files
plates-server/src/users/users.controller.ts
2025-09-26 08:48:22 +08:00

404 lines
15 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 { 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: '请选择要上传的图片文件' };
}
this.winstonLogger.info(`receive file, fileSize: ${file.size}`, {
context: 'UsersController',
userId: user?.sub,
file,
})
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);
}
}