康心伴Logo
康心伴WellAlly
健康技术

构建Node.js + Postgres健康游戏化API:完整技术指南

W
WellAlly 技术团队
5 分钟阅读

构建Node.js + Postgres健康游戏化API:完整技术指南

引言:游戏化在健康科技中的力量

游戏化(Gamification)将游戏设计元素应用于非游戏场景,在健康领域已被证明能显著提高用户参与度和行为改变效果。研究表明,良好的游戏化机制可以将健康应用的用户留存率提高30-50%,将日常健康行为的完成率提高25-40%

本指南将详细介绍如何使用Node.js + PostgreSQL构建一个完整的健康游戏化API系统。

为什么选择Node.js + PostgreSQL?

技术栈优势健康应用适用性
Node.js高并发处理、丰富的生态系统、实时能力实时健康数据同步、快速API响应
PostgreSQLACID事务、复杂查询、JSON支持复杂游戏规则、数据分析需求
TypeScript类型安全、更好的IDE支持减少运行时错误、提高代码质量

核心游戏化机制

本指南实现的系统包括:

  1. 积分系统:健康行为积分奖励
  2. 成就系统:里程碑式成就解锁
  3. 排行榜:社交竞争机制
  4. 每日挑战:短期目标激励
  5. 等级系统:长期进步追踪
  6. 徽章收集:可视化成就展示

系统架构设计

整体架构

code
┌──────────────────────────────────────────────────────────────┐
│                         客户端应用                            │
│  (移动应用 / Web应用 / 可穿戴设备)                             │
└────────────────────┬─────────────────────────────────────────┘
                     │ HTTP/REST API
                     │ WebSocket (实时通知)
┌────────────────────┴─────────────────────────────────────────┐
│                    API网关层 (Express.js)                     │
│  - 身份认证 (JWT)                                             │
│  - 速率限制                                                   │
│  - 请求日志                                                   │
└────────────────────┬─────────────────────────────────────────┘
                     │
┌────────────────────┴─────────────────────────────────────────┐
│                    业务逻辑层 (Services)                      │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐        │
│  │ Gamification │  │  Health      │  │  Social      │        │
│  │   Service    │  │  Tracking    │  │  Service     │        │
│  │              │  │  Service     │  │              │        │
│  └──────────────┘  └──────────────┘  └──────────────┘        │
└────────────────────┬─────────────────────────────────────────┘
                     │
┌────────────────────┴─────────────────────────────────────────┐
│                    数据访问层 (Repository)                    │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐        │
│  │   User       │  │   Points     │  │ Achievement  │        │
│  │ Repository   │  │ Repository   │  │ Repository   │        │
│  └──────────────┘  └──────────────┘  └──────────────┘        │
└────────────────────┬─────────────────────────────────────────┘
                     │
┌────────────────────┴─────────────────────────────────────────┐
│              PostgreSQL 数据库 (带 pgBouncer 连接池)          │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐        │
│  │   Users      │  │   Points     │  │ Achievements │        │
│  │   Table      │  │   Table      │  │   Table      │        │
│  └──────────────┘  └──────────────┘  └──────────────┘        │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐        │
│  │  Badges      │  │  Leaderboard │  │  Challenges  │        │
│  │   Table      │  │   Table      │  │   Table      │        │
│  └──────────────┘  └──────────────┘  └──────────────┘        │
└──────────────────────────────────────────────────────────────┘
                     │
┌────────────────────┴─────────────────────────────────────────┐
│                    Redis 缓存层                               │
│  - 会话管理                                                  │
│  - 排行榜缓存                                                │
│  - 实时统计                                                  │
└──────────────────────────────────────────────────────────────┘
Code collapsed

技术栈

code
{
  "backend": {
    "runtime": "Node.js 20 LTS",
    "framework": "Express.js 4.18",
    "language": "TypeScript 5.3"
  },
  "database": {
    "primary": "PostgreSQL 16",
    "cache": "Redis 7.2",
    "orm": "Prisma 5.7"
  },
  "libraries": {
    "authentication": "jsonwebtoken, bcrypt",
    "validation": "zod",
    "websocket": "socket.io",
    "testing": "jest, supertest"
  }
}
Code collapsed

数据库设计

Prisma Schema定义

code
// prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// 用户表
model User {
  id            String    @id @default(cuid())
  email         String    @unique
  username      String    @unique
  passwordHash  String
  displayName   String
  avatarUrl     String?
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt

  // 游戏化字段
  level         Int       @default(1)
  totalPoints   Int       @default(0)
  currentExp    Int       @default(0)

  // 关系
  points        Point[]
  achievements  UserAchievement[]
  badges        UserBadge[]
  challenges    UserChallenge[]
  leaderboardEntry Leaderboard?

  @@index([email])
  @@index([username])
  @@map("users")
}

// 积分记录表
model Point {
  id          String   @id @default(cuid())
  userId      String
  user        User     @relation(fields: [userId], references: [id])

  // 积分类型和来源
  type        PointType
  source      String   // 例如:"daily_steps", "water_intake", "medication_adherence"
  amount      Int
  description String?

  // 元数据(JSON字段存储额外信息)
  metadata    Json?

  timestamp   DateTime @default(now())

  @@index([userId, timestamp])
  @@index([type])
  @@map("points")
}

enum PointType {
  DAILY_STEPS
  WATER_INTAKE
  MEDICATION_ADHERENCE
  SLEEP_TRACKING
  EXERCISE
  MEAL_LOGGING
  BLOOD_PRESSURE
  BLOOD_SUGAR
  WEIGHT_LOGGING
  CHALLENGE_COMPLETION
  ACHIEVEMENT_UNLOCK
  STREAK_BONUS
  SOCIAL_INTERACTION
}

// 成就表
model Achievement {
  id          String   @id @default(cuid())
  name        String
  description String
  iconUrl     String?
  category    AchievementCategory

  // 解锁条件
  requirement Json     // 存储解锁条件的JSON配置
  pointsReward Int    @default(0)

  // 难度级别
  difficulty  DifficultyLevel @default(MEDIUM)

  createdAt   DateTime @default(now())

  // 关系
  userAchievements UserAchievement[]

  @@index([category])
  @@map("achievements")
}

enum AchievementCategory {
  STEPS
  NUTRITION
  MEDICATION
  SLEEP
  EXERCISE
  SOCIAL
  STREAK
  MILESTONE
}

enum DifficultyLevel {
  EASY
  MEDIUM
  HARD
  EXPERT
}

// 用户成就关联表
model UserAchievement {
  id            String      @id @default(cuid())
  userId        String
  user          User        @relation(fields: [userId], references: [id])
  achievementId String
  achievement   Achievement @relation(fields: [achievementId], references: [id])

  progress      Int         @default(0)      // 当前进度
  target        Int                          // 目标值
  unlocked      Boolean     @default(false)
  unlockedAt    DateTime?

  @@unique([userId, achievementId])
  @@index([userId])
  @@index([achievementId])
  @@map("user_achievements")
}

// 徽章表
model Badge {
  id          String   @id @default(cuid())
  name        String
  description String
  iconUrl     String
  rarity      BadgeRarity @default(COMMON)

  // 解锁条件(多个成就的组合)
  requirements Json     // 成就ID数组和条件

  createdAt   DateTime @default(now())

  // 关系
  userBadges  UserBadge[]

  @@map("badges")
}

enum BadgeRarity {
  COMMON
  RARE
  EPIC
  LEGENDARY
}

// 用户徽章关联表
model UserBadge {
  id        String   @id @default(cuid())
  userId    String
  user      User     @relation(fields: [userId], references: [id])
  badgeId   String
  badge     Badge    @relation(fields: [badgeId], references: [id])

  earnedAt  DateTime @default(now())

  @@unique([userId, badgeId])
  @@index([userId])
  @@map("user_badges")
}

// 每日挑战表
model Challenge {
  id          String   @id @default(cuid())
  title       String
  description String
  type        ChallengeType

  // 挑战目标
  targetValue Int
  unit        String   // 例如:"steps", "glasses", "minutes"

  // 奖励
  pointsReward Int
  badgeReward  String?  // 徽章ID(可选)

  // 时间范围
  startDate   DateTime
  endDate     DateTime

  // 难度
  difficulty  DifficultyLevel @default(MEDIUM)

  createdAt   DateTime @default(now())

  // 关系
  userChallenges UserChallenge[]

  @@index([startDate, endDate])
  @@map("challenges")
}

enum ChallengeType {
  DAILY_STEPS
  WATER_INTAKE
  EXERCISE_MINUTES
  SLEEP_HOURS
  CALORIE_BURN
  MEDITATION_MINUTES
  MEAL_LOGGING
  MEDICATION_ADHERENCE
}

// 用户挑战关联表
model UserChallenge {
  id          String    @id @default(cuid())
  userId      String
  user        User      @relation(fields: [userId], references: [id])
  challengeId String
  challenge   Challenge @relation(fields: [challengeId], references: [id])

  progress    Int       @default(0)
  completed   Boolean   @default(false)
  completedAt DateTime?

  @@unique([userId, challengeId])
  @@index([userId])
  @@map("user_challenges")
}

// 排行榜表
model Leaderboard {
  id              String   @id @default(cuid())
  userId          String   @unique
  user            User     @relation(fields: [userId], references: [id])

  // 排行榜类型
  type            LeaderboardType @default(OVERALL)

  // 得分
  score           Int      @default(0)
  weeklyScore     Int      @default(0)

  // 排名(缓存)
  rank            Int?
  weeklyRank      Int?

  // 时间范围
  periodStart     DateTime @default(now())
  periodEnd       DateTime

  updatedAt       DateTime @updatedAt

  @@index([type, score])
  @@index([type, weeklyScore])
  @@map("leaderboard")
}

enum LeaderboardType {
  OVERALL
  STEPS
  EXERCISE
  NUTRITION
  SLEEP
  STREAK
  WEEKLY
  MONTHLY
}

// 社交关系表
model SocialConnection {
  id          String   @id @default(cuid())
  userId      String
  friendId    String

  // 关系类型
  connectionType ConnectionType @default(FRIEND)

  createdAt   DateTime @default(now())

  @@unique([userId, friendId])
  @@index([userId])
  @@index([friendId])
  @@map("social_connections")
}

enum ConnectionType {
  FRIEND
  FOLLOWER
  CHALLENGE_PARTNER
}
Code collapsed

数据库迁移

code
# 生成迁移文件
npx prisma migrate dev --name init_gamification_schema

# 应用迁移到生产环境
npx prisma migrate deploy

# 生成Prisma客户端
npx prisma generate
Code collapsed

核心服务实现

用户服务(UserService)

code
// src/services/user.service.ts

import { PrismaClient, User } from '@prisma/client';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { ConflictError, UnauthorizedError } from '../utils/errors';

export interface CreateUserData {
  email: string;
  username: string;
  password: string;
  displayName: string;
}

export interface LoginData {
  email: string;
  password: string;
}

export class UserService {
  private prisma: PrismaClient;
  private jwtSecret: string;

  constructor() {
    this.prisma = new PrismaClient();
    this.jwtSecret = process.env.JWT_SECRET || 'your-secret-key';
  }

  /**
   * 创建新用户
   */
  async createUser(data: CreateUserData): Promise<{ user: Partial<User>; token: string }> {
    // 检查邮箱是否已存在
    const existingEmail = await this.prisma.user.findUnique({
      where: { email: data.email }
    });

    if (existingEmail) {
      throw new ConflictError('邮箱已被注册');
    }

    // 检查用户名是否已存在
    const existingUsername = await this.prisma.user.findUnique({
      where: { username: data.username }
    });

    if (existingUsername) {
      throw new ConflictError('用户名已被使用');
    }

    // 加密密码
    const passwordHash = await bcrypt.hash(data.password, 10);

    // 创建用户
    const user = await this.prisma.user.create({
      data: {
        email: data.email,
        username: data.username,
        passwordHash,
        displayName: data.displayName,
        level: 1,
        totalPoints: 0,
        currentExp: 0
      }
    });

    // 初始化排行榜记录
    await this.prisma.leaderboard.create({
      data: {
        userId: user.id,
        type: 'OVERALL',
        score: 0,
        weeklyScore: 0,
        periodEnd: this.getEndOfWeek()
      }
    });

    // 生成JWT令牌
    const token = this.generateToken(user.id);

    const { passwordHash: _, ...userWithoutPassword } = user;

    return { user: userWithoutPassword, token };
  }

  /**
   * 用户登录
   */
  async login(data: LoginData): Promise<{ user: Partial<User>; token: string }> {
    const user = await this.prisma.user.findUnique({
      where: { email: data.email }
    });

    if (!user) {
      throw new UnauthorizedError('邮箱或密码错误');
    }

    const isPasswordValid = await bcrypt.compare(
      data.password,
      user.passwordHash
    );

    if (!isPasswordValid) {
      throw new UnauthorizedError('邮箱或密码错误');
    }

    const token = this.generateToken(user.id);
    const { passwordHash: _, ...userWithoutPassword } = user;

    return { user: userWithoutPassword, token };
  }

  /**
   * 获取用户信息
   */
  async getUserById(userId: string): Promise<Partial<User>> {
    const user = await this.prisma.user.findUnique({
      where: { id: userId }
    });

    if (!user) {
      throw new Error('用户不存在');
    }

    const { passwordHash: _, ...userWithoutPassword } = user;
    return userWithoutPassword;
  }

  /**
   * 获取用户游戏化统计信息
   */
  async getUserGamificationStats(userId: string) {
    const [user, achievements, badges, points] = await Promise.all([
      this.prisma.user.findUnique({
        where: { id: userId },
        include: {
          leaderboardEntry: true
        }
      }),
      this.prisma.userAchievement.count({
        where: { userId, unlocked: true }
      }),
      this.prisma.userBadge.count({
        where: { userId }
      }),
      this.prisma.point.groupBy({
        by: ['type'],
        where: { userId },
        _sum: {
          amount: true
        }
      })
    ]);

    return {
      level: user?.level,
      totalPoints: user?.totalPoints,
      currentExp: user?.currentExp,
      expToNextLevel: this.getExpForLevel(user?.level || 1),
      achievementsUnlocked: achievements,
      badgesEarned: badges,
      leaderboardRank: user?.leaderboardEntry?.rank,
      pointsByType: points.map(p => ({
        type: p.type,
        total: p._sum.amount || 0
      }))
    };
  }

  /**
   * 生成JWT令牌
   */
  private generateToken(userId: string): string {
    return jwt.sign(
      { userId },
      this.jwtSecret,
      { expiresIn: '7d' }
    );
  }

  /**
   * 获取本周结束时间
   */
  private getEndOfWeek(): Date {
    const now = new Date();
    const dayOfWeek = now.getDay();
    const daysUntilSunday = 7 - dayOfWeek;
    const endOfWeek = new Date(now);
    endOfWeek.setDate(now.getDate() + daysUntilSunday);
    endOfWeek.setHours(23, 59, 59, 999);
    return endOfWeek;
  }

  /**
   * 计算升级所需经验值
   */
  private getExpForLevel(level: number): number {
    return Math.floor(100 * Math.pow(1.5, level - 1));
  }
}
Code collapsed

游戏化服务(GamificationService)

code
// src/services/gamification.service.ts

import { PrismaClient } from '@prisma/client';
import { UserService } from './user.service';
import { NotificationService } from './notification.service';

export interface AddPointsData {
  userId: string;
  type: string;
  source: string;
  amount: number;
  description?: string;
  metadata?: Record<string, any>;
}

export class GamificationService {
  private prisma: PrismaClient;
  private userService: UserService;
  private notificationService: NotificationService;

  constructor() {
    this.prisma = new PrismaClient();
    this.userService = new UserService();
    this.notificationService = new NotificationService();
  }

  /**
   * 添加积分并处理升级逻辑
   */
  async addPoints(data: AddPointsData) {
    return await this.prisma.$transaction(async (tx) => {
      // 1. 创建积分记录
      const point = await tx.point.create({
        data: {
          userId: data.userId,
          type: data.type as any,
          source: data.source,
          amount: data.amount,
          description: data.description,
          metadata: data.metadata as any
        }
      });

      // 2. 更新用户总积分
      const user = await tx.user.update({
        where: { id: data.userId },
        data: {
          totalPoints: {
            increment: data.amount
          },
          currentExp: {
            increment: data.amount
          }
        }
      });

      // 3. 检查是否升级
      const levelUp = await this.checkLevelUp(tx, user);

      // 4. 更新排行榜分数
      await tx.leaderboard.update({
        where: { userId: data.userId },
        data: {
          score: {
            increment: data.amount
          },
          weeklyScore: {
            increment: data.amount
          }
        }
      });

      // 5. 检查成就解锁
      await this.checkAchievements(tx, data.userId);

      return {
        point,
        newLevel: levelUp ? user.level : null,
        newExp: user.currentExp
      };
    });
  }

  /**
   * 检查并处理用户升级
   */
  private async checkLevelUp(
    tx: any,
    user: any
  ): Promise<boolean> {
    const expForNextLevel = this.getExpForLevel(user.level);

    if (user.currentExp >= expForNextLevel) {
      const newLevel = user.level + 1;
      const remainingExp = user.currentExp - expForNextLevel;

      await tx.user.update({
        where: { id: user.id },
        data: {
          level: newLevel,
          currentExp: remainingExp
        }
      });

      // 发送升级通知
      await this.notificationService.sendNotification({
        userId: user.id,
        type: 'LEVEL_UP',
        title: '恭喜升级!',
        message: `你已达到 ${newLevel} 级!`,
        data: { newLevel }
      });

      return true;
    }

    return false;
  }

  /**
   * 检查并解锁成就
   */
  private async checkAchievements(tx: any, userId: string) {
    // 获取所有未解锁的成就
    const achievements = await tx.achievement.findMany({
      where: {
        userAchievements: {
          none: {
            userId,
            unlocked: true
          }
        }
      }
    });

    for (const achievement of achievements) {
      const unlocked = await this.verifyAchievement(tx, userId, achievement);

      if (unlocked) {
        await this.unlockAchievement(tx, userId, achievement.id);
      }
    }
  }

  /**
   * 验证成就是否满足解锁条件
   */
  private async verifyAchievement(
    tx: any,
    userId: string,
    achievement: any
  ): Promise<boolean> {
    const requirement = achievement.requirement as any;

    switch (requirement.type) {
      case 'TOTAL_POINTS':
        const user = await tx.user.findUnique({
          where: { id: userId }
        });
        return user?.totalPoints >= requirement.value;

      case 'DAILY_STEPS_STREAK':
        // 检查连续打卡天数
        const streak = await this.getStreakDays(tx, userId, 'DAILY_STEPS');
        return streak >= requirement.value;

      case 'POINTS_FROM_SOURCE':
        const sourcePoints = await tx.point.aggregate({
          where: {
            userId,
            source: requirement.source
          },
          _sum: {
            amount: true
          }
        });
        return (sourcePoints._sum.amount || 0) >= requirement.value;

      case 'LEVEL_REACHED':
        const userLevel = await tx.user.findUnique({
          where: { id: userId }
        });
        return userLevel?.level >= requirement.value;

      default:
        return false;
    }
  }

  /**
   * 解锁成就
   */
  private async unlockAchievement(
    tx: any,
    userId: string,
    achievementId: string
  ) {
    const achievement = await tx.achievement.findUnique({
      where: { id: achievementId }
    });

    if (!achievement) return;

    // 更新或创建用户成就记录
    await tx.userAchievement.upsert({
      where: {
        userId_achievementId: {
          userId,
          achievementId
        }
      },
      create: {
        userId,
        achievementId,
        progress: 100,
        target: 100,
        unlocked: true,
        unlockedAt: new Date()
      },
      update: {
        unlocked: true,
        unlockedAt: new Date()
      }
    });

    // 给予积分奖励
    if (achievement.pointsReward > 0) {
      await tx.user.update({
        where: { id: userId },
        data: {
          totalPoints: {
            increment: achievement.pointsReward
          }
        }
      });
    }

    // 发送成就解锁通知
    await this.notificationService.sendNotification({
      userId,
      type: 'ACHIEVEMENT_UNLOCK',
      title: '成就解锁!',
      message: `你解锁了成就:${achievement.name}`,
      data: {
        achievementId,
        pointsReward: achievement.pointsReward
      }
    });

    // 检查徽章解锁
    await this.checkBadges(tx, userId);
  }

  /**
   * 检查并解锁徽章
   */
  private async checkBadges(tx: any, userId: string) {
    const badges = await tx.badge.findMany({
      where: {
        userBadges: {
          none: { userId }
        }
      }
    });

    for (const badge of badges) {
      const unlocked = await this.verifyBadgeRequirements(
        tx,
        userId,
        badge
      );

      if (unlocked) {
        await this.unlockBadge(tx, userId, badge.id);
      }
    }
  }

  /**
   * 验证徽章解锁条件
   */
  private async verifyBadgeRequirements(
    tx: any,
    userId: string,
    badge: any
  ): Promise<boolean> {
    const requirements = badge.requirements as any;

    if (requirements.requiredAchievements) {
      const unlockedAchievements = await tx.userAchievement.count({
        where: {
          userId,
          unlocked: true,
          achievementId: {
            in: requirements.requiredAchievements
          }
        }
      });

      return unlockedAchievements >= requirements.requiredAchievements.length;
    }

    return false;
  }

  /**
   * 解锁徽章
   */
  private async unlockBadge(
    tx: any,
    userId: string,
    badgeId: string
  ) {
    const badge = await tx.badge.findUnique({
      where: { id: badgeId }
    });

    if (!badge) return;

    await tx.userBadge.create({
      data: {
        userId,
        badgeId
      }
    });

    // 发送徽章解锁通知
    await this.notificationService.sendNotification({
      userId,
      type: 'BADGE_EARN',
      title: '获得徽章!',
      message: `你获得了徽章:${badge.name}`,
      data: { badgeId }
    });
  }

  /**
   * 获取用户连续打卡天数
   */
  private async getStreakDays(
    tx: any,
    userId: string,
    source: string
  ): Promise<number> {
    const today = new Date();
    today.setHours(0, 0, 0, 0);

    let streak = 0;
    let currentDate = today;

    while (true) {
      const dayStart = new Date(currentDate);
      const dayEnd = new Date(currentDate);
      dayEnd.setHours(23, 59, 59, 999);

      const point = await tx.point.findFirst({
        where: {
          userId,
          source,
          timestamp: {
            gte: dayStart,
            lte: dayEnd
          }
        }
      });

      if (point) {
        streak++;
        currentDate.setDate(currentDate.getDate() - 1);
      } else {
        break;
      }
    }

    return streak;
  }

  /**
   * 计算升级所需经验值
   */
  private getExpForLevel(level: number): number {
    return Math.floor(100 * Math.pow(1.5, level - 1));
  }
}
Code collapsed

健康追踪服务(HealthTrackingService)

code
// src/services/health-tracking.service.ts

import { PrismaClient } from '@prisma/client';
import { GamificationService } from './gamification.service';

export interface LogStepsData {
  userId: string;
  steps: number;
  date: Date;
}

export interface LogWaterIntakeData {
  userId: string;
  glasses: number;
}

export interface LogExerciseData {
  userId: string;
  minutes: number;
  type: string;
  calories?: number;
}

export class HealthTrackingService {
  private prisma: PrismaClient;
  private gamificationService: GamificationService;

  constructor() {
    this.prisma = new PrismaClient();
    this.gamificationService = new GamificationService();
  }

  /**
   * 记录步数
   */
  async logSteps(data: LogStepsData) {
    // 计算积分(每1000步 = 10积分)
    const pointsEarned = Math.floor(data.steps / 1000) * 10;

    const result = await this.gamificationService.addPoints({
      userId: data.userId,
      type: 'DAILY_STEPS',
      source: 'daily_steps',
      amount: pointsEarned,
      description: `完成 ${data.steps} 步`,
      metadata: {
        steps: data.steps,
        date: data.date
      }
    });

    // 检查每日挑战
    await this.checkDailyChallenge(data.userId, 'DAILY_STEPS', data.steps);

    return result;
  }

  /**
   * 记录饮水量
   */
  async logWaterIntake(data: LogWaterIntakeData) {
    // 每杯水 = 5积分
    const pointsEarned = data.glasses * 5;

    const result = await this.gamificationService.addPoints({
      userId: data.userId,
      type: 'WATER_INTAKE',
      source: 'water_intake',
      amount: pointsEarned,
      description: `饮用 ${data.glasses} 杯水`,
      metadata: {
        glasses: data.glasses
      }
    });

    // 检查每日挑战
    await this.checkDailyChallenge(data.userId, 'WATER_INTAKE', data.glasses);

    return result;
  }

  /**
   * 记录运动
   */
  async logExercise(data: LogExerciseData) {
    // 每分钟运动 = 2积分
    const pointsEarned = data.minutes * 2;

    const result = await this.gamificationService.addPoints({
      userId: data.userId,
      type: 'EXERCISE',
      source: 'exercise',
      amount: pointsEarned,
      description: `${data.type} ${data.minutes} 分钟`,
      metadata: {
        minutes: data.minutes,
        type: data.type,
        calories: data.calories
      }
    });

    // 检查每日挑战
    await this.checkDailyChallenge(
      data.userId,
      'EXERCISE_MINUTES',
      data.minutes
    );

    return result;
  }

  /**
   * 检查并更新每日挑战进度
   */
  private async checkDailyChallenge(
    userId: string,
    challengeType: string,
    value: number
  ) {
    const today = new Date();
    today.setHours(0, 0, 0, 0);

    const tomorrow = new Date(today);
    tomorrow.setDate(tomorrow.getDate() + 1);

    // 查找今日的活跃挑战
    const challenge = await this.prisma.challenge.findFirst({
      where: {
        type: challengeType as any,
        startDate: { lte: today },
        endDate: { gt: today }
      },
      include: {
        userChallenges: {
          where: { userId }
        }
      }
    });

    if (!challenge) return;

    // 获取或创建用户挑战记录
    const userChallenge = await this.prisma.userChallenge.upsert({
      where: {
        userId_challengeId: {
          userId,
          challengeId: challenge.id
        }
      },
      create: {
        userId,
        challengeId: challenge.id,
        progress: value
      },
      update: {
        progress: {
          increment: value
        }
      }
    });

    // 检查是否完成挑战
    if (
      !userChallenge.completed &&
      userChallenge.progress >= challenge.targetValue
    ) {
      await this.prisma.userChallenge.update({
        where: { id: userChallenge.id },
        data: {
          completed: true,
          completedAt: new Date()
        }
      });

      // 给予奖励
      await this.gamificationService.addPoints({
        userId,
        type: 'CHALLENGE_COMPLETION',
        source: 'daily_challenge',
        amount: challenge.pointsReward,
        description: `完成挑战:${challenge.title}`
      });
    }
  }
}
Code collapsed

排行榜服务(LeaderboardService)

code
// src/services/leaderboard.service.ts

import { PrismaClient } from '@prisma/client';
import { Redis } from 'ioredis';

export class LeaderboardService {
  private prisma: PrismaClient;
  private redis: Redis;

  constructor() {
    this.prisma = new PrismaClient();
    this.redis = new Redis({
      host: process.env.REDIS_HOST || 'localhost',
      port: parseInt(process.env.REDIS_PORT || '6379')
    });
  }

  /**
   * 获取排行榜
   */
  async getLeaderboard(
    type: string = 'OVERALL',
    limit: number = 100,
    offset: number = 0
  ) {
    const cacheKey = `leaderboard:${type}:${limit}:${offset}`;

    // 尝试从缓存获取
    const cached = await this.redis.get(cacheKey);
    if (cached) {
      return JSON.parse(cached);
    }

    // 从数据库查询
    const leaderboard = await this.prisma.leaderboard.findMany({
      where: { type: type as any },
      include: {
        user: {
          select: {
            username: true,
            displayName: true,
            avatarUrl: true,
            level: true
          }
        }
      },
      orderBy: [
        { score: 'desc' },
        { updatedAt: 'asc' }
      ],
      take: limit,
      skip: offset
    });

    // 格式化结果
    const result = leaderboard.map((entry, index) => ({
      rank: offset + index + 1,
      userId: entry.userId,
      username: entry.user.username,
      displayName: entry.user.displayName,
      avatarUrl: entry.user.avatarUrl,
      level: entry.user.level,
      score: entry.score,
      weeklyScore: entry.weeklyScore
    }));

    // 缓存结果(5分钟)
    await this.redis.setex(cacheKey, 300, JSON.stringify(result));

    return result;
  }

  /**
   * 获取用户排名
   */
  async getUserRank(userId: string, type: string = 'OVERALL') {
    const cacheKey = `user_rank:${userId}:${type}`;

    const cached = await this.redis.get(cacheKey);
    if (cached) {
      return JSON.parse(cached);
    }

    const entry = await this.prisma.leaderboard.findUnique({
      where: { userId_type: { userId, type: type as any } }
    });

    if (!entry) {
      return { rank: null, score: 0 };
    }

    // 计算实际排名
    const rank = await this.prisma.leaderboard.count({
      where: {
        type: type as any,
        score: { gt: entry.score }
      }
    }) + 1;

    const result = {
      rank,
      score: entry.score,
      weeklyScore: entry.weeklyScore
    };

    await this.redis.setex(cacheKey, 60, JSON.stringify(result));

    return result;
  }

  /**
   * 更新排行榜缓存
   */
  async updateLeaderboardCache() {
    const types = ['OVERALL', 'STEPS', 'EXERCISE', 'NUTRITION', 'WEEKLY'];

    for (const type of types) {
      const cacheKey = `leaderboard:${type}:100:0`;

      const leaderboard = await this.prisma.leaderboard.findMany({
        where: { type: type as any },
        include: {
          user: {
            select: {
              username: true,
              displayName: true,
              avatarUrl: true,
              level: true
            }
          }
        },
        orderBy: [
          { score: 'desc' },
          { updatedAt: 'asc' }
        ],
        take: 100
      });

      const result = leaderboard.map((entry, index) => ({
        rank: index + 1,
        userId: entry.userId,
        username: entry.user.username,
        displayName: entry.user.displayName,
        avatarUrl: entry.user.avatarUrl,
        level: entry.user.level,
        score: entry.score
      }));

      await this.redis.setex(cacheKey, 300, JSON.stringify(result));
    }
  }
}
Code collapsed

API端点实现

Express路由设置

code
// src/routes/gamification.routes.ts

import { Router } from 'express';
import { authenticateToken } from '../middleware/auth';
import { GamificationService } from '../services/gamification.service';
import { HealthTrackingService } from '../services/health-tracking.service';
import { LeaderboardService } from '../services/leaderboard.service';
import { z } from 'zod';

const router = Router();
const gamificationService = new GamificationService();
const healthTrackingService = new HealthTrackingService();
const leaderboardService = new LeaderboardService();

// ============================================
// 健康数据追踪端点
// ============================================

/**
 * POST /api/health/steps
 * 记录步数
 */
router.post('/health/steps', authenticateToken, async (req, res, next) => {
  try {
    const schema = z.object({
      steps: z.number().min(0).max(100000),
      date: z.string().datetime().optional()
    });

    const data = schema.parse(req.body);

    const result = await healthTrackingService.logSteps({
      userId: req.user.userId,
      steps: data.steps,
      date: data.date ? new Date(data.date) : new Date()
    });

    res.json({
      success: true,
      data: result
    });
  } catch (error) {
    next(error);
  }
});

/**
 * POST /api/health/water
 * 记录饮水量
 */
router.post('/health/water', authenticateToken, async (req, res, next) => {
  try {
    const schema = z.object({
      glasses: z.number().min(1).max(20)
    });

    const data = schema.parse(req.body);

    const result = await healthTrackingService.logWaterIntake({
      userId: req.user.userId,
      glasses: data.glasses
    });

    res.json({
      success: true,
      data: result
    });
  } catch (error) {
    next(error);
  }
});

/**
 * POST /api/health/exercise
 * 记录运动
 */
router.post('/health/exercise', authenticateToken, async (req, res, next) => {
  try {
    const schema = z.object({
      minutes: z.number().min(1).max(300),
      type: z.string(),
      calories: z.number().optional()
    });

    const data = schema.parse(req.body);

    const result = await healthTrackingService.logExercise({
      userId: req.user.userId,
      minutes: data.minutes,
      type: data.type,
      calories: data.calories
    });

    res.json({
      success: true,
      data: result
    });
  } catch (error) {
    next(error);
  }
});

// ============================================
// 游戏化统计端点
// ============================================

/**
 * GET /api/gamification/stats
 * 获取用户游戏化统计
 */
router.get('/gamification/stats', authenticateToken, async (req, res, next) => {
  try {
    const stats = await gamificationService.getUserGamificationStats(
      req.user.userId
    );

    res.json({
      success: true,
      data: stats
    });
  } catch (error) {
    next(error);
  }
});

/**
 * GET /api/gamification/achievements
 * 获取用户成就列表
 */
router.get('/gamification/achievements', authenticateToken, async (req, res, next) => {
  try {
    const prisma = new PrismaClient();
    const achievements = await prisma.userAchievement.findMany({
      where: { userId: req.user.userId },
      include: {
        achievement: true
      },
      orderBy: { unlockedAt: 'desc' }
    });

    res.json({
      success: true,
      data: achievements
    });
  } catch (error) {
    next(error);
  }
});

/**
 * GET /api/gamification/badges
 * 获取用户徽章列表
 */
router.get('/gamification/badges', authenticateToken, async (req, res, next) => {
  try {
    const prisma = new PrismaClient();
    const badges = await prisma.userBadge.findMany({
      where: { userId: req.user.userId },
      include: {
        badge: true
      },
      orderBy: { earnedAt: 'desc' }
    });

    res.json({
      success: true,
      data: badges
    });
  } catch (error) {
    next(error);
  }
});

// ============================================
// 排行榜端点
// ============================================

/**
 * GET /api/leaderboard
 * 获取排行榜
 */
router.get('/leaderboard', async (req, res, next) => {
  try {
    const schema = z.object({
      type: z.enum(['OVERALL', 'STEPS', 'EXERCISE', 'NUTRITION', 'WEEKLY']).optional(),
      limit: z.coerce.number().min(1).max(100).optional(),
      offset: z.coerce.number().min(0).optional()
    });

    const query = schema.parse(req.query);

    const leaderboard = await leaderboardService.getLeaderboard(
      query.type || 'OVERALL',
      query.limit || 50,
      query.offset || 0
    );

    res.json({
      success: true,
      data: leaderboard
    });
  } catch (error) {
    next(error);
  }
});

/**
 * GET /api/leaderboard/my-rank
 * 获取当前用户排名
 */
router.get('/leaderboard/my-rank', authenticateToken, async (req, res, next) => {
  try {
    const schema = z.object({
      type: z.enum(['OVERALL', 'STEPS', 'EXERCISE', 'NUTRITION', 'WEEKLY']).optional()
    });

    const query = schema.parse(req.query);

    const rank = await leaderboardService.getUserRank(
      req.user.userId,
      query.type || 'OVERALL'
    );

    res.json({
      success: true,
      data: rank
    });
  } catch (error) {
    next(error);
  }
});

export default router;
Code collapsed

测试实现

单元测试示例

code
// tests/services/gamification.service.test.ts

import { GamificationService } from '../../src/services/gamification.service';
import { PrismaClient } from '@prisma/client';

describe('GamificationService', () => {
  let gamificationService: GamificationService;
  let prisma: PrismaClient;

  beforeEach(() => {
    prisma = new PrismaClient();
    gamificationService = new GamificationService();
  });

  afterEach(async () => {
    await prisma.$disconnect();
  });

  describe('addPoints', () => {
    it('应该正确添加积分', async () => {
      // 创建测试用户
      const user = await prisma.user.create({
        data: {
          email: 'test@example.com',
          username: 'testuser',
          passwordHash: 'hash',
          displayName: 'Test User'
        }
      });

      const result = await gamificationService.addPoints({
        userId: user.id,
        type: 'DAILY_STEPS',
        source: 'daily_steps',
        amount: 100,
        description: '完成目标'
      });

      expect(result.point.amount).toBe(100);
      expect(result.newExp).toBeGreaterThan(0);
    });

    it('应该在达到阈值时升级用户', async () => {
      const user = await prisma.user.create({
        data: {
          email: 'test2@example.com',
          username: 'testuser2',
          passwordHash: 'hash',
          displayName: 'Test User 2',
          level: 1,
          currentExp: 90
        }
      });

      // 添加足够经验升级
      const result = await gamificationService.addPoints({
        userId: user.id,
        type: 'DAILY_STEPS',
        source: 'daily_steps',
        amount: 20
      });

      expect(result.newLevel).toBe(2);
    });
  });

  describe('achievements', () => {
    it('应该在达到条件时解锁成就', async () => {
      // 创建测试成就
      const achievement = await prisma.achievement.create({
        data: {
          name: '初次体验',
          description: '获得第一个积分',
          category: 'MILESTONE',
          requirement: {
            type: 'TOTAL_POINTS',
            value: 1
          },
          pointsReward: 50
        }
      });

      const user = await prisma.user.create({
        data: {
          email: 'test3@example.com',
          username: 'testuser3',
          passwordHash: 'hash',
          displayName: 'Test User 3'
        }
      });

      await gamificationService.addPoints({
        userId: user.id,
        type: 'DAILY_STEPS',
        source: 'test',
        amount: 10
      });

      const userAchievement = await prisma.userAchievement.findUnique({
        where: {
          userId_achievementId: {
            userId: user.id,
            achievementId: achievement.id
          }
        }
      });

      expect(userAchievement?.unlocked).toBe(true);
    });
  });
});
Code collapsed

API集成测试

code
// tests/api/gamification.routes.test.ts

import request from 'supertest';
import app from '../../src/app';
import { PrismaClient } from '@prisma/client';
import jwt from 'jsonwebtoken';

describe('Gamification API', () => {
  let prisma: PrismaClient;
  let authToken: string;
  let testUserId: string;

  beforeAll(async () => {
    prisma = new PrismaClient();

    // 创建测试用户
    const user = await prisma.user.create({
      data: {
        email: 'api-test@example.com',
        username: 'apitest',
        passwordHash: 'hash',
        displayName: 'API Test User'
      }
    });

    testUserId = user.id;
    authToken = jwt.sign({ userId: user.id }, 'test-secret');
  });

  afterAll(async () => {
    // 清理测试数据
    await prisma.user.deleteMany({
      where: { email: 'api-test@example.com' }
    });
    await prisma.$disconnect();
  });

  describe('POST /api/health/steps', () => {
    it('应该成功记录步数', async () => {
      const response = await request(app)
        .post('/api/health/steps')
        .set('Authorization', `Bearer ${authToken}`)
        .send({ steps: 10000 })
        .expect(200);

      expect(response.body.success).toBe(true);
      expect(response.body.data.point.amount).toBeGreaterThan(0);
    });

    it('应该验证步数范围', async () => {
      await request(app)
        .post('/api/health/steps')
        .set('Authorization', `Bearer ${authToken}`)
        .send({ steps: -100 })
        .expect(400);
    });
  });

  describe('GET /api/leaderboard', () => {
    it('应该返回排行榜数据', async () => {
      const response = await request(app)
        .get('/api/leaderboard')
        .expect(200);

      expect(response.body.success).toBe(true);
      expect(Array.isArray(response.body.data)).toBe(true);
    });
  });
});
Code collapsed

部署配置

Docker Compose配置

code
# docker-compose.yml

version: '3.8'

services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgresql://user:password@db:5432/health_gamification
      - REDIS_URL=redis://redis:6379
      - JWT_SECRET=${JWT_SECRET}
    depends_on:
      - db
      - redis
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    volumes:
      - postgres_data:/var/lib/postgresql/data
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=health_gamification
    ports:
      - "5432:5432"
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    restart: unless-stopped

  pgadmin:
    image: dpage/pgadmin4:latest
    environment:
      - PGADMIN_DEFAULT_EMAIL=admin@wellally.com
      - PGADMIN_DEFAULT_PASSWORD=admin
    ports:
      - "5050:80"
    depends_on:
      - db

volumes:
  postgres_data:
Code collapsed

生产环境最佳实践

  1. 连接池配置
code
// 使用pgBouncer进行连接池管理
const prisma = new PrismaClient({
  datasources: {
    db: {
      url: process.env.DATABASE_URL
    }
  }
});

// 配置连接池
await prisma.$connect();
Code collapsed
  1. 错误处理和日志
code
// src/utils/error-handler.ts
import { Request, Response, NextFunction } from 'express';
import { ZodError } from 'zod';

export function errorHandler(
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
) {
  console.error('Error:', err);

  if (err instanceof ZodError) {
    return res.status(400).json({
      success: false,
      error: '验证错误',
      details: err.errors
    });
  }

  res.status(500).json({
    success: false,
    error: '服务器内部错误'
  });
}
Code collapsed
  1. 速率限制
code
// src/middleware/rate-limit.ts
import rateLimit from 'express-rate-limit';

export const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15分钟
  max: 100, // 限制每个IP 15分钟内最多100个请求
  message: '请求过于频繁,请稍后再试'
});
Code collapsed

性能优化策略

数据库优化

  1. 索引策略
code
-- 为常用查询创建索引
CREATE INDEX idx_points_user_type_timestamp ON points(user_id, type, timestamp DESC);
CREATE INDEX idx_leaderboard_type_score ON leaderboard(type, score DESC);
CREATE INDEX idx_user_achievements_unlocked ON user_achievements(user_id) WHERE unlocked = true;
Code collapsed
  1. 查询优化
code
// 使用select字段限制返回数据
const user = await prisma.user.findUnique({
  where: { id: userId },
  select: {
    id: true,
    username: true,
    displayName: true,
    level: true,
    totalPoints: true
    // 不返回passwordHash等敏感字段
  }
});
Code collapsed
  1. 批量操作
code
// 批量插入积分记录
await prisma.point.createMany({
  data: pointsData,
  skipDuplicates: true
});
Code collapsed

缓存策略

数据类型缓存时间缓存策略
排行榜5分钟Redis缓存
用户统计1分钟Redis缓存
成就列表1小时Redis缓存
用户配置30分钟Redis缓存

监控和分析

关键指标监控

code
// src/utils/metrics.ts
import { Counter, Histogram, Registry } from 'prom-client';

export const metrics = {
  // API请求计数
  httpRequestsTotal: new Counter({
    name: 'http_requests_total',
    help: 'Total HTTP requests',
    labelNames: ['method', 'route', 'status_code']
  }),

  // API响应时间
  httpRequestDuration: new Histogram({
    name: 'http_request_duration_seconds',
    help: 'HTTP request duration',
    labelNames: ['method', 'route'],
    buckets: [0.1, 0.5, 1, 2, 5]
  }),

  // 游戏化事件
  gamificationEvents: new Counter({
    name: 'gamification_events_total',
    help: 'Total gamification events',
    labelNames: ['event_type']
  })
};
Code collapsed

安全最佳实践

  1. 输入验证

    • 使用Zod进行严格的输入验证
    • 防止SQL注入
    • XSS防护
  2. 认证和授权

    • JWT令牌验证
    • 权限检查
    • 速率限制
  3. 数据保护

    • 密码加密(bcrypt)
    • 敏感数据加密
    • HTTPS强制

未来扩展方向

  1. 机器学习集成

    • 个性化积分推荐
    • 用户行为预测
    • 自适应挑战难度
  2. 社交功能增强

    • 好友系统
    • 群组挑战
    • 社交分享
  3. 多租户支持

    • 企业版功能
    • 定制化游戏规则
    • 白标解决方案

结论

构建成功的健康游戏化系统需要技术实现与行为设计的完美结合。本指南提供的Node.js + PostgreSQL架构为开发者提供了坚实的基础,同时保持了足够的灵活性来满足不同健康应用的需求。

关键成功因素:

  • 用户参与度:设计有趣且有意义的游戏机制
  • 技术稳定性:确保系统可靠、快速、安全
  • 数据驱动:持续分析和优化游戏化策略
  • 隐私保护:遵守医疗数据保护法规(HIPAA/GDPR)

通过遵循本指南的最佳实践,开发团队可以构建出既技术先进又用户友好的健康游戏化平台,真正帮助用户建立和维持健康的生活习惯。

#

文章标签

nodejs
postgres
游戏化
健康api
健康科技
数字健康
行为设计

觉得这篇文章有帮助?

立即体验康心伴,开始您的健康管理之旅