diff --git a/src/ai-coach/ai-coach.controller.ts b/src/ai-coach/ai-coach.controller.ts new file mode 100644 index 0000000..c4b2abe --- /dev/null +++ b/src/ai-coach/ai-coach.controller.ts @@ -0,0 +1,75 @@ +import { Body, Controller, Post, Res, StreamableFile, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBody } from '@nestjs/swagger'; +import { Response } from 'express'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { AccessTokenPayload } from '../users/services/apple-auth.service'; +import { AiCoachService } from './ai-coach.service'; +import { AiChatRequestDto, AiChatResponseDto } from './dto/ai-chat.dto'; + +@ApiTags('ai-coach') +@Controller('ai-coach') +@UseGuards(JwtAuthGuard) +export class AiCoachController { + constructor(private readonly aiCoachService: AiCoachService) { } + + @Post('chat') + @ApiOperation({ summary: '流式大模型对话(普拉提教练)' }) + @ApiBody({ type: AiChatRequestDto }) + async chat( + @Body() body: AiChatRequestDto, + @CurrentUser() user: AccessTokenPayload, + @Res({ passthrough: false }) res: Response, + ): Promise { + const userId = user.sub; + const stream = body.stream !== false; // 默认流式 + + // 创建或沿用会话ID,并保存用户消息 + const { conversationId } = await this.aiCoachService.createOrAppendMessages({ + userId, + conversationId: body.conversationId, + userContent: body.messages?.[body.messages.length - 1]?.content || '', + }); + + if (!stream) { + // 非流式:聚合后一次性返回文本 + const readable = await this.aiCoachService.streamChat({ + userId, + conversationId, + userContent: body.messages?.[body.messages.length - 1]?.content || '', + }); + let text = ''; + for await (const chunk of readable) { + text += chunk.toString(); + } + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.send({ conversationId, text }); + return; + } + + // 流式:SSE/文本流 + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Transfer-Encoding', 'chunked'); + + const readable = await this.aiCoachService.streamChat({ + userId, + conversationId, + userContent: body.messages?.[body.messages.length - 1]?.content || '', + }); + + readable.on('data', (chunk) => { + res.write(chunk); + }); + readable.on('end', () => { + res.end(); + }); + readable.on('error', () => { + res.end(); + }); + + return; + } +} + + diff --git a/src/ai-coach/ai-coach.module.ts b/src/ai-coach/ai-coach.module.ts new file mode 100644 index 0000000..2f79be2 --- /dev/null +++ b/src/ai-coach/ai-coach.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { ConfigModule } from '@nestjs/config'; +import { AiCoachController } from './ai-coach.controller'; +import { AiCoachService } from './ai-coach.service'; +import { AiMessage } from './models/ai-message.model'; +import { UsersModule } from '../users/users.module'; + +@Module({ + imports: [ + ConfigModule, + UsersModule, + SequelizeModule.forFeature([AiMessage]), + ], + controllers: [AiCoachController], + providers: [AiCoachService], +}) +export class AiCoachModule { } + diff --git a/src/ai-coach/ai-coach.service.ts b/src/ai-coach/ai-coach.service.ts new file mode 100644 index 0000000..59d2403 --- /dev/null +++ b/src/ai-coach/ai-coach.service.ts @@ -0,0 +1,110 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { OpenAI } from 'openai'; +import { Readable } from 'stream'; +import { AiMessage, RoleType } from './models/ai-message.model'; + +const SYSTEM_PROMPT = `你是一位资深的普拉提与运动康复教练(Pilates Coach),具备运动解剖学、体态评估、疼痛预防、功能性训练、力量与柔韧性、营养与饮食建议等专业知识。 +请遵循以下规则作答: +1) 话题范围仅限:健康、健身、普拉提、康复、形体训练、柔韧性、力量训练、运动损伤预防与恢复、营养与饮食。 +2) 拒绝回答医疗诊断、情感心理、时政金融、编程等不相关或高风险话题,礼貌解释并引导回合适范围。 +3) 语言风格:亲切、专业、简洁分点回答;必要时提供可在家执行的分步骤方案与注意事项;给出不同水平与疼痛人群的替代动作与安全提示。 +4) 强调循序渐进与个体差异,避免绝对化表述;涉及疼痛或既往伤病时,建议在医生评估后进行训练。 +5) 所有训练建议默认不需要器械或仅需常见小器械(瑜伽垫、弹力带、泡沫轴等),估算时长与频率,并提供跟踪与自测方法。`; + +@Injectable() +export class AiCoachService { + private readonly logger = new Logger(AiCoachService.name); + private readonly client: OpenAI; + private readonly model: string; + + constructor(private readonly configService: ConfigService) { + const dashScopeApiKey = this.configService.get('DASHSCOPE_API_KEY') || ''; + const baseURL = this.configService.get('DASHSCOPE_BASE_URL') || 'https://dashscope.aliyuncs.com/compatible-mode/v1'; + + this.client = new OpenAI({ + apiKey: dashScopeApiKey, + baseURL, + }); + // 默认选择通义千问对话模型(OpenAI兼容名),可通过环境覆盖 + this.model = this.configService.get('DASHSCOPE_MODEL') || 'qwen-plus'; + } + + async createOrAppendMessages(params: { + userId: string; + conversationId?: string; + userContent: string; + }): Promise<{ conversationId: string }> { + const conversationId = params.conversationId || `${params.userId}-${Date.now()}`; + await AiMessage.create({ + conversationId, + userId: params.userId, + role: RoleType.User, + content: params.userContent, + metadata: null, + }); + return { conversationId }; + } + + buildChatHistory = async (userId: string, conversationId: string) => { + const history = await AiMessage.findAll({ + where: { userId, conversationId }, + order: [['created_at', 'ASC']], + }); + + const messages = [ + { role: 'system' as const, content: SYSTEM_PROMPT }, + ...history.map((m) => ({ role: m.role as 'user' | 'assistant' | 'system', content: m.content })), + ]; + return messages; + }; + + async streamChat(params: { + userId: string; + conversationId: string; + userContent: string; + }): Promise { + // 上下文:系统提示 + 历史 + 当前用户消息 + const messages = await this.buildChatHistory(params.userId, params.conversationId); + + const stream = await this.client.chat.completions.create({ + model: this.model, + messages, + stream: true, + temperature: 0.7, + max_tokens: 1024, + }); + + const readable = new Readable({ read() { } }); + let assistantContent = ''; + + (async () => { + try { + for await (const chunk of stream) { + const delta = chunk.choices?.[0]?.delta?.content || ''; + if (delta) { + assistantContent += delta; + readable.push(delta); + } + } + // 结束:将assistant消息入库 + await AiMessage.create({ + conversationId: params.conversationId, + userId: params.userId, + role: RoleType.Assistant, + content: assistantContent, + metadata: { model: this.model }, + }); + } catch (error) { + this.logger.error(`stream error: ${error?.message || error}`); + readable.push('\n[对话发生错误,请稍后重试]'); + } finally { + readable.push(null); + } + })(); + + return readable; + } +} + + diff --git a/src/ai-coach/dto/ai-chat.dto.ts b/src/ai-coach/dto/ai-chat.dto.ts new file mode 100644 index 0000000..ffe9675 --- /dev/null +++ b/src/ai-coach/dto/ai-chat.dto.ts @@ -0,0 +1,37 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, IsBoolean, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class AiChatMessageDto { + @ApiProperty({ enum: ['user', 'assistant', 'system'] }) + @IsString() + role: 'user' | 'assistant' | 'system'; + + @ApiProperty() + @IsString() + @IsNotEmpty() + @MaxLength(8000) + content: string; +} + +export class AiChatRequestDto { + @ApiProperty({ description: '会话ID。未提供则创建新会话' }) + @IsOptional() + @IsString() + conversationId?: string; + + @ApiProperty({ type: [AiChatMessageDto], description: '历史消息,后端会自动注入系统提示词' }) + @IsArray() + messages: AiChatMessageDto[]; + + @ApiProperty({ required: false, description: '是否启用流式输出', default: true }) + @IsOptional() + @IsBoolean() + stream?: boolean; +} + +export class AiChatResponseDto { + @ApiProperty() + conversationId: string; +} + + diff --git a/src/ai-coach/models/ai-message.model.ts b/src/ai-coach/models/ai-message.model.ts new file mode 100644 index 0000000..845a581 --- /dev/null +++ b/src/ai-coach/models/ai-message.model.ts @@ -0,0 +1,62 @@ +import { Column, DataType, Index, Model, PrimaryKey, Table } from 'sequelize-typescript'; + +export enum RoleType { + System = 'system', + User = 'user', + Assistant = 'assistant', +} + +@Table({ + tableName: 't_ai_messages', + underscored: true, +}) +export class AiMessage extends Model { + @PrimaryKey + @Column({ + type: DataType.STRING, + allowNull: false, + comment: '会话ID', + primaryKey: true, + }) + declare conversationId: string; + + @Column({ + type: DataType.STRING, + allowNull: false, + comment: '用户ID', + }) + declare userId: string; + + @Column({ + type: DataType.ENUM('system', 'user', 'assistant'), + allowNull: false, + }) + declare role: RoleType; + + @Column({ + type: DataType.TEXT('long'), + allowNull: false, + }) + declare content: string; + + @Column({ + type: DataType.JSON, + allowNull: true, + comment: '扩展元数据,如token用量、模型名等', + }) + declare metadata: Record | null; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + }) + declare createdAt: Date; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + }) + declare updatedAt: Date; +} + + diff --git a/src/app.module.ts b/src/app.module.ts index c7a7c30..af52376 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,6 +6,7 @@ import { UsersModule } from "./users/users.module"; import { ConfigModule } from '@nestjs/config'; import { LoggerModule } from './common/logger/logger.module'; import { CheckinsModule } from './checkins/checkins.module'; +import { AiCoachModule } from './ai-coach/ai-coach.module'; @Module({ imports: [ @@ -17,6 +18,7 @@ import { CheckinsModule } from './checkins/checkins.module'; DatabaseModule, UsersModule, CheckinsModule, + AiCoachModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/checkins/checkins.controller.ts b/src/checkins/checkins.controller.ts index c42dbb5..6db201e 100644 --- a/src/checkins/checkins.controller.ts +++ b/src/checkins/checkins.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, HttpCode, HttpStatus, Post, Put, Delete, UseGuards, Get, Query } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { CheckinsService } from './checkins.service'; -import { CreateCheckinDto, UpdateCheckinDto, CompleteCheckinDto, RemoveCheckinDto, CheckinResponseDto, GetDailyCheckinsQueryDto } from './dto/checkin.dto'; +import { CreateCheckinDto, UpdateCheckinDto, CompleteCheckinDto, RemoveCheckinDto, CheckinResponseDto, GetDailyCheckinsQueryDto, GetDailyStatusRangeQueryDto, DailyStatusItem } from './dto/checkin.dto'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; import { CurrentUser } from '../common/decorators/current-user.decorator'; import { AccessTokenPayload } from '../users/services/apple-auth.service'; @@ -54,6 +54,14 @@ export class CheckinsController { async getDaily(@Query() query: GetDailyCheckinsQueryDto, @CurrentUser() user: AccessTokenPayload): Promise { return this.checkinsService.getDaily(user.sub, query.date); } + + @Get('range/daily-status') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '按时间范围返回每天是否打卡' }) + @ApiResponse({ description: '数组,元素含 date(YYYY-MM-DD) 与 checkedIn(boolean)' }) + async getDailyStatusRange(@Query() query: GetDailyStatusRangeQueryDto, @CurrentUser() user: AccessTokenPayload): Promise> { + return this.checkinsService.getDailyStatusRange(user.sub, query); + } } diff --git a/src/checkins/checkins.service.ts b/src/checkins/checkins.service.ts index 28a39aa..b9d7be7 100644 --- a/src/checkins/checkins.service.ts +++ b/src/checkins/checkins.service.ts @@ -1,7 +1,7 @@ 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 } from './dto/checkin.dto'; +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'; @@ -115,6 +115,62 @@ export class CheckinsService { 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, + [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 }; + } } diff --git a/src/checkins/dto/checkin.dto.ts b/src/checkins/dto/checkin.dto.ts index 8d4555a..14dd61f 100644 --- a/src/checkins/dto/checkin.dto.ts +++ b/src/checkins/dto/checkin.dto.ts @@ -133,4 +133,19 @@ export class GetDailyCheckinsQueryDto { date?: string; } +export class GetDailyStatusRangeQueryDto { + @ApiProperty({ description: '开始日期(YYYY-MM-DD)', example: '2025-01-01' }) + @IsString() + startDate: string; + + @ApiProperty({ description: '结束日期(YYYY-MM-DD)', example: '2025-01-31' }) + @IsString() + endDate: string; +} + +export interface DailyStatusItem { + date: string; // YYYY-MM-DD + checkedIn: boolean; +} + diff --git a/src/users/dto/update-user.dto.ts b/src/users/dto/update-user.dto.ts index aba0c70..4824c22 100644 --- a/src/users/dto/update-user.dto.ts +++ b/src/users/dto/update-user.dto.ts @@ -31,6 +31,27 @@ export class UpdateUserDto { @ApiProperty({ description: '出生年月日', example: 1713859200 }) birthDate: number; + // 扩展字段 + @IsOptional() + @ApiProperty({ description: '每日步数目标', example: 8000 }) + dailyStepsGoal?: number; + + @IsOptional() + @ApiProperty({ description: '每日卡路里消耗目标', example: 500 }) + dailyCaloriesGoal?: number; + + @IsOptional() + @ApiProperty({ description: '普拉提目的(多选)', example: ['塑形', '康复'] }) + pilatesPurposes?: string[]; + + @IsOptional() + @ApiProperty({ description: '体重(公斤)', example: 55.5 }) + weight?: number; + + @IsOptional() + @ApiProperty({ description: '身高(厘米)', example: 168 }) + height?: number; + } export class UpdateUserResponseDto { diff --git a/src/users/dto/user-response.dto.ts b/src/users/dto/user-response.dto.ts index 69a7091..ce7a976 100644 --- a/src/users/dto/user-response.dto.ts +++ b/src/users/dto/user-response.dto.ts @@ -1,4 +1,5 @@ import { User } from '../models/user.model'; +import { UserProfile } from '../models/user-profile.model'; import { BaseResponseDto, ResponseCode } from '../../base.dto'; // 定义包含购买状态的用户数据接口 @@ -24,6 +25,7 @@ export interface UserWithPurchaseStatus { maxUsageCount: number; favoriteTopicCount: number; isVip: boolean; + profile?: Pick; } export class UserResponseDto implements BaseResponseDto { diff --git a/src/users/models/user-profile.model.ts b/src/users/models/user-profile.model.ts new file mode 100644 index 0000000..352a11b --- /dev/null +++ b/src/users/models/user-profile.model.ts @@ -0,0 +1,69 @@ +import { BelongsTo, Column, DataType, ForeignKey, Model, Table } from 'sequelize-typescript'; +import { User } from './user.model'; + +@Table({ + tableName: 't_user_profile', + underscored: true, +}) +export class UserProfile extends Model { + @ForeignKey(() => User) + @Column({ + type: DataType.STRING, + primaryKey: true, + allowNull: false, + comment: '与用户一对一的主键,等同于用户ID', + }) + declare userId: string; + + @BelongsTo(() => User) + user: User; + + @Column({ + type: DataType.INTEGER, + allowNull: true, + comment: '每日步数目标', + }) + declare dailyStepsGoal: number | null; + + @Column({ + type: DataType.INTEGER, + allowNull: true, + comment: '每日卡路里消耗目标', + }) + declare dailyCaloriesGoal: number | null; + + @Column({ + type: DataType.JSON, + allowNull: true, + comment: '普拉提目的(多选)', + }) + declare pilatesPurposes: string[] | null; + + @Column({ + type: DataType.FLOAT, + allowNull: true, + comment: '体重(公斤)', + }) + declare weight: number | null; + + @Column({ + type: DataType.INTEGER, + allowNull: true, + comment: '身高(厘米)', + }) + declare height: number | null; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + }) + declare createdAt: Date; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + }) + declare updatedAt: Date; +} + + diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index a60f04d..2e34c77 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -30,6 +30,7 @@ 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') @@ -39,6 +40,7 @@ export class UsersController { constructor( private readonly usersService: UsersService, @Inject(WINSTON_MODULE_PROVIDER) private readonly winstonLogger: WinstonLogger, + private readonly cosService: CosService, ) { } @UseGuards(JwtAuthGuard) @@ -118,6 +120,26 @@ export class UsersController { 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 }; + } + } + // App Store 服务器通知接收接口 @Public() @Post('app-store-notifications') diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 7814144..a5d3124 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -3,6 +3,7 @@ import { SequelizeModule } from "@nestjs/sequelize"; import { UsersController } from "./users.controller"; import { UsersService } from "./users.service"; import { User } from "./models/user.model"; +import { UserProfile } from "./models/user-profile.model"; import { ApplePurchaseService } from "./services/apple-purchase.service"; import { EncryptionService } from "../common/encryption.service"; import { AppleAuthService } from "./services/apple-auth.service"; @@ -11,6 +12,7 @@ import { BlockedTransaction } from "./models/blocked-transaction.model"; import { UserPurchase } from "./models/user-purchase.model"; import { PurchaseRestoreLog } from "./models/purchase-restore-log.model"; import { RevenueCatEvent } from "./models/revenue-cat-event.model"; +import { CosService } from './cos.service'; @Module({ imports: [ @@ -20,6 +22,7 @@ import { RevenueCatEvent } from "./models/revenue-cat-event.model"; UserPurchase, PurchaseRestoreLog, RevenueCatEvent, + UserProfile, ]), JwtModule.register({ secret: process.env.JWT_ACCESS_SECRET || 'your-access-token-secret-key', @@ -27,7 +30,7 @@ import { RevenueCatEvent } from "./models/revenue-cat-event.model"; }), ], controllers: [UsersController], - providers: [UsersService, ApplePurchaseService, EncryptionService, AppleAuthService], + providers: [UsersService, ApplePurchaseService, EncryptionService, AppleAuthService, CosService], exports: [UsersService, AppleAuthService], }) export class UsersModule { } diff --git a/src/users/users.service.ts b/src/users/users.service.ts index df282c2..aa07389 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -25,6 +25,7 @@ import { DeleteAccountDto, DeleteAccountResponseDto } from './dto/delete-account import { GuestLoginDto, GuestLoginResponseDto, RefreshGuestTokenDto, RefreshGuestTokenResponseDto } from './dto/guest-login.dto'; import { AppStoreServerNotificationDto, ProcessNotificationResponseDto, NotificationType } from './dto/app-store-notification.dto'; import { RevenueCatEvent } from './models/revenue-cat-event.model'; +import { UserProfile } from './models/user-profile.model'; import { RevenueCatWebhookDto, RevenueCatEventType } from './dto/revenue-cat-webhook.dto'; import { RestorePurchaseDto, RestorePurchaseResponseDto, RestoredPurchaseInfo, ActiveEntitlement, NonSubscriptionTransaction } from './dto/restore-purchase.dto'; import { PurchaseRestoreLog, RestoreStatus, RestoreSource } from './models/purchase-restore-log.model'; @@ -49,6 +50,8 @@ export class UsersService { private applePurchaseService: ApplePurchaseService, @InjectModel(BlockedTransaction) private blockedTransactionModel: typeof BlockedTransaction, + @InjectModel(UserProfile) + private userProfileModel: typeof UserProfile, @InjectConnection() private sequelize: Sequelize, ) { } @@ -77,10 +80,18 @@ export class UsersService { }; } + const profile = await this.userProfileModel.findByPk(existingUser.id); const returnData = { ...existingUser.toJSON(), maxUsageCount: DEFAULT_FREE_USAGE_COUNT, isVip: existingUser.isVip, + profile: profile ? { + dailyStepsGoal: profile.dailyStepsGoal, + dailyCaloriesGoal: profile.dailyCaloriesGoal, + pilatesPurposes: profile.pilatesPurposes, + weight: profile.weight, + height: profile.height, + } : undefined, } this.logger.log(`getProfile returnData: ${JSON.stringify(returnData, null, 2)}`); @@ -107,7 +118,7 @@ export class UsersService { // 更新用户昵称、头像 async updateUser(updateUserDto: UpdateUserDto): Promise { - const { userId, name, avatar, gender, birthDate } = updateUserDto; + const { userId, name, avatar, gender, birthDate, dailyStepsGoal, dailyCaloriesGoal, pilatesPurposes, weight, height } = updateUserDto; this.logger.log(`updateUser: ${JSON.stringify(updateUserDto, null, 2)}`); @@ -136,7 +147,19 @@ export class UsersService { await user.save(); - + // 更新或创建扩展信息 + if (dailyStepsGoal !== undefined || dailyCaloriesGoal !== undefined || pilatesPurposes !== undefined || weight !== undefined || height !== undefined) { + const [profile] = await this.userProfileModel.findOrCreate({ + where: { userId }, + defaults: { userId }, + }); + if (dailyStepsGoal !== undefined) profile.dailyStepsGoal = dailyStepsGoal as any; + if (dailyCaloriesGoal !== undefined) profile.dailyCaloriesGoal = dailyCaloriesGoal as any; + if (pilatesPurposes !== undefined) profile.pilatesPurposes = pilatesPurposes as any; + if (weight !== undefined) profile.weight = weight as any; + if (height !== undefined) profile.height = height as any; + await profile.save(); + } return { code: ResponseCode.SUCCESS, @@ -192,6 +215,9 @@ export class UsersService { isNewUser = true; this.logger.log(`创建新的Apple用户: ${userId}`); + // 创建默认扩展记录 + await this.userProfileModel.findOrCreate({ where: { userId }, defaults: { userId } }); + } else { // 更新现有用户的登录时间 user.lastLogin = new Date(); @@ -204,11 +230,19 @@ export class UsersService { const refreshToken = this.appleAuthService.generateRefreshToken(userId); // 构造用户数据 + const profileForLogin = await this.userProfileModel.findByPk(user.id); const userData = { ...user.toJSON(), isNew: isNewUser, isVip: user.isVip, maxUsageCount: DEFAULT_FREE_USAGE_COUNT, + profile: profileForLogin ? { + dailyStepsGoal: profileForLogin.dailyStepsGoal, + dailyCaloriesGoal: profileForLogin.dailyCaloriesGoal, + pilatesPurposes: profileForLogin.pilatesPurposes, + weight: profileForLogin.weight, + height: profileForLogin.height, + } : undefined, }; return { @@ -295,6 +329,12 @@ export class UsersService { transaction, }); + // 2. 删除用户扩展信息 + await this.userProfileModel.destroy({ + where: { userId }, + transaction, + }); + // 最后删除用户本身 await this.userModel.destroy({ where: { id: userId }, @@ -364,6 +404,8 @@ export class UsersService { isNewUser = true; this.logger.log(`创建新的游客用户: ${guestUserId}`); + await this.userProfileModel.findOrCreate({ where: { userId: guestUserId }, defaults: { userId: guestUserId } }); + } else { // 更新现有游客用户的登录时间和设备信息 user.lastLogin = new Date(); @@ -378,12 +420,20 @@ export class UsersService { const refreshToken = this.appleAuthService.generateRefreshToken(guestUserId); // 构造用户数据 + const profileForGuest = await this.userProfileModel.findByPk(user.id); const userData = { ...user.toJSON(), isNew: isNewUser, isVip: user.membershipExpiration ? dayjs(user.membershipExpiration).isAfter(dayjs()) : false, isGuest: true, maxUsageCount: DEFAULT_FREE_USAGE_COUNT, + profile: profileForGuest ? { + dailyStepsGoal: profileForGuest.dailyStepsGoal, + dailyCaloriesGoal: profileForGuest.dailyCaloriesGoal, + pilatesPurposes: profileForGuest.pilatesPurposes, + weight: profileForGuest.weight, + height: profileForGuest.height, + } : undefined, }; return {