# 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 /// /// 机床管理服务,处理机床的CRUD操作和状态查询 /// public class MachineService : IMachineService { /// /// 获取机床分页列表 /// /// 查询条件(关键字、车间ID、在线状态等) /// 分页结果,包含机床列表和总数 public PagedResult 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 /// /// 机床管理控制器 /// 对应页面:MachineListPage、MachineDetailPage /// API文档:docs/03-API接口设计.md → 3.3 设备管理模块 /// 数据库表:cnc_machine, cnc_worker_machine, cnc_collect_record /// ``` --- ## 五、编码规范 ### 5.1 分层职责 | 层 | 职责 | 不做 | |----|------|------| | **Controller** | 参数校验、调用Service、包装响应格式 | 不写业务逻辑、不直接操作数据库 | | **Service** | 业务逻辑编排、数据转换、事务管理 | 不直接写SQL、不返回IHttpActionResult | | **Repository** | SQL编写、数据库读写、对象映射 | 不写业务判断、不知道API概念 | | **Models** | 纯数据定义、枚举、常量 | 不包含任何逻辑 | ### 5.2 统一响应格式 所有 API 返回统一的 `ApiResponse` 包装: ```csharp /// /// 统一API响应格式 /// public class ApiResponse { /// 错误码,0=成功 public int Code { get; set; } /// 提示信息 public string Message { get; set; } /// 业务数据 public T Data { get; set; } public static ApiResponse Success(T data, string message = "success") => new ApiResponse { Code = 0, Message = message, Data = data }; public static ApiResponse Fail(int code, string message) => new ApiResponse { Code = code, Message = message, Data = default(T) }; } ``` ### 5.2.1 Mock数据结构对齐(强制) > **核心原则:后端 API 返回的 `data` 内部结构必须严格匹配 Mock 文件中的定义。前端代码按 Mock 结构取值,后端返回格式不一致将导致前端取不到数据。** > > **注意:此规则仅约束返回的 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` 包含 `items/total/page/pageSize` 四个字段 2. **非分页列表接口**(如车间下拉、品牌下拉等):Mock 返回 `{ code: 0, data: { items: [...] } }`,后端必须返回 `ApiResponse` 并将列表包装为 `new { items = list }`,**禁止**直接返回 `ApiResponse>` 3. **单对象接口**(如详情、统计):Mock 返回 `{ code: 0, data: { ... } }`,后端返回 `ApiResponse` 即可 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>.Success(result)); // JSON: { "code": 0, "data": [{...}, {...}] } // ✅ 正确:包装为 items 对象,与 Mock 的 { data: { items: [...] } } 一致 return Ok(ApiResponse.Success(new { items = result })); // JSON: { "code": 0, "data": { "items": [{...}, {...}] } } ``` ```csharp // ❌ 错误:PagedResult 的 Items 字段不匹配(虽然是 PagedResult) // 如果前端期望 { items, total, page, pageSize },PagedResult 字段名必须完全匹配 // 当前 PagedResult 已定义为 Items/Total/Page/PageSize,经 CamelCase 序列化为 items/total/page/pageSize ✅ // ✅ 分页列表直接返回 PagedResult 即可 return Ok(ApiResponse>.Success(result)); // JSON: { "code": 0, "data": { "items": [...], "total": 160, "page": 1, "pageSize": 20 } } ``` #### 检查方法 每个接口开发完成后,必须: 1. 打开对应的 Mock 文件(`frontend/mock/*.ts`),找到该 URL 的 `response` 定义 2. 确认 Mock 中 `data` 的结构(是 `{ items: [...] }` 还是 `{ ... }` 或是 `null`) 3. 确认后端 `ApiResponse` 中的 `T` 能序列化出相同结构 4. 如有差异,调整后端返回格式以匹配 Mock ### 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; } /// /// 创建新的数据库连接,调用方需 using 释放 /// protected IDbConnection CreateConnection() { return new MySqlConnection(_connectionString); } } // 使用示例 public Machine GetById(int id) { using (var conn = CreateConnection()) { return conn.QueryFirstOrDefault( "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(); mockRepo.Setup(r => r.GetList(It.IsAny())) .Returns(new List { ... }); 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=""` → 原样存储不执行 | | 敏感字段脱敏 | 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 /// /// 参数校验:必填字段为空或空白时必须抛出异常 /// 覆盖场景:null、空字符串、纯空格 /// [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public void Create_WithBlankName_ThrowsArgumentException(string name) { var request = new CreateWorkshopRequest { Name = name, SortOrder = 1 }; var ex = Assert.Throws(() => _service.Create(request)); Assert.Contains("车间名称", ex.Message); } /// /// 唯一性校验:不同车间名称各创建一次均成功,重复名称抛40003 /// 覆盖场景:正常创建、重复名称 /// [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(() => _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="...")]` | 禁止跳过测试 | --- ## 七、依赖注入 使用 Unity 或手动实现简易 DI 容器(VS2017 默认不支持 .NET Core 风格的内置DI)。 ```csharp // App_Start/UnityConfig.cs 注册依赖 container.RegisterType(); container.RegisterType(); ``` 每个 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 | 先跑通 **登录→系统设置→车间管理** 这条最小链路,再逐步扩展其他模块。