额外内容 — 扩展 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 — 总结
- 演示:零售分析
- 深入解析:架构