經過 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: '資料匯入 - 從舊系統遷移'
}
};
// 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;
}
// 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;
};
}
// 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);
});
}
// 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 // 相比自建系統
}
};
今天我們完成了健身房會員服務的完整實作: