跳至內容

額外內容 — 擴充 API

📎 額外實作課程 — 不屬於 Copilot SDK。 此內容涵蓋示範應用程式中的 ASP.NET Core、EF Core 與 xUnit,並將 Copilot 作為 程式設計助理,但完全未使用 Copilot SDK。此內容為選修,且與編號的 SDK 學習路徑彼此獨立。

目標: 新增端點及其測試,同時確保現有 26 項測試維持通過,並保留刻意留下的程式碼異味。

時間: 約 30 分鐘

必要條件: 額外內容 — 治理掛鉤 完成,兩項服務皆可執行。

⚠️ 基本規則

  1. 不要修正四個刻意保留的程式碼異味。 後續示範仰賴這些問題。如果 Copilot 提議清理 GetTransactionsWithSegmentsAsync,請拒絕。
  2. 確保現有 14 項測試全部持續通過。 新增測試會使該數字增加。
  3. 遵循以下位置的慣例: copilot-instructions.md — 檔案範圍命名空間、主要建構函式、 async/Async 後綴, CancellationToken, record DTO。

您將建置的內容

GET /api/segments/summary — 所有客群區隔的投資組合層級統計資料:

{
  "totalSegments": 4,
  "totalCustomers": 4660,
  "weightedAverageRetention": 0.71,
  "highestRetention": "High Value",
  "lowestRetention": "At Risk"
}

刻意地 只是直接傳遞:加權平均必須依客戶數量為留存率加權,這正是值得撰寫測試的情況。

步驟 1 — 研究現有結構

cat src/AgentOrchestrator/AgentHQDemo.Api/Controllers/SegmentsController.cs

請注意應比照採用的模式:

  • 主要建構函式插入: SegmentsController(RetailAnalyticsService service)
  • [HttpGet("{id:int}")] 路由限制
  • ActionResult<T> 會傳回, NotFound() 用於找不到資源的情況
  • 控制器保持精簡;邏輯放在服務中

步驟 2 — 查看測試的撰寫方式

cat src/AgentOrchestrator/tests/AgentHQDemo.Tests/RetailAnalyticsServiceTests.cs

此測試類別會建置 記憶體內 SQLite 內容:

var options = new DbContextOptionsBuilder<RetailDbContext>()
    .UseSqlite("DataSource=:memory:")
    .Options;

_db = new RetailDbContext(options);
_db.Database.OpenConnection();
_db.Database.EnsureCreated();

OpenConnection() 呼叫很重要 — 記憶體內 SQLite 資料庫只會在連線保持開啟時存在。關閉連線,結構描述就會在測試途中消失。

步驟 3 — 新增 DTO

建立 src/AgentOrchestrator/AgentHQDemo.Api/Models/SegmentSummary.cs:

namespace AgentHQDemo.Api.Models;

/// <summary>
/// Portfolio-level statistics across all customer segments.
/// </summary>
public record SegmentSummary(
    int TotalSegments,
    int TotalCustomers,
    decimal WeightedAverageRetention,
    string HighestRetention,
    string LowestRetention);

一個 record ,因為它是不可變的 DTO — 這是專案慣用樣式。

步驟 4 — 新增服務方法

向 Copilot 提問,並事先提供限制條件:

Add a GetSegmentSummaryAsync method to RetailAnalyticsService that returns a
SegmentSummary. Weight the average retention by CustomerCount, not a plain
mean. Handle the empty-segment case without throwing. Accept an optional
CancellationToken and pass it to async EF Core calls. Follow the existing
conventions in this file. Do not modify any other method.

目標結構如下:

public async Task<SegmentSummary> GetSegmentSummaryAsync(
    CancellationToken cancellationToken = default)
{
    var segments = await _db.Segments.ToListAsync(cancellationToken);

    if (segments.Count == 0)
        return new SegmentSummary(0, 0, 0m, string.Empty, string.Empty);

    var totalCustomers = segments.Sum(s => s.CustomerCount);
    var weighted = totalCustomers == 0
        ? 0m
        : segments.Sum(s => s.RetentionRate * s.CustomerCount) / totalCustomers;

    return new SegmentSummary(
        segments.Count,
        totalCustomers,
        Math.Round(weighted, 2),
        segments.OrderByDescending(s => s.RetentionRate).First().Name,
        segments.OrderBy(s => s.RetentionRate).First().Name);
}

⚠️ 守住邊界。 totalCustomers 除以零時會擲回例外。植入資料時不太可能有空白資料表,但測試可以建構這種情況 — 檢閱人員也會提出此問題。

步驟 5 — 新增端點

SegmentsController:

[HttpGet("summary")]
public async Task<ActionResult<SegmentSummary>> GetSummary(
    CancellationToken cancellationToken)
{
    return Ok(await service.GetSegmentSummaryAsync(cancellationToken));
}

⚠️ 路由順序。 summary 不能被其他路由擷取。此處是安全的,因為相鄰路由限制為 {id:int} — 如果只是單純的 {id}, /api/segments/summary 會嘗試將「summary」繫結為識別碼,然後失敗。若不確定,請向 Copilot 詢問路由優先順序。

步驟 6 — 撰寫測試

Add xUnit tests to RetailAnalyticsServiceTests for GetSegmentSummaryAsync.
Cover: the seeded four-segment case, correct weighted average (not a plain
mean), and an empty database returning zeros without throwing. Follow the
existing in-memory SQLite setup in this class. Remember that the constructor
starts with an empty database, so seed the seeded-case tests explicitly.

加權平均是最值得留意的案例。使用種子資料時:

客群區隔 客戶 留存率
高價值 150 0.92
一般 3,200 0.78
有風險 890 0.45
新增 420 0.65

直接平均會得到 0.70。加權結果是 (150×0.92 + 3200×0.78 + 890×0.45 + 420×0.65) / 4660 ≈ 0.71.

這些值確實不同 — 也正因如此,這項測試值得撰寫。請判斷提示加權值,讓過度簡化的實作明確失敗。

[Fact]
public async Task GetSegmentSummaryAsync_WeightsRetentionByCustomerCount()
{
    await _service.SeedDataAsync();

    var summary = await _service.GetSegmentSummaryAsync();

    Assert.Equal(4, summary.TotalSegments);
    Assert.Equal(4660, summary.TotalCustomers);
    Assert.Equal("High Value", summary.HighestRetention);
    Assert.Equal("At Risk", summary.LowestRetention);
    Assert.Equal(0.71m, summary.WeightedAverageRetention);
    Assert.NotEqual(0.70m, summary.WeightedAverageRetention);
}

💡 記憶體內資料庫一開始是空的。判斷四個範例客群區隔的測試應植入資料;零客群區隔案例則維持空白。

步驟 7 — 建置並測試

dotnet build src/AgentOrchestrator/AgentHQDemo.slnx
dotnet test  src/AgentOrchestrator/AgentHQDemo.slnx

預期結果:順利完成建置,且 超過 14 項 通過,沒有任何失敗。

⚠️ 若原本通過的測試現在失敗,表示新程式碼以外的項目已變更。請檢查差異:

git diff --stat

只有 SegmentSummary.cs, RetailAnalyticsService.cs, SegmentsController.cs,而且應該會出現測試檔案。

步驟 8 — 即時驗證

dotnet run --project src/AgentOrchestrator/AgentHQDemo.Api --urls "http://localhost:5050"
curl -s http://localhost:5050/api/segments/summary | jq

確認數字與上表相符,且現有端點仍能正常運作:

curl -s http://localhost:5050/api/segments | jq 'length'          # 4
curl -s http://localhost:5050/api/segments/predict/C003 | jq -r .predictedSegment

步驟 9 — 檢閱您自己的變更

使用實作課程 03 的代理程式完成回饋迴路:

copilot --agent dotnet-reviewer -p "Review my uncommitted changes for correctness, async usage, and adherence to .github/copilot-instructions.md. Report only." --allow-all-tools

然後更新根目錄中的 API 表格 README.md 列出新端點 — 文件內容過時也是檢閱發現。

✅ 檢查點

  • [x] 新增的 record DTO、服務方法及端點均已新增
  • [x] 測試涵蓋加權平均與空白案例
  • [x] 原有 26 項測試全部持續通過
  • [x] 四個刻意保留的程式碼異味均未變更
  • [x] 已針對執行中的 API 驗證端點

💡 延伸練習

新增 GET /api/transactions/summary — 依產品類別與商店彙總。請考量以下內容中的 N+1 模式是否 GetTransactionsWithSegmentsAsync 可能會悄悄出現,請撰寫測試加以防範。