新增AI教练模块,包括控制器、服务、模型及数据传输对象,更新应用模块以引入新模块,同时在打卡模块中添加按时间范围返回每日打卡状态的功能
This commit is contained in:
75
src/ai-coach/ai-coach.controller.ts
Normal file
75
src/ai-coach/ai-coach.controller.ts
Normal file
@@ -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<StreamableFile | AiChatResponseDto | void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
19
src/ai-coach/ai-coach.module.ts
Normal file
19
src/ai-coach/ai-coach.module.ts
Normal file
@@ -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 { }
|
||||
|
||||
110
src/ai-coach/ai-coach.service.ts
Normal file
110
src/ai-coach/ai-coach.service.ts
Normal file
@@ -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<string>('DASHSCOPE_API_KEY') || '';
|
||||
const baseURL = this.configService.get<string>('DASHSCOPE_BASE_URL') || 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||||
|
||||
this.client = new OpenAI({
|
||||
apiKey: dashScopeApiKey,
|
||||
baseURL,
|
||||
});
|
||||
// 默认选择通义千问对话模型(OpenAI兼容名),可通过环境覆盖
|
||||
this.model = this.configService.get<string>('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<Readable> {
|
||||
// 上下文:系统提示 + 历史 + 当前用户消息
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
37
src/ai-coach/dto/ai-chat.dto.ts
Normal file
37
src/ai-coach/dto/ai-chat.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
62
src/ai-coach/models/ai-message.model.ts
Normal file
62
src/ai-coach/models/ai-message.model.ts
Normal file
@@ -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<string, any> | null;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATE,
|
||||
defaultValue: DataType.NOW,
|
||||
})
|
||||
declare createdAt: Date;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATE,
|
||||
defaultValue: DataType.NOW,
|
||||
})
|
||||
declare updatedAt: Date;
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<CheckinResponseDto> {
|
||||
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<CheckinResponseDto<DailyStatusItem[]>> {
|
||||
return this.checkinsService.getDailyStatusRange(user.sub, query);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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<CheckinResponseDto<DailyStatusItem[]>> {
|
||||
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<string>();
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<UserProfile, 'dailyStepsGoal' | 'dailyCaloriesGoal' | 'pilatesPurposes' | 'weight' | 'height'>;
|
||||
}
|
||||
|
||||
export class UserResponseDto implements BaseResponseDto<UserWithPurchaseStatus> {
|
||||
|
||||
69
src/users/models/user-profile.model.ts
Normal file
69
src/users/models/user-profile.model.ts
Normal file
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 { }
|
||||
|
||||
@@ -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<UpdateUserResponseDto> {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user