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

16
backend/.env.example Normal file
View File

@@ -0,0 +1,16 @@
# 后端服务端口
PORT=3000
# 前端(微信小程序)请求的基础地址,用于生成可被小程序访问的媒体 URL。
# 开发时填写你本机局域网 IP例如 http://192.168.1.10:3000
# 生产时填写你的域名(必须 HTTPS且已在小程序后台配置 request 合法域名)
API_BASE_URL=http://localhost:3000
# 媒体资源基础地址GIF / 图片)。默认直接指向源数据集在 jsDelivr 的 CDN
# 无需本地下载媒体文件即可在开发环境加载动作演示。
# 注意微信小程序要求图片域名在「request/downloadFile 合法域名」中白名单,
# 开发阶段可在开发者工具勾选「不校验合法域名」;正式发布需替换为自有 CDN 并配置白名单。
MEDIA_BASE_URL=https://cdn.jsdelivr.net/gh/hasaneyldrm/exercises-dataset@main
# 是否允许跨域(小程序走 wx.request 通常不需要,但方便 Web 调试 / Postman
CORS_ENABLED=true

78
backend/README.md Normal file
View File

@@ -0,0 +1,78 @@
# FITCOACH 后端NestJS
健身动作数据代理与推荐服务。所有动作数据统一存储在后端,向前端小程序提供 REST 接口。
## 技术栈
- NestJS 10 + Express
- 内存数据集(构建时由 `exercises-dataset` 导入),查询/筛选/推荐均在内存完成,毫秒级响应
- 可选静态媒体服务(`/media`,指向 `backend/media/`
## 环境变量(`.env`,参考 `.env.example`
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `PORT` | `3000` | 服务端口 |
| `API_BASE_URL` | `http://localhost:3000` | 小程序访问基址(生成媒体 URL 用) |
| `MEDIA_BASE_URL` | `http://localhost:3000/media` | 媒体资源基址;可改为 CDN |
| `CORS_ENABLED` | `true` | 是否允许跨域Web 调试用) |
## 安装与运行
```bash
npm install
npm run build # 触发 prebuild: 自动下载 exercises.json 到 src/exercises/data/
npm run start:dev # 开发watch
npm run start:prod # 生产node dist/main.js
```
## 脚本
- `npm run sync:media` — 将数据集的图片/动画同步到 `backend/media/`(供 `/media` 提供)。可选环境变量 `MEDIA_CONCURRENCY`(并发)、`MEDIA_ONLY=gif|image`
- `node scripts/analyze.mjs` — 扫描数据集,输出各维度去重值与缺失的中文标签(用于补全 `labels.ts`)。
## 数据导入说明
数据集 `exercises.json`1,324 条)通过 `scripts/ensure-data.mjs` 在构建时自动从 GitHub 下载;
也可手动把 `exercises-dataset/data/exercises.json` 复制到 `src/exercises/data/exercises.json`
服务启动时由 `ExercisesService.onModuleInit()` 读取并 `normalize()` 为带中文字段的结构化对象。
## 接口示例
```bash
# 列表 + 筛选(按器械 dumbbell分页
curl "http://localhost:3000/api/exercises?equipment=dumbbell&pageSize=5"
# 动作详情
curl "http://localhost:3000/api/exercises/0001"
# 目标肌群智能推荐(肱二头肌,限定哑铃)
curl "http://localhost:3000/api/recommend?target=biceps&equipment=dumbbell&limit=10"
# 分类元数据
curl "http://localhost:3000/api/categories/targets"
curl "http://localhost:3000/api/categories/equipment"
# 训练组合
curl "http://localhost:3000/api/collections"
curl "http://localhost:3000/api/collections/legs/exercises?pageSize=10"
# 搜索
curl "http://localhost:3000/api/search?q=abs"
```
## 推荐算法
对每条动作计算相关性评分:
- 目标肌群完全匹配 `target`+100理由「主要训练 XX」
- 出现在协同肌群 `secondaryMuscles`+40理由「协同训练 XX」
- 指定器械匹配:+30若为自重可替代+10
仅返回评分 > 0 的动作,按评分降序、名称升序返回。
## 目录
```
src/exercises/
├── exercises.module.ts
├── exercises.service.ts # 数据加载、筛选、分类、推荐、组合
├── exercises.controller.ts # /api 路由
├── exercises.interface.ts # 类型定义
├── dto/ # QueryExercisesDto / RecommendQueryDtoclass-validator 校验)
├── utils/normalize.ts # 原始 → 结构化(类型推导、媒体 URL、中文标签
└── data/
├── exercises.json # 数据集(构建时生成)
├── labels.ts # 中英文标签映射
└── collections.ts # 智能训练组合定义
```

12
backend/nest-cli.json Normal file
View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": false,
"assets": [
{ "include": "exercises/data/exercises.json", "outDir": "dist" }
],
"watchAssets": true
}
}

4603
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
backend/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "fitness-coach-backend",
"version": "1.0.0",
"description": "健身动作推荐小程序 - NestJS 后端代理与数据服务",
"author": "WorkBuddy",
"private": true,
"license": "MIT",
"scripts": {
"prebuild": "node scripts/ensure-data.mjs",
"build": "nest build && node scripts/copy-data.mjs",
"start": "nest start",
"dev": "nest start --watch",
"start:prod": "node dist/main.js",
"sync:media": "node scripts/sync-media.mjs",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
},
"dependencies": {
"@nestjs/common": "^10.4.4",
"@nestjs/core": "^10.4.4",
"@nestjs/platform-express": "^10.4.4",
"@nestjs/serve-static": "^4.0.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.5",
"@nestjs/schematics": "^10.1.4",
"@nestjs/testing": "^10.4.4",
"@types/express": "^4.17.21",
"@types/node": "^20.14.0",
"typescript": "^5.5.4"
}
}

View File

@@ -0,0 +1,74 @@
/**
* 数据分析脚本:扫描 exercises.json输出各维度的去重取值与数量
* 并报告哪些取值尚未被中文标签映射覆盖(便于补全 labels.ts
*
* 用法: node scripts/analyze.mjs [path-to-exercises.json]
*/
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA = process.argv[2] || path.join(__dirname, '..', 'src', 'exercises', 'data', 'exercises.json');
// 复用 TS 标签映射(用 require 不行,这里用一份 JS 镜像做缺失检测)
const BODY_PART_LABELS = {
back: '背部', cardio: '有氧心肺', chest: '胸部', 'lower arms': '前臂',
'lower legs': '小腿', neck: '颈部', shoulders: '肩部', 'upper arms': '上臂',
'upper legs': '大腿', waist: '腰腹',
};
const EQUIPMENT_LABELS = {
'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': '健腹轮',
none: '无器械', other: '其他',
};
const MUSCLE_LABELS = {
biceps: '肱二头肌', triceps: '肱三头肌', forearms: '前臂', abs: '腹肌', abdominals: '腹肌',
obliques: '腹斜肌', pectorals: '胸大肌', chest: '胸部', quadriceps: '股四头肌',
hamstrings: '腘绳肌', glutes: '臀大肌', calves: '小腿', traps: '斜方肌', 'upper traps': '上斜方肌',
lats: '背阔肌', 'lower back': '下背', delts: '三角肌', shoulders: '肩部', hips: '髋部',
adductors: '内收肌', neck: '颈部', 'serratus anterior': '前锯肌', 'spinal erectors': '竖脊肌',
'hip flexors': '髋屈肌', 'middle back': '中背',
};
function tally(arr, keyFn) {
const m = new Map();
for (const x of arr) {
const k = keyFn(x);
if (!k) continue;
if (Array.isArray(k)) k.forEach((v) => m.set(v, (m.get(v) || 0) + 1));
else m.set(k, (m.get(k) || 0) + 1);
}
return m;
}
function report(name, map, labels) {
const missing = [];
const rows = Array.from(map.entries()).sort((a, b) => b[1] - a[1]);
console.log(`\n=== ${name} (${rows.length} 种) ===`);
for (const [v, c] of rows) {
const has = labels[v] ? '' : ' ⚠ 缺标签';
if (!labels[v]) missing.push(v);
console.log(` ${v.padEnd(22)} ${String(c).padStart(5)} ${labels[v] || ''}${has}`);
}
return missing;
}
const raw = JSON.parse(fs.readFileSync(DATA, 'utf-8'));
console.log(`总动作数: ${raw.length}`);
const eqMiss = report('EQUIPMENT', tally(raw, (e) => e.equipment), EQUIPMENT_LABELS);
const tMiss = report('TARGET', tally(raw, (e) => e.target), MUSCLE_LABELS);
const mgMiss = report('MUSCLE_GROUP', tally(raw, (e) => e.muscle_group), MUSCLE_LABELS);
const smMiss = report('SECONDARY_MUSCLES', tally(raw, (e) => e.secondary_muscles), MUSCLE_LABELS);
console.log('\n=== 缺失中文标签汇总(需补全 labels.ts===');
console.log('equipment :', eqMiss.join(', ') || '(无)');
console.log('target :', tMiss.join(', ') || '(无)');
console.log('muscle_group:', mgMiss.join(', ') || '(无)');
console.log('secondary :', smMiss.join(', ') || '(无)');

View File

@@ -0,0 +1,20 @@
// 将原始数据集 exercises.json 从 src 复制到编译产物 dist
// 因为 tsc 不会拷贝非 .ts 资源文件。
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
const src = path.join(root, 'src', 'exercises', 'data', 'exercises.json');
const destDir = path.join(root, 'dist', 'exercises', 'data');
const dest = path.join(destDir, 'exercises.json');
if (!fs.existsSync(src)) {
console.warn('[copy-data] 源数据集不存在,跳过:', src);
process.exit(0);
}
fs.mkdirSync(destDir, { recursive: true });
fs.copyFileSync(src, dest);
console.log('[copy-data] 已复制数据集 ->', dest);

View File

@@ -0,0 +1,33 @@
/**
* prebuild 脚本:确保后端数据集存在。
* 若 backend/src/exercises/data/exercises.json 不存在,则从 GitHub 原始数据集下载。
*/
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_FILE = path.join(__dirname, '..', 'src', 'exercises', 'data', 'exercises.json');
const SOURCE =
'https://raw.githubusercontent.com/hasaneyldrm/exercises-dataset/main/data/exercises.json';
async function main() {
if (fs.existsSync(DATA_FILE)) {
console.log('[ensure-data] 数据集已存在,跳过下载。');
return;
}
console.log('[ensure-data] 未找到数据集,开始从 GitHub 下载 ...');
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
const res = await fetch(SOURCE);
if (!res.ok) throw new Error(`下载失败: HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
fs.writeFileSync(DATA_FILE, buf);
console.log(`[ensure-data] 已保存数据集 (${(buf.length / 1024 / 1024).toFixed(1)} MB)`);
}
main().catch((e) => {
console.error('[ensure-data] 错误:', e.message);
console.error('请手动将 exercises-dataset 的 data/exercises.json 放入 backend/src/exercises/data/');
process.exit(1);
});

View File

@@ -0,0 +1,74 @@
/**
* 媒体同步脚本:将 exercises-dataset 的 images/ 与 videos/ 下载到 backend/media/
* 使后端 /media 静态服务可直接提供缩略图与动画 GIF。
*
* 用法: node scripts/sync-media.mjs
* 可选环境变量:
* MEDIA_CONCURRENCY=8 并发下载数
* MEDIA_ONLY=gif|image 仅下载某类gif / image
*/
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_FILE = path.join(__dirname, '..', 'src', 'exercises', 'data', 'exercises.json');
const MEDIA_DIR = path.join(__dirname, '..', 'media');
const RAW_BASE = 'https://raw.githubusercontent.com/hasaneyldrm/exercises-dataset/main/';
const CONCURRENCY = parseInt(process.env.MEDIA_CONCURRENCY || '8', 10);
const ONLY = process.env.MEDIA_ONLY || 'all';
async function main() {
if (!fs.existsSync(DATA_FILE)) {
console.error('[sync-media] 未找到数据集,请先运行 npm run build 或手动放置 exercises.json');
process.exit(1);
}
const raw = JSON.parse(fs.readFileSync(DATA_FILE, 'utf-8'));
const paths = new Set();
for (const e of raw) {
if (e.image && (ONLY === 'all' || ONLY === 'image')) paths.add(e.image);
if (e.gif_url && (ONLY === 'all' || ONLY === 'gif')) paths.add(e.gif_url);
}
fs.mkdirSync(MEDIA_DIR, { recursive: true });
const list = Array.from(paths);
console.log(`[sync-media] 待下载媒体 ${list.length} 个,并发 ${CONCURRENCY}`);
let done = 0;
let skipped = 0;
let failed = 0;
const queue = [...list];
async function worker() {
while (queue.length) {
const p = queue.shift();
const dest = path.join(MEDIA_DIR, p);
if (fs.existsSync(dest) && fs.statSync(dest).size > 0) {
skipped++;
continue;
}
try {
const res = await fetch(RAW_BASE + p);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, buf);
done++;
} catch (err) {
failed++;
console.error(` 失败 ${p}: ${err.message}`);
}
if ((done + skipped + failed) % 50 === 0) {
console.log(` 进度 ${done + skipped + failed}/${list.length}`);
}
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
console.log(`[sync-media] 完成:新增 ${done},已存在 ${skipped},失败 ${failed}`);
}
main().catch((e) => {
console.error('[sync-media] 错误:', e.message);
process.exit(1);
});

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;
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

23
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"esModuleInterop": true
},
"exclude": ["node_modules", "dist"]
}