# CNC机床数据采集系统 - 后端开发规范

> 最后更新：2026-04-28
> 适用范围：ASP.NET Web API 2 后端工程

---

## 一、技术栈

| 项 | 选型 | 版本 |
|----|------|------|
| IDE | Visual Studio 2017 | 15.9+ |
| 框架 | ASP.NET Web API 2 | .NET Framework 4.7.2 |
| ORM | Dapper | 最新稳定版 |
| 数据库 | MariaDB | 11.8 |
| 数据库驱动 | MySqlConnector | 最新稳定版（非Oracle的MySql.Data） |
| 测试框架 | xUnit | 最新稳定版 |
| Mock框架 | Moq | 最新稳定版 |
| JSON序列化 | Newtonsoft.Json | 12.0.3（VS2017兼容） |
| 认证 | JWT Bearer Token | 自行实现，不依赖Identity |
| 日志 | log4net | 最新稳定版 |

---

## 二、解决方案结构

```
E:\opencode\haoliang\
├── CncDataSystem.sln                    ← VS2017 解决方案文件
│
├── src/                                 ← 源码目录
│   ├── CncModels/                       ← 数据模型层
│   │   ├── CncModels.csproj
│   │   ├── Entity/                      ← 数据库表对应的实体类
│   │   ├── Dto/                         ← API请求/响应的DTO类
│   │   ├── Enum/                        ← 枚举定义
│   │   └── Constants/                   ← 常量定义
│   │
│   ├── CncRepository/                   ← 数据访问层
│   │   ├── CncRepository.csproj
│   │   ├── Base/                        ← 基础仓储（泛型CRUD）
│   │   └── Impl/                        ← 各表的具体仓储实现
│   │
│   ├── CncService/                      ← 业务逻辑层
│   │   ├── CncService.csproj
│   │   ├── Interface/                   ← 服务接口定义
│   │   └── Impl/                        ← 服务实现
│   │
│   └── CncWebApi/                       ← Web API 主项目
│       ├── CncWebApi.csproj
│       ├── App_Start/                   ← WebApiConfig、FilterConfig
│       ├── Controllers/                 ← API控制器
│       ├── Filters/                     ← 自定义过滤器（认证、异常）
│       ├── Infrastructure/              ← 中间件、扩展方法
│       └── Web.config                   ← 数据库连接串、JWT密钥
│
├── tests/                               ← 测试目录
│   ├── CncModels.Tests/                 ← 模型层测试
│   ├── CncRepository.Tests/             ← 仓储层测试
│   ├── CncService.Tests/                ← 服务层测试
│   └── CncWebApi.Tests/                 ← 控制器层测试
│
├── frontend/                            ← 前端工程（解决方案文件夹引用，不编译）
├── docs/                                ← 设计文档
└── database/                            ← 数据库脚本
```

### 项目引用关系

```
CncWebApi → CncService → CncRepository → CncModels
    ↘            ↘              ↘
   CncModels    CncModels      （无依赖）
```

每个项目只引用自己直接依赖的项目，不跨层引用。

---

## 三、命名规范

### 3.1 通用规则

| 项 | 规范 | 示例 |
|----|------|------|
| 类名 | PascalCase | `MachineService` |
| 方法名 | PascalCase | `GetById()` |
| 参数名 | camelCase | `machineId` |
| 局部变量 | camelCase | `totalCount` |
| 私有字段 | _camelCase | `_connectionString` |
| 常量 | PascalCase 或 UPPER_SNAKE | `MaxRetryCount` |
| 枚举值 | PascalCase | `AlertType.CollectFail` |

### 3.2 各层命名

| 层 | 类名后缀 | 示例 | 文件位置 |
|----|---------|------|---------|
| Entity | 无后缀 | `Machine`（对应表 `cnc_machine`） | `CncModels/Entity/` |
| 请求DTO | `Request` 后缀 | `CreateMachineRequest` | `CncModels/Dto/` |
| 响应DTO | `Response` 后缀 | `MachineListResponse` | `CncModels/Dto/` |
| 仓储接口 | `IRepository` 后缀 | `IMachineRepository` | `CncRepository/` |
| 仓储实现 | `Repository` 后缀 | `MachineRepository` | `CncRepository/Impl/` |
| 服务接口 | `IService` 后缀 | `IMachineService` | `CncService/Interface/` |
| 服务实现 | `Service` 后缀 | `MachineService` | `CncService/Impl/` |
| 控制器 | `Controller` 后缀 | `MachineController` | `CncWebApi/Controllers/` |
| 测试类 | `Tests` 后缀 | `MachineServiceTests` | `tests/CncService.Tests/` |

### 3.3 Entity 与数据库表映射

```csharp
// 表名 cnc_machine → 类名 Machine
// 表名 cnc_daily_production → 类名 DailyProduction
// 表名 log_collect_raw → 类名 CollectRaw（跨日志库，仓储中指定库名）
// 表名前缀 cnc_ / log_ 在Entity类名中去掉
```

### 3.4 控制器与路由

```csharp
// 控制器名 → 路由前缀
// MachineController → /api/admin/machine
// DashboardController → /api/admin/dashboard
// ScreenController → /api/screen
// SysConfigController → /api/admin/sys-config

// 路由模板统一使用属性路由
[RoutePrefix("api/admin/machine")]
public class MachineController : ApiController
{
    [HttpGet, Route("")]
    public IHttpActionResult GetList(...) { }

    [HttpGet, Route("{id:int}")]
    public IHttpActionResult GetById(int id) { }

    [HttpPost, Route("")]
    public IHttpActionResult Create([FromBody] CreateMachineRequest request) { }

    [HttpPut, Route("{id:int}")]
    public IHttpActionResult Update(int id, [FromBody] UpdateMachineRequest request) { }

    [HttpDelete, Route("{id:int}")]
    public IHttpActionResult Delete(int id) { }
}
```

---

## 四、注释规范

### 4.1 XML文档注释（必须）

所有 **public 类、方法、属性、接口** 必须有 XML 文档注释（`///`）。

```csharp
/// <summary>
/// 机床管理服务，处理机床的CRUD操作和状态查询
/// </summary>
public class MachineService : IMachineService
{
    /// <summary>
    /// 获取机床分页列表
    /// </summary>
    /// <param name="query">查询条件（关键字、车间ID、在线状态等）</param>
    /// <returns>分页结果，包含机床列表和总数</returns>
    public PagedResult<MachineListItem> GetList(MachineQuery query)
    {
        ...
    }
}
```

### 4.2 行内注释（必须）

复杂逻辑、业务规则、SQL语句必须有行内注释。

```csharp
// 日均单机产量 = 日期范围内总产量 / 天数 / 机床数
// 多天范围时Y轴单位为"件/台/天"，单天为"件/台"
var days = Math.Max(1, (endDate - startDate).Days + 1);
var avgQuantity = Math.Round((decimal)totalQuantity / days / machineCount, 1);
```

```csharp
// A-B-C-A-B场景：程序A第二次出现时创建新段记录
// 日汇总按(machine_id, production_date, program_name)合并
if (currentSegment.ProgramName != newProgramName)
{
    CloseSegment(currentSegment, "program_change");
    CreateSegment(machineId, newProgramName);
}
```

### 4.3 文件头注释（必须）

每个 .cs 文件顶部必须有文件头注释：

```csharp
/// <summary>
/// 机床管理控制器
/// 对应页面：MachineListPage、MachineDetailPage
/// API文档：docs/03-API接口设计.md → 3.3 设备管理模块
/// 数据库表：cnc_machine, cnc_worker_machine, cnc_collect_record
/// </summary>
```

---

## 五、编码规范

### 5.1 分层职责

| 层 | 职责 | 不做 |
|----|------|------|
| **Controller** | 参数校验、调用Service、包装响应格式 | 不写业务逻辑、不直接操作数据库 |
| **Service** | 业务逻辑编排、数据转换、事务管理 | 不直接写SQL、不返回IHttpActionResult |
| **Repository** | SQL编写、数据库读写、对象映射 | 不写业务判断、不知道API概念 |
| **Models** | 纯数据定义、枚举、常量 | 不包含任何逻辑 |

### 5.2 统一响应格式

所有 API 返回统一的 `ApiResponse<T>` 包装：

```csharp
/// <summary>
/// 统一API响应格式
/// </summary>
public class ApiResponse<T>
{
    /// <summary>错误码，0=成功</summary>
    public int Code { get; set; }
    /// <summary>提示信息</summary>
    public string Message { get; set; }
    /// <summary>业务数据</summary>
    public T Data { get; set; }

    public static ApiResponse<T> Success(T data, string message = "success")
        => new ApiResponse<T> { Code = 0, Message = message, Data = data };

    public static ApiResponse<T> Fail(int code, string message)
        => new ApiResponse<T> { Code = code, Message = message, Data = default(T) };
}
```

### 5.2.1 Mock数据结构对齐（强制）

> **核心原则：后端 API 返回的 `data` 内部结构必须严格匹配页面文件§9（接口引用与数据结构）中定义的数据结构。页面文件§9是数据结构的唯一来源，Mock文件按§9生成，后端也必须对齐§9。**
>
> **注意：此规则仅约束返回的 JSON 数据结构，不约束 URL 路径和 HTTP 方法。** URL 和方法按 §1.3 RESTful 规范实现，与 Mock 可能有差异（差异对照见 `03-API接口设计.md` 端点清单的双列URL）。

#### 规则说明

1. **列表接口**：Mock 返回 `{ code: 0, data: { items: [...], total: N, page: N, pageSize: N } }`，后端必须返回 `ApiResponse<PagedResult<T>>`，`PagedResult` 包含 `items/total/page/pageSize` 四个字段
2. **非分页列表接口**（如车间下拉、品牌下拉等）：Mock 返回 `{ code: 0, data: { items: [...] } }`，后端必须返回 `ApiResponse<object>` 并将列表包装为 `new { items = list }`，**禁止**直接返回 `ApiResponse<List<T>>`
3. **单对象接口**（如详情、统计）：Mock 返回 `{ code: 0, data: { ... } }`，后端返回 `ApiResponse<T>` 即可
4. **写操作接口**（新增/编辑/删除）：Mock 返回 `{ code: 0, message: "success", data: null }` 或 `{ code: 0, data: { id: N } }`，后端保持一致
5. **字段名必须与 Mock 一致**：经 CamelCase 序列化后的 JSON 字段名必须与 Mock 中的 key 完全匹配

#### 错误示例与正确示例

```csharp
// ❌ 错误：直接返回 List，data 变成数组，前端 r.data.items 为 undefined
return Ok(ApiResponse<List<WorkshopListItem>>.Success(result));
// JSON: { "code": 0, "data": [{...}, {...}] }

// ✅ 正确：包装为 items 对象，与 Mock 的 { data: { items: [...] } } 一致
return Ok(ApiResponse<object>.Success(new { items = result }));
// JSON: { "code": 0, "data": { "items": [{...}, {...}] } }
```

```csharp
// ❌ 错误：PagedResult 的 Items 字段不匹配（虽然是 PagedResult）
// 如果前端期望 { items, total, page, pageSize }，PagedResult 字段名必须完全匹配
// 当前 PagedResult<T> 已定义为 Items/Total/Page/PageSize，经 CamelCase 序列化为 items/total/page/pageSize ✅

// ✅ 分页列表直接返回 PagedResult 即可
return Ok(ApiResponse<PagedResult<MachineListItem>>.Success(result));
// JSON: { "code": 0, "data": { "items": [...], "total": 160, "page": 1, "pageSize": 20 } }
```

#### 检查方法

每个接口开发完成后，必须：
1. 打开对应的页面文件§9（接口引用与数据结构），找到该接口的 Response 定义
2. 确认页面文件§9中 `data` 的结构（是 `{ items: [...] }` 还是 `{ ... }` 或是 `null`）
3. 确认后端 `ApiResponse<T>` 中的 `T` 能序列化出相同结构
4. 如有差异，调整后端返回格式以匹配页面文件§9的定义

### 5.3 异常处理

```csharp
// Controller 层不 try-catch，由全局异常过滤器统一处理
// Service 层抛出业务异常
public class BusinessException : Exception
{
    public int Code { get; }
    public BusinessException(int code, string message) : base(message) { Code = code; }
}

// 使用示例
if (existingMachine != null)
    throw new BusinessException(40003, "设备编码已存在");

// 全局异常过滤器
public class GlobalExceptionFilter : ExceptionFilterAttribute
{
    public override void OnException(HttpActionExecutedContext context)
    {
        if (context.Exception is BusinessException bex)
        {
            context.Response = ... // 返回 { code: bex.Code, message: bex.Message }
        }
        else
        {
            // 记录日志，返回 50001
        }
    }
}
```

### 5.4 数据库连接管理

```csharp
// Repository 基类提供连接，每个方法 using 自动释放
public abstract class BaseRepository
{
    private readonly string _connectionString;

    protected BaseRepository(string connectionString)
    {
        _connectionString = connectionString;
    }

    /// <summary>
    /// 创建新的数据库连接，调用方需 using 释放
    /// </summary>
    protected IDbConnection CreateConnection()
    {
        return new MySqlConnection(_connectionString);
    }
}

// 使用示例
public Machine GetById(int id)
{
    using (var conn = CreateConnection())
    {
        return conn.QueryFirstOrDefault<Machine>(
            "SELECT * FROM cnc_machine WHERE id = @Id", new { Id = id });
    }
}
```

### 5.5 双库切换

```csharp
// 业务库和日志库连接串不同，通过两个 BaseRepository 子类区分
public class BusinessRepository : BaseRepository { ... } // → cnc_business
public class LogRepository : BaseRepository { ... }       // → cnc_log

// 需要访问日志库的仓储继承 LogRepository
// 其他继承 BusinessRepository
```

---

## 六、测试规范

### 6.1 覆盖率要求

- **每个 public 方法必须有至少一个测试用例**
- 目标：**100% 方法覆盖，≥95% 分支覆盖**
- 内部 private 方法通过 public 方法间接覆盖，不单独测试

### 6.2 测试命名

```
[Method]_[Scenario]_[ExpectedResult]

示例：
GetList_WithKeywordFilter_ReturnsFilteredMachines
GetById_WhenNotExists_ThrowsBusinessException
Create_WithDuplicateDeviceCode_Throws40003
```

### 6.3 测试结构（AAA模式）

```csharp
[Fact]
public void GetList_WithKeywordFilter_ReturnsFilteredMachines()
{
    // Arrange - 准备数据
    var mockRepo = new Mock<IMachineRepository>();
    mockRepo.Setup(r => r.GetList(It.IsAny<MachineQuery>()))
            .Returns(new List<Machine> { ... });

    var service = new MachineService(mockRepo.Object);

    // Act - 执行操作
    var result = service.GetList(new MachineQuery { Keyword = "西" });

    // Assert - 验证结果
    Assert.Single(result.Items);
    Assert.Contains("西", result.Items[0].Name);
}
```

### 6.4 各层测试策略

| 层 | 测试方式 | Mock对象 |
|----|---------|---------|
| **Controller** | 测试路由匹配、参数校验、响应格式 | Mock Service |
| **Service** | 测试业务逻辑、数据转换、异常抛出 | Mock Repository |
| **Repository** | 测试SQL正确性、数据映射 | 使用测试数据库（真实MariaDB） |
| **Models** | 测试属性默认值、枚举值、验证逻辑 | 无依赖 |

### 6.5 测试项目配置

```csharp
// 每个测试项目引用对应的生产项目
// CncService.Tests → 引用 CncService、CncModels
// 使用 Moq 框架 mock 接口
// 测试数据通过 [InlineData] 或测试初始化方法提供
```

---

### 6.6 必测场景清单（每个方法逐项检查）

以下场景**适用于所有层的每个 public 方法**，开发者编写测试时必须逐项对照，确认是否需要覆盖：

#### A. 正常路径（Happy Path）

| 场景 | 说明 | 示例 |
|------|------|------|
| 标准输入 | 常规参数，期望正常返回 | `Create(validRequest)` → 成功 |
| 边界值-最小 | 数值型参数传最小合法值 | `pageSize=1`、`sortOrder=0` |
| 边界值-最大 | 数值型参数传最大合法值 | `pageSize=100`、`sortOrder=99` |
| 边界值-零 | 数值型参数传0 | `collectInterval=0`（如果业务允许） |
| 边界值-空集合 | 查询结果为空列表 | `GetList(不存在的关键词)` → `items=[], total=0` |
| 边界值-单条数据 | 查询结果恰好1条 | `GetList(精确匹配)` → `items=[1条]` |
| 边界值-满页 | 查询结果恰好等于pageSize | 分页边界不丢数据 |
| 边界值-跨页 | 查询结果超过pageSize，翻页正确 | 第1页20条、第2页从第21条开始 |
| 字符串-最大长度 | 字符串参数传maxlength边界 | `name` 传100字符 |
| 字符串-含特殊字符 | 中文、括号、横杠、空格 | `"A栋（二期）"`、`"西-1.8"` |

#### B. 参数校验异常（输入不合法）

| 场景 | 说明 | 示例 |
|------|------|------|
| null 参数 | 必填参数传 null | `Create(null)` → 抛出 ArgumentNullException |
| 空字符串 | 必填字符串传 `""` | `name=""` → 校验失败 |
| 纯空格 | 字符串参数传 `"  "` | `name="  "` → 视为空 |
| 超长字符串 | 超过 maxlength | `name` 传201字符 → 校验失败 |
| 负数 | 应为正整数的参数传负数 | `collectInterval=-1` → 校验失败 |
| 超范围数值 | 超出业务允许范围 | `sortOrder=100` → 校验失败 |
| 非法格式 | IP地址、URL格式不正确 | `ipAddress="abc"` → 校验失败 |
| 缺失必填字段 | 请求体缺少必填字段 | 只有 `name` 没有 `deviceCode` |
| 类型不匹配 | 数字字段传字符串等 | pageSize="abc"（API层拦截） |

#### C. 业务规则异常（输入合法但违反业务约束）

| 场景 | 说明 | 示例 |
|------|------|------|
| 唯一性冲突 | 唯一键重复 | `Create(已存在的deviceCode)` → 40003 |
| 外键不存在 | 关联实体不存在 | `Create(workshopId=999)` → 外键校验失败 |
| 状态不允许操作 | 当前状态下不能执行该操作 | 删除有关联机床的车间 → 40001 |
| 资源不存在 | 操作的目标ID不存在 | `Update(id=99999)` → 40002 |
| 资源已停用 | 操作已停用的资源 | 编辑已停用的品牌（如果业务限制） |
| 跨天日期逻辑 | 开始日期 > 结束日期 | `startDate=4月30日, endDate=4月28日` → 校验失败 |
| 密码校验 | 旧密码不正确 | `changePassword(错误旧密码)` → 40001 |
| 自引用冲突 | 不能删除自己 | 不能删除正在使用的Token（如果适用） |

#### D. 数据一致性（并发和状态）

| 场景 | 说明 | 示例 |
|------|------|------|
| 并发修改 | 两人同时编辑同一条记录 | 以最后一次为准，不报错 |
| 先查后改一致性 | 查出来时有数据，改的时候数据已被删 | 返回40002而非抛空引用异常 |
| 事务完整性 | 多表操作部分失败时全部回滚 | 创建机床+绑定工人，工人绑定失败时机床也回滚 |
| 批量操作部分失败 | 批量处理中部分成功部分失败 | 全部失败或全部成功，不允许部分成功 |

#### E. 边界日期和时间

| 场景 | 说明 | 示例 |
|------|------|------|
| 今日 | `startDate=endDate=今天` | 单天查询 |
| 昨日 | 查询昨天的数据 | 跨天边界正确 |
| 大范围 | `startDate` 和 `endDate` 差90天 | 不报错，SQL性能可接受 |
| 跨月 | 查询范围跨越月份分区 | 分区表查询正确（4月30日~5月2日） |
| 日期为null | 可选日期参数不传 | 使用默认值（今日） |

#### F. 安全性

| 场景 | 说明 | 示例 |
|------|------|------|
| SQL注入 | 参数含SQL片段 | `keyword="' OR 1=1 --"` → 不影响查询 |
| XSS | 参数含脚本标签 | `name="<script>alert(1)</script>"` → 原样存储不执行 |
| 敏感字段脱敏 | Token/密码字段返回时遮蔽 | `api_token` 返回 `"********"` |
| 无Token访问 | 未登录访问管理后台API | 返回 40101 |
| 无效Token | Token过期或伪造 | 返回 40101 |
| 大屏免认证 | `/api/screen/**` 不需要Token | 正常返回数据 |

#### G. 分页参数

| 场景 | 说明 | 示例 |
|------|------|------|
| 默认分页 | 不传page/pageSize | 使用默认值 page=1, pageSize=20 |
| 超大页码 | `page=99999` 无数据 | 返回 `items=[], total=原始总数` |
| page=0 或负数 | 非法页码 | 自动修正为 page=1 |
| pageSize超大 | `pageSize=9999` | 限制最大100 |
| pageSize=0 或负数 | 非法pageSize | 自动修正为默认20 |

---

### 6.7 测试场景检查模板

每个方法编写测试前，填写此检查清单，标注 ✅已覆盖 / ❌不适用：

```
方法：MachineService.Create(CreateMachineRequest request)

A. 正常路径
  ✅ 标准输入 → 成功返回新建ID
  ✅ 字符串含中文和特殊字符 → 正常存储
  ❌ 边界值-最大长度（在DTO校验测试中覆盖）

B. 参数校验异常
  ✅ request为null → ArgumentNullException
  ✅ deviceCode为空 → 校验失败
  ✅ name为纯空格 → 校验失败
  ✅ ipAddress格式错误 → 校验失败

C. 业务规则异常
  ✅ deviceCode已存在 → BusinessException 40003
  ✅ workshopId不存在 → BusinessException 40002
  ✅ collectAddressId不存在 → BusinessException 40002
  ✅ brandId不存在 → BusinessException 40002

D. 数据一致性
  ✅ 事务回滚（机床创建成功但后续操作失败）

E. 日期时间
  ❌ 不涉及

F. 安全性
  ✅ deviceCode含SQL注入片段 → 不影响

G. 分页
  ❌ 不涉及（非查询方法）
```

### 6.8 参数化测试（减少重复代码）

对于同一方法的多种输入组合，使用 `[Theory]` + `[InlineData]` 合并：

```csharp
/// <summary>
/// 参数校验：必填字段为空或空白时必须抛出异常
/// 覆盖场景：null、空字符串、纯空格
/// </summary>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("   ")]
public void Create_WithBlankName_ThrowsArgumentException(string name)
{
    var request = new CreateWorkshopRequest { Name = name, SortOrder = 1 };
    var ex = Assert.Throws<ArgumentException>(() => _service.Create(request));
    Assert.Contains("车间名称", ex.Message);
}

/// <summary>
/// 唯一性校验：不同车间名称各创建一次均成功，重复名称抛40003
/// 覆盖场景：正常创建、重复名称
/// </summary>
[Fact]
public void Create_WithDuplicateName_Throws40003()
{
    _mockRepo.Setup(r => r.GetByName("A栋")).Returns((Workshop)null);
    _mockRepo.Setup(r => r.GetByName("B栋")).Returns((Workshop)null);
    _mockRepo.Setup(r => r.GetByName("A栋")).Returns(new Workshop { Name = "A栋" });

    // 第一次创建成功
    _service.Create(new CreateWorkshopRequest { Name = "A栋", SortOrder = 1 });

    // 第二次创建同名 → 异常
    var ex = Assert.Throws<BusinessException>(() =>
        _service.Create(new CreateWorkshopRequest { Name = "A栋", SortOrder = 2 }));
    Assert.Equal(40003, ex.Code);
}
```

### 6.9 Repository 层测试（真实数据库）

Repository 测试使用**真实 MariaDB 测试库**，不走 Mock：

```
测试库：cnc_business_test / cnc_log_test
原则：每个测试方法前 TRUNCATE 相关表，插入已知数据，执行测试，验证结果
```

```csharp
public class MachineRepositoryTests : IDisposable
{
    private readonly MySqlConnection _conn;
    private readonly MachineRepository _repo;

    public MachineRepositoryTests()
    {
        _conn = new MySqlConnection("Server=localhost;Database=cnc_business_test;Uid=root;Pwd=root;");
        _conn.Open();
        _repo = new MachineRepository(_conn);
        // 清空测试表，插入基础数据
        CleanTestData();
    }

    [Fact]
    public void GetById_ExistingMachine_ReturnsCorrectEntity()
    {
        // Arrange
        _conn.Execute("INSERT INTO cnc_machine (id, device_code, name, ...) VALUES (1, 'test_001', '测试机床', ...)");

        // Act
        var machine = _repo.GetById(1);

        // Assert
        Assert.NotNull(machine);
        Assert.Equal("test_001", machine.DeviceCode);
        Assert.Equal("测试机床", machine.Name);
    }

    [Fact]
    public void GetById_NotExists_ReturnsNull()
    {
        var machine = _repo.GetById(99999);
        Assert.Null(machine);
    }

    public void Dispose()
    {
        CleanTestData();
        _conn?.Close();
    }
}
```

### 6.10 禁止的测试行为

| 禁止 | 原因 |
|------|------|
| `Assert.True(true)` | 无意义的断言，自欺欺人 |
| 只测正常路径不测异常 | 隐藏生产环境Bug |
| 测试方法间有执行顺序依赖 | 并行执行时全部失败 |
| Mock了被测方法本身 | 测了个寂寞 |
| 用 `// TODO: 补充测试` 跳过 | 不允许提交未完成的测试 |
| 删除测试来"通过"构建 | 禁止，必须修复 |
| 忽略 `[Fact(Skip="...")]` | 禁止跳过测试 |

### 6.11 API端点实现对照表（强制）

**适用时机**：每个模块的 Controller + Service 开发完成后、声明"模块完成"之前。

**规范要求：**

1. 打开 `docs/03-API接口设计.md`，复制该模块的完整端点清单
2. 在 `tests/CncService.Tests/Checklists/` 目录下创建对照清单文件，命名规则：`{模块名}-endpoints.md`，每行包含：

```
| 端点 | HTTP方法 | URL | Controller方法 | Service方法 | 测试方法 | 状态 |
|------|---------|-----|---------------|------------|---------|------|
| 机床列表 | GET | /api/admin/machine | GetList | GetList | GetList_xxx | ✅ |
| 机床详情 | GET | /api/admin/machine/{id} | GetById | GetById | GetById_xxx | ✅ |
| 机床状态 | GET | /api/admin/machine/{id}/status | GetStatus | GetStatus | GetStatus_xxx | ❌未实现 |
| ... | ... | ... | ... | ... | ... | ... |
```

3. **全部端点状态为 ✅ 时，才算模块开发完成**
4. 对照清单文件随测试文件一起提交到 Git

**禁止行为：**
- 禁止只实现 CRUD 五件套（列表/详情/新增/编辑/删除）就声称模块完成，忽略设计文档中定义的子端点（如 status、production/today、production/trend、collect-records 等）
- 禁止"测了多少就覆盖多少"——覆盖率数字只统计已存在的方法

### 6.12 DTO断言完整性（强制）

**规范要求：**

1. GetById、GetDetail 等详情接口的测试，必须对 DTO 中的**每个字段**做断言
2. 外键 ID 字段（如 `WorkshopId`）和关联名称字段（如 `WorkshopName`）**必须成对断言**：

```csharp
// ✅ 正确：ID 和 Name 成对断言
Assert.True(result.WorkshopId > 0);
Assert.NotNull(result.WorkshopName);
Assert.NotEmpty(result.WorkshopName);

// ❌ 错误：只断言 ID，Name 为 null 也通过
Assert.Equal(1, result.WorkshopId);
// WorkshopName 未断言 → null 也通过测试
```

3. Controller 层测试同样必须对返回对象的每个字段做断言，不能只验证单个字段

**检查方法：**
- 写完测试后，数一下断言语句的数量，必须 ≥ DTO 的 public 属性数量
- 如果 DTO 新增了字段，对应测试必须同步新增断言

### 6.13 布尔字段类型规范

**规范要求：**

1. **C# DTO 层**：二元状态字段使用 `bool` 类型，不用 `int`

```csharp
// ✅ 正确
public bool IsEnabled { get; set; }
public bool IsOnline { get; set; }

// ❌ 错误
public int IsEnabled { get; set; }  // 0/1 语义不明确
```

2. **C# Entity 层**：如果 Dapper 映射需要，Entity 可以用 `int`/`sbyte`，但必须在 Repository 映射时转为 `bool`

3. **测试断言**：布尔字段用 `Assert.True()`/`Assert.False()`，不用 `Assert.Equal(1, ...)`/`Assert.Equal(0, ...)`

```csharp
// ✅ 正确
Assert.True(result.IsEnabled);

// ❌ 错误——与实现同源，如果实现用错了 int，测试也用 int，两者同错
Assert.Equal(1, result.IsEnabled);
```

### 6.14 测试从需求文档编写（强制）

**规范要求：**

1. **写测试的输入顺序**：
   - 第一步：打开 `docs/03-API接口设计.md`，列出该模块所有端点和业务规则
   - 第二步：根据端点清单和业务规则编写测试方法签名和断言
   - 第三步：运行测试，确认哪些通过、哪些失败（此时再检查实现是否遗漏）

2. **禁止的行为**：
   - 禁止看 Service/Controller 代码后补测试——这会导致"实现有什么就测什么"，遗漏的功能永远不会被发现
   - 禁止只测试已存在的方法——API 设计文档中定义但未实现的方法，必须有对应的失败测试（标记为 TODO 或 `[Fact(Skip="端点未实现")]`）

3. **新增端点时**：先更新 API 设计文档，再写测试（TDD 风格），最后写实现

### 6.15 覆盖率诚实声明

**规范要求：**

1. 声明覆盖率时，必须附加以下免责说明：

```
覆盖率统计范围：仅覆盖已实现的 Controller/Service/Repository 方法。
API 设计文档中定义但尚未实现的端点不在统计范围内。
完整性请参照 API 端点实现对照表（见 6.11）。
```

2. **禁止**只报一个数字（如"390个测试，100%方法覆盖"）而不说明统计范围

3. 每个模块完成时，覆盖率报告必须与端点对照表一起呈现，两个指标缺一不可：
   - 已实现方法的覆盖率（方法覆盖 + 分支覆盖）
   - 已设计端点的实现率（已实现端点数 / 文档定义端点总数）

---

## 七、依赖注入

使用 Unity 或手动实现简易 DI 容器（VS2017 默认不支持 .NET Core 风格的内置DI）。

```csharp
// App_Start/UnityConfig.cs 注册依赖
container.RegisterType<IMachineService, MachineService>();
container.RegisterType<IMachineRepository, MachineRepository>();
```

每个 Service 通过构造函数注入依赖的 Repository：

```csharp
public class MachineService : IMachineService
{
    private readonly IMachineRepository _machineRepo;
    private readonly IWorkerRepository _workerRepo;

    public MachineService(IMachineRepository machineRepo, IWorkerRepository workerRepo)
    {
        _machineRepo = machineRepo;
        _workerRepo = workerRepo;
    }
}
```

---

## 八、Git提交规范

- 每完成一个模块（含测试）提交一次
- 提交信息中文，格式：`feat(module): 描述`
- 编译+测试通过后才能提交
- 与前端共用同一仓库 `main` 分支

---

## 九、开发顺序

按依赖关系从底层往上开发：

| 步骤 | 内容 | 依赖 |
|------|------|------|
| 1 | 搭建解决方案 + 项目结构 + NuGet包 | 无 |
| 2 | CncModels（Entity + Dto + Enum） | 无 |
| 3 | CncModels.Tests | 步骤2 |
| 4 | CncRepository（基础仓储 + 连接管理） | 步骤2 |
| 5 | CncRepository.Tests（登录/系统配置/车间） | 步骤4 |
| 6 | CncService（业务逻辑） | 步骤4 |
| 7 | CncService.Tests | 步骤6 |
| 8 | CncWebApi（控制器 + 路由 + 过滤器） | 步骤6 |
| 9 | CncWebApi.Tests | 步骤8 |

先跑通 **登录→系统设置→车间管理** 这条最小链路，再逐步扩展其他模块。
