iT邦幫忙

2025 iThome 鐵人賽

DAY 12
0
Software Development

30 天打造工作室 SaaS 產品 (後端篇)系列 第 12

Day 12:30天打造SaaS產品軟體開發篇-健身房會員服務與查詢系統

  • 分享至 

  • xImage
  •  

前情提要

經過 Day 11 的認證服務建立,我們已經有了完整的多租戶身份認證系統。今天我們要實作會員管理服務 (Member Service),這是健身房 SaaS 的核心業務服務之一。

我們將打造一個高性能、可擴展的會員 CRUD 系統,支援複雜查詢、批次操作、以及健身房特有的業務邏輯。

健身房會員管理需求分析

會員資料特性

// 健身房會員完整資料模型
interface Member {
  // 基礎身份資訊
  id: string;
  tenantId: string;
  memberCode: string;           // 健身房內部會員編號
  cognitoUsername?: string;     // Cognito 認證帳號 (可選)

  // 個人基本資料
  personalInfo: {
    name: string;
    email?: string;
    phone?: string;
    dateOfBirth?: Date;
    gender?: 'male' | 'female' | 'other';
    profilePhotoUrl?: string;
    emergencyContact?: {
      name: string;
      phone: string;
      relationship: string;
    };
  };

  // 健身相關資料
  fitnessProfile: {
    height?: number;             // 身高 (cm)
    weight?: number;             // 體重 (kg)
    bodyFatPercentage?: number;  // 體脂率
    muscleMass?: number;         // 肌肉量
    fitnessGoals: string[];      // 健身目標
    medicalConditions?: string[]; // 健康狀況
    experienceLevel: 'beginner' | 'intermediate' | 'advanced';
  };

  // 會籍資訊
  membershipInfo: {
    status: 'active' | 'inactive' | 'suspended' | 'expired';
    joinDate: Date;
    lastVisitDate?: Date;
    totalVisits: number;
    currentPlan?: {
      planId: string;
      planName: string;
      startDate: Date;
      endDate: Date;
      remainingSessions?: number;
    };
  };

  // 客製化欄位 - 每家健身房不同需求
  customFields: Record<string, any>;

  // 系統欄位
  metadata: {
    createdAt: Date;
    updatedAt: Date;
    createdBy: string;
    lastModifiedBy: string;
    version: number;             // 樂觀鎖版本
  };
}

業務需求場景

// 健身房會員管理典型場景
const memberManagementScenarios = {
  // 櫃檯場景
  frontDesk: {
    checkIn: '會員報到 - 快速查詢與記錄',
    register: '現場註冊 - 完整資料建立',
    planRenewal: '續約流程 - 會籍狀態更新',
    guestPass: '體驗券管理 - 臨時會員建立'
  },

  // 教練場景
  trainer: {
    clientList: '學員清單 - 分組與篩選',
    progressTracking: '進度追蹤 - 體態數據更新',
    sessionBooking: '課程預約 - 可用時段查詢'
  },

  // 管理場景
  management: {
    memberAnalytics: '會員分析 - 留存率、活躍度',
    bulkOperations: '批次操作 - 會籍到期通知',
    reportGeneration: '報表產生 - 營收與趨勢分析',
    dataImport: '資料匯入 - 從舊系統遷移'
  }
};

🏗️ Member Service 架構設計

服務分層架構

// packages/kyo-core/src/services/member/member-service.ts
import { DatabaseConnection } from '../database/tenant-connection';
import { MemberRepository } from '../repositories/member-repository';
import { CacheService } from '../cache/cache-service';
import { EventBus } from '../events/event-bus';

export class MemberService {
  private memberRepository: MemberRepository;
  private cacheService: CacheService;
  private eventBus: EventBus;

  constructor(
    private tenantId: string,
    private dbConnection: DatabaseConnection
  ) {
    this.memberRepository = new MemberRepository(dbConnection);
    this.cacheService = new CacheService(tenantId);
    this.eventBus = new EventBus();
  }

  // 建立會員
  async createMember(memberData: CreateMemberRequest, operatorId: string): Promise<Member> {
    try {
      // 1. 驗證資料
      const validatedData = await this.validateMemberData(memberData);

      // 2. 生成會員編號
      const memberCode = await this.generateMemberCode();

      // 3. 建立會員記錄
      const member = await this.memberRepository.create({
        ...validatedData,
        tenantId: this.tenantId,
        memberCode,
        membershipInfo: {
          ...validatedData.membershipInfo,
          status: 'active',
          joinDate: new Date(),
          totalVisits: 0
        },
        metadata: {
          createdAt: new Date(),
          updatedAt: new Date(),
          createdBy: operatorId,
          lastModifiedBy: operatorId,
          version: 1
        }
      });

      // 4. 清除相關快取
      await this.cacheService.invalidatePattern(`members:${this.tenantId}:*`);

      // 5. 發布事件
      await this.eventBus.publish('member.created', {
        tenantId: this.tenantId,
        memberId: member.id,
        memberCode: member.memberCode,
        operatorId
      });

      return member;
    } catch (error) {
      console.error('Create member failed:', error);
      throw error;
    }
  }

  // 更新會員資料
  async updateMember(
    memberId: string,
    updateData: UpdateMemberRequest,
    operatorId: string
  ): Promise<Member> {
    try {
      // 1. 檢查會員是否存在 + 樂觀鎖
      const existingMember = await this.memberRepository.findById(memberId);
      if (!existingMember) {
        throw new Error('Member not found');
      }

      if (updateData.version !== existingMember.metadata.version) {
        throw new Error('Concurrent modification detected');
      }

      // 2. 驗證更新資料
      const validatedData = await this.validateUpdateData(updateData, existingMember);

      // 3. 更新資料
      const updatedMember = await this.memberRepository.update(memberId, {
        ...validatedData,
        metadata: {
          ...existingMember.metadata,
          updatedAt: new Date(),
          lastModifiedBy: operatorId,
          version: existingMember.metadata.version + 1
        }
      });

      // 4. 更新快取
      await this.cacheService.set(
        `member:${this.tenantId}:${memberId}`,
        updatedMember,
        3600 // 1小時
      );

      // 5. 發布更新事件
      await this.eventBus.publish('member.updated', {
        tenantId: this.tenantId,
        memberId: updatedMember.id,
        changes: this.getFieldChanges(existingMember, updatedMember),
        operatorId
      });

      return updatedMember;
    } catch (error) {
      console.error('Update member failed:', error);
      throw error;
    }
  }

  // 智慧查詢 - 支援複雜條件
  async searchMembers(searchParams: MemberSearchParams): Promise<MemberSearchResult> {
    try {
      // 1. 建構快取鍵
      const cacheKey = `search:${this.tenantId}:${this.hashSearchParams(searchParams)}`;

      // 2. 檢查快取
      const cachedResult = await this.cacheService.get<MemberSearchResult>(cacheKey);
      if (cachedResult) {
        return cachedResult;
      }

      // 3. 執行搜尋
      const result = await this.memberRepository.search(searchParams);

      // 4. 快取結果 (短期快取,因為會員資料變動頻繁)
      await this.cacheService.set(cacheKey, result, 300); // 5分鐘

      return result;
    } catch (error) {
      console.error('Search members failed:', error);
      throw error;
    }
  }

  // 會員報到
  async checkIn(memberIdentifier: string, operatorId: string): Promise<CheckInResult> {
    try {
      // 1. 識別會員 (支援會員編號、手機、Email)
      const member = await this.findMemberByIdentifier(memberIdentifier);
      if (!member) {
        throw new Error('Member not found');
      }

      // 2. 檢查會員狀態
      if (member.membershipInfo.status !== 'active') {
        throw new Error(`Member status is ${member.membershipInfo.status}`);
      }

      // 3. 記錄報到
      const checkInRecord = await this.recordCheckIn(member.id, operatorId);

      // 4. 更新會員最後訪問時間與總訪問次數
      await this.updateVisitStats(member.id);

      // 5. 發布報到事件
      await this.eventBus.publish('member.checkedIn', {
        tenantId: this.tenantId,
        memberId: member.id,
        memberCode: member.memberCode,
        checkInTime: checkInRecord.checkInTime,
        operatorId
      });

      return {
        success: true,
        member: {
          id: member.id,
          name: member.personalInfo.name,
          memberCode: member.memberCode,
          profilePhotoUrl: member.personalInfo.profilePhotoUrl
        },
        checkInTime: checkInRecord.checkInTime,
        totalVisits: member.membershipInfo.totalVisits + 1
      };
    } catch (error) {
      console.error('Check-in failed:', error);
      throw error;
    }
  }

  // 批次操作 - 會籍到期提醒
  async processMembershipExpirations(): Promise<ExpirationProcessResult> {
    try {
      // 1. 查詢即將到期的會員 (7天內)
      const expiringMembers = await this.memberRepository.findExpiringMemberships(7);

      // 2. 批次處理
      const results = await Promise.allSettled(
        expiringMembers.map(member => this.processExpiringMember(member))
      );

      // 3. 統計結果
      const successful = results.filter(r => r.status === 'fulfilled').length;
      const failed = results.filter(r => r.status === 'rejected').length;

      return {
        totalProcessed: expiringMembers.length,
        successful,
        failed,
        details: results
      };
    } catch (error) {
      console.error('Process membership expirations failed:', error);
      throw error;
    }
  }

  private async generateMemberCode(): Promise<string> {
    // 生成格式:M + YYYYMMDD + 4位序號
    const today = new Date();
    const dateString = today.toISOString().slice(0, 10).replace(/-/g, '');

    // 查詢今日最大序號
    const lastCode = await this.memberRepository.getLastMemberCodeByDate(dateString);
    const sequence = lastCode ? parseInt(lastCode.slice(-4)) + 1 : 1;

    return `M${dateString}${sequence.toString().padStart(4, '0')}`;
  }

  private async validateMemberData(data: CreateMemberRequest): Promise<CreateMemberRequest> {
    // 1. 基本資料驗證
    if (!data.personalInfo.name || data.personalInfo.name.trim().length === 0) {
      throw new Error('Member name is required');
    }

    // 2. Email 格式驗證 (如果提供)
    if (data.personalInfo.email && !this.isValidEmail(data.personalInfo.email)) {
      throw new Error('Invalid email format');
    }

    // 3. 手機號碼驗證 (如果提供)
    if (data.personalInfo.phone && !this.isValidPhone(data.personalInfo.phone)) {
      throw new Error('Invalid phone format');
    }

    // 4. 檢查重複 - Email 和手機不可重複
    if (data.personalInfo.email) {
      const existing = await this.memberRepository.findByEmail(data.personalInfo.email);
      if (existing) {
        throw new Error('Email already exists');
      }
    }

    if (data.personalInfo.phone) {
      const existing = await this.memberRepository.findByPhone(data.personalInfo.phone);
      if (existing) {
        throw new Error('Phone already exists');
      }
    }

    return data;
  }
}

interface MemberSearchParams {
  // 基礎搜尋
  keyword?: string;              // 姓名、電話、Email 模糊搜尋
  memberCode?: string;           // 會員編號精確搜尋

  // 狀態篩選
  status?: MemberStatus[];       // 會員狀態
  planIds?: string[];           // 會籍方案

  // 時間範圍
  joinDateFrom?: Date;          // 加入時間起始
  joinDateTo?: Date;            // 加入時間結束
  lastVisitFrom?: Date;         // 最後到訪起始
  lastVisitTo?: Date;           // 最後到訪結束

  // 健身資料篩選
  ageFrom?: number;             // 年齡範圍
  ageTo?: number;
  gender?: Gender[];            // 性別
  fitnessGoals?: string[];      // 健身目標

  // 自訂欄位篩選
  customFields?: Record<string, any>;

  // 排序與分頁
  sortBy?: 'name' | 'joinDate' | 'lastVisit' | 'totalVisits';
  sortOrder?: 'asc' | 'desc';
  page?: number;
  limit?: number;
}

Repository 層實作

高效能資料存取層

// packages/kyo-core/src/repositories/member-repository.ts
export class MemberRepository {
  constructor(private dbConnection: DatabaseConnection) {}

  async create(memberData: Omit<Member, 'id'>): Promise<Member> {
    const query = `
      INSERT INTO members (
        tenant_id, member_code, cognito_username,
        personal_info, fitness_profile, membership_info,
        custom_fields, metadata
      ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
      RETURNING *
    `;

    const values = [
      memberData.tenantId,
      memberData.memberCode,
      memberData.cognitoUsername,
      JSON.stringify(memberData.personalInfo),
      JSON.stringify(memberData.fitnessProfile),
      JSON.stringify(memberData.membershipInfo),
      JSON.stringify(memberData.customFields),
      JSON.stringify(memberData.metadata)
    ];

    const result = await this.dbConnection.query(query, values);
    return this.mapRowToMember(result.rows[0]);
  }

  async findById(id: string): Promise<Member | null> {
    const query = 'SELECT * FROM members WHERE id = $1';
    const result = await this.dbConnection.query(query, [id]);

    return result.rows.length > 0 ? this.mapRowToMember(result.rows[0]) : null;
  }

  // 智能搜尋實作 - 支援複雜查詢條件
  async search(params: MemberSearchParams): Promise<MemberSearchResult> {
    const conditions: string[] = [];
    const values: any[] = [];
    let paramIndex = 1;

    // 基礎條件
    if (params.keyword) {
      conditions.push(`(
        personal_info->>'name' ILIKE $${paramIndex} OR
        personal_info->>'email' ILIKE $${paramIndex} OR
        personal_info->>'phone' ILIKE $${paramIndex}
      )`);
      values.push(`%${params.keyword}%`);
      paramIndex++;
    }

    if (params.memberCode) {
      conditions.push(`member_code = $${paramIndex}`);
      values.push(params.memberCode);
      paramIndex++;
    }

    // 狀態篩選
    if (params.status && params.status.length > 0) {
      conditions.push(`membership_info->>'status' = ANY($${paramIndex})`);
      values.push(params.status);
      paramIndex++;
    }

    // 時間範圍篩選
    if (params.joinDateFrom) {
      conditions.push(`(membership_info->>'joinDate')::timestamp >= $${paramIndex}`);
      values.push(params.joinDateFrom.toISOString());
      paramIndex++;
    }

    if (params.joinDateTo) {
      conditions.push(`(membership_info->>'joinDate')::timestamp <= $${paramIndex}`);
      values.push(params.joinDateTo.toISOString());
      paramIndex++;
    }

    // 健身資料篩選
    if (params.gender && params.gender.length > 0) {
      conditions.push(`personal_info->>'gender' = ANY($${paramIndex})`);
      values.push(params.gender);
      paramIndex++;
    }

    if (params.ageFrom || params.ageTo) {
      // 計算年齡範圍
      const currentYear = new Date().getFullYear();
      if (params.ageFrom) {
        conditions.push(`
          EXTRACT(YEAR FROM AGE((personal_info->>'dateOfBirth')::date)) >= $${paramIndex}
        `);
        values.push(params.ageFrom);
        paramIndex++;
      }
      if (params.ageTo) {
        conditions.push(`
          EXTRACT(YEAR FROM AGE((personal_info->>'dateOfBirth')::date)) <= $${paramIndex}
        `);
        values.push(params.ageTo);
        paramIndex++;
      }
    }

    // 自訂欄位篩選
    if (params.customFields) {
      Object.entries(params.customFields).forEach(([key, value]) => {
        conditions.push(`custom_fields->>'${key}' = $${paramIndex}`);
        values.push(value);
        paramIndex++;
      });
    }

    // 建構 WHERE 子句
    const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';

    // 排序
    const sortBy = params.sortBy || 'metadata->>"createdAt"';
    const sortOrder = params.sortOrder || 'desc';
    const orderClause = `ORDER BY ${this.mapSortField(sortBy)} ${sortOrder}`;

    // 分頁
    const limit = params.limit || 20;
    const offset = ((params.page || 1) - 1) * limit;
    const limitClause = `LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
    values.push(limit, offset);

    // 執行查詢
    const countQuery = `SELECT COUNT(*) FROM members ${whereClause}`;
    const dataQuery = `SELECT * FROM members ${whereClause} ${orderClause} ${limitClause}`;

    const [countResult, dataResult] = await Promise.all([
      this.dbConnection.query(countQuery, values.slice(0, -2)), // 移除 LIMIT/OFFSET 參數
      this.dbConnection.query(dataQuery, values)
    ]);

    const totalCount = parseInt(countResult.rows[0].count);
    const members = dataResult.rows.map(row => this.mapRowToMember(row));

    return {
      members,
      pagination: {
        currentPage: params.page || 1,
        pageSize: limit,
        totalCount,
        totalPages: Math.ceil(totalCount / limit)
      }
    };
  }

  // 批次查詢即將到期的會員
  async findExpiringMemberships(daysAhead: number): Promise<Member[]> {
    const query = `
      SELECT * FROM members
      WHERE membership_info->>'status' = 'active'
        AND (membership_info->>'endDate')::timestamp
        BETWEEN NOW() AND NOW() + INTERVAL '${daysAhead} days'
      ORDER BY (membership_info->>'endDate')::timestamp ASC
    `;

    const result = await this.dbConnection.query(query);
    return result.rows.map(row => this.mapRowToMember(row));
  }

  // 效能優化查詢 - 僅取得必要欄位
  async findMemberSummaryByIdentifier(identifier: string): Promise<MemberSummary | null> {
    const query = `
      SELECT
        id,
        member_code,
        personal_info->>'name' as name,
        personal_info->>'profilePhotoUrl' as profile_photo_url,
        membership_info->>'status' as status,
        membership_info->>'totalVisits' as total_visits
      FROM members
      WHERE member_code = $1
         OR personal_info->>'email' = $1
         OR personal_info->>'phone' = $1
      LIMIT 1
    `;

    const result = await this.dbConnection.query(query, [identifier]);

    if (result.rows.length === 0) return null;

    const row = result.rows[0];
    return {
      id: row.id,
      memberCode: row.member_code,
      name: row.name,
      profilePhotoUrl: row.profile_photo_url,
      status: row.status as MemberStatus,
      totalVisits: parseInt(row.total_visits)
    };
  }

  // 統計查詢
  async getMemberStatistics(dateFrom?: Date, dateTo?: Date): Promise<MemberStatistics> {
    const conditions = [];
    const values = [];
    let paramIndex = 1;

    if (dateFrom) {
      conditions.push(`(metadata->>'createdAt')::timestamp >= $${paramIndex}`);
      values.push(dateFrom.toISOString());
      paramIndex++;
    }

    if (dateTo) {
      conditions.push(`(metadata->>'createdAt')::timestamp <= $${paramIndex}`);
      values.push(dateTo.toISOString());
      paramIndex++;
    }

    const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';

    const query = `
      SELECT
        COUNT(*) as total_members,
        COUNT(CASE WHEN membership_info->>'status' = 'active' THEN 1 END) as active_members,
        COUNT(CASE WHEN membership_info->>'status' = 'inactive' THEN 1 END) as inactive_members,
        COUNT(CASE WHEN membership_info->>'status' = 'suspended' THEN 1 END) as suspended_members,
        COUNT(CASE WHEN membership_info->>'status' = 'expired' THEN 1 END) as expired_members,
        AVG((membership_info->>'totalVisits')::int) as avg_visits,
        COUNT(CASE WHEN personal_info->>'gender' = 'male' THEN 1 END) as male_count,
        COUNT(CASE WHEN personal_info->>'gender' = 'female' THEN 1 END) as female_count
      FROM members ${whereClause}
    `;

    const result = await this.dbConnection.query(query, values);
    const row = result.rows[0];

    return {
      totalMembers: parseInt(row.total_members),
      activeMembers: parseInt(row.active_members),
      inactiveMembers: parseInt(row.inactive_members),
      suspendedMembers: parseInt(row.suspended_members),
      expiredMembers: parseInt(row.expired_members),
      averageVisits: parseFloat(row.avg_visits) || 0,
      genderDistribution: {
        male: parseInt(row.male_count),
        female: parseInt(row.female_count),
        other: parseInt(row.total_members) - parseInt(row.male_count) - parseInt(row.female_count)
      }
    };
  }

  private mapRowToMember(row: any): Member {
    return {
      id: row.id,
      tenantId: row.tenant_id,
      memberCode: row.member_code,
      cognitoUsername: row.cognito_username,
      personalInfo: JSON.parse(row.personal_info),
      fitnessProfile: JSON.parse(row.fitness_profile),
      membershipInfo: JSON.parse(row.membership_info),
      customFields: JSON.parse(row.custom_fields),
      metadata: JSON.parse(row.metadata)
    };
  }

  private mapSortField(sortBy: string): string {
    const mapping: Record<string, string> = {
      'name': 'personal_info->>"name"',
      'joinDate': 'membership_info->>"joinDate"',
      'lastVisit': 'membership_info->>"lastVisitDate"',
      'totalVisits': '(membership_info->>"totalVisits")::int'
    };

    return mapping[sortBy] || 'metadata->>"createdAt"';
  }
}

interface MemberSummary {
  id: string;
  memberCode: string;
  name: string;
  profilePhotoUrl?: string;
  status: MemberStatus;
  totalVisits: number;
}

interface MemberStatistics {
  totalMembers: number;
  activeMembers: number;
  inactiveMembers: number;
  suspendedMembers: number;
  expiredMembers: number;
  averageVisits: number;
  genderDistribution: {
    male: number;
    female: number;
    other: number;
  };
}

API 路由實作

Fastify + oRPC 型別安全 API

// apps/kyo-otp-service/src/routes/members.ts
import { FastifyInstance } from 'fastify';
import { createORPCHandler } from '@orpc/server/fastify';
import { z } from 'zod';

// Zod 驗證 Schema
const CreateMemberSchema = z.object({
  personalInfo: z.object({
    name: z.string().min(1).max(100),
    email: z.string().email().optional(),
    phone: z.string().regex(/^[\+]?[\d\s\-\(\)]+$/).optional(),
    dateOfBirth: z.string().datetime().optional(),
    gender: z.enum(['male', 'female', 'other']).optional(),
    emergencyContact: z.object({
      name: z.string().min(1),
      phone: z.string(),
      relationship: z.string()
    }).optional()
  }),
  fitnessProfile: z.object({
    height: z.number().positive().optional(),
    weight: z.number().positive().optional(),
    bodyFatPercentage: z.number().min(0).max(100).optional(),
    fitnessGoals: z.array(z.string()).default([]),
    medicalConditions: z.array(z.string()).default([]),
    experienceLevel: z.enum(['beginner', 'intermediate', 'advanced']).default('beginner')
  }),
  customFields: z.record(z.any()).default({})
});

const UpdateMemberSchema = CreateMemberSchema.partial().extend({
  version: z.number().int().positive()
});

const MemberSearchSchema = z.object({
  keyword: z.string().optional(),
  memberCode: z.string().optional(),
  status: z.array(z.enum(['active', 'inactive', 'suspended', 'expired'])).optional(),
  joinDateFrom: z.string().datetime().optional(),
  joinDateTo: z.string().datetime().optional(),
  gender: z.array(z.enum(['male', 'female', 'other'])).optional(),
  ageFrom: z.number().int().min(0).max(150).optional(),
  ageTo: z.number().int().min(0).max(150).optional(),
  sortBy: z.enum(['name', 'joinDate', 'lastVisit', 'totalVisits']).default('name'),
  sortOrder: z.enum(['asc', 'desc']).default('asc'),
  page: z.number().int().positive().default(1),
  limit: z.number().int().min(1).max(100).default(20)
});

export async function memberRoutes(fastify: FastifyInstance) {
  // 型別安全的 oRPC Handler
  const memberHandler = createORPCHandler({
    // 建立會員
    createMember: {
      input: CreateMemberSchema,
      output: z.object({
        id: z.string(),
        memberCode: z.string(),
        personalInfo: z.object({
          name: z.string(),
          email: z.string().optional(),
          phone: z.string().optional()
        })
      }),
      handler: async (input, context) => {
        // 從 JWT Token 取得操作者資訊
        const operatorId = context.user.id;
        const tenantId = context.user.tenantId;

        const memberService = new MemberService(tenantId, context.dbConnection);
        const member = await memberService.createMember(input, operatorId);

        return {
          id: member.id,
          memberCode: member.memberCode,
          personalInfo: {
            name: member.personalInfo.name,
            email: member.personalInfo.email,
            phone: member.personalInfo.phone
          }
        };
      }
    },

    // 更新會員
    updateMember: {
      input: z.object({
        id: z.string(),
        data: UpdateMemberSchema
      }),
      output: z.object({
        success: z.boolean(),
        member: z.object({
          id: z.string(),
          version: z.number()
        })
      }),
      handler: async (input, context) => {
        const operatorId = context.user.id;
        const tenantId = context.user.tenantId;

        const memberService = new MemberService(tenantId, context.dbConnection);
        const member = await memberService.updateMember(input.id, input.data, operatorId);

        return {
          success: true,
          member: {
            id: member.id,
            version: member.metadata.version
          }
        };
      }
    },

    // 搜尋會員
    searchMembers: {
      input: MemberSearchSchema,
      output: z.object({
        members: z.array(z.object({
          id: z.string(),
          memberCode: z.string(),
          name: z.string(),
          status: z.enum(['active', 'inactive', 'suspended', 'expired']),
          joinDate: z.string(),
          lastVisitDate: z.string().optional(),
          totalVisits: z.number()
        })),
        pagination: z.object({
          currentPage: z.number(),
          pageSize: z.number(),
          totalCount: z.number(),
          totalPages: z.number()
        })
      }),
      handler: async (input, context) => {
        const tenantId = context.user.tenantId;
        const memberService = new MemberService(tenantId, context.dbConnection);

        const result = await memberService.searchMembers(input);

        return {
          members: result.members.map(member => ({
            id: member.id,
            memberCode: member.memberCode,
            name: member.personalInfo.name,
            status: member.membershipInfo.status,
            joinDate: member.membershipInfo.joinDate.toISOString(),
            lastVisitDate: member.membershipInfo.lastVisitDate?.toISOString(),
            totalVisits: member.membershipInfo.totalVisits
          })),
          pagination: result.pagination
        };
      }
    },

    // 會員報到
    checkInMember: {
      input: z.object({
        identifier: z.string() // 會員編號、電話或 Email
      }),
      output: z.object({
        success: z.boolean(),
        member: z.object({
          id: z.string(),
          name: z.string(),
          memberCode: z.string(),
          profilePhotoUrl: z.string().optional()
        }),
        checkInTime: z.string(),
        totalVisits: z.number()
      }),
      handler: async (input, context) => {
        const operatorId = context.user.id;
        const tenantId = context.user.tenantId;

        const memberService = new MemberService(tenantId, context.dbConnection);
        const result = await memberService.checkIn(input.identifier, operatorId);

        return {
          ...result,
          checkInTime: result.checkInTime.toISOString()
        };
      }
    },

    // 取得會員統計
    getMemberStatistics: {
      input: z.object({
        dateFrom: z.string().datetime().optional(),
        dateTo: z.string().datetime().optional()
      }),
      output: z.object({
        totalMembers: z.number(),
        activeMembers: z.number(),
        inactiveMembers: z.number(),
        averageVisits: z.number(),
        genderDistribution: z.object({
          male: z.number(),
          female: z.number(),
          other: z.number()
        })
      }),
      handler: async (input, context) => {
        const tenantId = context.user.tenantId;
        const memberService = new MemberService(tenantId, context.dbConnection);

        const memberRepository = new MemberRepository(context.dbConnection);
        const statistics = await memberRepository.getMemberStatistics(
          input.dateFrom ? new Date(input.dateFrom) : undefined,
          input.dateTo ? new Date(input.dateTo) : undefined
        );

        return statistics;
      }
    }
  });

  // 註冊路由
  fastify.register(async function(fastify) {
    fastify.all('/api/members/*', memberHandler);
  });
}

快取策略與效能優化

Redis 快取分層設計

// packages/kyo-core/src/cache/member-cache.ts
export class MemberCacheService extends CacheService {
  constructor(tenantId: string) {
    super(tenantId);
  }

  // L1 快取:單一會員資料 (高頻存取)
  async getMember(memberId: string): Promise<Member | null> {
    const cacheKey = `member:${this.tenantId}:${memberId}`;
    return await this.get<Member>(cacheKey);
  }

  async setMember(member: Member, ttl: number = 3600): Promise<void> {
    const cacheKey = `member:${this.tenantId}:${member.id}`;
    await this.set(cacheKey, member, ttl);
  }

  // L2 快取:搜尋結果 (中頻存取)
  async getSearchResult(searchHash: string): Promise<MemberSearchResult | null> {
    const cacheKey = `search:${this.tenantId}:${searchHash}`;
    return await this.get<MemberSearchResult>(cacheKey);
  }

  async setSearchResult(
    searchHash: string,
    result: MemberSearchResult,
    ttl: number = 300
  ): Promise<void> {
    const cacheKey = `search:${this.tenantId}:${searchHash}`;
    await this.set(cacheKey, result, ttl);
  }

  // L3 快取:統計資料 (低頻存取,長時間有效)
  async getStatistics(dateRange?: string): Promise<MemberStatistics | null> {
    const cacheKey = `stats:${this.tenantId}:members${dateRange ? `:${dateRange}` : ''}`;
    return await this.get<MemberStatistics>(cacheKey);
  }

  async setStatistics(
    statistics: MemberStatistics,
    dateRange?: string,
    ttl: number = 1800
  ): Promise<void> {
    const cacheKey = `stats:${this.tenantId}:members${dateRange ? `:${dateRange}` : ''}`;
    await this.set(cacheKey, statistics, ttl);
  }

  // 智慧失效:相關資料聯動清除
  async invalidateMemberCache(memberId: string): Promise<void> {
    // 清除單一會員快取
    await this.delete(`member:${this.tenantId}:${memberId}`);

    // 清除相關搜尋結果 (使用模式匹配)
    await this.invalidatePattern(`search:${this.tenantId}:*`);

    // 清除統計快取
    await this.invalidatePattern(`stats:${this.tenantId}:*`);
  }

  // 批次預加載:熱門會員資料
  async preloadPopularMembers(memberIds: string[]): Promise<void> {
    const pipeline = this.redis.pipeline();

    for (const memberId of memberIds) {
      const cacheKey = `member:${this.tenantId}:${memberId}`;
      pipeline.get(cacheKey);
    }

    const results = await pipeline.exec();

    // 找出未快取的會員,從資料庫批次載入
    const missingIds: string[] = [];
    results?.forEach((result, index) => {
      if (!result[1]) { // 快取未命中
        missingIds.push(memberIds[index]);
      }
    });

    if (missingIds.length > 0) {
      // TODO: 從資料庫批次載入並快取
      console.log('Preloading missing members:', missingIds);
    }
  }
}

事件驅動與業務整合

會員事件系統

// packages/kyo-core/src/events/member-events.ts
export interface MemberEvent {
  type: 'member.created' | 'member.updated' | 'member.checkedIn' | 'member.expired';
  tenantId: string;
  memberId: string;
  timestamp: Date;
  operatorId?: string;
  data: any;
}

export class MemberEventHandler {
  constructor(private eventBus: EventBus) {
    this.setupEventHandlers();
  }

  private setupEventHandlers(): void {
    // 會員建立事件 - 觸發歡迎流程
    this.eventBus.on('member.created', async (event: MemberEvent) => {
      try {
        // 1. 發送歡迎簡訊/Email
        await this.sendWelcomeMessage(event);

        // 2. 建立初始健身檔案
        await this.createInitialFitnessProfile(event);

        // 3. 分配預設教練 (如果有設定)
        await this.assignDefaultTrainer(event);

        console.log(`Welcome flow completed for member ${event.memberId}`);
      } catch (error) {
        console.error('Welcome flow failed:', error);
      }
    });

    // 會員報到事件 - 更新活躍度
    this.eventBus.on('member.checkedIn', async (event: MemberEvent) => {
      try {
        // 1. 更新活躍度分數
        await this.updateActivityScore(event);

        // 2. 檢查里程碑成就
        await this.checkMilestoneAchievements(event);

        // 3. 個人化推薦 (基於報到頻率)
        await this.generatePersonalizedRecommendations(event);

      } catch (error) {
        console.error('Check-in event processing failed:', error);
      }
    });

    // 會員資料更新 - 同步到相關服務
    this.eventBus.on('member.updated', async (event: MemberEvent) => {
      try {
        // 1. 同步到課程預約系統
        await this.syncToBookingSystem(event);

        // 2. 更新個人化設定
        await this.updatePersonalizationSettings(event);

        // 3. 重新計算推薦內容
        await this.recalculateRecommendations(event);

      } catch (error) {
        console.error('Member update sync failed:', error);
      }
    });

    // 會籍到期事件 - 自動化處理
    this.eventBus.on('member.expired', async (event: MemberEvent) => {
      try {
        // 1. 暫停相關服務存取權限
        await this.suspendServiceAccess(event);

        // 2. 發送續約提醒
        await this.sendRenewalReminder(event);

        // 3. 歸檔歷史資料
        await this.archiveHistoricalData(event);

      } catch (error) {
        console.error('Member expiration processing failed:', error);
      }
    });
  }

  private async sendWelcomeMessage(event: MemberEvent): Promise<void> {
    const notificationService = new NotificationService(event.tenantId);

    await notificationService.send({
      memberId: event.memberId,
      type: 'welcome',
      channels: ['sms', 'email'],
      template: 'member_welcome',
      data: {
        memberName: event.data.memberName,
        gymName: event.data.gymName
      }
    });
  }

  private async updateActivityScore(event: MemberEvent): Promise<void> {
    const analyticsService = new AnalyticsService(event.tenantId);

    await analyticsService.updateMemberActivity({
      memberId: event.memberId,
      activityType: 'check_in',
      timestamp: event.timestamp,
      value: 10 // 基礎分數
    });
  }
}

成本與效能分析

會員服務成本結構

const memberServiceCostAnalysis = {
  // 資料庫儲存成本 (每家健身房)
  databaseStorage: {
    // 平均每位會員資料大小
    averageMemberSize: '2KB',

    // 不同規模健身房
    smallGym: {
      members: 300,
      storageSize: '600KB',
      monthlyCost: 0.01 // 幾乎可忽略
    },

    mediumGym: {
      members: 1000,
      storageSize: '2MB',
      monthlyCost: 0.05
    },

    largeGym: {
      members: 5000,
      storageSize: '10MB',
      monthlyCost: 0.25
    }
  },

  // Redis 快取成本
  redisCaching: {
    // cache.t3.micro (0.5GB)
    microInstance: {
      capacity: '500MB',
      supportMembers: 10000, // 熱門會員快取
      monthlyCost: 15
    },

    // 快取命中率效益
    performanceGains: {
      cacheHitRate: '85%',
      responseTimeImprovement: '70%', // 從 200ms -> 60ms
      databaseLoadReduction: '60%'
    }
  },

  // API 請求成本
  apiRequests: {
    // 每會員每天平均請求數
    averageRequestsPerMemberPerDay: 5,

    mediumGym: {
      members: 1000,
      dailyRequests: 5000,
      monthlyRequests: 150000,
      apiGatewayCost: 0.15 // $0.001 per 1000 requests
    }
  },

  // 總成本分析 (中型健身房)
  totalMonthlyCost: {
    storage: 0.05,
    cache: 15,
    api: 0.15,
    compute: 25, // ECS task 分攤
    monitoring: 3,
    total: 43.2 // $43.2/月,支援 1000 會員
  },

  // ROI 效益
  roi: {
    perMemberCost: 0.043, // $0.043/會員/月
    replacementValue: 200, // 傳統系統單次客製開發
    paybackPeriod: '1.2個月',
    annualSaving: 2000 // 相比自建系統
  }
};

今日總結

今天我們完成了健身房會員服務的完整實作

核心功能

  1. 完整 CRUD 操作: 建立、查詢、更新、刪除會員資料
  2. 智能搜尋系統: 支援多條件、分頁、排序的複雜查詢
  3. 會員報到系統: 快速識別與訪問記錄
  4. 批次處理: 會籍到期提醒與狀態管理

效能優化

  1. 分層快取策略: L1/L2/L3 三層快取設計
  2. 智慧失效機制: 相關資料聯動更新
  3. 批次預加載: 熱門會員資料預先快取
  4. 查詢優化: 索引設計與 SQL 最佳化

業務整合

  1. 事件驅動架構: 會員生命週期事件處理
  2. 服務間協作: 與認證、通知、分析服務整合
  3. 個人化推薦: 基於活躍度的智慧建議
  4. 自動化流程: 歡迎流程、續約提醒

成本效益

  • 中型健身房: 每月 $43.2 (1000 會員)
  • 每會員成本: $0.043/月
  • 效能提升: 70% 回應時間改善

企業級特性

  • 樂觀鎖機制: 防止併發修改衝突
  • 審計追蹤: 完整的操作記錄
  • 資料驗證: Zod Schema 型別安全
  • 錯誤處理: 優雅的異常管理

上一篇
Day 11: 30天打造SaaS產品軟體開發篇-Auth Service 多租戶認證與 RBAC 系統
下一篇
Day 13:30天打造SaaS產品軟體開發篇-教練管理與排課服務 (Trainer Service)
系列文
30 天打造工作室 SaaS 產品 (後端篇)16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言