feat: init fitness-coach (backend + miniprogram)

- backend: NestJS service with exercise/plan data
- miniprogram: WeChat mini program client
- exclude node_modules, dist, runtime data-store, local env
This commit is contained in:
2026-07-22 00:35:40 +08:00
commit b3b58e66fb
103 changed files with 154759 additions and 0 deletions

27
backend/src/app.module.ts Normal file
View File

@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
import { ServeStaticModule } from '@nestjs/serve-static';
import { ExercisesModule } from './exercises/exercises.module';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { FavoritesModule } from './favorites/favorites.module';
import { PlansModule } from './plans/plans.module';
// 若 backend/media 目录存在(放有 images/ 与 videos/),则通过 /media 暴露静态资源
const mediaDir = path.join(__dirname, '..', 'media');
const staticImports = fs.existsSync(mediaDir)
? [ServeStaticModule.forRoot({ rootPath: mediaDir, serveRoot: '/media' })]
: [];
@Module({
imports: [
ExercisesModule,
AuthModule,
UsersModule,
FavoritesModule,
PlansModule,
...staticImports,
],
})
export class AppModule {}

View File

@@ -0,0 +1,21 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { AuthGuard } from '../common/auth.guard';
import { CurrentUser } from '../common/current-user.decorator';
@Controller('api/auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Post('login')
login(@Body() dto: LoginDto) {
return this.auth.login(dto.code, dto.userInfo);
}
@Get('me')
@UseGuards(AuthGuard)
me(@CurrentUser() user: { openid: string }) {
return this.auth.me(user.openid);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UsersModule } from '../users/users.module';
@Module({
imports: [UsersModule],
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,85 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { LoginDto, devOpenid } from './dto/login.dto';
import { signToken } from '../common/token';
import { UsersService } from '../users/users.service';
export interface LoginResult {
token: string;
user: {
openid: string;
nickname: string;
avatar: string;
createdAt: string;
};
}
@Injectable()
export class AuthService {
constructor(private readonly users: UsersService) {}
async login(code: string, userInfo?: LoginDto['userInfo']): Promise<LoginResult> {
const openid = await this.resolveOpenid(code);
// 首次登录自动建号;若携带 userInfo 则同步昵称/头像
let user = this.users.findByOpenid(openid);
if (!user) {
user = this.users.create({
openid,
nickname: userInfo?.nickname,
avatar: userInfo?.avatar,
});
} else if (userInfo?.nickname || userInfo?.avatar) {
user = this.users.update(openid, {
...(userInfo.nickname ? { nickname: userInfo.nickname } : {}),
...(userInfo.avatar ? { avatar: userInfo.avatar } : {}),
});
}
return {
token: signToken(openid),
user: {
openid: user.openid,
nickname: user.nickname,
avatar: user.avatar,
createdAt: user.createdAt,
},
};
}
/** 解析 openid生产走 code2session开发模式降级为虚拟 openid */
private async resolveOpenid(code: string): Promise<string> {
const appid = process.env.WX_APPID;
const secret = process.env.WX_SECRET;
if (appid && secret && code && code !== 'tourist' && code !== 'mock') {
try {
const url =
`https://api.weixin.qq.com/sns/jscode2session?appid=${appid}` +
`&secret=${secret}&js_code=${encodeURIComponent(code)}&grant_type=authorization_code`;
const res = await fetch(url);
const json = (await res.json()) as {
openid?: string;
errcode?: number;
errmsg?: string;
};
if (json.openid) return json.openid;
// 失败则降级,避免阻塞开发
return devOpenid(code);
} catch {
return devOpenid(code);
}
}
return devOpenid(code);
}
me(openid: string) {
const user = this.users.findByOpenid(openid);
if (!user) throw new UnauthorizedException('用户不存在,请重新登录');
return {
openid: user.openid,
nickname: user.nickname,
avatar: user.avatar,
createdAt: user.createdAt,
};
}
}

View File

@@ -0,0 +1,35 @@
import { IsString, IsOptional, IsObject } from 'class-validator';
export class LoginDto {
@IsString()
code: string;
@IsOptional()
@IsObject()
userInfo?: { nickname?: string; avatar?: string };
}
/** 开发模式(未配置 APPID/SECRET依据 code 生成稳定的虚拟 openid */
export function devOpenid(code: string): string {
// 延迟引入避免循环crypto 在 token.ts 已用,这里直接 require
// eslint-disable-next-line @typescript-eslint/no-var-requires
const crypto = require('crypto');
return 'dev_' + crypto.createHash('sha256').update(code || 'tourist').digest('hex').slice(0, 16);
}
export const PLAN_LEVELS = ['beginner', 'intermediate', 'advanced', 'all'] as const;
export type PlanLevel = (typeof PLAN_LEVELS)[number];
export function isPlanLevel(v: unknown): v is PlanLevel {
return typeof v === 'string' && (PLAN_LEVELS as readonly string[]).includes(v);
}
export class UpdateProfileDto {
@IsOptional()
@IsString()
nickname?: string;
@IsOptional()
@IsString()
avatar?: string;
}

View File

@@ -0,0 +1,20 @@
import {
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { verifyToken } from './token';
/** 从 Authorization: Bearer <token> 中解析并校验登录态,注入 req.user */
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
const header = req.headers['authorization'] || '';
const m = /^Bearer\s+(.+)$/i.exec(header);
if (!m) throw new UnauthorizedException('请先登录');
const payload = verifyToken(m[1]);
if (!payload) throw new UnauthorizedException('登录已过期,请重新登录');
req.user = { openid: payload.openid };
return true;
}
}

View File

@@ -0,0 +1,6 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
/** 取出 AuthGuard 注入的当前用户 openid: @CurrentUser() user: { openid: string } */
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext) => ctx.switchToHttp().getRequest().user,
);

View File

@@ -0,0 +1,77 @@
import * as fs from 'fs';
import * as path from 'path';
/**
* 轻量级文件持久化存储(单进程内单例)。
* 每个 name 对应 backend/data-store/<name>.json启动时从磁盘加载
* 每次写操作整体落盘。适合 demo / 轻量生产前置,避免引入数据库依赖。
*/
const ROOT = path.join(process.cwd(), 'data-store');
export class JsonStore<T> {
private file: string;
private data: T[] = [];
constructor(name: string) {
this.file = path.join(ROOT, `${name}.json`);
if (fs.existsSync(this.file)) {
try {
this.data = JSON.parse(fs.readFileSync(this.file, 'utf-8'));
} catch {
this.data = [];
}
} else {
fs.mkdirSync(ROOT, { recursive: true });
this.data = [];
this.flush();
}
}
private flush() {
fs.writeFileSync(this.file, JSON.stringify(this.data, null, 2));
}
all(): T[] {
return this.data;
}
find(pred: (x: T) => boolean): T | undefined {
return this.data.find(pred);
}
filter(pred: (x: T) => boolean): T[] {
return this.data.filter(pred);
}
insert(item: T): T {
this.data.push(item);
this.flush();
return item;
}
update(pred: (x: T) => boolean, patch: Partial<T>): T | undefined {
const it = this.data.find(pred);
if (!it) return undefined;
Object.assign(it, patch);
this.flush();
return it;
}
remove(pred: (x: T) => boolean): boolean {
const i = this.data.findIndex(pred);
if (i >= 0) {
this.data.splice(i, 1);
this.flush();
return true;
}
return false;
}
}
const singletons: Record<string, JsonStore<any>> = {};
/** 进程内单例:同一 name 始终返回同一实例,避免多服务各自加载导致写覆盖 */
export function getStore<T>(name: string): JsonStore<T> {
if (!singletons[name]) singletons[name] = new JsonStore<T>(name);
return singletons[name] as JsonStore<T>;
}

View File

@@ -0,0 +1,42 @@
import * as crypto from 'crypto';
/**
* 极简 tokenbase64url(payload).HMAC-SHA256(payload)
* 不依赖数据库会话payload 内含 openid 与过期时间,校验时重算签名即可。
* 生产环境请通过环境变量 JWT_SECRET 设置强密钥。
*/
const SECRET = process.env.JWT_SECRET || 'fitcoach-dev-secret';
const TTL = 30 * 24 * 60 * 60 * 1000; // 30 天
export interface TokenPayload {
openid: string;
iat: number;
exp: number;
}
export function signToken(openid: string): string {
const payload: TokenPayload = {
openid,
iat: Date.now(),
exp: Date.now() + TTL,
};
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
const sig = crypto.createHmac('sha256', SECRET).update(body).digest('base64url');
return `${body}.${sig}`;
}
export function verifyToken(token: string): TokenPayload | null {
try {
const [body, sig] = token.split('.');
if (!body || !sig) return null;
const expected = crypto.createHmac('sha256', SECRET).update(body).digest('base64url');
// 防时序攻击的常量比较
if (sig.length !== expected.length) return null;
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
const data = JSON.parse(Buffer.from(body, 'base64url').toString()) as TokenPayload;
if (!data.exp || data.exp < Date.now()) return null;
return data;
} catch {
return null;
}
}

View File

@@ -0,0 +1,208 @@
import { CollectionDef } from '../exercises.interface';
/**
* 智能训练组合(训练分化 / 场景化动作包)
* 每个组合通过 filter 条件在服务端实时解析动作,无需预存列表。
*/
export const COLLECTIONS: CollectionDef[] = [
{
slug: 'push',
name: '推日训练',
nameEn: 'Push Day',
emoji: '💪',
color: '#FF6B6B',
description: '胸、肩、肱三头肌为主,适合「推」类动作的集中刺激。',
level: 'intermediate',
durationWeeks: 8,
sessionsPerWeek: 2,
focus: '胸肩三头均衡发展',
filter: { bodyParts: ['chest', 'shoulders'] },
},
{
slug: 'pull',
name: '拉日训练',
nameEn: 'Pull Day',
emoji: '🪢',
color: '#4D96FF',
description: '背、肱二头、前臂为主,强化「拉」类动作与背部厚度。',
level: 'intermediate',
durationWeeks: 8,
sessionsPerWeek: 2,
focus: '背部厚度与手臂线条',
filter: { bodyParts: ['back', 'lower arms', 'upper arms'] },
},
{
slug: 'legs',
name: '腿部训练',
nameEn: 'Leg Day',
emoji: '🦵',
color: '#6BCB77',
description: '大腿与小腿全覆盖,打造下肢力量与线条。',
level: 'advanced',
durationWeeks: 10,
sessionsPerWeek: 2,
focus: '下肢力量与围度突破',
filter: { bodyParts: ['upper legs', 'lower legs'] },
},
{
slug: 'core',
name: '核心训练',
nameEn: 'Core Day',
emoji: '🔥',
color: '#FFD93D',
description: '腰腹核心集中训练,提升稳定性与体态。',
level: 'beginner',
durationWeeks: 4,
sessionsPerWeek: 3,
focus: '腰腹稳定与体态改善',
filter: { bodyParts: ['waist'], limit: 16 },
},
{
slug: 'upper',
name: '上肢训练',
nameEn: 'Upper Body',
emoji: '🦾',
color: '#9B72FF',
description: '胸背肩臂全覆盖,一次练透上半身。',
level: 'intermediate',
durationWeeks: 8,
sessionsPerWeek: 3,
focus: '上半身整体塑形',
filter: { bodyParts: ['chest', 'back', 'shoulders', 'upper arms', 'lower arms', 'neck'] },
},
{
slug: 'full',
name: '全身训练',
nameEn: 'Full Body',
emoji: '🌟',
color: '#FF9F45',
description: '力量与柔韧动作组合,适合全身性训练日。',
level: 'beginner',
durationWeeks: 6,
sessionsPerWeek: 3,
focus: '零基础全身激活',
filter: { type: ['strength', 'flexibility'], limit: 18 },
},
{
slug: 'home',
name: '居家无器械',
nameEn: 'Home / No Equipment',
emoji: '🏠',
color: '#38C6C0',
description: '仅需自重即可完成,足不出户也能练。',
level: 'beginner',
durationWeeks: 6,
sessionsPerWeek: 4,
focus: '自重也能练出线条',
filter: { equipment: ['body weight', 'none'], excludeBodyParts: ['cardio'], limit: 18 },
},
{
slug: 'cardio',
name: '有氧心肺',
nameEn: 'Cardio',
emoji: '🏃',
color: '#FF7BAC',
description: '提升心肺耐力与燃脂效率的有氧动作。',
level: 'beginner',
durationWeeks: 4,
sessionsPerWeek: 4,
focus: '心肺耐力与燃脂',
filter: { bodyParts: ['cardio'], limit: 14 },
},
// ===== 新增:按训练水平分级的官方推荐计划 =====
{
slug: 'beginner-7day',
name: '新手七天全身',
nameEn: '7-Day Starter',
emoji: '🌱',
color: '#7CD992',
description: '零基础友好,自重为主,用一周建立正确动作模式与训练习惯。',
level: 'beginner',
durationWeeks: 4,
sessionsPerWeek: 3,
focus: '建立动作基础与训练习惯',
filter: { equipment: ['body weight', 'none'], type: ['strength'], excludeBodyParts: ['cardio'], limit: 12 },
},
{
slug: 'beginner-fatburn',
name: '新手燃脂起步',
nameEn: 'Fat Burn Kickstart',
emoji: '🔥',
color: '#FF9F68',
description: '低门槛有氧 + 自重循环,轻松开启燃脂第一步。',
level: 'beginner',
durationWeeks: 4,
sessionsPerWeek: 4,
focus: '低门槛高效燃脂',
filter: { type: ['cardio', 'strength'], equipment: ['body weight', 'none'], limit: 14 },
},
{
slug: 'intermediate-hypertrophy',
name: '进阶增肌分化',
nameEn: 'Hypertrophy Split',
emoji: '📈',
color: '#C792EA',
description: '系统分化训练,以肌肥大为目标,覆盖全身主要肌群。',
level: 'intermediate',
durationWeeks: 12,
sessionsPerWeek: 5,
focus: '系统增肌 · 肌肥大优先',
filter: { type: ['strength'], limit: 24 },
},
{
slug: 'intermediate-strength',
name: '进阶力量推拉腿',
nameEn: 'Strength PPL',
emoji: '🏋️',
color: '#5AA9FF',
description: '推/拉/腿经典分化,专注复合动作与力量提升。',
level: 'intermediate',
durationWeeks: 12,
sessionsPerWeek: 4,
focus: '复合动作力量进阶',
filter: { bodyParts: ['chest', 'back', 'shoulders', 'upper legs', 'lower legs'], limit: 20 },
},
{
slug: 'advanced-athlete',
name: '高级竞技爆发',
nameEn: 'Athletic Power',
emoji: '⚡',
color: '#FF5C8A',
description: '高负荷器械训练,面向进阶者的力量与爆发力进阶。',
level: 'advanced',
durationWeeks: 12,
sessionsPerWeek: 5,
focus: '最大力量与爆发力',
filter: { equipment: ['barbell', 'dumbbell', 'kettlebell'], type: ['strength'], limit: 22 },
},
{
slug: 'mobility-recovery',
name: '舒展放松恢复',
nameEn: 'Mobility & Recovery',
emoji: '🧘',
color: '#5FD0C5',
description: '柔韧与放松动作,缓解久坐疲劳,改善活动度。',
level: 'all',
durationWeeks: 0,
sessionsPerWeek: 5,
focus: '柔韧改善与日常恢复',
filter: { type: ['flexibility'], limit: 14 },
},
{
slug: 'female-toning',
name: '女子塑形紧致',
nameEn: 'Tone & Sculpt',
emoji: '🌸',
color: '#FF8FB1',
description: '针对腰腹、臀腿与肩臂的塑形组合,紧致线条不粗壮。',
level: 'intermediate',
durationWeeks: 8,
sessionsPerWeek: 4,
focus: '线条紧致与体态雕琢',
filter: { bodyParts: ['waist', 'upper legs', 'lower legs', 'shoulders'], limit: 18 },
},
];
export function findCollection(slug: string): CollectionDef | undefined {
return COLLECTIONS.find((c) => c.slug === slug);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,123 @@
/**
* 中英文标签映射
* 说明:本文件在数据分析阶段由 scripts/analyze.mjs 扫描真实数据集生成,
* 未知取值会回退为「首字母大写」的英文原文,保证不丢数据。
*/
export const BODY_PART_LABELS: Record<string, string> = {
back: '背部',
cardio: '有氧心肺',
chest: '胸部',
'lower arms': '前臂',
'lower legs': '小腿',
neck: '颈部',
shoulders: '肩部',
'upper arms': '上臂',
'upper legs': '大腿',
waist: '腰腹',
};
export const EQUIPMENT_LABELS: Record<string, string> = {
'body weight': '自重',
dumbbell: '哑铃',
barbell: '杠铃',
kettlebell: '壶铃',
cable: '钢索',
machine: '固定器械',
band: '弹力带',
'exercise ball': '健身球',
'medicine ball': '药球',
'ez bar': 'EZ曲杆',
'foam roll': '泡沫轴',
treadmill: '跑步机',
'stationary bike': '固定单车',
rope: '战绳',
tire: '轮胎',
skier: '滑雪训练器',
'sled machine': '雪橇机',
roller: '滚轮',
'elliptical machine': '椭圆机',
'olympic barbell': '奥杆',
'decline bench': '下斜凳',
'flat bench': '平板凳',
'pull-up bar': '单杠',
'stability ball': '瑞士球',
trx: 'TRX悬挂带',
'assistance machine': '辅助器械',
'bosu ball': 'BOSU半球',
'wheel roller': '健腹轮',
'leverage machine': '杠杆器械',
'smith machine': '史密斯机',
weighted: '负重',
'ez barbell': 'EZ杠铃',
assisted: '辅助器械',
'resistance band': '阻力带',
'upper body ergometer': '上肢测功仪',
'skierg machine': '滑雪机',
hammer: '锤',
'trap bar': '六角杠',
'stepmill machine': '楼梯机',
none: '无器械',
other: '其他',
};
export const MUSCLE_LABELS: Record<string, string> = {
biceps: '肱二头肌',
triceps: '肱三头肌',
'forearms': '前臂',
abs: '腹肌',
'abdominals': '腹肌',
obliques: '腹斜肌',
'pectorals': '胸大肌',
'chest': '胸部',
'quadriceps': '股四头肌',
'hamstrings': '腘绳肌',
glutes: '臀大肌',
calves: '小腿',
'calves ': '小腿',
traps: '斜方肌',
'upper traps': '上斜方肌',
lats: '背阔肌',
'lower back': '下背',
delts: '三角肌',
'shoulders': '肩部',
hips: '髋部',
adductors: '内收肌',
neck: '颈部',
'serratus anterior': '前锯肌',
'spinal erectors': '竖脊肌',
'hip flexors': '髋屈肌',
'middle back': '中背',
'upper back': '上背',
quads: '股四头肌',
'cardiovascular system': '心血管系统',
spine: '脊柱',
abductors: '外展肌',
'levator scapulae': '肩胛提肌',
trapezius: '斜方肌',
deltoids: '三角肌',
ankles: '踝部',
core: '核心',
'rotator cuff': '肩袖',
soleus: '比目鱼肌',
rhomboids: '菱形肌',
'wrist flexors': '腕屈肌',
'latissimus dorsi': '背阔肌',
'ankle stabilizers': '踝稳定肌',
'wrist extensors': '腕伸肌',
wrists: '腕部',
hands: '手部',
'rear deltoids': '后三角肌',
brachialis: '肱肌',
back: '背部',
feet: '足部',
'upper chest': '上胸',
'sternocleidomastoid': '胸锁乳突肌',
groin: '腹股沟',
};
/** 取标签;缺失时回退为首字母大写的英文原文 */
export function labelOf(map: Record<string, string>, key: string): string {
if (!key) return '';
return map[key] ?? key.replace(/\b\w/g, (c) => c.toUpperCase());
}

View File

@@ -0,0 +1,49 @@
import { IsOptional, IsString, IsInt, Min, Max, IsIn } from 'class-validator';
import { Type } from 'class-transformer';
export class QueryExercisesDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(50)
pageSize = 20;
@IsOptional()
@IsString()
bodyPart?: string;
@IsOptional()
@IsString()
equipment?: string;
@IsOptional()
@IsString()
target?: string;
@IsOptional()
@IsString()
muscleGroup?: string;
@IsOptional()
@IsIn(['strength', 'cardio', 'flexibility'])
type?: 'strength' | 'cardio' | 'flexibility';
@IsOptional()
@IsString()
secondary?: string;
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@IsIn(['default', 'name'])
sort?: 'default' | 'name' = 'default';
}

View File

@@ -0,0 +1,18 @@
import { IsString, IsOptional, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
export class RecommendQueryDto {
@IsString()
target: string;
@IsOptional()
@IsString()
equipment?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(50)
limit = 20;
}

View File

@@ -0,0 +1,79 @@
import {
Controller,
Get,
Query,
Param,
ParseIntPipe,
} from '@nestjs/common';
import { ExercisesService } from './exercises.service';
import { QueryExercisesDto } from './dto/query-exercises.dto';
import { RecommendQueryDto } from './dto/recommend-query.dto';
@Controller('api')
export class ExercisesController {
constructor(private readonly service: ExercisesService) {}
@Get('exercises')
findAll(@Query() query: QueryExercisesDto) {
return this.service.findAll(query);
}
@Get('exercises/:id')
findOne(@Param('id') id: string) {
return this.service.findOne(id);
}
@Get('categories/body-parts')
bodyParts() {
return this.service.getBodyPartCategories();
}
@Get('categories/equipment')
equipment() {
return this.service.getEquipmentCategories();
}
@Get('categories/targets')
targets() {
return this.service.getTargetCategories();
}
@Get('categories/muscle-groups')
muscleGroups() {
return this.service.getMuscleGroupCategories();
}
@Get('categories/types')
types() {
return this.service.getTypeCategories();
}
@Get('recommend')
recommend(@Query() dto: RecommendQueryDto) {
return this.service.recommend(dto);
}
@Get('collections')
collections() {
return this.service.listCollections();
}
@Get('collections/:slug/exercises')
collectionExercises(
@Param('slug') slug: string,
@Query('page', new ParseIntPipe({ optional: true })) page = 1,
@Query('pageSize', new ParseIntPipe({ optional: true })) pageSize = 20,
) {
return this.service.getCollectionExercises(slug, page, pageSize);
}
@Get('search')
search(@Query('q') q: string) {
return this.service.findAll({ q, page: 1, pageSize: 30 });
}
@Get('stats')
stats() {
return this.service.stats();
}
}

View File

@@ -0,0 +1,128 @@
/**
* 健身动作数据接口定义
* 原始数据来自 exercises-datasethasaneyldrm/exercises-dataset
* 经后端 normalize 后对外提供结构化、带中文标签的字段。
*/
/** 数据集原始单条记录 */
export interface RawExercise {
id: string;
name: string;
category?: string;
body_part: string;
equipment: string;
instructions: Record<string, string>;
instruction_steps: Record<string, string[]>;
muscle_group: string;
secondary_muscles: string[];
target: string;
media_id: string;
image: string;
gif_url: string;
attribution: string;
created_at: string;
}
/** 锻炼类型 */
export type ExerciseType = 'strength' | 'cardio' | 'flexibility';
/** 对外暴露(详情)的结构化动作 */
export interface Exercise {
id: string;
name: string;
bodyPart: string;
bodyPartLabel: string;
equipment: string;
equipmentLabel: string;
target: string;
targetLabel: string;
muscleGroup: string;
muscleGroupLabel: string;
secondaryMuscles: string[];
secondaryMusclesLabels: string[];
type: ExerciseType;
typeLabel: string;
image: string;
gifUrl: string;
/** 仅保留中英双语说明以控制体积 */
instructions: { zh: string; en: string };
instructionSteps: { zh: string[]; en: string[] };
attribution: string;
mediaId: string;
}
/** 列表项(轻量投影,不含完整说明,保证接口性能) */
export interface ExerciseSummary {
id: string;
name: string;
bodyPart: string;
bodyPartLabel: string;
equipment: string;
equipmentLabel: string;
target: string;
targetLabel: string;
type: ExerciseType;
typeLabel: string;
image: string;
gifUrl: string;
}
/** 分类元数据项 */
export interface CategoryItem {
value: string;
label: string;
count: number;
/** 仅 target 分类会附带其所属部位,便于前端按部位分组 */
bodyPart?: string;
bodyPartLabel?: string;
}
/** 推荐结果项(带相关性评分与推荐理由) */
export interface RecommendItem extends ExerciseSummary {
score: number;
reason: string;
}
export interface RecommendResult {
target: string;
targetLabel: string;
total: number;
items: RecommendItem[];
}
/** 官方计划难度等级 */
export type PlanLevel = 'beginner' | 'intermediate' | 'advanced' | 'all';
/** 智能训练组合(官方计划)定义 */
export interface CollectionDef {
slug: string;
name: string;
nameEn: string;
emoji: string;
color: string;
description: string;
/** 难度等级,用于官方计划分级展示 */
level?: PlanLevel;
/** 周期(周) */
durationWeeks?: number;
/** 每周训练次数 */
sessionsPerWeek?: number;
/** 一句话训练目标 */
focus?: string;
/** 用于服务端解析动作的过滤条件 */
filter: {
bodyParts?: string[];
equipment?: string[];
type?: ExerciseType[];
excludeBodyParts?: string[];
/** 计划动作数量上限,便于组合成「一份计划」而非全部 */
limit?: number;
};
}
/** 官方计划按等级分组后的结构 */
export interface OfficialPlanGroup {
level: PlanLevel;
label: string;
plans: CollectionDef[];
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ExercisesService } from './exercises.service';
import { ExercisesController } from './exercises.controller';
@Module({
controllers: [ExercisesController],
providers: [ExercisesService],
exports: [ExercisesService],
})
export class ExercisesModule {}

View File

@@ -0,0 +1,246 @@
import { Injectable, NotFoundException, OnModuleInit } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
import {
Exercise,
ExerciseSummary,
CategoryItem,
RecommendResult,
RecommendItem,
CollectionDef,
RawExercise,
} from './exercises.interface';
import { QueryExercisesDto } from './dto/query-exercises.dto';
import { RecommendQueryDto } from './dto/recommend-query.dto';
import { normalize, toSummary } from './utils/normalize';
import { COLLECTIONS, findCollection } from './data/collections';
import { labelOf, MUSCLE_LABELS } from './data/labels';
@Injectable()
export class ExercisesService implements OnModuleInit {
private exercises: Exercise[] = [];
private summaries: ExerciseSummary[] = [];
onModuleInit() {
this.load();
}
private load() {
const file = path.join(__dirname, 'data', 'exercises.json');
if (!fs.existsSync(file)) {
// 数据缺失时给出明确提示,避免静默空返回
console.warn(
`[exercises] 未找到数据集: ${file}。请先执行仓库根目录的媒体/数据准备脚本,或将 exercises.json 放入 backend/src/exercises/data/。`,
);
this.exercises = [];
this.summaries = [];
return;
}
const raw: RawExercise[] = JSON.parse(fs.readFileSync(file, 'utf-8'));
this.exercises = raw.map(normalize);
this.summaries = this.exercises.map(toSummary);
console.log(`[exercises] 已加载 ${this.exercises.length} 条健身动作`);
}
/** 列表查询:分页 + 多维筛选 + 搜索 + 排序 */
findAll(query: QueryExercisesDto) {
let list = this.exercises;
if (query.bodyPart) list = list.filter((e) => e.bodyPart === query.bodyPart);
if (query.equipment) list = list.filter((e) => e.equipment === query.equipment);
if (query.type) list = list.filter((e) => e.type === query.type);
if (query.target) list = list.filter((e) => e.target === query.target);
if (query.muscleGroup)
list = list.filter((e) => e.muscleGroup === query.muscleGroup);
if (query.secondary)
list = list.filter((e) => e.secondaryMuscles.includes(query.secondary));
if (query.q) {
const q = query.q.trim().toLowerCase();
list = list.filter(
(e) =>
e.name.toLowerCase().includes(q) ||
e.targetLabel.toLowerCase().includes(q) ||
e.equipmentLabel.toLowerCase().includes(q) ||
e.bodyPartLabel.toLowerCase().includes(q),
);
}
if (query.sort === 'name') {
list = [...list].sort((a, b) => a.name.localeCompare(b.name));
}
const total = list.length;
const page = query.page || 1;
const pageSize = query.pageSize || 20;
const start = (page - 1) * pageSize;
const pageItems = list.slice(start, start + pageSize).map(toSummary);
return { total, page, pageSize, items: pageItems };
}
findOne(id: string): Exercise {
const ex = this.exercises.find((e) => e.id === id);
if (!ex) throw new NotFoundException(`动作 ${id} 不存在`);
return ex;
}
private countBy(getKey: (e: Exercise) => string | string[]): Map<string, number> {
const map = new Map<string, number>();
for (const e of this.exercises) {
const k = getKey(e);
if (Array.isArray(k)) {
k.forEach((v) => map.set(v, (map.get(v) || 0) + 1));
} else if (k) {
map.set(k, (map.get(k) || 0) + 1);
}
}
return map;
}
private toCategory(map: Map<string, number>, labelMap?: Record<string, string>): CategoryItem[] {
return Array.from(map.entries())
.map(([value, count]) => ({
value,
label: labelMap ? labelOf(labelMap, value) : value,
count,
}))
.sort((a, b) => b.count - a.count);
}
getBodyPartCategories(): CategoryItem[] {
return this.toCategory(this.countBy((e) => e.bodyPart), require('./data/labels').BODY_PART_LABELS);
}
getEquipmentCategories(): CategoryItem[] {
return this.toCategory(this.countBy((e) => e.equipment), require('./data/labels').EQUIPMENT_LABELS);
}
getTargetCategories(): CategoryItem[] {
// 统计每个 target 对应的部位分布,取出现最多的部位作为分组依据
const bpByTarget = new Map<string, Map<string, number>>();
for (const e of this.exercises) {
if (!bpByTarget.has(e.target)) bpByTarget.set(e.target, new Map());
const m = bpByTarget.get(e.target)!;
m.set(e.bodyPart, (m.get(e.bodyPart) || 0) + 1);
}
const items = this.toCategory(this.countBy((e) => e.target), MUSCLE_LABELS);
const bpLabels = require('./data/labels').BODY_PART_LABELS;
return items.map((it) => {
const dist = bpByTarget.get(it.value);
let topBp = '';
let topCount = -1;
dist?.forEach((c, bp) => {
if (c > topCount) {
topCount = c;
topBp = bp;
}
});
return {
...it,
bodyPart: topBp,
bodyPartLabel: topBp ? labelOf(bpLabels, topBp) : undefined,
};
});
}
getMuscleGroupCategories(): CategoryItem[] {
return this.toCategory(this.countBy((e) => e.muscleGroup), MUSCLE_LABELS);
}
getTypeCategories(): CategoryItem[] {
const counts = this.countBy((e) => e.type);
const labels: Record<string, string> = { strength: '力量', cardio: '有氧', flexibility: '柔韧' };
return Array.from(counts.entries()).map(([value, count]) => ({
value,
label: labels[value] || value,
count,
}));
}
/** 核心推荐:按目标肌肉群智能排序 */
recommend(dto: RecommendQueryDto): RecommendResult {
const target = dto.target;
const targetLabel = labelOf(MUSCLE_LABELS, target);
const equipment = dto.equipment;
const scored: RecommendItem[] = [];
for (const ex of this.exercises) {
let score = 0;
const reasons: string[] = [];
// 只有真正训练目标肌群的动作才进入推荐(器械仅作为加分项,不能凭空纳入无关动作)
const trainsTarget =
ex.target === target || ex.secondaryMuscles.includes(target);
if (!trainsTarget) continue;
if (ex.target === target) {
score += 100;
reasons.push(`${targetLabel}为主要训练目标`);
}
if (ex.secondaryMuscles.includes(target)) {
score += 40;
reasons.push(`可协同强化${targetLabel}`);
}
if (equipment) {
if (ex.equipment === equipment) {
score += 30;
reasons.push('适配你选择的器械');
} else if (ex.equipment === 'body weight') {
score += 10;
reasons.push('自重即可替代');
}
}
scored.push({
...toSummary(ex),
score,
reason: reasons.join(' · '),
});
}
scored.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
const items = scored.slice(0, dto.limit || 20);
return { target, targetLabel, total: scored.length, items };
}
listCollections(): CollectionDef[] {
return COLLECTIONS;
}
getCollectionExercises(slug: string, page = 1, pageSize = 20) {
const def = findCollection(slug);
if (!def) throw new NotFoundException(`训练组合 ${slug} 不存在`);
let list = this.exercises;
if (def.filter.bodyParts)
list = list.filter((e) => def.filter.bodyParts!.includes(e.bodyPart));
if (def.filter.excludeBodyParts)
list = list.filter((e) => !def.filter.excludeBodyParts!.includes(e.bodyPart));
if (def.filter.equipment)
list = list.filter((e) => def.filter.equipment!.includes(e.equipment));
if (def.filter.type)
list = list.filter((e) => def.filter.type!.includes(e.type));
// 计划可对动作总数做上限,便于组合成一份可执行的计划
if (def.filter.limit && def.filter.limit > 0 && list.length > def.filter.limit) {
list = list.slice(0, def.filter.limit);
}
const total = list.length;
const start = (page - 1) * pageSize;
const items = list.slice(start, start + pageSize).map(toSummary);
return { collection: def, total, page, pageSize, items };
}
stats() {
return {
total: this.exercises.length,
bodyParts: this.getBodyPartCategories(),
equipment: this.getEquipmentCategories(),
types: this.getTypeCategories(),
};
}
}

View File

@@ -0,0 +1,86 @@
import {
RawExercise,
Exercise,
ExerciseSummary,
ExerciseType,
} from '../exercises.interface';
import {
BODY_PART_LABELS,
EQUIPMENT_LABELS,
MUSCLE_LABELS,
labelOf,
} from '../data/labels';
const MEDIA_BASE_URL = (
process.env.MEDIA_BASE_URL || 'https://cdn.jsdelivr.net/gh/hasaneyldrm/exercises-dataset@main'
).replace(/\/$/, '');
function toMediaUrl(path: string): string {
if (!path) return '';
if (/^https?:\/\//.test(path)) return path;
return `${MEDIA_BASE_URL}/${path}`;
}
function deriveType(raw: RawExercise): ExerciseType {
if (raw.body_part === 'cardio') return 'cardio';
const hay = `${raw.name} ${raw.equipment} ${raw.category || ''}`.toLowerCase();
const flexKeywords = ['stretch', 'yoga', 'mobility', 'pilates', 'flexibility', 'foam roll'];
if (flexKeywords.some((k) => hay.includes(k))) return 'flexibility';
return 'strength';
}
const TYPE_LABELS: Record<ExerciseType, string> = {
strength: '力量',
cardio: '有氧',
flexibility: '柔韧',
};
export function normalize(raw: RawExercise): Exercise {
const type = deriveType(raw);
const zh = raw.instructions?.zh || '';
const en = raw.instructions?.en || '';
const zhSteps = raw.instruction_steps?.zh || [];
const enSteps = raw.instruction_steps?.en || [];
return {
id: raw.id,
name: raw.name,
bodyPart: raw.body_part,
bodyPartLabel: labelOf(BODY_PART_LABELS, raw.body_part),
equipment: raw.equipment,
equipmentLabel: labelOf(EQUIPMENT_LABELS, raw.equipment),
target: raw.target,
targetLabel: labelOf(MUSCLE_LABELS, raw.target),
muscleGroup: raw.muscle_group,
muscleGroupLabel: labelOf(MUSCLE_LABELS, raw.muscle_group),
secondaryMuscles: raw.secondary_muscles || [],
secondaryMusclesLabels: (raw.secondary_muscles || []).map(
(m) => labelOf(MUSCLE_LABELS, m),
),
type,
typeLabel: TYPE_LABELS[type],
image: toMediaUrl(raw.image),
gifUrl: toMediaUrl(raw.gif_url),
instructions: { zh, en },
instructionSteps: { zh: zhSteps, en: enSteps },
attribution: raw.attribution,
mediaId: raw.media_id,
};
}
export function toSummary(ex: Exercise): ExerciseSummary {
return {
id: ex.id,
name: ex.name,
bodyPart: ex.bodyPart,
bodyPartLabel: ex.bodyPartLabel,
equipment: ex.equipment,
equipmentLabel: ex.equipmentLabel,
target: ex.target,
targetLabel: ex.targetLabel,
type: ex.type,
typeLabel: ex.typeLabel,
image: ex.image,
gifUrl: ex.gifUrl,
};
}

View File

@@ -0,0 +1,66 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { FavoritesService } from './favorites.service';
import { AuthGuard } from '../common/auth.guard';
import { CurrentUser } from '../common/current-user.decorator';
import { IsString } from 'class-validator';
class FavoriteBody {
@IsString()
target: string; // exerciseId 或 plan slug
}
@Controller('api/favorites')
@UseGuards(AuthGuard)
export class FavoritesController {
constructor(private readonly fav: FavoritesService) {}
// ---- 动作收藏 ----
@Get('exercises')
listExercises(@CurrentUser() u: { openid: string }) {
return this.fav.listExercises(u.openid);
}
@Post('exercises')
addExercise(@CurrentUser() u: { openid: string }, @Body() body: FavoriteBody) {
return this.fav.addExercise(u.openid, body.target);
}
@Get('exercises/:exerciseId/status')
exerciseStatus(@CurrentUser() u: { openid: string }, @Param('exerciseId') exerciseId: string) {
return this.fav.exerciseStatus(u.openid, exerciseId);
}
@Delete('exercises/:exerciseId')
removeExercise(@CurrentUser() u: { openid: string }, @Param('exerciseId') exerciseId: string) {
return this.fav.removeExercise(u.openid, exerciseId);
}
// ---- 计划收藏 ----
@Get('plans')
listPlans(@CurrentUser() u: { openid: string }) {
return this.fav.listPlans(u.openid);
}
@Post('plans')
addPlan(@CurrentUser() u: { openid: string }, @Body() body: FavoriteBody) {
return this.fav.addPlan(u.openid, body.target);
}
@Get('plans/:slug/status')
planStatus(@CurrentUser() u: { openid: string }, @Param('slug') slug: string) {
return this.fav.planStatus(u.openid, slug);
}
@Delete('plans/:slug')
removePlan(@CurrentUser() u: { openid: string }, @Param('slug') slug: string) {
return this.fav.removePlan(u.openid, slug);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { FavoritesService } from './favorites.service';
import { FavoritesController } from './favorites.controller';
import { ExercisesModule } from '../exercises/exercises.module';
@Module({
imports: [ExercisesModule],
controllers: [FavoritesController],
providers: [FavoritesService],
exports: [FavoritesService],
})
export class FavoritesModule {}

View File

@@ -0,0 +1,110 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { getStore } from '../common/store';
import { ExercisesService } from '../exercises/exercises.service';
import { toSummary } from '../exercises/utils/normalize';
import { ExerciseSummary, CollectionDef } from '../exercises/exercises.interface';
import { findCollection } from '../exercises/data/collections';
interface ExerciseFavorite {
id: string;
openid: string;
exerciseId: string;
createdAt: string;
}
interface PlanFavorite {
id: string;
openid: string;
slug: string;
createdAt: string;
}
@Injectable()
export class FavoritesService {
private exStore = getStore<ExerciseFavorite>('favorites-exercises');
private planStore = getStore<PlanFavorite>('favorites-plans');
constructor(private readonly exercises: ExercisesService) {}
// ---------- 动作收藏 ----------
listExercises(openid: string): ExerciseSummary[] {
const ids = this.exStore
.filter((f) => f.openid === openid)
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
.map((f) => f.exerciseId);
return ids
.map((id) => {
try {
return toSummary(this.exercises.findOne(id));
} catch {
return null;
}
})
.filter((x): x is ExerciseSummary => x !== null);
}
addExercise(openid: string, exerciseId: string) {
// 校验动作存在(不存在会抛 404
this.exercises.findOne(exerciseId);
const exists = this.exStore.find(
(f) => f.openid === openid && f.exerciseId === exerciseId,
);
if (!exists) {
this.exStore.insert({
id: randomUUID(),
openid,
exerciseId,
createdAt: new Date().toISOString(),
});
}
return { favorited: true, total: this.exStore.filter((f) => f.openid === openid).length };
}
removeExercise(openid: string, exerciseId: string) {
this.exStore.remove((f) => f.openid === openid && f.exerciseId === exerciseId);
return { favorited: false, total: this.exStore.filter((f) => f.openid === openid).length };
}
exerciseStatus(openid: string, exerciseId: string) {
return {
favorited: !!this.exStore.find(
(f) => f.openid === openid && f.exerciseId === exerciseId,
),
};
}
// ---------- 计划收藏(官方计划) ----------
listPlans(openid: string): CollectionDef[] {
return this.planStore
.filter((f) => f.openid === openid)
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
.map((f) => findCollection(f.slug))
.filter((c): c is CollectionDef => !!c);
}
addPlan(openid: string, slug: string) {
const def = findCollection(slug);
if (!def) throw new NotFoundException(`官方计划 ${slug} 不存在`);
const exists = this.planStore.find((f) => f.openid === openid && f.slug === slug);
if (!exists) {
this.planStore.insert({
id: randomUUID(),
openid,
slug,
createdAt: new Date().toISOString(),
});
}
return { favorited: true, total: this.planStore.filter((f) => f.openid === openid).length };
}
removePlan(openid: string, slug: string) {
this.planStore.remove((f) => f.openid === openid && f.slug === slug);
return { favorited: false, total: this.planStore.filter((f) => f.openid === openid).length };
}
planStatus(openid: string, slug: string) {
return {
favorited: !!this.planStore.find((f) => f.openid === openid && f.slug === slug),
};
}
}

34
backend/src/main.ts Normal file
View File

@@ -0,0 +1,34 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
if (process.env.CORS_ENABLED !== 'false') {
app.enableCors();
}
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
transformOptions: { enableImplicitConversion: true },
}),
);
const port = parseInt(process.env.PORT || '3000', 10);
await app.listen(port);
console.log(`🏋️ 健身动作推荐后端已启动: http://localhost:${port}`);
console.log(` · 动作列表 GET /api/exercises`);
console.log(` · 动作详情 GET /api/exercises/:id`);
console.log(` · 智能推荐 GET /api/recommend?target=biceps`);
console.log(` · 训练组合 GET /api/collections`);
console.log(` · 分类元数据 GET /api/categories/*`);
console.log(` · 微信登录 POST /api/auth/login`);
console.log(` · 我的资料 GET /api/auth/me | PATCH /api/users/me`);
console.log(` · 动作收藏 GET/POST/DELETE /api/favorites/exercises`);
console.log(` · 计划收藏 GET/POST/DELETE /api/favorites/plans`);
console.log(` · 我的计划 GET/POST /api/plans | 官方计划 GET /api/plans/official`);
}
bootstrap();

View File

@@ -0,0 +1,56 @@
import { IsString, IsOptional, IsArray } from 'class-validator';
import { PlanLevel } from '../../auth/dto/login.dto';
export class CreatePlanDto {
@IsString()
name: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
emoji?: string;
@IsOptional()
@IsString()
color?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
exerciseIds?: string[];
}
export class UpdatePlanDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
emoji?: string;
@IsOptional()
@IsString()
color?: string;
}
export class AddExerciseDto {
@IsString()
exerciseId: string;
}
export class ImportPlanDto {
@IsString()
slug: string;
@IsOptional()
@IsString()
name?: string;
}

View File

@@ -0,0 +1,106 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { PlansService } from './plans.service';
import { ExercisesService } from '../exercises/exercises.service';
import { AuthGuard } from '../common/auth.guard';
import { CurrentUser } from '../common/current-user.decorator';
import { CreatePlanDto, UpdatePlanDto, AddExerciseDto, ImportPlanDto } from './dto/create-plan.dto';
import { CollectionDef, PlanLevel, OfficialPlanGroup } from '../exercises/exercises.interface';
const LEVEL_ORDER: PlanLevel[] = ['beginner', 'intermediate', 'advanced', 'all'];
const LEVEL_LABEL: Record<PlanLevel, string> = {
beginner: '新手入门',
intermediate: '进阶提升',
advanced: '高级挑战',
all: '全阶段适用',
};
@Controller('api/plans')
@UseGuards(AuthGuard)
export class PlansController {
constructor(
private readonly plans: PlansService,
private readonly exercises: ExercisesService,
) {}
@Get()
list(@CurrentUser() u: { openid: string }) {
return this.plans.list(u.openid);
}
@Post()
create(@CurrentUser() u: { openid: string }, @Body() dto: CreatePlanDto) {
return this.plans.create(u.openid, dto);
}
@Post('import')
importFromOfficial(@CurrentUser() u: { openid: string }, @Body() dto: ImportPlanDto) {
return this.plans.importFromOfficial(u.openid, dto);
}
@Get('official')
official() {
const all = this.exercises.listCollections();
const groups: OfficialPlanGroup[] = LEVEL_ORDER.map((level) => ({
level,
label: LEVEL_LABEL[level],
plans: all.filter((c) => (c.level || 'all') === level),
})).filter((g) => g.plans.length > 0);
return { groups };
}
@Get('official/:slug/exercises')
officialExercises(
@Param('slug') slug: string,
@Query('page') page = 1,
@Query('pageSize') pageSize = 20,
) {
return this.exercises.getCollectionExercises(slug, Number(page), Number(pageSize));
}
@Get(':id')
get(@CurrentUser() u: { openid: string }, @Param('id') id: string) {
return this.plans.get(u.openid, id);
}
@Patch(':id')
update(
@CurrentUser() u: { openid: string },
@Param('id') id: string,
@Body() dto: UpdatePlanDto,
) {
return this.plans.update(u.openid, id, dto);
}
@Delete(':id')
remove(@CurrentUser() u: { openid: string }, @Param('id') id: string) {
return this.plans.remove(u.openid, id);
}
@Post(':id/exercises')
addExercise(
@CurrentUser() u: { openid: string },
@Param('id') id: string,
@Body() dto: AddExerciseDto,
) {
return this.plans.addExercise(u.openid, id, dto.exerciseId);
}
@Delete(':id/exercises/:exerciseId')
removeExercise(
@CurrentUser() u: { openid: string },
@Param('id') id: string,
@Param('exerciseId') exerciseId: string,
) {
return this.plans.removeExercise(u.openid, id, exerciseId);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PlansService } from './plans.service';
import { PlansController } from './plans.controller';
import { ExercisesModule } from '../exercises/exercises.module';
@Module({
imports: [ExercisesModule],
controllers: [PlansController],
providers: [PlansService],
exports: [PlansService],
})
export class PlansModule {}

View File

@@ -0,0 +1,164 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { getStore } from '../common/store';
import { ExercisesService } from '../exercises/exercises.service';
import { toSummary } from '../exercises/utils/normalize';
import { ExerciseSummary } from '../exercises/exercises.interface';
import { CreatePlanDto, UpdatePlanDto, ImportPlanDto } from './dto/create-plan.dto';
export interface PlanRecord {
id: string;
openid: string;
name: string;
description: string;
emoji: string;
color: string;
exerciseIds: string[];
createdAt: string;
updatedAt: string;
}
export interface PlanSummary {
id: string;
name: string;
description: string;
emoji: string;
color: string;
exerciseCount: number;
coverExerciseId?: string;
createdAt: string;
updatedAt: string;
}
export interface PlanDetail extends PlanSummary {
exercises: ExerciseSummary[];
}
const DEFAULT_EMOJI = '📋';
const PALETTE = ['#D9B36C', '#58C9B9', '#9B72FF', '#FF6B6B', '#4D96FF', '#6BCB77'];
@Injectable()
export class PlansService {
private store = getStore<PlanRecord>('plans');
constructor(private readonly exercises: ExercisesService) {}
private toSummary(p: PlanRecord): PlanSummary {
return {
id: p.id,
name: p.name,
description: p.description,
emoji: p.emoji,
color: p.color,
exerciseCount: p.exerciseIds.length,
coverExerciseId: p.exerciseIds[0],
createdAt: p.createdAt,
updatedAt: p.updatedAt,
};
}
list(openid: string): PlanSummary[] {
return this.store
.filter((p) => p.openid === openid)
.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1))
.map((p) => this.toSummary(p));
}
create(openid: string, dto: CreatePlanDto): PlanDetail {
const now = new Date().toISOString();
const ids = (dto.exerciseIds || []).filter((id) => this.safeExists(id));
const plan: PlanRecord = {
id: randomUUID(),
openid,
name: dto.name.trim() || '我的训练计划',
description: dto.description?.trim() || '',
emoji: dto.emoji || DEFAULT_EMOJI,
color: dto.color || PALETTE[this.store.all().length % PALETTE.length],
exerciseIds: ids,
createdAt: now,
updatedAt: now,
};
this.store.insert(plan);
return this.toDetail(plan);
}
private getOwned(openid: string, id: string): PlanRecord {
const p = this.store.find((x) => x.id === id);
if (!p) throw new NotFoundException(`计划 ${id} 不存在`);
if (p.openid !== openid) throw new ForbiddenException('无权访问该计划');
return p;
}
get(openid: string, id: string): PlanDetail {
return this.toDetail(this.getOwned(openid, id));
}
private toDetail(p: PlanRecord): PlanDetail {
const exercises = p.exerciseIds
.map((id) => {
try {
return toSummary(this.exercises.findOne(id));
} catch {
return null;
}
})
.filter((x): x is ExerciseSummary => x !== null);
return { ...this.toSummary(p), exercises };
}
update(openid: string, id: string, dto: UpdatePlanDto): PlanDetail {
const patch: Partial<PlanRecord> = { updatedAt: new Date().toISOString() };
if (dto.name !== undefined) patch.name = dto.name.trim() || '我的训练计划';
if (dto.description !== undefined) patch.description = dto.description.trim();
if (dto.emoji !== undefined) patch.emoji = dto.emoji;
if (dto.color !== undefined) patch.color = dto.color;
const updated = this.store.update((x) => x.id === id && x.openid === openid, patch);
if (!updated) throw new NotFoundException(`计划 ${id} 不存在`);
return this.toDetail(updated as PlanRecord);
}
remove(openid: string, id: string) {
const p = this.getOwned(openid, id);
this.store.remove((x) => x.id === p.id);
return { deleted: true };
}
addExercise(openid: string, id: string, exerciseId: string): PlanDetail {
const p = this.getOwned(openid, id);
this.exercises.findOne(exerciseId); // 校验存在
if (!p.exerciseIds.includes(exerciseId)) {
p.exerciseIds.push(exerciseId);
this.store.update((x) => x.id === id, { exerciseIds: p.exerciseIds, updatedAt: new Date().toISOString() });
}
return this.toDetail(p);
}
removeExercise(openid: string, id: string, exerciseId: string): PlanDetail {
const p = this.getOwned(openid, id);
p.exerciseIds = p.exerciseIds.filter((x) => x !== exerciseId);
this.store.update((x) => x.id === id, { exerciseIds: p.exerciseIds, updatedAt: new Date().toISOString() });
return this.toDetail(p);
}
/** 从官方计划一键导入为我的计划 */
importFromOfficial(openid: string, dto: ImportPlanDto): PlanDetail {
const res = this.exercises.getCollectionExercises(dto.slug, 1, 999);
const ids = res.items.map((e) => e.id);
return this.create(openid, {
name: dto.name?.trim() || `${res.collection.name} · 我的副本`,
description: res.collection.description,
emoji: res.collection.emoji,
color: res.collection.color,
exerciseIds: ids,
});
}
private safeExists(id: string): boolean {
try {
this.exercises.findOne(id);
return true;
} catch {
return false;
}
}
}

View File

@@ -0,0 +1,19 @@
import { Body, Controller, Patch, UseGuards } from '@nestjs/common';
import { UsersService } from './users.service';
import { UpdateProfileDto } from '../auth/dto/login.dto';
import { AuthGuard } from '../common/auth.guard';
import { CurrentUser } from '../common/current-user.decorator';
@Controller('api/users')
@UseGuards(AuthGuard)
export class UsersController {
constructor(private readonly users: UsersService) {}
@Patch('me')
update(
@CurrentUser() user: { openid: string },
@Body() dto: UpdateProfileDto,
) {
return this.users.update(user.openid, dto);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
import { getStore } from '../common/store';
export interface UserRecord {
openid: string;
nickname: string;
avatar: string;
createdAt: string;
updatedAt: string;
}
@Injectable()
export class UsersService {
private store = getStore<UserRecord>('users');
findByOpenid(openid: string): UserRecord | undefined {
return this.store.find((u) => u.openid === openid);
}
create(input: { openid: string; nickname?: string; avatar?: string }): UserRecord {
const now = new Date().toISOString();
const user: UserRecord = {
openid: input.openid,
nickname: input.nickname?.trim() || '健身学员',
avatar: input.avatar || '🏃',
createdAt: now,
updatedAt: now,
};
return this.store.insert(user);
}
update(openid: string, patch: { nickname?: string; avatar?: string }): UserRecord {
const now = new Date().toISOString();
const updated = this.store.update(
(u) => u.openid === openid,
{
...(patch.nickname !== undefined ? { nickname: patch.nickname.trim() || '健身学员' } : {}),
...(patch.avatar !== undefined ? { avatar: patch.avatar } : {}),
updatedAt: now,
},
);
return updated as UserRecord;
}
}