額外內容 — 擴充 API¶
📎 額外實作課程 — 不屬於 Copilot SDK。 此內容涵蓋示範應用程式中的 ASP.NET Core、EF Core 與 xUnit,並將 Copilot 作為 程式設計助理,但完全未使用 Copilot SDK。此內容為選修,且與編號的 SDK 學習路徑彼此獨立。
目標: 新增端點及其測試,同時確保現有 26 項測試維持通過,並保留刻意留下的程式碼異味。
時間: 約 30 分鐘
必要條件: 額外內容 — 治理掛鉤 完成,兩項服務皆可執行。
⚠️ 基本規則¶
- 不要修正四個刻意保留的程式碼異味。 後續示範仰賴這些問題。如果 Copilot 提議清理
GetTransactionsWithSegmentsAsync,請拒絕。 - 確保現有 14 項測試全部持續通過。 新增測試會使該數字增加。
- 遵循以下位置的慣例:
copilot-instructions.md— 檔案範圍命名空間、主要建構函式、async/Async後綴,CancellationToken,recordDTO。
您將建置的內容¶
GET /api/segments/summary — 所有客群區隔的投資組合層級統計資料:
{
"totalSegments": 4,
"totalCustomers": 4660,
"weightedAverageRetention": 0.71,
"highestRetention": "High Value",
"lowestRetention": "At Risk"
}
刻意地 未 只是直接傳遞:加權平均必須依客戶數量為留存率加權,這正是值得撰寫測試的情況。
步驟 1 — 研究現有結構¶
請注意應比照採用的模式:
- 主要建構函式插入:
SegmentsController(RetailAnalyticsService service) [HttpGet("{id:int}")]路由限制ActionResult<T>會傳回,NotFound()用於找不到資源的情況- 控制器保持精簡;邏輯放在服務中
步驟 2 — 查看測試的撰寫方式¶
此測試類別會建置 記憶體內 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 項 通過,沒有任何失敗。
⚠️ 若原本通過的測試現在失敗,表示新程式碼以外的項目已變更。請檢查差異:
只有 SegmentSummary.cs, RetailAnalyticsService.cs, SegmentsController.cs,而且應該會出現測試檔案。
步驟 8 — 即時驗證¶
確認數字與上表相符,且現有端點仍能正常運作:
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] 新增的
recordDTO、服務方法及端點均已新增 - [x] 測試涵蓋加權平均與空白案例
- [x] 原有 26 項測試全部持續通過
- [x] 四個刻意保留的程式碼異味均未變更
- [x] 已針對執行中的 API 驗證端點
💡 延伸練習¶
新增 GET /api/transactions/summary — 依產品類別與商店彙總。請考量以下內容中的 N+1 模式是否 GetTransactionsWithSegmentsAsync 可能會悄悄出現,請撰寫測試加以防範。
相關內容¶
- 下一步: 實作課程 07 — 總結
- 示範:零售分析
- 深入解析:架構