diff --git a/src/ai-coach/ai-coach.controller.ts b/src/ai-coach/ai-coach.controller.ts index 7ba31d7..bb51410 100644 --- a/src/ai-coach/ai-coach.controller.ts +++ b/src/ai-coach/ai-coach.controller.ts @@ -6,6 +6,7 @@ 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'; +import { PostureAssessmentRequestDto, PostureAssessmentResponseDto } from './dto/posture-assessment.dto'; @ApiTags('ai-coach') @Controller('ai-coach') @@ -31,19 +32,30 @@ export class AiCoachController { 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) { // 非流式:聚合后一次性返回文本 const readable = await this.aiCoachService.streamChat({ userId, conversationId, userContent: body.messages?.[body.messages.length - 1]?.content || '', + systemNotice: weightInfo.weightKg ? `系统提示:已从图片识别体重为${weightInfo.weightKg}kg,并已为你更新到个人资料。` : undefined, }); let text = ''; for await (const chunk of readable) { text += chunk.toString(); } res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.send({ conversationId, text }); + res.send({ conversationId, text, weightKg: weightInfo.weightKg }); return; } @@ -56,9 +68,12 @@ export class AiCoachController { userId, conversationId, userContent: body.messages?.[body.messages.length - 1]?.content || '', + systemNotice: weightInfo.weightKg ? `系统提示:已从图片识别体重为${weightInfo.weightKg}kg,并已为你更新到个人资料。` : undefined, }); readable.on('data', (chunk) => { + // 流水首段可提示体重已更新 + // 简化处理:服务端不额外注入推送段,直接靠 systemNotice res.write(chunk); }); readable.on('end', () => { @@ -101,6 +116,24 @@ export class AiCoachController { const ok = await this.aiCoachService.deleteConversation(user.sub, conversationId); return { success: ok }; } + + @Post('posture-assessment') + @ApiOperation({ summary: 'AI体态评估' }) + @ApiBody({ type: PostureAssessmentRequestDto }) + async postureAssessment( + @Body() body: PostureAssessmentRequestDto, + @CurrentUser() user: AccessTokenPayload, + ): Promise { + 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; + } } diff --git a/src/ai-coach/ai-coach.module.ts b/src/ai-coach/ai-coach.module.ts index f0b359d..ed98aea 100644 --- a/src/ai-coach/ai-coach.module.ts +++ b/src/ai-coach/ai-coach.module.ts @@ -5,13 +5,14 @@ import { AiCoachController } from './ai-coach.controller'; import { AiCoachService } from './ai-coach.service'; import { AiMessage } from './models/ai-message.model'; import { AiConversation } from './models/ai-conversation.model'; +import { PostureAssessment } from './models/posture-assessment.model'; import { UsersModule } from '../users/users.module'; @Module({ imports: [ ConfigModule, UsersModule, - SequelizeModule.forFeature([AiConversation, AiMessage]), + SequelizeModule.forFeature([AiConversation, AiMessage, PostureAssessment]), ], controllers: [AiCoachController], providers: [AiCoachService], diff --git a/src/ai-coach/ai-coach.service.ts b/src/ai-coach/ai-coach.service.ts index 9513f0f..e5b0b31 100644 --- a/src/ai-coach/ai-coach.service.ts +++ b/src/ai-coach/ai-coach.service.ts @@ -4,6 +4,9 @@ import { OpenAI } from 'openai'; import { Readable } from 'stream'; import { AiMessage, RoleType } from './models/ai-message.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),我拥有丰富的专业知识,包括但不限于运动解剖学、体态评估、疼痛预防、功能性训练、力量与柔韧性训练以及营养与饮食建议。请遵循以下指导原则进行交流: - **话题范围**:讨论将仅限于健康、健身、普拉提、康复、形体训练、柔韧性提升、力量训练、运动损伤预防与恢复、营养与饮食等领域。 - **拒绝回答的内容**:对于医疗诊断、情感心理支持、时政金融分析或编程等非相关或高风险问题,我会礼貌地解释为何这些不在我的专业范围内,并尝试将对话引导回上述合适的话题领域内。 - **语言风格**:我的回复将以亲切且专业的态度呈现,尽量做到条理清晰、分点阐述;当需要时,会提供可以在家轻松实践的具体步骤指南及注意事项;同时考虑到不同水平参与者的需求,特别是那些可能有轻微不适或曾受过伤的人群,我会给出相应的调整建议和安全提示。 - **个性化与安全性**:强调每个人的身体状况都是独一无二的,在提出任何锻炼计划之前都会提醒大家根据自身情况适当调整强度;如果涉及到具体的疼痛问题或是旧伤复发的情况,则强烈建议先咨询医生的意见再开始新的训练项目。 - **设备要求**:所有推荐的练习都假设参与者只有基础的家庭健身器材可用,比如瑜伽垫、弹力带或者泡沫轴等;此外还会对每项活动的大致持续时间和频率做出估计,并分享一些自我监测进步的方法。 请告诉我您具体想了解哪方面的信息,以便我能更好地为您提供帮助。`; @@ -12,8 +15,9 @@ export class AiCoachService { private readonly logger = new Logger(AiCoachService.name); private readonly client: OpenAI; 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('DASHSCOPE_API_KEY') || 'sk-e3ff4494c2f1463a8910d5b3d05d3143'; const baseURL = this.configService.get('DASHSCOPE_BASE_URL') || 'https://dashscope.aliyuncs.com/compatible-mode/v1'; @@ -23,6 +27,7 @@ export class AiCoachService { }); // 默认选择通义千问对话模型(OpenAI兼容名),可通过环境覆盖 this.model = this.configService.get('DASHSCOPE_MODEL') || 'qwen-flash'; + this.visionModel = this.configService.get('DASHSCOPE_VISION_MODEL') || 'qwen-vl-plus'; } async createOrAppendMessages(params: { @@ -64,9 +69,13 @@ export class AiCoachService { userId: string; conversationId: string; userContent: string; + systemNotice?: string; }): Promise { // 上下文:系统提示 + 历史 + 当前用户消息 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({ model: this.model, @@ -160,6 +169,143 @@ export class AiCoachService { await AiConversation.destroy({ where: { id: conversationId, userId } }); 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 {}; + } + } } diff --git a/src/ai-coach/dto/ai-chat.dto.ts b/src/ai-coach/dto/ai-chat.dto.ts index 2eb2cde..3fbc719 100644 --- a/src/ai-coach/dto/ai-chat.dto.ts +++ b/src/ai-coach/dto/ai-chat.dto.ts @@ -23,6 +23,11 @@ export class AiChatRequestDto { @IsArray() messages: AiChatMessageDto[]; + @ApiProperty({ required: false, description: '当用户要记体重时的图片URL(电子秤等)' }) + @IsOptional() + @IsString() + imageUrl?: string; + @ApiProperty({ required: false, description: '是否启用流式输出', default: true }) @IsOptional() @IsBoolean() diff --git a/src/ai-coach/dto/posture-assessment.dto.ts b/src/ai-coach/dto/posture-assessment.dto.ts new file mode 100644 index 0000000..6965a4f --- /dev/null +++ b/src/ai-coach/dto/posture-assessment.dto.ts @@ -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; +} + + diff --git a/src/ai-coach/models/ai-message.model.ts b/src/ai-coach/models/ai-message.model.ts index 8a509e6..51f3174 100644 --- a/src/ai-coach/models/ai-message.model.ts +++ b/src/ai-coach/models/ai-message.model.ts @@ -13,6 +13,12 @@ export enum RoleType { }) export class AiMessage extends Model { @PrimaryKey + @Column({ + type: DataType.BIGINT, + autoIncrement: true, + }) + declare id: number; + @ForeignKey(() => AiConversation) @Column({ type: DataType.STRING, diff --git a/src/ai-coach/models/posture-assessment.model.ts b/src/ai-coach/models/posture-assessment.model.ts new file mode 100644 index 0000000..0f16a97 --- /dev/null +++ b/src/ai-coach/models/posture-assessment.model.ts @@ -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 | 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 af52376..c6be767 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -7,6 +7,9 @@ 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'; +import { TrainingPlansModule } from './training-plans/training-plans.module'; +import { ArticlesModule } from './articles/articles.module'; +import { RecommendationsModule } from './recommendations/recommendations.module'; @Module({ imports: [ @@ -19,6 +22,9 @@ import { AiCoachModule } from './ai-coach/ai-coach.module'; UsersModule, CheckinsModule, AiCoachModule, + TrainingPlansModule, + ArticlesModule, + RecommendationsModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/articles/articles.controller.ts b/src/articles/articles.controller.ts new file mode 100644 index 0000000..1fcf4a3 --- /dev/null +++ b/src/articles/articles.controller.ts @@ -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 { + return this.articlesService.create(dto); + } + + @Get('list') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '查询文章列表(分页)' }) + async list(@Query() query: QueryArticlesDto): Promise { + return this.articlesService.query(query); + } + + @Get(':id') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '获取文章详情并增加阅读数' }) + async getOne(@Param('id') id: string): Promise { + return this.articlesService.getAndIncreaseReadCount(id); + } +} + + diff --git a/src/articles/articles.module.ts b/src/articles/articles.module.ts new file mode 100644 index 0000000..8632640 --- /dev/null +++ b/src/articles/articles.module.ts @@ -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 { } + + diff --git a/src/articles/articles.service.ts b/src/articles/articles.service.ts new file mode 100644 index 0000000..baac5c2 --- /dev/null +++ b/src/articles/articles.service.ts @@ -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 }; + } +} + + diff --git a/src/articles/dto/article.dto.ts b/src/articles/dto/article.dto.ts new file mode 100644 index 0000000..888ab5e --- /dev/null +++ b/src/articles/dto/article.dto.ts @@ -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; +export type QueryArticlesResponseDto = BaseResponseDto<{ list: ArticleVo[]; total: number; page: number; pageSize: number }>; + + diff --git a/src/articles/models/article.model.ts b/src/articles/models/article.model.ts new file mode 100644 index 0000000..57295c8 --- /dev/null +++ b/src/articles/models/article.model.ts @@ -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; +} + + diff --git a/src/recommendations/dto/recommendation.dto.ts b/src/recommendations/dto/recommendation.dto.ts new file mode 100644 index 0000000..52f202a --- /dev/null +++ b/src/recommendations/dto/recommendation.dto.ts @@ -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; +} + +export type GetRecommendationsResponseDto = BaseResponseDto; + + diff --git a/src/recommendations/recommendations.controller.ts b/src/recommendations/recommendations.controller.ts new file mode 100644 index 0000000..8890585 --- /dev/null +++ b/src/recommendations/recommendations.controller.ts @@ -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 { + return this.recommendationsService.list(query); + } +} + + diff --git a/src/recommendations/recommendations.module.ts b/src/recommendations/recommendations.module.ts new file mode 100644 index 0000000..fac10ce --- /dev/null +++ b/src/recommendations/recommendations.module.ts @@ -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 { } + + diff --git a/src/recommendations/recommendations.service.ts b/src/recommendations/recommendations.service.ts new file mode 100644 index 0000000..b240bb7 --- /dev/null +++ b/src/recommendations/recommendations.service.ts @@ -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 { + 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 }; + } +} + + diff --git a/src/training-plans/dto/training-plan.dto.ts b/src/training-plans/dto/training-plan.dto.ts new file mode 100644 index 0000000..65ef852 --- /dev/null +++ b/src/training-plans/dto/training-plan.dto.ts @@ -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; +} + + diff --git a/src/training-plans/models/training-plan.model.ts b/src/training-plans/models/training-plan.model.ts new file mode 100644 index 0000000..a42b838 --- /dev/null +++ b/src/training-plans/models/training-plan.model.ts @@ -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; +} + + diff --git a/src/training-plans/training-plans.controller.ts b/src/training-plans/training-plans.controller.ts new file mode 100644 index 0000000..f5648d0 --- /dev/null +++ b/src/training-plans/training-plans.controller.ts @@ -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); + } +} + + diff --git a/src/training-plans/training-plans.module.ts b/src/training-plans/training-plans.module.ts new file mode 100644 index 0000000..3fa4b50 --- /dev/null +++ b/src/training-plans/training-plans.module.ts @@ -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 { } + + diff --git a/src/training-plans/training-plans.service.ts b/src/training-plans/training-plans.service.ts new file mode 100644 index 0000000..30cc3f4 --- /dev/null +++ b/src/training-plans/training-plans.service.ts @@ -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(); + } +} + + diff --git a/src/users/models/user-weight-history.model.ts b/src/users/models/user-weight-history.model.ts new file mode 100644 index 0000000..1a3c157 --- /dev/null +++ b/src/users/models/user-weight-history.model.ts @@ -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; +} + + diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 2e34c77..9c9bd3c 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -18,7 +18,7 @@ import { Logger as WinstonLogger } from 'winston'; import { UsersService } from './users.service'; import { CreateUserDto } from './dto/create-user.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 { AppleLoginDto, AppleLoginResponseDto, RefreshTokenDto, RefreshTokenResponseDto } from './dto/apple-login.dto'; import { DeleteAccountDto, DeleteAccountResponseDto } from './dto/delete-account.dto'; @@ -54,6 +54,19 @@ export class UsersController { 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) diff --git a/src/users/users.module.ts b/src/users/users.module.ts index a5d3124..671186b 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -4,6 +4,7 @@ import { UsersController } from "./users.controller"; import { UsersService } from "./users.service"; import { User } from "./models/user.model"; import { UserProfile } from "./models/user-profile.model"; +import { UserWeightHistory } from "./models/user-weight-history.model"; import { ApplePurchaseService } from "./services/apple-purchase.service"; import { EncryptionService } from "../common/encryption.service"; import { AppleAuthService } from "./services/apple-auth.service"; @@ -23,6 +24,7 @@ import { CosService } from './cos.service'; PurchaseRestoreLog, RevenueCatEvent, UserProfile, + UserWeightHistory, ]), JwtModule.register({ secret: process.env.JWT_ACCESS_SECRET || 'your-access-token-secret-key', diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 83f3660..59e6de9 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -30,6 +30,7 @@ import { RevenueCatWebhookDto, RevenueCatEventType } from './dto/revenue-cat-web import { RestorePurchaseDto, RestorePurchaseResponseDto, RestoredPurchaseInfo, ActiveEntitlement, NonSubscriptionTransaction } from './dto/restore-purchase.dto'; import { PurchaseRestoreLog, RestoreStatus, RestoreSource } from './models/purchase-restore-log.model'; import { BlockedTransaction, BlockReason } from './models/blocked-transaction.model'; +import { UserWeightHistory, WeightUpdateSource } from './models/user-weight-history.model'; const DEFAULT_FREE_USAGE_COUNT = 10; @@ -52,6 +53,8 @@ export class UsersService { private blockedTransactionModel: typeof BlockedTransaction, @InjectModel(UserProfile) private userProfileModel: typeof UserProfile, + @InjectModel(UserWeightHistory) + private userWeightHistoryModel: typeof UserWeightHistory, @InjectConnection() private sequelize: Sequelize, ) { } @@ -157,7 +160,14 @@ export class UsersService { 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 (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; await profile.save(); } @@ -172,6 +182,35 @@ export class UsersService { }; } + async addWeightByVision(userId: string, weight: number): Promise { + 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 登录