新增训练计划模块,包括控制器、服务、模型及数据传输对象,更新应用模块以引入新模块,同时在AI教练模块中添加体态评估功能,支持体重识别与更新,优化用户体重历史记录管理。
This commit is contained in:
@@ -6,6 +6,7 @@ import { CurrentUser } from '../common/decorators/current-user.decorator';
|
|||||||
import { AccessTokenPayload } from '../users/services/apple-auth.service';
|
import { AccessTokenPayload } from '../users/services/apple-auth.service';
|
||||||
import { AiCoachService } from './ai-coach.service';
|
import { AiCoachService } from './ai-coach.service';
|
||||||
import { AiChatRequestDto, AiChatResponseDto } from './dto/ai-chat.dto';
|
import { AiChatRequestDto, AiChatResponseDto } from './dto/ai-chat.dto';
|
||||||
|
import { PostureAssessmentRequestDto, PostureAssessmentResponseDto } from './dto/posture-assessment.dto';
|
||||||
|
|
||||||
@ApiTags('ai-coach')
|
@ApiTags('ai-coach')
|
||||||
@Controller('ai-coach')
|
@Controller('ai-coach')
|
||||||
@@ -31,19 +32,30 @@ export class AiCoachController {
|
|||||||
userContent: body.messages?.[body.messages.length - 1]?.content || '',
|
userContent: body.messages?.[body.messages.length - 1]?.content || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 智能体重识别:若疑似“记体重”且传入图片,则优先识别并更新体重
|
||||||
|
let weightInfo: { weightKg?: number } = {};
|
||||||
|
try {
|
||||||
|
weightInfo = await this.aiCoachService.maybeExtractAndUpdateWeight(
|
||||||
|
userId,
|
||||||
|
body.imageUrl,
|
||||||
|
body.messages?.[body.messages.length - 1]?.content,
|
||||||
|
);
|
||||||
|
} catch { }
|
||||||
|
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
// 非流式:聚合后一次性返回文本
|
// 非流式:聚合后一次性返回文本
|
||||||
const readable = await this.aiCoachService.streamChat({
|
const readable = await this.aiCoachService.streamChat({
|
||||||
userId,
|
userId,
|
||||||
conversationId,
|
conversationId,
|
||||||
userContent: body.messages?.[body.messages.length - 1]?.content || '',
|
userContent: body.messages?.[body.messages.length - 1]?.content || '',
|
||||||
|
systemNotice: weightInfo.weightKg ? `系统提示:已从图片识别体重为${weightInfo.weightKg}kg,并已为你更新到个人资料。` : undefined,
|
||||||
});
|
});
|
||||||
let text = '';
|
let text = '';
|
||||||
for await (const chunk of readable) {
|
for await (const chunk of readable) {
|
||||||
text += chunk.toString();
|
text += chunk.toString();
|
||||||
}
|
}
|
||||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
res.send({ conversationId, text });
|
res.send({ conversationId, text, weightKg: weightInfo.weightKg });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,9 +68,12 @@ export class AiCoachController {
|
|||||||
userId,
|
userId,
|
||||||
conversationId,
|
conversationId,
|
||||||
userContent: body.messages?.[body.messages.length - 1]?.content || '',
|
userContent: body.messages?.[body.messages.length - 1]?.content || '',
|
||||||
|
systemNotice: weightInfo.weightKg ? `系统提示:已从图片识别体重为${weightInfo.weightKg}kg,并已为你更新到个人资料。` : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
readable.on('data', (chunk) => {
|
readable.on('data', (chunk) => {
|
||||||
|
// 流水首段可提示体重已更新
|
||||||
|
// 简化处理:服务端不额外注入推送段,直接靠 systemNotice
|
||||||
res.write(chunk);
|
res.write(chunk);
|
||||||
});
|
});
|
||||||
readable.on('end', () => {
|
readable.on('end', () => {
|
||||||
@@ -101,6 +116,24 @@ export class AiCoachController {
|
|||||||
const ok = await this.aiCoachService.deleteConversation(user.sub, conversationId);
|
const ok = await this.aiCoachService.deleteConversation(user.sub, conversationId);
|
||||||
return { success: ok };
|
return { success: ok };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('posture-assessment')
|
||||||
|
@ApiOperation({ summary: 'AI体态评估' })
|
||||||
|
@ApiBody({ type: PostureAssessmentRequestDto })
|
||||||
|
async postureAssessment(
|
||||||
|
@Body() body: PostureAssessmentRequestDto,
|
||||||
|
@CurrentUser() user: AccessTokenPayload,
|
||||||
|
): Promise<PostureAssessmentResponseDto> {
|
||||||
|
const res = await this.aiCoachService.assessPosture({
|
||||||
|
userId: user.sub,
|
||||||
|
frontImageUrl: body.frontImageUrl,
|
||||||
|
sideImageUrl: body.sideImageUrl,
|
||||||
|
backImageUrl: body.backImageUrl,
|
||||||
|
heightCm: body.heightCm,
|
||||||
|
weightKg: body.weightKg,
|
||||||
|
});
|
||||||
|
return res as any;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import { AiCoachController } from './ai-coach.controller';
|
|||||||
import { AiCoachService } from './ai-coach.service';
|
import { AiCoachService } from './ai-coach.service';
|
||||||
import { AiMessage } from './models/ai-message.model';
|
import { AiMessage } from './models/ai-message.model';
|
||||||
import { AiConversation } from './models/ai-conversation.model';
|
import { AiConversation } from './models/ai-conversation.model';
|
||||||
|
import { PostureAssessment } from './models/posture-assessment.model';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
SequelizeModule.forFeature([AiConversation, AiMessage]),
|
SequelizeModule.forFeature([AiConversation, AiMessage, PostureAssessment]),
|
||||||
],
|
],
|
||||||
controllers: [AiCoachController],
|
controllers: [AiCoachController],
|
||||||
providers: [AiCoachService],
|
providers: [AiCoachService],
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { OpenAI } from 'openai';
|
|||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
import { AiMessage, RoleType } from './models/ai-message.model';
|
import { AiMessage, RoleType } from './models/ai-message.model';
|
||||||
import { AiConversation } from './models/ai-conversation.model';
|
import { AiConversation } from './models/ai-conversation.model';
|
||||||
|
import { PostureAssessment } from './models/posture-assessment.model';
|
||||||
|
import { UserProfile } from '../users/models/user-profile.model';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `作为一名资深的普拉提与运动康复教练(Pilates Coach),我拥有丰富的专业知识,包括但不限于运动解剖学、体态评估、疼痛预防、功能性训练、力量与柔韧性训练以及营养与饮食建议。请遵循以下指导原则进行交流: - **话题范围**:讨论将仅限于健康、健身、普拉提、康复、形体训练、柔韧性提升、力量训练、运动损伤预防与恢复、营养与饮食等领域。 - **拒绝回答的内容**:对于医疗诊断、情感心理支持、时政金融分析或编程等非相关或高风险问题,我会礼貌地解释为何这些不在我的专业范围内,并尝试将对话引导回上述合适的话题领域内。 - **语言风格**:我的回复将以亲切且专业的态度呈现,尽量做到条理清晰、分点阐述;当需要时,会提供可以在家轻松实践的具体步骤指南及注意事项;同时考虑到不同水平参与者的需求,特别是那些可能有轻微不适或曾受过伤的人群,我会给出相应的调整建议和安全提示。 - **个性化与安全性**:强调每个人的身体状况都是独一无二的,在提出任何锻炼计划之前都会提醒大家根据自身情况适当调整强度;如果涉及到具体的疼痛问题或是旧伤复发的情况,则强烈建议先咨询医生的意见再开始新的训练项目。 - **设备要求**:所有推荐的练习都假设参与者只有基础的家庭健身器材可用,比如瑜伽垫、弹力带或者泡沫轴等;此外还会对每项活动的大致持续时间和频率做出估计,并分享一些自我监测进步的方法。 请告诉我您具体想了解哪方面的信息,以便我能更好地为您提供帮助。`;
|
const SYSTEM_PROMPT = `作为一名资深的普拉提与运动康复教练(Pilates Coach),我拥有丰富的专业知识,包括但不限于运动解剖学、体态评估、疼痛预防、功能性训练、力量与柔韧性训练以及营养与饮食建议。请遵循以下指导原则进行交流: - **话题范围**:讨论将仅限于健康、健身、普拉提、康复、形体训练、柔韧性提升、力量训练、运动损伤预防与恢复、营养与饮食等领域。 - **拒绝回答的内容**:对于医疗诊断、情感心理支持、时政金融分析或编程等非相关或高风险问题,我会礼貌地解释为何这些不在我的专业范围内,并尝试将对话引导回上述合适的话题领域内。 - **语言风格**:我的回复将以亲切且专业的态度呈现,尽量做到条理清晰、分点阐述;当需要时,会提供可以在家轻松实践的具体步骤指南及注意事项;同时考虑到不同水平参与者的需求,特别是那些可能有轻微不适或曾受过伤的人群,我会给出相应的调整建议和安全提示。 - **个性化与安全性**:强调每个人的身体状况都是独一无二的,在提出任何锻炼计划之前都会提醒大家根据自身情况适当调整强度;如果涉及到具体的疼痛问题或是旧伤复发的情况,则强烈建议先咨询医生的意见再开始新的训练项目。 - **设备要求**:所有推荐的练习都假设参与者只有基础的家庭健身器材可用,比如瑜伽垫、弹力带或者泡沫轴等;此外还会对每项活动的大致持续时间和频率做出估计,并分享一些自我监测进步的方法。 请告诉我您具体想了解哪方面的信息,以便我能更好地为您提供帮助。`;
|
||||||
|
|
||||||
@@ -12,8 +15,9 @@ export class AiCoachService {
|
|||||||
private readonly logger = new Logger(AiCoachService.name);
|
private readonly logger = new Logger(AiCoachService.name);
|
||||||
private readonly client: OpenAI;
|
private readonly client: OpenAI;
|
||||||
private readonly model: string;
|
private readonly model: string;
|
||||||
|
private readonly visionModel: string;
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(private readonly configService: ConfigService, private readonly usersService: UsersService) {
|
||||||
const dashScopeApiKey = this.configService.get<string>('DASHSCOPE_API_KEY') || 'sk-e3ff4494c2f1463a8910d5b3d05d3143';
|
const dashScopeApiKey = this.configService.get<string>('DASHSCOPE_API_KEY') || 'sk-e3ff4494c2f1463a8910d5b3d05d3143';
|
||||||
const baseURL = this.configService.get<string>('DASHSCOPE_BASE_URL') || 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
const baseURL = this.configService.get<string>('DASHSCOPE_BASE_URL') || 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||||||
|
|
||||||
@@ -23,6 +27,7 @@ export class AiCoachService {
|
|||||||
});
|
});
|
||||||
// 默认选择通义千问对话模型(OpenAI兼容名),可通过环境覆盖
|
// 默认选择通义千问对话模型(OpenAI兼容名),可通过环境覆盖
|
||||||
this.model = this.configService.get<string>('DASHSCOPE_MODEL') || 'qwen-flash';
|
this.model = this.configService.get<string>('DASHSCOPE_MODEL') || 'qwen-flash';
|
||||||
|
this.visionModel = this.configService.get<string>('DASHSCOPE_VISION_MODEL') || 'qwen-vl-plus';
|
||||||
}
|
}
|
||||||
|
|
||||||
async createOrAppendMessages(params: {
|
async createOrAppendMessages(params: {
|
||||||
@@ -64,9 +69,13 @@ export class AiCoachService {
|
|||||||
userId: string;
|
userId: string;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
userContent: string;
|
userContent: string;
|
||||||
|
systemNotice?: string;
|
||||||
}): Promise<Readable> {
|
}): Promise<Readable> {
|
||||||
// 上下文:系统提示 + 历史 + 当前用户消息
|
// 上下文:系统提示 + 历史 + 当前用户消息
|
||||||
const messages = await this.buildChatHistory(params.userId, params.conversationId);
|
const messages = await this.buildChatHistory(params.userId, params.conversationId);
|
||||||
|
if (params.systemNotice) {
|
||||||
|
messages.unshift({ role: 'system', content: params.systemNotice });
|
||||||
|
}
|
||||||
|
|
||||||
const stream = await this.client.chat.completions.create({
|
const stream = await this.client.chat.completions.create({
|
||||||
model: this.model,
|
model: this.model,
|
||||||
@@ -160,6 +169,143 @@ export class AiCoachService {
|
|||||||
await AiConversation.destroy({ where: { id: conversationId, userId } });
|
await AiConversation.destroy({ where: { id: conversationId, userId } });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI体态评估:
|
||||||
|
* - 汇总用户身高体重
|
||||||
|
* - 使用视觉模型读取三张图片(正/侧/背)
|
||||||
|
* - 通过强约束的 JSON Schema 产出结构化结果
|
||||||
|
* - 存储评估记录并返回
|
||||||
|
*/
|
||||||
|
async assessPosture(params: {
|
||||||
|
userId: string;
|
||||||
|
frontImageUrl: string;
|
||||||
|
sideImageUrl: string;
|
||||||
|
backImageUrl: string;
|
||||||
|
heightCm?: number;
|
||||||
|
weightKg?: number;
|
||||||
|
}) {
|
||||||
|
// 获取默认身高体重
|
||||||
|
let heightCm: number | undefined = params.heightCm;
|
||||||
|
let weightKg: number | undefined = params.weightKg;
|
||||||
|
if (heightCm == null || weightKg == null) {
|
||||||
|
const profile = await UserProfile.findOne({ where: { userId: params.userId } });
|
||||||
|
if (heightCm == null) heightCm = profile?.height ?? undefined;
|
||||||
|
if (weightKg == null) weightKg = profile?.weight ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const schemaInstruction = `请以严格合法的JSON返回体态评估结果,键名与类型必须匹配以下Schema,不要输出多余文本:
|
||||||
|
{
|
||||||
|
"overallScore": number(0-5),
|
||||||
|
"radar": {
|
||||||
|
"骨盆中立": number(0-5),
|
||||||
|
"肩带稳": number(0-5),
|
||||||
|
"胸廓控": number(0-5),
|
||||||
|
"主排列": number(0-5),
|
||||||
|
"柔对线": number(0-5),
|
||||||
|
"核心": number(0-5)
|
||||||
|
},
|
||||||
|
"frontView": {
|
||||||
|
"描述": string,
|
||||||
|
"问题要点": string[],
|
||||||
|
"建议动作": string[]
|
||||||
|
},
|
||||||
|
"sideView": {
|
||||||
|
"描述": string,
|
||||||
|
"问题要点": string[],
|
||||||
|
"建议动作": string[]
|
||||||
|
},
|
||||||
|
"backView": {
|
||||||
|
"描述": string,
|
||||||
|
"问题要点": string[],
|
||||||
|
"建议动作": string[]
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const persona = `你是一名资深体态评估与普拉提康复教练。结合用户提供的三张照片(正面/侧面/背面)进行体态评估。严格限制话题在健康、姿势、普拉提与训练建议范围内。用词亲切但专业,强调安全、循序渐进与个体差异。用户资料:身高${heightCm ?? '未知'}cm,体重${weightKg ?? '未知'}kg。`;
|
||||||
|
|
||||||
|
const completion = await this.client.chat.completions.create({
|
||||||
|
model: this.visionModel,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: persona },
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: schemaInstruction },
|
||||||
|
{ type: 'text', text: '这三张图分别是正面、侧面、背面:' },
|
||||||
|
{ type: 'image_url', image_url: { url: params.frontImageUrl } as any },
|
||||||
|
{ type: 'image_url', image_url: { url: params.sideImageUrl } as any },
|
||||||
|
{ type: 'image_url', image_url: { url: params.backImageUrl } as any },
|
||||||
|
] as any,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
temperature: 0,
|
||||||
|
response_format: { type: 'json_object' } as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const raw = completion.choices?.[0]?.message?.content || '{}';
|
||||||
|
let result: any = {};
|
||||||
|
try { result = JSON.parse(raw); } catch { }
|
||||||
|
const overallScore = typeof result.overallScore === 'number' ? result.overallScore : null;
|
||||||
|
|
||||||
|
const rec = await PostureAssessment.create({
|
||||||
|
userId: params.userId,
|
||||||
|
frontImageUrl: params.frontImageUrl,
|
||||||
|
sideImageUrl: params.sideImageUrl,
|
||||||
|
backImageUrl: params.backImageUrl,
|
||||||
|
heightCm: heightCm != null ? heightCm : null,
|
||||||
|
weightKg: weightKg != null ? weightKg : null,
|
||||||
|
overallScore,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { id: rec.id, overallScore, result };
|
||||||
|
}
|
||||||
|
|
||||||
|
private isLikelyWeightLogIntent(text: string | undefined): boolean {
|
||||||
|
if (!text) return false;
|
||||||
|
const t = text.toLowerCase();
|
||||||
|
return /体重|称重|秤|kg|公斤|weigh|weight/.test(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
async maybeExtractAndUpdateWeight(userId: string, imageUrl?: string, userText?: string): Promise<{ weightKg?: number }> {
|
||||||
|
if (!imageUrl || !this.isLikelyWeightLogIntent(userText)) return {};
|
||||||
|
try {
|
||||||
|
const sys = '从照片中读取电子秤的数字,单位通常为kg。仅返回JSON,例如 {"weightKg": 65.2},若无法识别,返回 {"weightKg": null}。不要添加其他文本。';
|
||||||
|
const completion = await this.client.chat.completions.create({
|
||||||
|
model: this.visionModel,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: sys },
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: '请从图片中提取体重(kg)。若图中单位为斤或lb,请换算为kg。' },
|
||||||
|
{ type: 'image_url', image_url: { url: imageUrl } as any },
|
||||||
|
] as any,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
temperature: 0,
|
||||||
|
response_format: { type: 'json_object' } as any,
|
||||||
|
});
|
||||||
|
const raw = completion.choices?.[0]?.message?.content || '';
|
||||||
|
let weightKg: number | undefined;
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(raw);
|
||||||
|
weightKg = typeof obj.weightKg === 'number' ? obj.weightKg : undefined;
|
||||||
|
} catch {
|
||||||
|
const m = raw.match(/\d+(?:\.\d+)?/);
|
||||||
|
weightKg = m ? parseFloat(m[0]) : undefined;
|
||||||
|
}
|
||||||
|
if (weightKg && isFinite(weightKg) && weightKg > 0 && weightKg < 400) {
|
||||||
|
await this.usersService.addWeightByVision(userId, weightKg);
|
||||||
|
return { weightKg };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`maybeExtractAndUpdateWeight error: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export class AiChatRequestDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
messages: AiChatMessageDto[];
|
messages: AiChatMessageDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: '当用户要记体重时的图片URL(电子秤等)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
imageUrl?: string;
|
||||||
|
|
||||||
@ApiProperty({ required: false, description: '是否启用流式输出', default: true })
|
@ApiProperty({ required: false, description: '是否启用流式输出', default: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
45
src/ai-coach/dto/posture-assessment.dto.ts
Normal file
45
src/ai-coach/dto/posture-assessment.dto.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsNumber, IsOptional, IsString, IsUrl, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class PostureAssessmentRequestDto {
|
||||||
|
@ApiProperty({ description: '正面图URL' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsUrl()
|
||||||
|
frontImageUrl: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '侧面图URL' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsUrl()
|
||||||
|
sideImageUrl: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '背面图URL' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsUrl()
|
||||||
|
backImageUrl: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: '身高(cm),缺省则从用户资料读取' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
heightCm?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: '体重(kg),缺省则从用户资料读取' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
weightKg?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PostureAssessmentResponseDto {
|
||||||
|
@ApiProperty({ description: '评估记录ID' })
|
||||||
|
id: number;
|
||||||
|
@ApiProperty({ description: '整体评分(0-5)' })
|
||||||
|
@Min(0)
|
||||||
|
@Max(5)
|
||||||
|
overallScore: number;
|
||||||
|
@ApiProperty({ description: '原始JSON结果' })
|
||||||
|
result: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -13,6 +13,12 @@ export enum RoleType {
|
|||||||
})
|
})
|
||||||
export class AiMessage extends Model {
|
export class AiMessage extends Model {
|
||||||
@PrimaryKey
|
@PrimaryKey
|
||||||
|
@Column({
|
||||||
|
type: DataType.BIGINT,
|
||||||
|
autoIncrement: true,
|
||||||
|
})
|
||||||
|
declare id: number;
|
||||||
|
|
||||||
@ForeignKey(() => AiConversation)
|
@ForeignKey(() => AiConversation)
|
||||||
@Column({
|
@Column({
|
||||||
type: DataType.STRING,
|
type: DataType.STRING,
|
||||||
|
|||||||
49
src/ai-coach/models/posture-assessment.model.ts
Normal file
49
src/ai-coach/models/posture-assessment.model.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript';
|
||||||
|
|
||||||
|
@Table({
|
||||||
|
tableName: 't_posture_assessments',
|
||||||
|
underscored: true,
|
||||||
|
})
|
||||||
|
export class PostureAssessment extends Model {
|
||||||
|
@PrimaryKey
|
||||||
|
@Column({
|
||||||
|
type: DataType.BIGINT,
|
||||||
|
autoIncrement: true,
|
||||||
|
})
|
||||||
|
declare id: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
})
|
||||||
|
declare userId: string;
|
||||||
|
|
||||||
|
@Column({ type: DataType.STRING, allowNull: true })
|
||||||
|
declare frontImageUrl: string | null;
|
||||||
|
|
||||||
|
@Column({ type: DataType.STRING, allowNull: true })
|
||||||
|
declare sideImageUrl: string | null;
|
||||||
|
|
||||||
|
@Column({ type: DataType.STRING, allowNull: true })
|
||||||
|
declare backImageUrl: string | null;
|
||||||
|
|
||||||
|
@Column({ type: DataType.INTEGER, allowNull: true, comment: '身高(cm)' })
|
||||||
|
declare heightCm: number | null;
|
||||||
|
|
||||||
|
@Column({ type: DataType.FLOAT, allowNull: true, comment: '体重(kg)' })
|
||||||
|
declare weightKg: number | null;
|
||||||
|
|
||||||
|
@Column({ type: DataType.FLOAT, allowNull: true, comment: '整体评分(0-5)' })
|
||||||
|
declare overallScore: number | null;
|
||||||
|
|
||||||
|
@Column({ type: DataType.JSON, allowNull: true, comment: '评估结果原始JSON' })
|
||||||
|
declare result: Record<string, any> | null;
|
||||||
|
|
||||||
|
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
|
||||||
|
declare createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
|
||||||
|
declare updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -7,6 +7,9 @@ import { ConfigModule } from '@nestjs/config';
|
|||||||
import { LoggerModule } from './common/logger/logger.module';
|
import { LoggerModule } from './common/logger/logger.module';
|
||||||
import { CheckinsModule } from './checkins/checkins.module';
|
import { CheckinsModule } from './checkins/checkins.module';
|
||||||
import { AiCoachModule } from './ai-coach/ai-coach.module';
|
import { AiCoachModule } from './ai-coach/ai-coach.module';
|
||||||
|
import { TrainingPlansModule } from './training-plans/training-plans.module';
|
||||||
|
import { ArticlesModule } from './articles/articles.module';
|
||||||
|
import { RecommendationsModule } from './recommendations/recommendations.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -19,6 +22,9 @@ import { AiCoachModule } from './ai-coach/ai-coach.module';
|
|||||||
UsersModule,
|
UsersModule,
|
||||||
CheckinsModule,
|
CheckinsModule,
|
||||||
AiCoachModule,
|
AiCoachModule,
|
||||||
|
TrainingPlansModule,
|
||||||
|
ArticlesModule,
|
||||||
|
RecommendationsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
37
src/articles/articles.controller.ts
Normal file
37
src/articles/articles.controller.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||||
|
import { ArticlesService } from './articles.service';
|
||||||
|
import { CreateArticleDto, QueryArticlesDto, CreateArticleResponseDto, QueryArticlesResponseDto } from './dto/article.dto';
|
||||||
|
|
||||||
|
@ApiTags('articles')
|
||||||
|
@Controller('articles')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class ArticlesController {
|
||||||
|
constructor(private readonly articlesService: ArticlesService) { }
|
||||||
|
|
||||||
|
@Post('create')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: '创建文章' })
|
||||||
|
@ApiBody({ type: CreateArticleDto })
|
||||||
|
@ApiResponse({ status: 200 })
|
||||||
|
async create(@Body() dto: CreateArticleDto): Promise<CreateArticleResponseDto> {
|
||||||
|
return this.articlesService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('list')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: '查询文章列表(分页)' })
|
||||||
|
async list(@Query() query: QueryArticlesDto): Promise<QueryArticlesResponseDto> {
|
||||||
|
return this.articlesService.query(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: '获取文章详情并增加阅读数' })
|
||||||
|
async getOne(@Param('id') id: string): Promise<CreateArticleResponseDto> {
|
||||||
|
return this.articlesService.getAndIncreaseReadCount(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
16
src/articles/articles.module.ts
Normal file
16
src/articles/articles.module.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SequelizeModule } from '@nestjs/sequelize';
|
||||||
|
import { ArticlesService } from './articles.service';
|
||||||
|
import { ArticlesController } from './articles.controller';
|
||||||
|
import { Article } from './models/article.model';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [SequelizeModule.forFeature([Article]), UsersModule],
|
||||||
|
providers: [ArticlesService],
|
||||||
|
controllers: [ArticlesController],
|
||||||
|
exports: [ArticlesService],
|
||||||
|
})
|
||||||
|
export class ArticlesModule { }
|
||||||
|
|
||||||
|
|
||||||
61
src/articles/articles.service.ts
Normal file
61
src/articles/articles.service.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectModel } from '@nestjs/sequelize';
|
||||||
|
import { Op } from 'sequelize';
|
||||||
|
import { Article } from './models/article.model';
|
||||||
|
import { CreateArticleDto, QueryArticlesDto, ArticleVo } from './dto/article.dto';
|
||||||
|
import { ResponseCode } from '../base.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ArticlesService {
|
||||||
|
private readonly logger = new Logger(ArticlesService.name);
|
||||||
|
constructor(
|
||||||
|
@InjectModel(Article)
|
||||||
|
private readonly articleModel: typeof Article,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
async create(dto: CreateArticleDto) {
|
||||||
|
const article = await this.articleModel.create({
|
||||||
|
title: dto.title,
|
||||||
|
publishedDate: dto.publishedDate as any,
|
||||||
|
htmlContent: dto.htmlContent,
|
||||||
|
});
|
||||||
|
return { code: ResponseCode.SUCCESS, message: 'success', data: article.toJSON() as ArticleVo };
|
||||||
|
}
|
||||||
|
|
||||||
|
async query(params: QueryArticlesDto) {
|
||||||
|
const page = Math.max(1, Number(params.page || 1));
|
||||||
|
const pageSize = Math.min(100, Math.max(1, Number(params.pageSize || 10)));
|
||||||
|
const where: any = {};
|
||||||
|
if (params.keyword) {
|
||||||
|
where.title = { [Op.like]: `%${params.keyword}%` };
|
||||||
|
}
|
||||||
|
if (params.startDate || params.endDate) {
|
||||||
|
where.publishedDate = {} as any;
|
||||||
|
if (params.startDate) (where.publishedDate as any)[Op.gte] = params.startDate as any;
|
||||||
|
if (params.endDate) (where.publishedDate as any)[Op.lte] = params.endDate as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows, count } = await this.articleModel.findAndCountAll({
|
||||||
|
where,
|
||||||
|
order: [['publishedDate', 'DESC'], ['createdAt', 'DESC']],
|
||||||
|
offset: (page - 1) * pageSize,
|
||||||
|
limit: pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: ResponseCode.SUCCESS,
|
||||||
|
message: 'success',
|
||||||
|
data: { list: rows.map(r => r.toJSON() as ArticleVo), total: count, page, pageSize },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAndIncreaseReadCount(id: string) {
|
||||||
|
const article = await this.articleModel.findByPk(id);
|
||||||
|
if (!article) throw new NotFoundException('文章不存在');
|
||||||
|
article.readCount += 1;
|
||||||
|
await article.save();
|
||||||
|
return { code: ResponseCode.SUCCESS, message: 'success', data: article.toJSON() as ArticleVo };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
43
src/articles/dto/article.dto.ts
Normal file
43
src/articles/dto/article.dto.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { BaseResponseDto } from '../../base.dto';
|
||||||
|
|
||||||
|
export class CreateArticleDto {
|
||||||
|
@ApiProperty({ description: '标题' })
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '发布日期 YYYY-MM-DD' })
|
||||||
|
publishedDate!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'HTML 富文本内容' })
|
||||||
|
htmlContent!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QueryArticlesDto {
|
||||||
|
@ApiProperty({ required: false, description: '关键词(匹配标题)' })
|
||||||
|
keyword?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: '起始日期 YYYY-MM-DD' })
|
||||||
|
startDate?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: '结束日期 YYYY-MM-DD' })
|
||||||
|
endDate?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: '分页页码,从1开始', default: 1 })
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: '分页大小', default: 10 })
|
||||||
|
pageSize?: number = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArticleVo {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
publishedDate: string;
|
||||||
|
readCount: number;
|
||||||
|
htmlContent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateArticleResponseDto = BaseResponseDto<ArticleVo>;
|
||||||
|
export type QueryArticlesResponseDto = BaseResponseDto<{ list: ArticleVo[]; total: number; page: number; pageSize: number }>;
|
||||||
|
|
||||||
|
|
||||||
57
src/articles/models/article.model.ts
Normal file
57
src/articles/models/article.model.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Column, Model, Table, DataType, Index } from 'sequelize-typescript';
|
||||||
|
|
||||||
|
@Table({
|
||||||
|
tableName: 't_articles',
|
||||||
|
underscored: true,
|
||||||
|
})
|
||||||
|
export class Article extends Model {
|
||||||
|
@Column({
|
||||||
|
type: DataType.UUID,
|
||||||
|
defaultValue: DataType.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
})
|
||||||
|
declare id: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.STRING(200),
|
||||||
|
allowNull: false,
|
||||||
|
comment: '标题',
|
||||||
|
})
|
||||||
|
declare title: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.DATEONLY,
|
||||||
|
allowNull: false,
|
||||||
|
comment: '发布日期(仅日期)',
|
||||||
|
})
|
||||||
|
declare publishedDate: string; // YYYY-MM-DD
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
comment: '阅读数',
|
||||||
|
})
|
||||||
|
declare readCount: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.TEXT('long'),
|
||||||
|
allowNull: false,
|
||||||
|
comment: 'HTML 富文本内容',
|
||||||
|
})
|
||||||
|
declare htmlContent: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.DATE,
|
||||||
|
defaultValue: DataType.NOW,
|
||||||
|
})
|
||||||
|
declare createdAt: Date;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.DATE,
|
||||||
|
defaultValue: DataType.NOW,
|
||||||
|
})
|
||||||
|
declare updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
29
src/recommendations/dto/recommendation.dto.ts
Normal file
29
src/recommendations/dto/recommendation.dto.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { BaseResponseDto } from '../../base.dto';
|
||||||
|
|
||||||
|
export enum RecommendationType {
|
||||||
|
Article = 'article',
|
||||||
|
Checkin = 'checkin',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetRecommendationsQueryDto {
|
||||||
|
@ApiProperty({ required: false, description: '数量,默认10' })
|
||||||
|
limit?: number = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecommendationCard {
|
||||||
|
id: string;
|
||||||
|
type: RecommendationType;
|
||||||
|
title?: string;
|
||||||
|
coverUrl?: string;
|
||||||
|
// 若为文章,关联文章ID
|
||||||
|
articleId?: string;
|
||||||
|
// 若为打卡,提示信息
|
||||||
|
subtitle?: string;
|
||||||
|
// 其他扩展
|
||||||
|
extra?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetRecommendationsResponseDto = BaseResponseDto<RecommendationCard[]>;
|
||||||
|
|
||||||
|
|
||||||
21
src/recommendations/recommendations.controller.ts
Normal file
21
src/recommendations/recommendations.controller.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Controller, Get, HttpCode, HttpStatus, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||||
|
import { GetRecommendationsQueryDto, GetRecommendationsResponseDto } from './dto/recommendation.dto';
|
||||||
|
import { RecommendationsService } from './recommendations.service';
|
||||||
|
|
||||||
|
@ApiTags('recommendations')
|
||||||
|
@Controller('recommendations')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class RecommendationsController {
|
||||||
|
constructor(private readonly recommendationsService: RecommendationsService) { }
|
||||||
|
|
||||||
|
@Get('list')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: '为你推荐列表' })
|
||||||
|
async list(@Query() query: GetRecommendationsQueryDto): Promise<GetRecommendationsResponseDto> {
|
||||||
|
return this.recommendationsService.list(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
14
src/recommendations/recommendations.module.ts
Normal file
14
src/recommendations/recommendations.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { RecommendationsService } from './recommendations.service';
|
||||||
|
import { RecommendationsController } from './recommendations.controller';
|
||||||
|
import { ArticlesModule } from '../articles/articles.module';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ArticlesModule, UsersModule],
|
||||||
|
providers: [RecommendationsService],
|
||||||
|
controllers: [RecommendationsController],
|
||||||
|
})
|
||||||
|
export class RecommendationsModule { }
|
||||||
|
|
||||||
|
|
||||||
41
src/recommendations/recommendations.service.ts
Normal file
41
src/recommendations/recommendations.service.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ArticlesService } from '../articles/articles.service';
|
||||||
|
import { GetRecommendationsQueryDto, GetRecommendationsResponseDto, RecommendationCard, RecommendationType } from './dto/recommendation.dto';
|
||||||
|
import { ResponseCode } from '../base.dto';
|
||||||
|
import * as dayjs from 'dayjs';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RecommendationsService {
|
||||||
|
private readonly logger = new Logger(RecommendationsService.name);
|
||||||
|
constructor(private readonly articlesService: ArticlesService) { }
|
||||||
|
|
||||||
|
// 为你推荐:混合文章与每日打卡卡片
|
||||||
|
async list(query: GetRecommendationsQueryDto): Promise<GetRecommendationsResponseDto> {
|
||||||
|
const limit = Math.min(50, Math.max(1, Number(query.limit || 10)));
|
||||||
|
|
||||||
|
// 取最新文章若干
|
||||||
|
const articlesRes = await this.articlesService.query({ page: 1, pageSize: limit } as any);
|
||||||
|
const articleCards: RecommendationCard[] = (articlesRes.data.list || []).map(a => ({
|
||||||
|
id: `article-${a.id}`,
|
||||||
|
type: RecommendationType.Article,
|
||||||
|
title: a.title,
|
||||||
|
articleId: a.id,
|
||||||
|
extra: { publishedDate: a.publishedDate, readCount: a.readCount },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 构造每日打卡卡片(今天)
|
||||||
|
const today = dayjs().format('YYYY-MM-DD');
|
||||||
|
const checkinCard: RecommendationCard = {
|
||||||
|
id: `checkin-${today}`,
|
||||||
|
type: RecommendationType.Checkin,
|
||||||
|
title: '今日打卡',
|
||||||
|
subtitle: '完成一次普拉提训练,记录你的坚持',
|
||||||
|
extra: { date: today },
|
||||||
|
};
|
||||||
|
|
||||||
|
const cards = [checkinCard, ...articleCards].slice(0, limit);
|
||||||
|
return { code: ResponseCode.SUCCESS, message: 'success', data: cards };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
46
src/training-plans/dto/training-plan.dto.ts
Normal file
46
src/training-plans/dto/training-plan.dto.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsArray, IsDateString, IsEnum, IsInt, IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min, ValidateIf } from 'class-validator';
|
||||||
|
import { PlanGoal, PlanMode } from '../models/training-plan.model';
|
||||||
|
|
||||||
|
export class CreateTrainingPlanDto {
|
||||||
|
@ApiProperty({ description: '开始日期(ISO)' })
|
||||||
|
@IsDateString()
|
||||||
|
startDate: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['daysOfWeek', 'sessionsPerWeek'] })
|
||||||
|
@IsEnum(['daysOfWeek', 'sessionsPerWeek'])
|
||||||
|
mode: PlanMode;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [Number], description: '按周几训练(0-6)', required: true })
|
||||||
|
@IsArray()
|
||||||
|
daysOfWeek: number[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: '每周训练次数(1-7)' })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(7)
|
||||||
|
sessionsPerWeek: number;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['postpartum_recovery', 'fat_loss', 'posture_correction', 'core_strength', 'flexibility', 'rehab', 'stress_relief', ''] })
|
||||||
|
@IsString()
|
||||||
|
goal: PlanGoal;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
startWeightKg?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['morning', 'noon', 'evening', ''], required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
preferredTimeOfDay?: 'morning' | 'noon' | 'evening' | '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TrainingPlanSummaryDto {
|
||||||
|
@ApiProperty() id: string;
|
||||||
|
@ApiProperty() createdAt: string;
|
||||||
|
@ApiProperty() startDate: string;
|
||||||
|
@ApiProperty() goal: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
49
src/training-plans/models/training-plan.model.ts
Normal file
49
src/training-plans/models/training-plan.model.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Column, DataType, Index, Model, PrimaryKey, Table } from 'sequelize-typescript';
|
||||||
|
|
||||||
|
export type PlanMode = 'daysOfWeek' | 'sessionsPerWeek';
|
||||||
|
export type PlanGoal = 'postpartum_recovery' | 'fat_loss' | 'posture_correction' | 'core_strength' | 'flexibility' | 'rehab' | 'stress_relief' | '';
|
||||||
|
|
||||||
|
@Table({
|
||||||
|
tableName: 't_training_plans',
|
||||||
|
underscored: true,
|
||||||
|
})
|
||||||
|
export class TrainingPlan extends Model {
|
||||||
|
@PrimaryKey
|
||||||
|
@Column({ type: DataType.STRING })
|
||||||
|
declare id: string;
|
||||||
|
|
||||||
|
@Column({ type: DataType.STRING, allowNull: false })
|
||||||
|
declare userId: string;
|
||||||
|
|
||||||
|
@Column({ type: DataType.DATE, allowNull: false })
|
||||||
|
declare createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: DataType.DATE, allowNull: false })
|
||||||
|
declare startDate: Date;
|
||||||
|
|
||||||
|
@Column({ type: DataType.ENUM('daysOfWeek', 'sessionsPerWeek'), allowNull: false })
|
||||||
|
declare mode: PlanMode;
|
||||||
|
|
||||||
|
@Column({ type: DataType.JSON, allowNull: false, comment: '0-6' })
|
||||||
|
declare daysOfWeek: number[];
|
||||||
|
|
||||||
|
@Column({ type: DataType.INTEGER, allowNull: false })
|
||||||
|
declare sessionsPerWeek: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.ENUM('postpartum_recovery', 'fat_loss', 'posture_correction', 'core_strength', 'flexibility', 'rehab', 'stress_relief', ''),
|
||||||
|
allowNull: false,
|
||||||
|
})
|
||||||
|
declare goal: PlanGoal;
|
||||||
|
|
||||||
|
@Column({ type: DataType.FLOAT, allowNull: true })
|
||||||
|
declare startWeightKg: number | null;
|
||||||
|
|
||||||
|
@Column({ type: DataType.ENUM('morning', 'noon', 'evening', ''), allowNull: false, defaultValue: '' })
|
||||||
|
declare preferredTimeOfDay: 'morning' | 'noon' | 'evening' | '';
|
||||||
|
|
||||||
|
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
|
||||||
|
declare updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
43
src/training-plans/training-plans.controller.ts
Normal file
43
src/training-plans/training-plans.controller.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { TrainingPlansService } from './training-plans.service';
|
||||||
|
import { CreateTrainingPlanDto } from './dto/training-plan.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';
|
||||||
|
|
||||||
|
@ApiTags('training-plans')
|
||||||
|
@Controller('training-plans')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class TrainingPlansController {
|
||||||
|
constructor(private readonly service: TrainingPlansService) { }
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: '新增训练计划' })
|
||||||
|
@ApiBody({ type: CreateTrainingPlanDto })
|
||||||
|
async create(@CurrentUser() user: AccessTokenPayload, @Body() dto: CreateTrainingPlanDto) {
|
||||||
|
return this.service.create(user.sub, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: '删除训练计划' })
|
||||||
|
@ApiParam({ name: 'id' })
|
||||||
|
async remove(@CurrentUser() user: AccessTokenPayload, @Param('id') id: string) {
|
||||||
|
return this.service.remove(user.sub, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: '训练计划列表' })
|
||||||
|
async list(@CurrentUser() user: AccessTokenPayload) {
|
||||||
|
return this.service.list(user.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: '训练计划详情' })
|
||||||
|
@ApiParam({ name: 'id' })
|
||||||
|
async detail(@CurrentUser() user: AccessTokenPayload, @Param('id') id: string) {
|
||||||
|
return this.service.detail(user.sub, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
19
src/training-plans/training-plans.module.ts
Normal file
19
src/training-plans/training-plans.module.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SequelizeModule } from '@nestjs/sequelize';
|
||||||
|
import { TrainingPlansService } from './training-plans.service';
|
||||||
|
import { TrainingPlansController } from './training-plans.controller';
|
||||||
|
import { TrainingPlan } from './models/training-plan.model';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
UsersModule,
|
||||||
|
SequelizeModule.forFeature([TrainingPlan]),
|
||||||
|
],
|
||||||
|
controllers: [TrainingPlansController],
|
||||||
|
providers: [TrainingPlansService],
|
||||||
|
exports: [TrainingPlansService],
|
||||||
|
})
|
||||||
|
export class TrainingPlansModule { }
|
||||||
|
|
||||||
|
|
||||||
53
src/training-plans/training-plans.service.ts
Normal file
53
src/training-plans/training-plans.service.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectModel } from '@nestjs/sequelize';
|
||||||
|
import { TrainingPlan } from './models/training-plan.model';
|
||||||
|
import { CreateTrainingPlanDto } from './dto/training-plan.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TrainingPlansService {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(TrainingPlan)
|
||||||
|
private trainingPlanModel: typeof TrainingPlan,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
async create(userId: string, dto: CreateTrainingPlanDto) {
|
||||||
|
const id = `plan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const createdAt = new Date();
|
||||||
|
const plan = await this.trainingPlanModel.create({
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
createdAt,
|
||||||
|
startDate: new Date(dto.startDate),
|
||||||
|
mode: dto.mode,
|
||||||
|
daysOfWeek: dto.daysOfWeek,
|
||||||
|
sessionsPerWeek: dto.sessionsPerWeek,
|
||||||
|
goal: dto.goal,
|
||||||
|
startWeightKg: dto.startWeightKg ?? null,
|
||||||
|
preferredTimeOfDay: dto.preferredTimeOfDay ?? '',
|
||||||
|
});
|
||||||
|
return plan.toJSON();
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(userId: string, id: string) {
|
||||||
|
const count = await this.trainingPlanModel.destroy({ where: { id, userId } });
|
||||||
|
return { success: count > 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(userId: string) {
|
||||||
|
const rows = await this.trainingPlanModel.findAll({ where: { userId }, order: [['created_at', 'DESC']] });
|
||||||
|
return rows.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
startDate: r.startDate,
|
||||||
|
goal: r.goal,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(userId: string, id: string) {
|
||||||
|
const plan = await this.trainingPlanModel.findOne({ where: { id, userId } });
|
||||||
|
if (!plan) throw new NotFoundException('训练计划不存在');
|
||||||
|
return plan.toJSON();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
56
src/users/models/user-weight-history.model.ts
Normal file
56
src/users/models/user-weight-history.model.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Column, DataType, Index, Model, PrimaryKey, Table } from 'sequelize-typescript';
|
||||||
|
|
||||||
|
export enum WeightUpdateSource {
|
||||||
|
Manual = 'manual',
|
||||||
|
Vision = 'vision',
|
||||||
|
Other = 'other',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Table({
|
||||||
|
tableName: 't_user_weight_history',
|
||||||
|
underscored: true,
|
||||||
|
})
|
||||||
|
export class UserWeightHistory extends Model {
|
||||||
|
@PrimaryKey
|
||||||
|
@Column({
|
||||||
|
type: DataType.BIGINT,
|
||||||
|
autoIncrement: true,
|
||||||
|
})
|
||||||
|
declare id: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
comment: '用户ID',
|
||||||
|
})
|
||||||
|
declare userId: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.FLOAT,
|
||||||
|
allowNull: false,
|
||||||
|
comment: '体重(kg)',
|
||||||
|
})
|
||||||
|
declare weight: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.ENUM('manual', 'vision', 'other'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'manual',
|
||||||
|
comment: '更新来源',
|
||||||
|
})
|
||||||
|
declare source: WeightUpdateSource;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.DATE,
|
||||||
|
defaultValue: DataType.NOW,
|
||||||
|
})
|
||||||
|
declare createdAt: Date;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.DATE,
|
||||||
|
defaultValue: DataType.NOW,
|
||||||
|
})
|
||||||
|
declare updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ import { Logger as WinstonLogger } from 'winston';
|
|||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service';
|
||||||
import { CreateUserDto } from './dto/create-user.dto';
|
import { CreateUserDto } from './dto/create-user.dto';
|
||||||
import { UserResponseDto } from './dto/user-response.dto';
|
import { UserResponseDto } from './dto/user-response.dto';
|
||||||
import { ApiOperation, ApiBody, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiBody, ApiResponse, ApiTags, ApiQuery } from '@nestjs/swagger';
|
||||||
import { UpdateUserDto, UpdateUserResponseDto } from './dto/update-user.dto';
|
import { UpdateUserDto, UpdateUserResponseDto } from './dto/update-user.dto';
|
||||||
import { AppleLoginDto, AppleLoginResponseDto, RefreshTokenDto, RefreshTokenResponseDto } from './dto/apple-login.dto';
|
import { AppleLoginDto, AppleLoginResponseDto, RefreshTokenDto, RefreshTokenResponseDto } from './dto/apple-login.dto';
|
||||||
import { DeleteAccountDto, DeleteAccountResponseDto } from './dto/delete-account.dto';
|
import { DeleteAccountDto, DeleteAccountResponseDto } from './dto/delete-account.dto';
|
||||||
@@ -54,6 +54,19 @@ export class UsersController {
|
|||||||
return this.usersService.getProfile(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)
|
@UseGuards(JwtAuthGuard)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { UsersController } from "./users.controller";
|
|||||||
import { UsersService } from "./users.service";
|
import { UsersService } from "./users.service";
|
||||||
import { User } from "./models/user.model";
|
import { User } from "./models/user.model";
|
||||||
import { UserProfile } from "./models/user-profile.model";
|
import { UserProfile } from "./models/user-profile.model";
|
||||||
|
import { UserWeightHistory } from "./models/user-weight-history.model";
|
||||||
import { ApplePurchaseService } from "./services/apple-purchase.service";
|
import { ApplePurchaseService } from "./services/apple-purchase.service";
|
||||||
import { EncryptionService } from "../common/encryption.service";
|
import { EncryptionService } from "../common/encryption.service";
|
||||||
import { AppleAuthService } from "./services/apple-auth.service";
|
import { AppleAuthService } from "./services/apple-auth.service";
|
||||||
@@ -23,6 +24,7 @@ import { CosService } from './cos.service';
|
|||||||
PurchaseRestoreLog,
|
PurchaseRestoreLog,
|
||||||
RevenueCatEvent,
|
RevenueCatEvent,
|
||||||
UserProfile,
|
UserProfile,
|
||||||
|
UserWeightHistory,
|
||||||
]),
|
]),
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_ACCESS_SECRET || 'your-access-token-secret-key',
|
secret: process.env.JWT_ACCESS_SECRET || 'your-access-token-secret-key',
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { RevenueCatWebhookDto, RevenueCatEventType } from './dto/revenue-cat-web
|
|||||||
import { RestorePurchaseDto, RestorePurchaseResponseDto, RestoredPurchaseInfo, ActiveEntitlement, NonSubscriptionTransaction } from './dto/restore-purchase.dto';
|
import { RestorePurchaseDto, RestorePurchaseResponseDto, RestoredPurchaseInfo, ActiveEntitlement, NonSubscriptionTransaction } from './dto/restore-purchase.dto';
|
||||||
import { PurchaseRestoreLog, RestoreStatus, RestoreSource } from './models/purchase-restore-log.model';
|
import { PurchaseRestoreLog, RestoreStatus, RestoreSource } from './models/purchase-restore-log.model';
|
||||||
import { BlockedTransaction, BlockReason } from './models/blocked-transaction.model';
|
import { BlockedTransaction, BlockReason } from './models/blocked-transaction.model';
|
||||||
|
import { UserWeightHistory, WeightUpdateSource } from './models/user-weight-history.model';
|
||||||
|
|
||||||
const DEFAULT_FREE_USAGE_COUNT = 10;
|
const DEFAULT_FREE_USAGE_COUNT = 10;
|
||||||
|
|
||||||
@@ -52,6 +53,8 @@ export class UsersService {
|
|||||||
private blockedTransactionModel: typeof BlockedTransaction,
|
private blockedTransactionModel: typeof BlockedTransaction,
|
||||||
@InjectModel(UserProfile)
|
@InjectModel(UserProfile)
|
||||||
private userProfileModel: typeof UserProfile,
|
private userProfileModel: typeof UserProfile,
|
||||||
|
@InjectModel(UserWeightHistory)
|
||||||
|
private userWeightHistoryModel: typeof UserWeightHistory,
|
||||||
@InjectConnection()
|
@InjectConnection()
|
||||||
private sequelize: Sequelize,
|
private sequelize: Sequelize,
|
||||||
) { }
|
) { }
|
||||||
@@ -157,7 +160,14 @@ export class UsersService {
|
|||||||
if (dailyStepsGoal !== undefined) profile.dailyStepsGoal = dailyStepsGoal as any;
|
if (dailyStepsGoal !== undefined) profile.dailyStepsGoal = dailyStepsGoal as any;
|
||||||
if (dailyCaloriesGoal !== undefined) profile.dailyCaloriesGoal = dailyCaloriesGoal as any;
|
if (dailyCaloriesGoal !== undefined) profile.dailyCaloriesGoal = dailyCaloriesGoal as any;
|
||||||
if (pilatesPurposes !== undefined) profile.pilatesPurposes = pilatesPurposes as any;
|
if (pilatesPurposes !== undefined) profile.pilatesPurposes = pilatesPurposes as any;
|
||||||
if (weight !== undefined) profile.weight = weight as any;
|
if (weight !== undefined) {
|
||||||
|
profile.weight = weight as any;
|
||||||
|
try {
|
||||||
|
await this.userWeightHistoryModel.create({ userId, weight, source: WeightUpdateSource.Manual });
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(`记录体重历史失败: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (height !== undefined) profile.height = height as any;
|
if (height !== undefined) profile.height = height as any;
|
||||||
await profile.save();
|
await profile.save();
|
||||||
}
|
}
|
||||||
@@ -172,6 +182,35 @@ export class UsersService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addWeightByVision(userId: string, weight: number): Promise<void> {
|
||||||
|
const t = await this.sequelize.transaction();
|
||||||
|
try {
|
||||||
|
const [profile] = await this.userProfileModel.findOrCreate({ where: { userId }, defaults: { userId }, transaction: t });
|
||||||
|
profile.weight = weight as any;
|
||||||
|
await profile.save({ transaction: t });
|
||||||
|
await this.userWeightHistoryModel.create({ userId, weight, source: WeightUpdateSource.Vision }, { transaction: t });
|
||||||
|
await t.commit();
|
||||||
|
} catch (e) {
|
||||||
|
await t.rollback();
|
||||||
|
this.logger.error(`addWeightByVision error: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWeightHistory(userId: string, params: { start?: Date; end?: Date; limit?: number } = {}) {
|
||||||
|
const where: any = { userId };
|
||||||
|
if (params.start || params.end) {
|
||||||
|
where.createdAt = {} as any;
|
||||||
|
if (params.start) where.createdAt.$gte = params.start as any;
|
||||||
|
if (params.end) where.createdAt.$lte = params.end as any;
|
||||||
|
}
|
||||||
|
const limit = params.limit && params.limit > 0 ? Math.min(1000, params.limit) : 200;
|
||||||
|
const rows = await this.userWeightHistoryModel.findAll({
|
||||||
|
where,
|
||||||
|
order: [['created_at', 'DESC']],
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
return rows.map(r => ({ weight: r.weight, source: r.source, createdAt: r.createdAt }));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apple 登录
|
* Apple 登录
|
||||||
|
|||||||
Reference in New Issue
Block a user