You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
25 KiB
25 KiB
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 与数据库表映射
// 表名 cnc_machine → 类名 Machine
// 表名 cnc_daily_production → 类名 DailyProduction
// 表名 log_collect_raw → 类名 CollectRaw(跨日志库,仓储中指定库名)
// 表名前缀 cnc_ / log_ 在Entity类名中去掉
3.4 控制器与路由
// 控制器名 → 路由前缀
// 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 文档注释(///)。
/// <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语句必须有行内注释。
// 日均单机产量 = 日期范围内总产量 / 天数 / 机床数
// 多天范围时Y轴单位为"件/台/天",单天为"件/台"
var days = Math.Max(1, (endDate - startDate).Days + 1);
var avgQuantity = Math.Round((decimal)totalQuantity / days / machineCount, 1);
// 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 文件顶部必须有文件头注释:
/// <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> 包装:
/// <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内部结构必须严格匹配 Mock 文件中的定义。前端代码按 Mock 结构取值,后端返回格式不一致将导致前端取不到数据。
规则说明
- 列表接口:Mock 返回
{ code: 0, data: { items: [...], total: N, page: N, pageSize: N } },后端必须返回ApiResponse<PagedResult<T>>,PagedResult包含items/total/page/pageSize四个字段 - 非分页列表接口(如车间下拉、品牌下拉等):Mock 返回
{ code: 0, data: { items: [...] } },后端必须返回ApiResponse<object>并将列表包装为new { items = list },禁止直接返回ApiResponse<List<T>> - 单对象接口(如详情、统计):Mock 返回
{ code: 0, data: { ... } },后端返回ApiResponse<T>即可 - 写操作接口(新增/编辑/删除):Mock 返回
{ code: 0, message: "success", data: null }或{ code: 0, data: { id: N } },后端保持一致 - 字段名必须与 Mock 一致:经 CamelCase 序列化后的 JSON 字段名必须与 Mock 中的 key 完全匹配
错误示例与正确示例
// ❌ 错误:直接返回 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": [{...}, {...}] } }
// ❌ 错误: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 } }
检查方法
每个接口开发完成后,必须:
- 打开对应的 Mock 文件(
frontend/mock/*.ts),找到该 URL 的response定义 - 确认 Mock 中
data的结构(是{ items: [...] }还是{ ... }或是null) - 确认后端
ApiResponse<T>中的T能序列化出相同结构 - 如有差异,调整后端返回格式以匹配 Mock
5.3 异常处理
// 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 数据库连接管理
// 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 双库切换
// 业务库和日志库连接串不同,通过两个 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模式)
[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 测试项目配置
// 每个测试项目引用对应的生产项目
// 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] 合并:
/// <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 相关表,插入已知数据,执行测试,验证结果
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)。
// App_Start/UnityConfig.cs 注册依赖
container.RegisterType<IMachineService, MachineService>();
container.RegisterType<IMachineRepository, MachineRepository>();
每个 Service 通过构造函数注入依赖的 Repository:
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 |
先跑通 登录→系统设置→车间管理 这条最小链路,再逐步扩展其他模块。