404 lines
15 KiB
TypeScript
404 lines
15 KiB
TypeScript
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);
|
||
}
|
||
|
||
}
|