iT邦幫忙

2025 iThome 鐵人賽

DAY 9
0
Software Development

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

Day 9: 30天打造SaaS產品後端篇-後端前端整合與效能最佳化

  • 分享至 

  • xImage
  •  

前情提要

在 Day 8 我們建立了全面的效能監控系統,今天我們要將模擬 API 升級為真實的後端整合。在我們的 Kyo 系統中,前端目前使用模擬 API,而後端已經有完整的 Fastify 實作。

透過將 React Query 與真實 API 結合,我們能實現更好的快取策略、錯誤處理,並結合 Day 8 的效能監控做出最佳化。

API 設計原則與演進

Day 8 → Day 9 的設計演進

Day 8 簡單 API 設計:

// 基本的 API 端點,缺乏統一規範
app.post('/send-otp', async (req, res) => {
  const { phone } = req.body;
  // 簡單處理...
  res.json({ success: true });
});

app.get('/templates', async (req, res) => {
  const templates = await getTemplates();
  res.json(templates);
});

Day 9 標準化 API 設計:

// 統一的響應格式與錯誤處理
interface ApiResponse<T = any> {
  success: boolean;
  data?: T;
  error?: ApiError;
  meta?: ResponseMeta;
}

interface ApiError {
  code: string;
  message: string;
  details?: any;
}

interface ResponseMeta {
  timestamp: string;
  requestId: string;
  version: string;
}

RESTful API 標準實現

1. 統一響應格式設計

// shared/api-types.ts
export interface StandardApiResponse<T = any> {
  success: boolean;
  data?: T;
  error?: {
    code: string;
    message: string;
    details?: Record<string, any>;
  };
  meta: {
    timestamp: string;
    requestId: string;
    version: string;
    pagination?: {
      page: number;
      limit: number;
      total: number;
      hasNext: boolean;
    };
  };
}

// 成功響應
export const createSuccessResponse = <T>(
  data: T,
  meta: Partial<ResponseMeta> = {}
): StandardApiResponse<T> => ({
  success: true,
  data,
  meta: {
    timestamp: new Date().toISOString(),
    requestId: generateRequestId(),
    version: process.env.API_VERSION || '1.0',
    ...meta
  }
});

// 錯誤響應
export const createErrorResponse = (
  code: string,
  message: string,
  details?: any
): StandardApiResponse => ({
  success: false,
  error: { code, message, details },
  meta: {
    timestamp: new Date().toISOString(),
    requestId: generateRequestId(),
    version: process.env.API_VERSION || '1.0'
  }
});

2. 資源導向的API端點設計

// api/routes/templates.ts - RESTful 模板管理
export const templateRoutes = (app: FastifyInstance) => {
  // GET /api/v1/templates - 獲取模板列表
  app.get('/api/v1/templates', {
    schema: {
      querystring: {
        type: 'object',
        properties: {
          page: { type: 'number', minimum: 1, default: 1 },
          limit: { type: 'number', minimum: 1, maximum: 100, default: 20 },
          status: { type: 'string', enum: ['active', 'inactive', 'all'], default: 'all' },
          search: { type: 'string', maxLength: 100 }
        }
      },
      response: {
        200: {
          type: 'object',
          properties: {
            success: { type: 'boolean' },
            data: {
              type: 'array',
              items: {
                type: 'object',
                properties: {
                  id: { type: 'number' },
                  name: { type: 'string' },
                  content: { type: 'string' },
                  isActive: { type: 'boolean' },
                  createdAt: { type: 'string', format: 'date-time' },
                  updatedAt: { type: 'string', format: 'date-time' }
                }
              }
            },
            meta: { type: 'object' }
          }
        }
      }
    },
    handler: async (request, reply) => {
      const { page, limit, status, search } = request.query as any;

      const filters = {
        ...(status !== 'all' && { isActive: status === 'active' }),
        ...(search && { name: { contains: search } })
      };

      const [templates, total] = await Promise.all([
        templateService.findMany({
          where: filters,
          skip: (page - 1) * limit,
          take: limit,
          orderBy: { updatedAt: 'desc' }
        }),
        templateService.count({ where: filters })
      ]);

      return createSuccessResponse(templates, {
        pagination: {
          page,
          limit,
          total,
          hasNext: page * limit < total
        }
      });
    }
  });

  // POST /api/v1/templates - 創建新模板
  app.post('/api/v1/templates', {
    schema: {
      body: {
        type: 'object',
        required: ['name', 'content'],
        properties: {
          name: { type: 'string', minLength: 1, maxLength: 100 },
          content: { type: 'string', minLength: 1, maxLength: 500 },
          isActive: { type: 'boolean', default: true }
        }
      },
      response: {
        201: {
          type: 'object',
          properties: {
            success: { type: 'boolean' },
            data: {
              type: 'object',
              properties: {
                id: { type: 'number' },
                name: { type: 'string' },
                content: { type: 'string' },
                isActive: { type: 'boolean' },
                createdAt: { type: 'string' },
                updatedAt: { type: 'string' }
              }
            }
          }
        }
      }
    },
    handler: async (request, reply) => {
      const templateData = request.body as CreateTemplateDto;

      // 檢查名稱是否已存在
      const existingTemplate = await templateService.findByName(templateData.name);
      if (existingTemplate) {
        return reply.code(409).send(createErrorResponse(
          'TEMPLATE_NAME_EXISTS',
          'Template with this name already exists'
        ));
      }

      const template = await templateService.create(templateData);

      reply.code(201);
      return createSuccessResponse(template);
    }
  });

  // PUT /api/v1/templates/:id - 更新模板
  app.put('/api/v1/templates/:id', {
    schema: {
      params: {
        type: 'object',
        properties: {
          id: { type: 'number', minimum: 1 }
        },
        required: ['id']
      },
      body: {
        type: 'object',
        properties: {
          name: { type: 'string', minLength: 1, maxLength: 100 },
          content: { type: 'string', minLength: 1, maxLength: 500 },
          isActive: { type: 'boolean' }
        },
        additionalProperties: false
      }
    },
    handler: async (request, reply) => {
      const { id } = request.params as { id: number };
      const updateData = request.body as Partial<CreateTemplateDto>;

      const template = await templateService.findById(id);
      if (!template) {
        return reply.code(404).send(createErrorResponse(
          'TEMPLATE_NOT_FOUND',
          'Template not found'
        ));
      }

      const updatedTemplate = await templateService.update(id, updateData);
      return createSuccessResponse(updatedTemplate);
    }
  });

  // DELETE /api/v1/templates/:id - 刪除模板
  app.delete('/api/v1/templates/:id', {
    schema: {
      params: {
        type: 'object',
        properties: {
          id: { type: 'number', minimum: 1 }
        },
        required: ['id']
      }
    },
    handler: async (request, reply) => {
      const { id } = request.params as { id: number };

      const template = await templateService.findById(id);
      if (!template) {
        return reply.code(404).send(createErrorResponse(
          'TEMPLATE_NOT_FOUND',
          'Template not found'
        ));
      }

      await templateService.delete(id);

      reply.code(204);
      return;
    }
  });
};

3. OTP API 的改進設計

// api/routes/otp.ts - 改進的 OTP API
export const otpRoutes = (app: FastifyInstance) => {
  // POST /api/v1/otp/send - 發送 OTP
  app.post('/api/v1/otp/send', {
    schema: {
      body: {
        type: 'object',
        required: ['phone'],
        properties: {
          phone: {
            type: 'string',
            pattern: '^09\\d{8}$',
            description: '台灣手機號碼格式:09XXXXXXXX'
          },
          templateId: {
            type: 'number',
            minimum: 1,
            description: '可選的簡訊模板 ID'
          },
          purpose: {
            type: 'string',
            enum: ['login', 'register', 'reset_password', 'verify_phone'],
            default: 'login',
            description: 'OTP 用途分類'
          }
        }
      },
      response: {
        200: {
          type: 'object',
          properties: {
            success: { type: 'boolean' },
            data: {
              type: 'object',
              properties: {
                messageId: { type: 'string' },
                phone: { type: 'string' },
                expiresAt: { type: 'string', format: 'date-time' },
                attemptsLeft: { type: 'number' }
              }
            }
          }
        }
      }
    },
    preHandler: [rateLimitMiddleware], // 速率限制
    handler: async (request, reply) => {
      const { phone, templateId, purpose } = request.body as SendOtpDto;

      // 檢查發送頻率限制
      const lastSent = await otpService.getLastSentTime(phone);
      if (lastSent && Date.now() - lastSent < 60000) { // 1分鐘限制
        return reply.code(429).send(createErrorResponse(
          'TOO_FREQUENT',
          'Please wait before requesting another OTP'
        ));
      }

      const result = await otpService.send({
        phone,
        templateId,
        purpose,
        ttl: 300 // 5分鐘有效期
      });

      return createSuccessResponse({
        messageId: result.messageId,
        phone: result.phone,
        expiresAt: new Date(Date.now() + 300000).toISOString(),
        attemptsLeft: 3
      });
    }
  });

  // POST /api/v1/otp/verify - 驗證 OTP
  app.post('/api/v1/otp/verify', {
    schema: {
      body: {
        type: 'object',
        required: ['phone', 'code'],
        properties: {
          phone: { type: 'string', pattern: '^09\\d{8}$' },
          code: {
            type: 'string',
            pattern: '^\\d{6}$',
            description: '6位數驗證碼'
          },
          purpose: {
            type: 'string',
            enum: ['login', 'register', 'reset_password', 'verify_phone'],
            default: 'login'
          }
        }
      }
    },
    handler: async (request, reply) => {
      const { phone, code, purpose } = request.body as VerifyOtpDto;

      const result = await otpService.verify(phone, code, purpose);

      if (!result.success) {
        const statusCode = result.reason === 'EXPIRED' ? 410 :
                          result.reason === 'INVALID' ? 400 : 429;

        return reply.code(statusCode).send(createErrorResponse(
          result.reason,
          result.message,
          { attemptsLeft: result.attemptsLeft }
        ));
      }

      return createSuccessResponse({
        verified: true,
        token: result.token // JWT token for authenticated session
      });
    }
  });
};

自動產生文件

1. OpenAPI/Swagger 整合

// api/swagger.ts - Swagger 配置
import { FastifyInstance } from 'fastify';
import fastifySwagger from '@fastify/swagger';
import fastifySwaggerUi from '@fastify/swagger-ui';

export const setupSwagger = async (app: FastifyInstance) => {
  await app.register(fastifySwagger, {
    openapi: {
      openapi: '3.0.0',
      info: {
        title: 'Kyo OTP Service API',
        description: 'OTP 驗證服務的 RESTful API 文件',
        version: '1.0.0',
        contact: {
          name: 'Kyo Development Team',
          email: 'dev@kyo.com'
        },
        license: {
          name: 'MIT',
          url: 'https://opensource.org/licenses/MIT'
        }
      },
      servers: [
        {
          url: 'http://localhost:3000',
          description: 'Development server'
        },
        {
          url: 'https://api.kyo.com',
          description: 'Production server'
        }
      ],
      components: {
        securitySchemes: {
          bearerAuth: {
            type: 'http',
            scheme: 'bearer',
            bearerFormat: 'JWT'
          },
          apiKey: {
            type: 'apiKey',
            in: 'header',
            name: 'X-API-Key'
          }
        },
        schemas: {
          Template: {
            type: 'object',
            properties: {
              id: { type: 'number', example: 1 },
              name: { type: 'string', example: 'default' },
              content: { type: 'string', example: '您的驗證碼:{code}' },
              isActive: { type: 'boolean', example: true },
              createdAt: { type: 'string', format: 'date-time' },
              updatedAt: { type: 'string', format: 'date-time' }
            }
          },
          ApiError: {
            type: 'object',
            properties: {
              success: { type: 'boolean', example: false },
              error: {
                type: 'object',
                properties: {
                  code: { type: 'string', example: 'VALIDATION_ERROR' },
                  message: { type: 'string', example: 'Invalid input data' },
                  details: { type: 'object' }
                }
              },
              meta: {
                type: 'object',
                properties: {
                  timestamp: { type: 'string', format: 'date-time' },
                  requestId: { type: 'string' },
                  version: { type: 'string', example: '1.0' }
                }
              }
            }
          }
        }
      },
      tags: [
        {
          name: 'Templates',
          description: '簡訊模板管理'
        },
        {
          name: 'OTP',
          description: 'OTP 發送與驗證'
        },
        {
          name: 'Health',
          description: '系統健康檢查'
        }
      ]
    }
  });

  await app.register(fastifySwaggerUi, {
    routePrefix: '/docs',
    uiConfig: {
      docExpansion: 'list',
      deepLinking: false
    },
    staticCSP: true,
    transformStaticCSP: (header) => header,
    transformSpecification: (swaggerObject) => {
      return swaggerObject;
    },
    transformSpecificationClone: true
  });
};

2. TypeScript 型別定義同步

// types/api.ts - 與 API schema 同步的型別定義
export interface Template {
  id: number;
  name: string;
  content: string;
  isActive: boolean;
  createdAt: string;
  updatedAt: string;
}

export interface CreateTemplateDto {
  name: string;
  content: string;
  isActive?: boolean;
}

export interface UpdateTemplateDto extends Partial<CreateTemplateDto> {}

export interface SendOtpDto {
  phone: string;
  templateId?: number;
  purpose?: 'login' | 'register' | 'reset_password' | 'verify_phone';
}

export interface VerifyOtpDto {
  phone: string;
  code: string;
  purpose?: 'login' | 'register' | 'reset_password' | 'verify_phone';
}

export interface OtpSendResponse {
  messageId: string;
  phone: string;
  expiresAt: string;
  attemptsLeft: number;
}

export interface OtpVerifyResponse {
  verified: boolean;
  token?: string;
}

// API Client 型別定義
export interface ApiClient {
  // Template operations
  getTemplates(params?: {
    page?: number;
    limit?: number;
    status?: 'active' | 'inactive' | 'all';
    search?: string;
  }): Promise<StandardApiResponse<Template[]>>;

  createTemplate(data: CreateTemplateDto): Promise<StandardApiResponse<Template>>;
  updateTemplate(id: number, data: UpdateTemplateDto): Promise<StandardApiResponse<Template>>;
  deleteTemplate(id: number): Promise<void>;

  // OTP operations
  sendOtp(data: SendOtpDto): Promise<StandardApiResponse<OtpSendResponse>>;
  verifyOtp(data: VerifyOtpDto): Promise<StandardApiResponse<OtpVerifyResponse>>;
}

3. 自動化測試與文件同步

// scripts/generate-docs.ts - 文件生成腳本
import { exec } from 'child_process';
import { promisify } from 'util';
import fs from 'fs/promises';

const execAsync = promisify(exec);

export class DocumentationGenerator {
  async generateApiDocs(): Promise<void> {
    console.log('Generating API documentation...');

    // 1. 從 Swagger 生成 OpenAPI spec
    const { stdout: swaggerJson } = await execAsync(
      'curl -s http://localhost:3000/docs/json'
    );

    await fs.writeFile('docs/openapi.json', swaggerJson);

    // 2. 生成 Markdown 文件
    await execAsync(
      'npx swagger-markdown -i docs/openapi.json -o docs/api.md'
    );

    // 3. 生成客戶端 SDK
    await this.generateClientSDK();

    // 4. 生成 Postman Collection
    await this.generatePostmanCollection();

    console.log('Documentation generated successfully!');
  }

  private async generateClientSDK(): Promise<void> {
    const configs = [
      {
        generator: 'typescript-fetch',
        output: 'clients/typescript',
        package: '@kyo/api-client'
      },
      {
        generator: 'python',
        output: 'clients/python',
        package: 'kyo-api-client'
      }
    ];

    for (const config of configs) {
      await execAsync(`
        npx @openapitools/openapi-generator-cli generate \
          -i docs/openapi.json \
          -g ${config.generator} \
          -o ${config.output} \
          --additional-properties packageName=${config.package}
      `);
    }
  }

  private async generatePostmanCollection(): Promise<void> {
    await execAsync(
      'npx openapi-to-postman -s docs/openapi.json -o docs/postman-collection.json'
    );
  }

  async validateDocumentation(): Promise<boolean> {
    try {
      // 驗證 OpenAPI spec
      await execAsync('npx swagger-codegen validate -i docs/openapi.json');

      // 運行 API 測試確保文件與實際 API 一致
      const { stdout } = await execAsync('npm run test:api');

      return !stdout.includes('FAIL');
    } catch (error) {
      console.error('Documentation validation failed:', error);
      return false;
    }
  }
}

// GitHub Actions 整合
export const setupCIDocumentation = `
# .github/workflows/docs.yml
name: API Documentation

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  docs:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install

      - name: Start API server
        run: pnpm run dev &

      - name: Wait for server
        run: sleep 10

      - name: Generate documentation
        run: pnpm run docs:generate

      - name: Validate documentation
        run: pnpm run docs:validate

      - name: Deploy to GitHub Pages
        if: github.ref == 'refs/heads/main'
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: \${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./docs
`;

API 版本管理

版本策略設計

// api/versioning.ts - API 版本管理
export class ApiVersionManager {
  private supportedVersions = ['1.0', '1.1', '2.0'];
  private defaultVersion = '2.0';

  getVersion(request: FastifyRequest): string {
    // 1. 從 Accept header 獲取版本
    const acceptHeader = request.headers.accept;
    const versionMatch = acceptHeader?.match(/application\/vnd\.kyo\.v(\d+\.\d+)\+json/);

    if (versionMatch) {
      const version = versionMatch[1];
      if (this.supportedVersions.includes(version)) {
        return version;
      }
    }

    // 2. 從 URL path 獲取版本 (/api/v1.1/...)
    const pathVersion = request.url.match(/\/api\/v(\d+\.\d+)\//)?.[1];
    if (pathVersion && this.supportedVersions.includes(pathVersion)) {
      return pathVersion;
    }

    // 3. 從 X-API-Version header 獲取
    const headerVersion = request.headers['x-api-version'] as string;
    if (headerVersion && this.supportedVersions.includes(headerVersion)) {
      return headerVersion;
    }

    return this.defaultVersion;
  }

  isDeprecated(version: string): boolean {
    const deprecatedVersions = ['1.0'];
    return deprecatedVersions.includes(version);
  }

  async handleVersionedRequest(
    request: FastifyRequest,
    reply: FastifyReply,
    handlers: Record<string, Function>
  ): Promise<any> {
    const version = this.getVersion(request);

    // 添加版本信息到響應 header
    reply.header('X-API-Version', version);

    // 檢查是否為棄用版本
    if (this.isDeprecated(version)) {
      reply.header('X-API-Deprecated', 'true');
      reply.header('X-API-Sunset', '2024-12-31'); // 停用日期
    }

    const handler = handlers[version] || handlers[this.defaultVersion];
    return handler(request, reply);
  }
}

// 使用範例
const versionManager = new ApiVersionManager();

app.get('/api/*/templates', async (request, reply) => {
  return versionManager.handleVersionedRequest(request, reply, {
    '1.0': handleTemplatesV1,
    '1.1': handleTemplatesV1_1,
    '2.0': handleTemplatesV2
  });
});

前端 API 客戶端改進

React Query 與型別安全整合

// frontend/src/api/client.ts - 改進的 API 客戶端
import { StandardApiResponse, Template, CreateTemplateDto } from '@kyo/shared-types';

class KyoApiClient {
  private baseURL: string;
  private version: string;

  constructor(baseURL: string, version = '2.0') {
    this.baseURL = baseURL;
    this.version = version;
  }

  private async request<T>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<StandardApiResponse<T>> {
    const url = `${this.baseURL}/api/v${this.version}${endpoint}`;

    const response = await fetch(url, {
      ...options,
      headers: {
        'Content-Type': 'application/json',
        'Accept': `application/vnd.kyo.v${this.version}+json`,
        ...options.headers
      }
    });

    if (!response.ok) {
      const errorData = await response.json();
      throw new ApiError(response.status, errorData);
    }

    return response.json();
  }

  // Template API methods
  async getTemplates(params?: GetTemplatesParams): Promise<StandardApiResponse<Template[]>> {
    const searchParams = new URLSearchParams();
    if (params?.page) searchParams.set('page', params.page.toString());
    if (params?.limit) searchParams.set('limit', params.limit.toString());
    if (params?.status) searchParams.set('status', params.status);
    if (params?.search) searchParams.set('search', params.search);

    const queryString = searchParams.toString();
    return this.request<Template[]>(`/templates${queryString ? `?${queryString}` : ''}`);
  }

  async createTemplate(data: CreateTemplateDto): Promise<StandardApiResponse<Template>> {
    return this.request<Template>('/templates', {
      method: 'POST',
      body: JSON.stringify(data)
    });
  }

  async updateTemplate(id: number, data: Partial<CreateTemplateDto>): Promise<StandardApiResponse<Template>> {
    return this.request<Template>(`/templates/${id}`, {
      method: 'PUT',
      body: JSON.stringify(data)
    });
  }

  async deleteTemplate(id: number): Promise<void> {
    await this.request(`/templates/${id}`, { method: 'DELETE' });
  }
}

export const apiClient = new KyoApiClient(
  process.env.REACT_APP_API_URL || 'http://localhost:3000',
  process.env.REACT_APP_API_VERSION || '2.0'
);

// 錯誤處理類
export class ApiError extends Error {
  constructor(
    public status: number,
    public response: StandardApiResponse
  ) {
    super(response.error?.message || 'API Error');
    this.name = 'ApiError';
  }
}

改進的 React Query Hooks

// frontend/src/hooks/api/useTemplates.ts - 型別安全的 hooks
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../api/client';

export const useTemplates = (params?: GetTemplatesParams) => {
  return useQuery({
    queryKey: ['templates', params],
    queryFn: () => apiClient.getTemplates(params),
    select: (response) => ({
      templates: response.data || [],
      pagination: response.meta?.pagination
    }),
    staleTime: 5 * 60 * 1000, // 5 minutes
    retry: (failureCount, error) => {
      if (error instanceof ApiError && error.status >= 400 && error.status < 500) {
        return false; // Don't retry client errors
      }
      return failureCount < 3;
    }
  });
};

export const useCreateTemplate = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: apiClient.createTemplate,
    onSuccess: (response) => {
      // Invalidate and refetch templates
      queryClient.invalidateQueries({ queryKey: ['templates'] });

      // Optimistically add to cache
      queryClient.setQueryData(['templates'], (old: any) => {
        if (!old) return old;
        return {
          ...old,
          data: [...(old.data || []), response.data]
        };
      });
    },
    onError: (error: ApiError) => {
      console.error('Failed to create template:', error);
      // Error handling is done in the UI layer
    }
  });
};

性能與監控

API 效能指標

// monitoring/api-metrics.ts - API 效能監控
export class ApiMetrics {
  private metrics = new Map<string, Array<number>>();

  recordResponseTime(endpoint: string, duration: number): void {
    if (!this.metrics.has(endpoint)) {
      this.metrics.set(endpoint, []);
    }

    const times = this.metrics.get(endpoint)!;
    times.push(duration);

    // Keep only last 100 requests
    if (times.length > 100) {
      times.shift();
    }
  }

  getAverageResponseTime(endpoint: string): number {
    const times = this.metrics.get(endpoint) || [];
    return times.length > 0 ? times.reduce((a, b) => a + b, 0) / times.length : 0;
  }

  getP95ResponseTime(endpoint: string): number {
    const times = this.metrics.get(endpoint) || [];
    if (times.length === 0) return 0;

    const sorted = [...times].sort((a, b) => a - b);
    const index = Math.ceil(sorted.length * 0.95) - 1;
    return sorted[index];
  }

  generateReport(): Record<string, any> {
    const report: Record<string, any> = {};

    for (const [endpoint, times] of this.metrics.entries()) {
      report[endpoint] = {
        requestCount: times.length,
        averageTime: this.getAverageResponseTime(endpoint),
        p95Time: this.getP95ResponseTime(endpoint),
        minTime: Math.min(...times),
        maxTime: Math.max(...times)
      };
    }

    return report;
  }
}

本日成果:
✅ RESTful API 標準化設計
✅ 統一響應格式與錯誤處理
✅ OpenAPI/Swagger 自動化文件
✅ TypeScript 型別安全整合
✅ API 版本管理策略
✅ 前端客戶端實作


上一篇
Day 8: 30天打造SaaS產品後端篇-效能監控與最佳化
系列文
30 天打造工作室 SaaS 產品 (後端篇)9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言