Skip to main content

GlobalHub API - 完整同步機制設計

最後更新: 2026-02-07
版本: v1.0
作者: Pi (OpenClaw Agent)


📚 目錄

  1. 系統架構概述
  2. 上游同步:SERP → GlobalHub
  3. 下游同步:GlobalHub → 區域站點
  4. 資料模型設計
  5. 同步策略
  6. 錯誤處理與重試
  7. 監控與告警
  8. 實作步驟

系統架構概述

整體資料流

graph TD
SERP[SERP API<br/>外部供應商] -->|1. 產品資料| GHA[GlobalHub API<br/>中央匯入服務]
GHA -->|2. 儲存| CentralDB[(中央資料庫<br/>PostgreSQL)]
CentralDB -->|3. 展示| GH[GlobalHub<br/>中央 B2C 前台]

GHA -->|4a. 同步| TW_API[ProductsAPI TW<br/>台灣站]
GHA -->|4b. 同步| US_API[ProductsAPI US<br/>美國站]
GHA -->|4c. 同步| JP_API[ProductsAPI JP<br/>日本站]

TW_API -->|5a. 管理| TW_BE[ProductBackend TW<br/>台灣 CMS]
US_API -->|5b. 管理| US_BE[ProductBackend US<br/>美國 CMS]
JP_API -->|5c. 管理| JP_BE[ProductBackend JP<br/>日本 CMS]

TW_BE -->|6a. 展示| TW_FE[台灣 B2C 前台]
US_BE -->|6b. 展示| US_FE[美國 B2C 前台]
JP_BE -->|6c. 展示| JP_FE[日本 B2C 前台]

核心職責劃分

元件職責資料流向
SERP API外部供應商,提供旅遊產品資料→ GlobalHub API
GlobalHub API中央匯入與分發服務← SERP / → 各區域
GlobalHub中央 B2C 前台展示← GlobalHub API
ProductsAPI (各區域)區域產品核心服務 + Elasticsearch← GlobalHub API
ProductBackend (各區域)區域 CMS 管理後台← ProductsAPI
區域 B2C 前台終端消費者網站← ProductBackend

上游同步:SERP → GlobalHub

架構設計

┌─────────────────────────────────────────────────────────┐
│ SERP API │
│ https://serp.api.liontravel.com/v1 │
└─────────────────────────────────────────────────────────┘

│ HTTP/JSON

┌─────────────────────────────────────────────────────────┐
│ GlobalHub API - 匯入層 │
│ ┌──────────────────────────────────────────────────┐ │
│ │ SerpApiClient │ │
│ │ - GetProducts(page, updatedSince) │ │
│ │ - GetProductById(id) │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ SerpProductMapper │ │
│ │ - MapToGlobalHubProduct(serpProduct) │ │
│ │ - ValidateProduct(product) │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ ProductEnrichmentService (AI) │ │
│ │ - EnrichDescriptions(product) │ │
│ │ - TranslateContent(product, targetLang) │ │
│ │ - GenerateTags(product) │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ ImportProductsJob (Hangfire) │ │
│ │ - ProcessAsync(importJobId) │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────┐
│ 中央資料庫 (PostgreSQL) │
│ - Products (產品主表) │
│ - ProductTranslations (多語言內容) │
│ - ProductPrices (多幣別價格) │
│ - ProductImages (圖片) │
│ - ImportJobs (匯入任務記錄) │
│ - SyncLogs (同步日誌) │
└─────────────────────────────────────────────────────────┘

觸發機制

1. Webhook (即時推送) - 優先 ⭐

// POST /api/webhooks/serp
public class SerpWebhookEndpoint
{
[HttpPost("webhooks/serp")]
public async Task<IResult> ReceiveWebhook(
[FromBody] SerpWebhookPayload payload,
[FromHeader("X-Serp-Signature")] string signature)
{
// 1. 驗證簽章
if (!_webhookValidator.Validate(payload, signature, _serpWebhookSecret))
{
return Results.Unauthorized();
}

// 2. 根據事件類型處理
var jobId = payload.EventType switch
{
"product.created" => await HandleProductCreated(payload.ProductId),
"product.updated" => await HandleProductUpdated(payload.ProductId),
"product.deleted" => await HandleProductDeleted(payload.ProductId),
"product.price_changed" => await HandlePriceChanged(payload.ProductId),
"product.availability_changed" => await HandleAvailabilityChanged(payload.ProductId),
_ => Guid.Empty
};

// 3. 回傳接受狀態
return Results.Accepted($"/api/import/jobs/{jobId}");
}

private async Task<Guid> HandleProductCreated(string serpProductId)
{
// 立即排程匯入任務
var command = new ImportProductsCommand
{
SupplierId = "SERP",
ProductIds = new[] { serpProductId },
Trigger = ImportTrigger.Webhook
};

var result = await _mediator.Send(command);

// 排程 Hangfire 背景處理
BackgroundJob.Enqueue<IImportProductsJob>(
x => x.ProcessAsync(result.JobId, CancellationToken.None));

return result.JobId;
}
}

2. 定時輪詢 (Scheduled Polling)

public class SerpSyncScheduler
{
// 在 Program.cs 註冊
public static void ConfigureRecurringJobs()
{
// 每天凌晨 2 點:全量同步
RecurringJob.AddOrUpdate<SerpFullSyncJob>(
"serp-full-sync",
job => job.ExecuteAsync(CancellationToken.None),
"0 2 * * *", // Cron: 每天 02:00 AM
new RecurringJobOptions
{
TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Taipei")
});

// 每小時:增量同步(只取得更新的產品)
RecurringJob.AddOrUpdate<SerpIncrementalSyncJob>(
"serp-incremental-sync",
job => job.ExecuteAsync(CancellationToken.None),
"0 * * * *", // Cron: 每小時
new RecurringJobOptions
{
TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Taipei")
});

// 每 15 分鐘:價格與庫存同步
RecurringJob.AddOrUpdate<SerpPriceAvailabilitySyncJob>(
"serp-price-availability-sync",
job => job.ExecuteAsync(CancellationToken.None),
"*/15 * * * *", // Cron: 每 15 分鐘
new RecurringJobOptions
{
TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Taipei")
});
}
}

3. 手動觸發 (Manual Trigger)

// POST /api/import/manual
public class ManualImportEndpoint
{
[HttpPost("import/manual")]
[Authorize(Policy = "AdminOnly")]
public async Task<IResult> TriggerManualImport(
[FromBody] ManualImportRequest request)
{
var command = new ImportProductsCommand
{
SupplierId = request.SupplierId,
ProductIds = request.ProductIds,
Trigger = ImportTrigger.Manual,
RequestedBy = User.Identity.Name
};

var result = await _mediator.Send(command);

return Results.Accepted($"/api/import/jobs/{result.JobId}", result);
}
}

資料處理流程

public class ImportProductsJob : IImportProductsJob
{
private readonly ISerpApiClient _serpClient;
private readonly ISerpProductMapper _mapper;
private readonly IProductEnrichmentService _enrichmentService;
private readonly IApplicationDbContext _context;
private readonly ILogger<ImportProductsJob> _logger;

public async Task ProcessAsync(Guid importJobId, CancellationToken ct)
{
var job = await GetImportJob(importJobId);

try
{
await UpdateJobStatus(job, ImportJobStatus.Running);

// Phase 1: 從 SERP 取得原始資料
_logger.LogInformation("Fetching products from SERP...");
var serpProducts = await FetchFromSerp(job, ct);
_logger.LogInformation("Fetched {Count} products from SERP", serpProducts.Count);

// Phase 2: 驗證與轉換
_logger.LogInformation("Validating and transforming products...");
var validatedProducts = await ValidateAndTransform(serpProducts, ct);
_logger.LogInformation("{ValidCount}/{TotalCount} products passed validation",
validatedProducts.Count, serpProducts.Count);

// Phase 3: AI 資料豐富化
_logger.LogInformation("Enriching products with AI...");
var enrichedProducts = await EnrichProducts(validatedProducts, ct);

// Phase 4: 儲存到中央資料庫
_logger.LogInformation("Saving products to central database...");
var savedProducts = await SaveToDatabase(enrichedProducts, ct);

// Phase 5: 標記完成
await CompleteJob(job, savedProducts.Count);
_logger.LogInformation("Import job {JobId} completed successfully", importJobId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Import job {JobId} failed", importJobId);
await FailJob(job, ex);
throw;
}
}

private async Task<List<SerpProduct>> FetchFromSerp(
ImportJob job,
CancellationToken ct)
{
var products = new List<SerpProduct>();

// 如果指定了特定產品 ID
if (job.ProductIds?.Any() == true)
{
foreach (var productId in job.ProductIds)
{
var product = await _serpClient.GetProductById(productId);
if (product != null)
products.Add(product);
}
return products;
}

// 分頁取得所有產品
int page = 1;
bool hasMore = true;

// 增量同步:只取得上次同步後更新的產品
var lastSync = await GetLastSuccessfulSync(job.SupplierId);

while (hasMore && !ct.IsCancellationRequested)
{
var response = await _serpClient.GetProducts(
page: page,
pageSize: 100,
updatedSince: lastSync?.CompletedAt
);

products.AddRange(response.Products);

hasMore = response.HasNextPage;
page++;

// 避免過度請求 SERP API
await Task.Delay(500, ct);
}

return products;
}

private async Task<List<GlobalHubProduct>> ValidateAndTransform(
List<SerpProduct> serpProducts,
CancellationToken ct)
{
var validProducts = new List<GlobalHubProduct>();

foreach (var serpProduct in serpProducts)
{
// 驗證必要欄位
var validationResult = _mapper.Validate(serpProduct);
if (!validationResult.IsValid)
{
_logger.LogWarning("Product {ProductId} validation failed: {Errors}",
serpProduct.Id, string.Join(", ", validationResult.Errors));
continue;
}

// 轉換資料格式
var product = _mapper.MapToGlobalHubProduct(serpProduct);
validProducts.Add(product);
}

return validProducts;
}

private async Task<List<GlobalHubProduct>> EnrichProducts(
List<GlobalHubProduct> products,
CancellationToken ct)
{
var enrichedProducts = new List<GlobalHubProduct>();

foreach (var product in products)
{
try
{
// AI 生成更豐富的描述
product.EnrichedDescription = await _enrichmentService
.EnrichDescription(product.Description, ct);

// AI 翻譯到多語言
product.Translations = await _enrichmentService
.TranslateProduct(product, new[] { "en-US", "zh-TW", "ja-JP" }, ct);

// AI 生成標籤
product.Tags = await _enrichmentService
.GenerateTags(product, ct);

enrichedProducts.Add(product);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to enrich product {ProductId}, using original data",
product.ExternalProductId);
enrichedProducts.Add(product);
}
}

return enrichedProducts;
}

private async Task<List<GlobalHubProduct>> SaveToDatabase(
List<GlobalHubProduct> products,
CancellationToken ct)
{
var savedProducts = new List<GlobalHubProduct>();

foreach (var product in products)
{
try
{
// 冪等性:使用 (SupplierId + ExternalProductId) 作為唯一鍵
var existing = await _context.Products
.FirstOrDefaultAsync(p =>
p.SupplierId == product.SupplierId &&
p.ExternalProductId == product.ExternalProductId, ct);

if (existing != null)
{
// 更新現有產品
UpdateProduct(existing, product);
}
else
{
// 新增產品
_context.Products.Add(product);
}

await _context.SaveChangesAsync(ct);
savedProducts.Add(product);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save product {ProductId}",
product.ExternalProductId);
}
}

return savedProducts;
}
}

下游同步:GlobalHub → 區域站點

架構設計

┌─────────────────────────────────────────────────────────┐
│ GlobalHub API - 中央分發層 │
│ ┌──────────────────────────────────────────────────┐ │
│ │ RegionSyncService │ │
│ │ - SyncToAllRegions(products) │ │
│ │ - SyncToRegion(regionCode, products) │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ RegionSyncJob (Hangfire) │ │
│ │ - ProcessAsync(regionCode) │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘

┌───────────────┼───────────────┐
│ │ │
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ TW API │ │ US API │ │ JP API │
│ (台灣) │ │ (美國) │ │ (日本) │
└─────────┘ └─────────┘ └─────────┘

區域配置

// appsettings.json
{
"Regions": {
"TW": {
"Code": "TW",
"Name": "Taiwan",
"ApiUrl": "https://tw.productsapi.liontravel.com",
"ApiKey": "tw_api_key_here",
"IsEnabled": true,
"Priority": 1,
"SyncSchedule": "*/30 * * * *", // 每 30 分鐘
"SupportedCurrencies": ["TWD", "USD"],
"DefaultLanguage": "zh-TW",
"SupportedLanguages": ["zh-TW", "en-US"]
},
"US": {
"Code": "US",
"Name": "United States",
"ApiUrl": "https://us.productsapi.liontravel.com",
"ApiKey": "us_api_key_here",
"IsEnabled": true,
"Priority": 2,
"SyncSchedule": "0 3 * * *", // 每天凌晨 3 點
"SupportedCurrencies": ["USD"],
"DefaultLanguage": "en-US",
"SupportedLanguages": ["en-US", "zh-TW"]
},
"JP": {
"Code": "JP",
"Name": "Japan",
"ApiUrl": "https://jp.productsapi.liontravel.com",
"ApiKey": "jp_api_key_here",
"IsEnabled": true,
"Priority": 3,
"SyncSchedule": "0 4 * * *", // 每天凌晨 4 點
"SupportedCurrencies": ["JPY", "USD"],
"DefaultLanguage": "ja-JP",
"SupportedLanguages": ["ja-JP", "en-US", "zh-TW"]
}
}
}

同步服務實作

public interface IRegionSyncService
{
Task SyncToAllRegions(CancellationToken ct = default);
Task SyncToRegion(string regionCode, CancellationToken ct = default);
Task SyncProductToRegion(string regionCode, Guid productId, CancellationToken ct = default);
}

public class RegionSyncService : IRegionSyncService
{
private readonly IApplicationDbContext _context;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IConfiguration _configuration;
private readonly ILogger<RegionSyncService> _logger;

public async Task SyncToAllRegions(CancellationToken ct = default)
{
var regions = GetEnabledRegions();

_logger.LogInformation("Starting sync to {RegionCount} regions", regions.Count);

// 平行同步到各區域
await Parallel.ForEachAsync(
regions,
new ParallelOptions
{
MaxDegreeOfParallelism = 3, // 最多同時 3 個區域
CancellationToken = ct
},
async (region, ct) =>
{
try
{
await SyncToRegion(region.Code, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to sync to region {RegionCode}", region.Code);
}
});

_logger.LogInformation("Completed sync to all regions");
}

public async Task SyncToRegion(string regionCode, CancellationToken ct = default)
{
var region = GetRegionConfig(regionCode);
if (region == null || !region.IsEnabled)
{
_logger.LogWarning("Region {RegionCode} is not enabled", regionCode);
return;
}

_logger.LogInformation("Starting sync to region {RegionCode}", regionCode);

// 1. 取得需要同步的產品
var products = await GetProductsForSync(regionCode, ct);
_logger.LogInformation("Found {ProductCount} products to sync to {RegionCode}",
products.Count, regionCode);

if (products.Count == 0)
{
_logger.LogInformation("No products to sync to region {RegionCode}", regionCode);
return;
}

// 2. 批次同步
const int BATCH_SIZE = 50;
var batches = products.Chunk(BATCH_SIZE);

int successCount = 0;
int failedCount = 0;

foreach (var batch in batches)
{
try
{
var result = await SendBatchToRegion(region, batch.ToList(), ct);

if (result.IsSuccess)
{
successCount += batch.Length;
await UpdateSyncStatus(regionCode, batch, SyncStatus.Success);
}
else
{
failedCount += batch.Length;
_logger.LogError("Batch sync to {RegionCode} failed: {Error}",
regionCode, result.Error);
await UpdateSyncStatus(regionCode, batch, SyncStatus.Failed);
}

// 避免壓垮區域 API
await Task.Delay(200, ct);
}
catch (Exception ex)
{
failedCount += batch.Length;
_logger.LogError(ex, "Exception during batch sync to {RegionCode}", regionCode);
await UpdateSyncStatus(regionCode, batch, SyncStatus.Failed);
}
}

_logger.LogInformation(
"Sync to region {RegionCode} completed: {SuccessCount} succeeded, {FailedCount} failed",
regionCode, successCount, failedCount);

// 3. 記錄同步日誌
await LogRegionSync(regionCode, products.Count, successCount, failedCount);
}

private async Task<List<Product>> GetProductsForSync(
string regionCode,
CancellationToken ct)
{
// 取得自上次同步後有變更的產品
var lastSync = await _context.RegionSyncLogs
.Where(l => l.RegionCode == regionCode && l.Status == SyncStatus.Success)
.OrderByDescending(l => l.CompletedAt)
.FirstOrDefaultAsync(ct);

var query = _context.Products
.Where(p => p.IsActive && p.Status == ProductStatus.Published);

// 增量同步:只取得更新的產品
if (lastSync != null)
{
query = query.Where(p => p.UpdatedAt > lastSync.CompletedAt);
}

return await query
.Include(p => p.Translations)
.Include(p => p.Prices)
.Include(p => p.Images)
.ToListAsync(ct);
}

private async Task<Result> SendBatchToRegion(
RegionConfig region,
List<Product> products,
CancellationToken ct)
{
var httpClient = _httpClientFactory.CreateClient("RegionApi");
httpClient.BaseAddress = new Uri(region.ApiUrl);
httpClient.DefaultRequestHeaders.Add("X-Api-Key", region.ApiKey);
httpClient.DefaultRequestHeaders.Add("X-Source", "GlobalHub");

var payload = new RegionSyncPayload
{
Products = products.Select(MapToRegionProduct).ToList(),
SyncTimestamp = DateTime.UtcNow,
SourceRegion = "Central"
};

try
{
var response = await httpClient.PostAsJsonAsync(
"/api/products/sync-from-globalhub",
payload,
ct);

if (response.IsSuccessStatusCode)
{
return Result.Success();
}

var errorContent = await response.Content.ReadAsStringAsync(ct);
return Result.Failure($"HTTP {response.StatusCode}: {errorContent}");
}
catch (HttpRequestException ex)
{
return Result.Failure($"HTTP request failed: {ex.Message}");
}
catch (Exception ex)
{
return Result.Failure($"Unexpected error: {ex.Message}");
}
}

private RegionProductDto MapToRegionProduct(Product product)
{
return new RegionProductDto
{
GlobalHubProductId = product.Id,
ExternalProductId = product.ExternalProductId,
SupplierId = product.SupplierId,
SupplierCode = "SERP",

// 基本資訊
Name = product.Translations.ToDictionary(
t => t.LanguageCode,
t => t.Name),
Description = product.Translations.ToDictionary(
t => t.LanguageCode,
t => t.Description),

Category = product.Category,
ProductType = product.ProductType,

// 價格資訊(區域 API 會根據本地幣別轉換)
Prices = product.Prices.Select(p => new PriceDto
{
Amount = p.Amount,
Currency = p.Currency,
PriceType = p.PriceType
}).ToList(),

// 圖片
Images = product.Images.Select(img => new ImageDto
{
Url = img.Url,
IsPrimary = img.IsPrimary,
Order = img.Order
}).ToList(),

// 庫存與狀態
AvailableSeats = product.AvailableSeats,
Status = product.Status,

// 時間戳記
CreatedAt = product.CreatedAt,
UpdatedAt = product.UpdatedAt,

// 中繼資料
Metadata = new Dictionary<string, string>
{
["SourceSystem"] = "GlobalHub",
["OriginalSupplier"] = "SERP",
["SyncedAt"] = DateTime.UtcNow.ToString("O")
}
};
}
}

區域站點接收端點 (ProductsAPI)

// ProductsAPI/Web.Api/Endpoints/Products/SyncFromGlobalHubEndpoint.cs
public class SyncFromGlobalHubEndpoint
{
// POST /api/products/sync-from-globalhub
[HttpPost("sync-from-globalhub")]
[Authorize(Policy = "GlobalHubOnly")] // 只允許 GlobalHub API 呼叫
public async Task<IResult> SyncFromGlobalHub(
[FromBody] RegionSyncPayload payload,
[FromHeader("X-Api-Key")] string apiKey,
[FromHeader("X-Source")] string source)
{
// 1. 驗證來源
if (source != "GlobalHub")
{
return Results.Unauthorized();
}

// 2. 驗證 API Key
if (!await _apiKeyValidator.ValidateAsync(apiKey))
{
return Results.Unauthorized();
}

// 3. 處理產品資料
var command = new SyncProductsFromGlobalHubCommand
{
Products = payload.Products,
SyncTimestamp = payload.SyncTimestamp,
SourceRegion = payload.SourceRegion
};

var result = await _mediator.Send(command);

if (result.IsSuccess)
{
return Results.Ok(new
{
Message = "Sync completed successfully",
ProcessedCount = result.ProcessedCount,
SuccessCount = result.SuccessCount,
FailedCount = result.FailedCount,
Details = result.Details
});
}

return Results.BadRequest(new
{
Message = "Sync failed",
Error = result.Error
});
}
}

// Application/Products/SyncFromGlobalHubCommandHandler.cs
public class SyncProductsFromGlobalHubCommandHandler
{
public async Task<Result<SyncResult>> Handle(
SyncProductsFromGlobalHubCommand command,
CancellationToken ct)
{
var results = new List<ProductSyncResult>();

foreach (var productDto in command.Products)
{
try
{
// 冪等性處理:檢查是否已存在
var existing = await _context.Products
.Include(p => p.FulfillmentProvider)
.FirstOrDefaultAsync(p =>
p.GlobalHubProductId == productDto.GlobalHubProductId, ct);

if (existing != null)
{
// 更新現有產品
UpdateExistingProduct(existing, productDto);
results.Add(ProductSyncResult.Updated(productDto.GlobalHubProductId));
}
else
{
// 建立新產品
var newProduct = await CreateNewProduct(productDto, ct);
results.Add(ProductSyncResult.Created(newProduct.Id));
}

await _context.SaveChangesAsync(ct);

// 同步到 Elasticsearch
await _searchService.IndexProduct(productDto.GlobalHubProductId, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to sync product {ProductId}",
productDto.GlobalHubProductId);
results.Add(ProductSyncResult.Failed(
productDto.GlobalHubProductId,
ex.Message));
}
}

return Result.Success(new SyncResult
{
ProcessedCount = results.Count,
SuccessCount = results.Count(r => r.IsSuccess),
FailedCount = results.Count(r => !r.IsSuccess),
Details = results
});
}

private async Task<Product> CreateNewProduct(
RegionProductDto dto,
CancellationToken ct)
{
// 1. 取得或建立 SERP Fulfillment Provider
var serpProvider = await GetOrCreateSerpFulfillmentProvider(ct);

// 2. 建立產品實體
var product = new Product
{
GlobalHubProductId = dto.GlobalHubProductId,
ExternalProductId = dto.ExternalProductId,
SupplierId = serpProvider.Id,

// 根據產品類型建立不同實體
ProductType = dto.ProductType,

// 多語言內容
Translations = dto.Name.Select(kvp => new ProductTranslation
{
LanguageCode = kvp.Key,
Name = kvp.Value,
Description = dto.Description.GetValueOrDefault(kvp.Key, "")
}).ToList(),

// 價格(轉換為本地幣別)
Prices = await ConvertPricesToLocalCurrency(dto.Prices, ct),

// 圖片
Images = dto.Images.Select(img => new ProductImage
{
Url = img.Url,
IsPrimary = img.IsPrimary,
Order = img.Order
}).ToList(),

// 履約設定
FulfillmentType = FulfillmentType.ExternalApi,
FulfillmentProvider = serpProvider,

// 狀態
Status = ProductStatus.Draft, // 預設為草稿,需要 CMS 審核後發布
IsActive = true,

// 時間戳記
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};

_context.Products.Add(product);

return product;
}

private async Task<FulfillmentProvider> GetOrCreateSerpFulfillmentProvider(
CancellationToken ct)
{
var existing = await _context.FulfillmentProviders
.FirstOrDefaultAsync(fp => fp.Code == "SERP", ct);

if (existing != null)
return existing;

// 建立 SERP Fulfillment Provider
var serpProvider = new FulfillmentProvider
{
Code = "SERP",
Name = "SERP 團體旅遊供應商",
ApiBaseUrl = "https://serp.api.liontravel.com/v1",
ApiKeyHeaderName = "X-Api-Key",
ApiKey = _configuration["Serp:ApiKey"], // 加密儲存
OrderPayloadFormat = "serp_v1",
IsActive = true
};

_context.FulfillmentProviders.Add(serpProvider);
await _context.SaveChangesAsync(ct);

return serpProvider;
}
}

資料模型設計

中央資料庫 (GlobalHub API)

// Domain/Product/Product.cs
public class Product : Entity
{
public Guid Id { get; set; }

// 外部識別
public string ExternalProductId { get; set; } // SERP Product ID
public string SupplierId { get; set; } // "SERP"
public string SupplierCode { get; set; } // "SERP"

// 基本資訊
public string Name { get; set; }
public string Description { get; set; }
public ProductType ProductType { get; set; } // Tour, Voucher, etc.
public string Category { get; set; }

// 多語言內容
public List<ProductTranslation> Translations { get; set; }

// 價格
public List<ProductPrice> Prices { get; set; }

// 圖片
public List<ProductImage> Images { get; set; }

// 庫存與狀態
public int? AvailableSeats { get; set; }
public ProductStatus Status { get; set; }
public bool IsActive { get; set; }

// AI 豐富化資料
public string? EnrichedDescription { get; set; }
public List<string>? Tags { get; set; }

// 時間戳記
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }

// 審計
public string? CreatedBy { get; set; }
public string? UpdatedBy { get; set; }
}

// Domain/Product/ProductTranslation.cs
public class ProductTranslation
{
public Guid Id { get; set; }
public Guid ProductId { get; set; }
public string LanguageCode { get; set; } // en-US, zh-TW, ja-JP
public string Name { get; set; }
public string Description { get; set; }
public string? ShortDescription { get; set; }
}

// Domain/Product/ProductPrice.cs
public class ProductPrice
{
public Guid Id { get; set; }
public Guid ProductId { get; set; }
public decimal Amount { get; set; }
public string Currency { get; set; } // TWD, USD, JPY
public PriceType PriceType { get; set; } // Base, Sale, Member
public DateTime? ValidFrom { get; set; }
public DateTime? ValidTo { get; set; }
}

// Domain/Product/ImportJob.cs
public class ImportJob : Entity
{
public Guid Id { get; set; }
public string SupplierId { get; set; }
public string[]? ProductIds { get; set; }
public ImportJobStatus Status { get; set; }
public ImportTrigger Trigger { get; set; }

public int TotalProcessed { get; set; }
public int SuccessCount { get; set; }
public int FailedCount { get; set; }

public DateTime CreatedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public string? ErrorMessage { get; set; }
public string? RequestedBy { get; set; }
}

// Domain/Product/RegionSyncLog.cs
public class RegionSyncLog : Entity
{
public Guid Id { get; set; }
public string RegionCode { get; set; }
public int ProductCount { get; set; }
public int SuccessCount { get; set; }
public int FailedCount { get; set; }
public SyncStatus Status { get; set; }
public DateTime StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public string? ErrorMessage { get; set; }
}

區域資料庫 (ProductsAPI)

// Domain/Product/PackageTour/Product.cs (ProductsAPI)
public abstract class Product : Entity
{
public Guid Id { get; set; }

// GlobalHub 關聯
public Guid? GlobalHubProductId { get; set; } // 中央產品 ID
public string? ExternalProductId { get; set; } // SERP Product ID

// 供應商資訊
public Guid SupplierId { get; set; }
public Supplier Supplier { get; set; }

// 履約資訊
public FulfillmentType FulfillmentType { get; set; }
public Guid? FulfillmentProviderId { get; set; }
public FulfillmentProvider? FulfillmentProvider { get; set; }

// 產品資訊
public ProductType ProductType { get; set; }
public List<ProductTranslation> Translations { get; set; }
public List<ProductPrice> Prices { get; set; }
public List<ProductImage> Images { get; set; }

// 狀態
public ProductStatus Status { get; set; } // Draft, Published, Archived
public bool IsActive { get; set; }

// 時間戳記
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public DateTime? PublishedAt { get; set; }
}

// Domain/Orders/FulfillmentProvider.cs
public class FulfillmentProvider : Entity
{
public Guid Id { get; set; }
public string Code { get; set; } // "SERP"
public string Name { get; set; }
public string ApiBaseUrl { get; set; }
public string ApiKeyHeaderName { get; set; }
public string ApiKey { get; set; } // 加密儲存
public string OrderPayloadFormat { get; set; } // "serp_v1"
public string? WebhookSecret { get; set; }
public bool IsActive { get; set; }
}

同步策略

全量同步 vs 增量同步

類型時機頻率資料範圍用途
全量同步定時排程每日一次(凌晨 2-4 AM)所有產品確保資料完整性
增量同步定時排程每小時或每 15 分鐘自上次同步後更新的產品保持資料新鮮度
即時同步Webhook 觸發即時單一產品重要變更立即生效

同步順序與優先級

1. 價格與庫存變更 (最高優先級)
├─ 每 15 分鐘同步
└─ Webhook 即時觸發

2. 產品基本資訊變更 (中優先級)
├─ 每小時增量同步
└─ Webhook 即時觸發

3. 圖片與描述變更 (一般優先級)
├─ 每天全量同步
└─ 批次處理

4. 新產品上架 (高優先級)
├─ Webhook 即時觸發
└─ 每小時增量同步兜底

衝突解決策略

public class ConflictResolutionService
{
public Product ResolveConflict(
Product centralVersion,
Product regionalVersion,
ConflictResolutionStrategy strategy)
{
return strategy switch
{
// 策略 1: 中央優先 (預設)
ConflictResolutionStrategy.CentralWins
=> centralVersion,

// 策略 2: 區域優先 (保護本地化內容)
ConflictResolutionStrategy.RegionalWins
=> MergeWithRegionalOverride(centralVersion, regionalVersion),

// 策略 3: 最新優先
ConflictResolutionStrategy.LatestWins
=> centralVersion.UpdatedAt > regionalVersion.UpdatedAt
? centralVersion
: regionalVersion,

// 策略 4: 智慧合併
ConflictResolutionStrategy.SmartMerge
=> SmartMerge(centralVersion, regionalVersion),

_ => centralVersion
};
}

private Product SmartMerge(Product central, Product regional)
{
return new Product
{
// 價格、庫存:使用中央資料
Prices = central.Prices,
AvailableSeats = central.AvailableSeats,

// 基本資訊:使用中央資料
Name = central.Name,
Description = central.Description,
Images = central.Images,

// 本地化內容:保留區域編輯
Translations = MergeTranslations(central.Translations, regional.Translations),

// 狀態:保留區域控制
Status = regional.Status,
PublishedAt = regional.PublishedAt,

// 時間戳記:取最新
UpdatedAt = central.UpdatedAt > regional.UpdatedAt
? central.UpdatedAt
: regional.UpdatedAt
};
}
}

錯誤處理與重試

重試策略

public class RetryPolicy
{
// Exponential Backoff 指數退避
public static AsyncRetryPolicy<HttpResponseMessage> HttpRetryPolicy =>
Policy
.HandleResult<HttpResponseMessage>(r =>
!r.IsSuccessStatusCode &&
(int)r.StatusCode >= 500) // 只重試 5xx 錯誤
.Or<HttpRequestException>()
.Or<TimeoutException>()
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: retryAttempt =>
TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), // 2, 4, 8 秒
onRetry: (outcome, timespan, retryCount, context) =>
{
_logger.LogWarning(
"Retry {RetryCount} after {Delay}s due to {Reason}",
retryCount, timespan.TotalSeconds, outcome.Exception?.Message);
});

// Circuit Breaker 斷路器
public static AsyncCircuitBreakerPolicy<HttpResponseMessage> CircuitBreakerPolicy =>
Policy
.HandleResult<HttpResponseMessage>(r =>
!r.IsSuccessStatusCode &&
(int)r.StatusCode >= 500)
.Or<HttpRequestException>()
.CircuitBreakerAsync(
handledEventsAllowedBeforeBreaking: 5, // 5 次失敗後開啟斷路器
durationOfBreak: TimeSpan.FromMinutes(1), // 斷路 1 分鐘
onBreak: (outcome, duration) =>
{
_logger.LogError(
"Circuit breaker opened for {Duration}s",
duration.TotalSeconds);
},
onReset: () =>
{
_logger.LogInformation("Circuit breaker reset");
});
}

// 使用 Polly 組合策略
public class ResilientHttpClient
{
private readonly HttpClient _httpClient;
private readonly IAsyncPolicy<HttpResponseMessage> _policy;

public ResilientHttpClient(HttpClient httpClient)
{
_httpClient = httpClient;

// 組合重試 + 斷路器策略
_policy = Policy.WrapAsync(
RetryPolicy.HttpRetryPolicy,
RetryPolicy.CircuitBreakerPolicy);
}

public async Task<HttpResponseMessage> PostAsync(
string url,
HttpContent content,
CancellationToken ct = default)
{
return await _policy.ExecuteAsync(async () =>
await _httpClient.PostAsync(url, content, ct));
}
}

Hangfire 自動重試

// 設定 Hangfire 背景任務自動重試
[AutomaticRetry(
Attempts = 3, // 最多重試 3 次
DelaysInSeconds = new[] { 60, 300, 900 }, // 1分, 5分, 15分後重試
LogEvents = true,
OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task ProcessAsync(Guid importJobId, CancellationToken ct)
{
// 任務邏輯
}

死信佇列 (Dead Letter Queue)

public class DeadLetterQueueService
{
public async Task HandleFailedSync(
string regionCode,
Product product,
string errorMessage)
{
// 1. 記錄到死信佇列
var deadLetter = new DeadLetterItem
{
EntityType = "Product",
EntityId = product.Id.ToString(),
TargetRegion = regionCode,
Payload = JsonSerializer.Serialize(product),
ErrorMessage = errorMessage,
FailedAt = DateTime.UtcNow,
RetryCount = 0
};

await _context.DeadLetterQueue.AddAsync(deadLetter);
await _context.SaveChangesAsync();

// 2. 發送告警通知
await _notificationService.SendAlertAsync(
$"Product sync failed: {product.Name} to {regionCode}",
errorMessage);
}

// 定期重試死信佇列中的項目
[AutomaticRetry(Attempts = 0)] // 不自動重試,由排程控制
public async Task ProcessDeadLetterQueue(CancellationToken ct)
{
var items = await _context.DeadLetterQueue
.Where(i => i.RetryCount < 5) // 最多重試 5 次
.OrderBy(i => i.FailedAt)
.Take(100)
.ToListAsync(ct);

foreach (var item in items)
{
try
{
// 重試同步
await RetrySyncFromDeadLetter(item, ct);

// 成功後移除
_context.DeadLetterQueue.Remove(item);
}
catch
{
// 增加重試次數
item.RetryCount++;
item.LastRetryAt = DateTime.UtcNow;
}
}

await _context.SaveChangesAsync(ct);
}
}

監控與告警

關鍵指標 (KPIs)

public class SyncMetrics
{
// 上游同步指標
public int SerpProductsFetched { get; set; }
public int SerpProductsImported { get; set; }
public int SerpImportFailures { get; set; }
public TimeSpan SerpSyncDuration { get; set; }
public DateTime LastSerpSyncTime { get; set; }

// 下游同步指標
public Dictionary<string, RegionSyncMetrics> RegionMetrics { get; set; }

// 整體健康指標
public double OverallSuccessRate =>
TotalProcessed > 0
? (double)TotalSuccess / TotalProcessed * 100
: 0;

public int TotalProcessed =>
SerpProductsImported +
RegionMetrics.Values.Sum(r => r.ProductsSynced);

public int TotalSuccess =>
SerpProductsImported +
RegionMetrics.Values.Sum(r => r.SuccessCount);
}

public class RegionSyncMetrics
{
public string RegionCode { get; set; }
public int ProductsSynced { get; set; }
public int SuccessCount { get; set; }
public int FailedCount { get; set; }
public TimeSpan SyncDuration { get; set; }
public DateTime LastSyncTime { get; set; }
public double SuccessRate =>
ProductsSynced > 0
? (double)SuccessCount / ProductsSynced * 100
: 0;
}

Prometheus Metrics

public class SyncMetricsCollector
{
private static readonly Counter SerpSyncTotal = Metrics
.CreateCounter(
"globalhub_serp_sync_total",
"Total SERP sync attempts",
new CounterConfiguration
{
LabelNames = new[] { "status" } // success, failed
});

private static readonly Counter RegionSyncTotal = Metrics
.CreateCounter(
"globalhub_region_sync_total",
"Total region sync attempts",
new CounterConfiguration
{
LabelNames = new[] { "region", "status" }
});

private static readonly Histogram SyncDuration = Metrics
.CreateHistogram(
"globalhub_sync_duration_seconds",
"Sync duration in seconds",
new HistogramConfiguration
{
LabelNames = new[] { "sync_type" }, // serp, region
Buckets = Histogram.ExponentialBuckets(1, 2, 10)
});

private static readonly Gauge LastSyncTimestamp = Metrics
.CreateGauge(
"globalhub_last_sync_timestamp",
"Last successful sync timestamp",
new GaugeConfiguration
{
LabelNames = new[] { "source" } // serp, tw, us, jp
});

public void RecordSerpSync(bool success, TimeSpan duration)
{
SerpSyncTotal
.WithLabels(success ? "success" : "failed")
.Inc();

SyncDuration
.WithLabels("serp")
.Observe(duration.TotalSeconds);

if (success)
{
LastSyncTimestamp
.WithLabels("serp")
.SetToCurrentTimeUtc();
}
}

public void RecordRegionSync(
string regionCode,
bool success,
TimeSpan duration)
{
RegionSyncTotal
.WithLabels(regionCode, success ? "success" : "failed")
.Inc();

SyncDuration
.WithLabels($"region_{regionCode}")
.Observe(duration.TotalSeconds);

if (success)
{
LastSyncTimestamp
.WithLabels(regionCode)
.SetToCurrentTimeUtc();
}
}
}

告警規則

# Prometheus Alert Rules
groups:
- name: globalhub_sync_alerts
interval: 1m
rules:
# SERP 同步失敗率過高
- alert: HighSerpSyncFailureRate
expr: |
(
rate(globalhub_serp_sync_total{status="failed"}[5m]) /
rate(globalhub_serp_sync_total[5m])
) > 0.1
for: 5m
labels:
severity: warning
annotations:
summary: "SERP sync failure rate > 10%"
description: "SERP sync has {{ $value | humanizePercentage }} failure rate"

# SERP 同步超過 1 小時沒有執行
- alert: SerpSyncStale
expr: |
(time() - globalhub_last_sync_timestamp{source="serp"}) > 3600
for: 5m
labels:
severity: critical
annotations:
summary: "SERP sync hasn't run for over 1 hour"
description: "Last SERP sync was {{ $value | humanizeDuration }} ago"

# 區域同步失敗
- alert: RegionSyncFailure
expr: |
rate(globalhub_region_sync_total{status="failed"}[5m]) > 0
for: 5m
labels:
severity: warning
annotations:
summary: "Region {{ $labels.region }} sync failures detected"
description: "Region {{ $labels.region }} has sync failures"

# 同步時間過長
- alert: SyncDurationTooLong
expr: |
histogram_quantile(0.95,
rate(globalhub_sync_duration_seconds_bucket[5m])
) > 300
for: 10m
labels:
severity: warning
annotations:
summary: "Sync duration P95 > 5 minutes"
description: "95th percentile sync duration is {{ $value }}s"

健康檢查端點

// GET /health/sync
public class SyncHealthCheckEndpoint
{
[HttpGet("health/sync")]
public async Task<IResult> GetSyncHealth()
{
var health = new SyncHealthStatus
{
OverallStatus = "Healthy",
Timestamp = DateTime.UtcNow,
Checks = new List<HealthCheck>()
};

// 檢查 SERP 同步狀態
var serpCheck = await CheckSerpSyncHealth();
health.Checks.Add(serpCheck);

// 檢查各區域同步狀態
var regions = new[] { "TW", "US", "JP" };
foreach (var region in regions)
{
var regionCheck = await CheckRegionSyncHealth(region);
health.Checks.Add(regionCheck);
}

// 計算整體狀態
if (health.Checks.Any(c => c.Status == "Critical"))
health.OverallStatus = "Critical";
else if (health.Checks.Any(c => c.Status == "Warning"))
health.OverallStatus = "Warning";

var statusCode = health.OverallStatus switch
{
"Healthy" => 200,
"Warning" => 200,
"Critical" => 503,
_ => 200
};

return Results.Json(health, statusCode: statusCode);
}

private async Task<HealthCheck> CheckSerpSyncHealth()
{
var lastSync = await _context.ImportJobs
.Where(j => j.SupplierId == "SERP" && j.Status == ImportJobStatus.Completed)
.OrderByDescending(j => j.CompletedAt)
.FirstOrDefaultAsync();

if (lastSync == null)
{
return new HealthCheck
{
Name = "SERP Sync",
Status = "Critical",
Message = "No successful SERP sync found",
LastRun = null
};
}

var timeSinceLastSync = DateTime.UtcNow - lastSync.CompletedAt.Value;

return new HealthCheck
{
Name = "SERP Sync",
Status = timeSinceLastSync.TotalHours > 2 ? "Warning" : "Healthy",
Message = $"Last sync: {timeSinceLastSync.TotalMinutes:F1} minutes ago",
LastRun = lastSync.CompletedAt,
Details = new
{
ProductsImported = lastSync.SuccessCount,
FailedCount = lastSync.FailedCount,
Duration = (lastSync.CompletedAt.Value - lastSync.CreatedAt).TotalSeconds
}
};
}

private async Task<HealthCheck> CheckRegionSyncHealth(string regionCode)
{
var lastSync = await _context.RegionSyncLogs
.Where(l => l.RegionCode == regionCode && l.Status == SyncStatus.Success)
.OrderByDescending(l => l.CompletedAt)
.FirstOrDefaultAsync();

if (lastSync == null)
{
return new HealthCheck
{
Name = $"Region {regionCode}",
Status = "Warning",
Message = $"No successful sync to {regionCode} found",
LastRun = null
};
}

var timeSinceLastSync = DateTime.UtcNow - lastSync.CompletedAt.Value;

return new HealthCheck
{
Name = $"Region {regionCode}",
Status = timeSinceLastSync.TotalHours > 4 ? "Warning" : "Healthy",
Message = $"Last sync: {timeSinceLastSync.TotalMinutes:F1} minutes ago",
LastRun = lastSync.CompletedAt,
Details = new
{
ProductsSynced = lastSync.ProductCount,
SuccessCount = lastSync.SuccessCount,
FailedCount = lastSync.FailedCount,
Duration = (lastSync.CompletedAt.Value - lastSync.StartedAt).TotalSeconds
}
};
}
}

實作步驟

Phase 1: 基礎架構 (Week 1-2)

1.1 資料庫 Schema

  • 建立 Products 表
  • 建立 ProductTranslations 表
  • 建立 ProductPrices 表
  • 建立 ProductImages 表
  • 建立 ImportJobs 表
  • 建立 RegionSyncLogs 表
  • 建立 DeadLetterQueue 表
  • 建立索引與外鍵約束

1.2 Domain 層

  • 定義 Product 實體
  • 定義 ProductTranslation 值物件
  • 定義 ProductPrice 值物件
  • 定義 ImportJob 實體
  • 定義 RegionSyncLog 實體
  • 定義領域事件

1.3 Infrastructure 層

  • 實作 ApplicationDbContext
  • 設定 EF Core Entity Configurations
  • 建立 Migrations
  • 設定 Hangfire
  • 設定 PostgreSQL 連線

Phase 2: 上游同步 - SERP Integration (Week 3-4)

2.1 SERP API Client

  • 實作 ISerpApiClient 介面
  • 實作 SerpApiClient (HttpClient 封裝)
  • 實作 Webhook 簽章驗證
  • 撰寫單元測試

2.2 資料轉換與驗證

  • 實作 SerpProductMapper
  • 實作資料驗證邏輯
  • 處理必填欄位缺失情況
  • 撰寫測試案例

2.3 AI 資料豐富化 (可選)

  • 整合 AI Service (OpenAI / Azure OpenAI)
  • 實作描述豐富化
  • 實作多語言翻譯
  • 實作標籤生成

2.4 匯入任務處理

  • 實作 ImportProductsJob
  • 實作批次處理邏輯
  • 實作錯誤處理與重試
  • 實作進度追蹤

2.5 觸發機制

  • 實作 Webhook 端點
  • 設定 Hangfire Recurring Jobs
  • 實作手動觸發端點
  • 撰寫整合測試

Phase 3: 下游同步 - Region Distribution (Week 5-6)

3.1 區域配置

  • 定義 RegionConfig 模型
  • 實作區域設定讀取
  • 建立區域 API Client
  • 實作 API Key 管理

3.2 區域同步服務

  • 實作 IRegionSyncService 介面
  • 實作 RegionSyncService
  • 實作批次同步邏輯
  • 實作增量同步機制

3.3 ProductsAPI 接收端點

  • 建立 SyncFromGlobalHub 端點
  • 實作 API Key 驗證
  • 實作 SyncProductsFromGlobalHubCommandHandler
  • 實作冪等性處理

3.4 同步排程

  • 設定區域同步 Recurring Jobs
  • 實作優先級排程
  • 實作平行同步控制
  • 撰寫測試

Phase 4: 錯誤處理與監控 (Week 7)

4.1 重試機制

  • 設定 Polly Retry Policy
  • 設定 Circuit Breaker
  • 設定 Hangfire 自動重試
  • 實作死信佇列

4.2 監控與指標

  • 實作 Prometheus Metrics
  • 建立 Grafana Dashboard
  • 設定告警規則
  • 實作健康檢查端點

4.3 日誌與追蹤

  • 設定 Serilog 結構化日誌
  • 整合 Seq / ELK
  • 實作分散式追蹤 (OpenTelemetry)
  • 建立日誌查詢 Dashboard

Phase 5: 測試與優化 (Week 8)

5.1 測試

  • 單元測試 (覆蓋率 > 80%)
  • 整合測試
  • 效能測試
  • 壓力測試

5.2 優化

  • 批次大小調優
  • 並行度調優
  • 資料庫查詢優化
  • HTTP 連線池優化

5.3 文件

  • API 文件 (Swagger/OpenAPI)
  • 架構文件
  • 運維手冊
  • 故障排除指南

Phase 6: 部署與上線 (Week 9-10)

6.1 環境準備

  • 設定測試環境
  • 設定預生產環境
  • 設定生產環境
  • 設定監控告警

6.2 部署

  • 部署到測試環境
  • 執行整合測試
  • 部署到預生產環境
  • 執行驗收測試

6.3 上線

  • 灰度發布 (Canary Deployment)
  • 監控關鍵指標
  • 處理問題與調整
  • 全量上線

6.4 後續維護

  • 建立 On-Call 輪值
  • 定期檢視同步狀態
  • 優化效能瓶頸
  • 收集使用者回饋

總結

這個完整的同步機制設計涵蓋:

上游同步 - SERP → GlobalHub API
下游同步 - GlobalHub API → 各區域 ProductsAPI
資料模型 - 清晰的領域模型設計
同步策略 - 全量/增量/即時混合
錯誤處理 - 重試、斷路器、死信佇列
監控告警 - Prometheus + Grafana
實作步驟 - 10 週完整開發計畫

預計工期: 10 週
團隊規模: 2-3 名開發者
技術棧: .NET 10, PostgreSQL, Hangfire, Elasticsearch, Prometheus


版本歷史:

  • v1.0 (2026-02-07) - 初版完成