跳至内容

额外内容 — 扩展 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 可能会悄悄出现,请编写测试加以防范。