Compare commits

...

405 Commits

Author SHA1 Message Date
d7c7b56b95 归档改动方案 2025-11-03 17:29:23 +08:00
545a53bb68 Merge pull request 'issue_50' (#57) from issue_50 into main
Reviewed-on: #57
2025-11-03 17:27:25 +08:00
9127eeaf31 优化设备服务方法的入参 2025-11-03 17:27:29 +08:00
f0b71b47a0 删除设备模板时检查和删除区域主控时检查 2025-11-03 17:11:51 +08:00
f569876225 删除设备时检查 2025-11-03 16:46:23 +08:00
8669dcd9b0 增加任务增删改查时对设备任务关联表的维护 2025-11-03 16:29:57 +08:00
66554a1376 增加任务设备关联表 2025-11-03 14:24:38 +08:00
b62a3d0e5d 计划逻辑迁移 2025-11-02 23:51:45 +08:00
026dad9374 需求文档 2025-11-02 23:26:16 +08:00
687c2f12ee 让任务可以提供自身使用设备 2025-11-02 20:47:25 +08:00
4b0be88fca 调整文件命名 2025-11-02 19:59:13 +08:00
bb42147974 使用plan service 替换子领域 2025-11-02 19:46:20 +08:00
8d7d9fc485 增加plan service 2025-11-02 19:33:05 +08:00
6cd566bc30 对外暴露接口 2025-11-02 18:27:40 +08:00
408df2f09c 修改方案 2025-11-02 18:20:02 +08:00
011658461e 重构名字 2025-11-02 18:16:44 +08:00
3ab2eb0535 重构计划 2025-11-02 18:11:48 +08:00
a29e15faba 增加文件目录树和生成命令, 方便ai阅读 2025-11-02 15:48:20 +08:00
8e97922012 还原改动, bmad真难用 2025-11-02 15:07:19 +08:00
548d3eae00 bmad 架构师工作 2025-11-01 23:29:42 +08:00
6f7e462589 bmad 分析师工作 2025-11-01 22:43:34 +08:00
cf9e43cdd8 bmad代理支持chrome mcp 2025-11-01 20:58:00 +08:00
426ae41f54 bmad初始化 2025-11-01 19:22:39 +08:00
5b21dc0bd5 更新makefile 2025-11-01 17:21:09 +08:00
67d4fb097d Merge pull request 'issue 52' (#54) from issue_52 into main
Reviewed-on: #54
2025-11-01 16:29:15 +08:00
0008141989 实现 2025-11-01 16:29:18 +08:00
c4ca0175dd 调整报错 2025-10-31 22:01:24 +08:00
193d77b5b7 修复bug 2025-10-31 18:14:12 +08:00
0c88c76417 修复bug 2025-10-31 17:59:48 +08:00
843bd8a814 修正page_size 2025-10-31 17:45:28 +08:00
348220bc7b Merge pull request 'issue_49' (#51) from issue_49 into main
Reviewed-on: #51
2025-10-31 17:05:22 +08:00
d6c18f0774 归档任务 2025-10-31 17:04:58 +08:00
e1c76fd8ec 任务1 and 3 2025-10-31 16:53:40 +08:00
bc6a960451 任务2.5 2025-10-31 16:49:35 +08:00
4e87436cc0 调整任务列表 2025-10-31 16:40:33 +08:00
942ffa29a1 任务2.4 2025-10-31 16:28:26 +08:00
b44e1a0e7c 任务2.3 2025-10-31 16:11:12 +08:00
d22ddac9cd 移除废弃接口 2025-10-31 16:01:49 +08:00
ccab7c98e4 任务2.2.3/2.2.4 2025-10-31 16:00:55 +08:00
3334537663 补充缺失任务 2025-10-31 15:54:17 +08:00
0c35e2ce7d 实现任务2.2 2025-10-31 15:38:10 +08:00
db11438f5c 填充design.md 2025-10-31 15:16:21 +08:00
9f3e800e59 任务2.1 2025-10-31 15:10:09 +08:00
8d8310fd2c 修正tasks.md 2025-10-31 14:39:37 +08:00
12c6dc515f 增加新发现的问题 2025-10-31 14:18:24 +08:00
c2c2383305 提案和任务列表 2025-10-31 14:11:01 +08:00
4a92324774 删掉失效的文件 2025-10-31 14:09:47 +08:00
a4bd19f950 删掉失效的文件 2025-10-30 23:22:45 +08:00
f71d04f8af Merge pull request 'issue_36' (#47) from issue_36 into main
Reviewed-on: #47
2025-10-30 18:25:26 +08:00
4b10efb13c openspec归档 2025-10-30 18:25:25 +08:00
b4c70d4d9c 完成任务6(修bug)和任务7和任务八 2025-10-30 18:07:17 +08:00
f624a8bf5e 部分完成任务6(先提交然后修bug) 2025-10-30 17:44:34 +08:00
8ce553a9e4 完成任务5 2025-10-30 17:39:05 +08:00
5b064b4015 调整openspace方案 2025-10-30 17:34:25 +08:00
6228534155 调整openspace方案 2025-10-30 17:23:07 +08:00
d235130d11 完成任务4 2025-10-30 17:15:14 +08:00
f0982839e0 完成任务3 2025-10-30 16:58:08 +08:00
ff8a8d2b97 完成任务3.1 2025-10-30 16:35:54 +08:00
f2078ea54a 修正任务清单 2025-10-30 16:27:49 +08:00
c463875fba 完成任务2 2025-10-30 16:19:24 +08:00
7c5232e71b 完成任务1 2025-10-30 16:11:59 +08:00
2c9b4777ae 生成openspace任务列表 2025-10-30 16:10:10 +08:00
93f67812ae openspec init 2025-10-30 14:26:48 +08:00
e5b75e3879 优化代码 2025-10-29 19:42:22 +08:00
67575c17bc 修bug 2025-10-29 19:15:52 +08:00
7ac9e49212 调整日志等级 2025-10-29 19:14:26 +08:00
ff45c59946 修bug 2025-10-29 19:07:00 +08:00
8d48576305 修bug 2025-10-29 18:56:05 +08:00
af8689d627 计划监控增加计划名 2025-10-29 17:52:07 +08:00
2910c9186a Merge pull request 'issue_42' (#46) from issue_42 into main
Reviewed-on: #46
2025-10-29 17:21:25 +08:00
b09d32b1d7 修改config.yml 2025-10-29 17:21:23 +08:00
403d46b777 删掉原来的定时采集线程 2025-10-29 17:13:03 +08:00
85bd5254c1 实现全量采集系统计划 2025-10-29 17:10:48 +08:00
5050f76066 增加全量采集任务 2025-10-29 16:37:05 +08:00
1ee3e638f7 controller调整, 增加计划类型 2025-10-29 16:25:39 +08:00
94e8768424 plan增加一个类型字段 2025-10-29 15:48:49 +08:00
675711cdcf 拆分task包 2025-10-29 15:30:16 +08:00
e66ee67cf7 Merge pull request 'issue_42' (#44) from issue_42 into main
Reviewed-on: #44
2025-10-26 15:57:04 +08:00
40eb57ee47 重构core包 2025-10-26 15:48:38 +08:00
6a8e8f1f7d 实现定时采集 2025-10-26 15:10:38 +08:00
5c83c19bce Merge pull request 'issue_22' (#41) from issue_22 into main
Reviewed-on: #41
2025-10-25 15:42:19 +08:00
86c9073da8 修bug 2025-10-25 15:41:49 +08:00
43c1839345 实现controller 2025-10-25 15:04:47 +08:00
f62cc1c4a9 实现service层 2025-10-25 14:36:24 +08:00
f6d2069e1a 发送通知时写入数据库 2025-10-25 14:17:17 +08:00
f33e14f60f 发送通知时写入数据库 2025-10-25 14:15:17 +08:00
d6f275b2d1 定义仓库方法 2025-10-25 13:35:43 +08:00
d8de5a68eb 定义通知model 2025-10-25 13:28:19 +08:00
bd8729d473 中文枚举 2025-10-24 21:38:52 +08:00
3fd97aa43f 日志发送逻辑及测试消息发送接口 2025-10-24 21:24:48 +08:00
9d6876684b 实现飞书/微信/邮件发送通知 2025-10-24 20:33:15 +08:00
47ed819b9d Merge pull request 'issue_39' (#40) from issue_39 into main
Reviewed-on: #40
2025-10-23 16:07:20 +08:00
b1dce77e51 修bug 2025-10-23 16:06:15 +08:00
21607559c4 修bug 2025-10-23 12:00:57 +08:00
af6a00ee47 优化报错 2025-10-23 11:52:08 +08:00
324a533c94 猪群相关接口增加当前总量和当前总存栏量 2025-10-23 11:29:48 +08:00
c1f71050e9 猪栏信息接口增加猪栏当前存栏量 2025-10-23 10:52:40 +08:00
db32c37318 修bug 2025-10-22 17:58:24 +08:00
3d5741f5fd 修bug 2025-10-20 20:58:24 +08:00
c4c9723b7b make dev 2025-10-20 20:50:03 +08:00
a32749cef8 lora mesh 发送即收到 2025-10-20 19:31:19 +08:00
be8275b936 Merge pull request 'air' (#38) from issue_37 into main
Reviewed-on: #38
2025-10-20 15:24:57 +08:00
169b2c79cb air 2025-10-20 15:22:08 +08:00
33ad309eeb 优化代码 2025-10-19 20:51:30 +08:00
ebaaa86f09 swag 2025-10-19 20:41:33 +08:00
71afbf5ff9 调整ListUserHistory 2025-10-19 20:36:10 +08:00
4e046021e3 注册api 2025-10-19 20:24:01 +08:00
4cbb4bb859 ListPigSales 2025-10-19 15:53:19 +08:00
0038f20334 ListPigPurchases 2025-10-19 14:54:13 +08:00
197af0181c ListPigSickLogs 2025-10-19 14:34:22 +08:00
1830fcd43e ListPigTransferLogs 2025-10-19 14:11:18 +08:00
53845422c1 ListWeighingRecords 2025-10-19 13:59:11 +08:00
757d38645e 移动位置 2025-10-19 13:47:37 +08:00
5ee6cbce8f 移动位置 2025-10-19 13:44:13 +08:00
fd39eb6450 ListWeighingBatches 2025-10-19 13:41:29 +08:00
89fbbbb75f ListPigBatchLogs 2025-10-19 12:41:13 +08:00
e150969ee3 ListMedicationLogs 2025-10-18 16:22:59 +08:00
4c6843afb4 ListFeedUsageRecords 2025-10-18 16:08:46 +08:00
eb0786ca27 ListRawMaterialStockLogs 2025-10-18 16:04:54 +08:00
7299c8ebe6 ListRawMaterialPurchases 2025-10-18 15:58:31 +08:00
bcdcaa5631 ListUserActionLogs 2025-10-18 15:47:13 +08:00
fab26ffca4 ListPendingCollections 2025-10-18 15:43:27 +08:00
6a93346e87 ListTaskExecutionLogs 2025-10-18 15:39:47 +08:00
df0dfd62c6 ListPlanExecutionLogs 2025-10-18 15:36:32 +08:00
51a873049e ListDeviceCommandLogs 2025-10-18 15:33:39 +08:00
05820438d0 ListSensorData 2025-10-18 15:31:05 +08:00
3b967aa449 优化日志 2025-10-17 10:44:20 +08:00
fa437b30aa 调整swag 2025-10-13 14:37:57 +08:00
bcec36f7e2 调整swag 2025-10-13 14:15:38 +08:00
8c0dc6c815 ManualControl 2025-10-13 12:16:36 +08:00
9b6548c1b4 ManualControl 2025-10-13 10:39:51 +08:00
b4d31d3133 处理路由冲突 2025-10-10 18:23:06 +08:00
6d8cb7ca4e Merge pull request 'issue_33' (#35) from issue_33 into main
Reviewed-on: #35
2025-10-10 17:19:53 +08:00
503feb1b21 实现 LoRaMeshUartPassthroughTransport 2025-10-10 15:59:58 +08:00
50a843c9ef 实现 LoRaMeshUartPassthroughTransport 2025-10-10 15:58:40 +08:00
8a5f6dc34e 实现 LoRaMeshUartPassthroughTransport 构造函数 2025-10-10 00:01:21 +08:00
38a01f4a6e 改造成支持lora mesh(没实现,只是支持) 2025-10-09 23:43:19 +08:00
ca544d7605 更新配置文件 2025-10-09 23:02:56 +08:00
ac8c8c56a6 优化proto 2025-10-09 13:55:46 +08:00
8a2e889048 采集失败处理 2025-10-08 17:52:30 +08:00
b611f132f1 更新proto 2025-10-07 16:14:47 +08:00
759caadb21 Merge pull request 'issue_29' (#32) from issue_29 into main
Reviewed-on: #32
2025-10-07 13:33:24 +08:00
4250f27e11 增加注释 2025-10-07 13:31:56 +08:00
77ab434d17 简化控制器层重复代码 2025-10-07 00:18:17 +08:00
21661eb748 简化控制器层重复代码 2025-10-06 23:48:31 +08:00
5e84b473f6 移除废弃代码 2025-10-06 23:24:16 +08:00
e142405bb3 猪群领域其他方法映射到api 2025-10-06 23:22:47 +08:00
632bd20e7d 更新猪群对应猪栏接口变更 2025-10-06 23:10:58 +08:00
aac0324616 实现 RemoveEmptyPenFromBatch 2025-10-06 22:47:47 +08:00
18b45b223c 调整swag 2025-10-06 22:26:57 +08:00
035da5293b 实现 RecordCull 和 RecordDeath 2025-10-06 22:22:10 +08:00
1290676fe4 实现 RecordSickPigCull 和 RecordSickPigDeath 2025-10-06 22:08:09 +08:00
73de8ad04f 实现 RecordSickPigRecovery 2025-10-06 21:57:53 +08:00
9a7b765b71 实现 RecordSickPigs 2025-10-06 21:54:55 +08:00
4fb8729a2a 定义病猪方法 2025-10-06 21:50:39 +08:00
84c22e342c 定义病猪方法 2025-10-06 21:27:23 +08:00
691810c591 实现 SickPigManager 2025-10-06 18:50:22 +08:00
67b45d2e05 删批次时释放猪栏 2025-10-06 18:15:47 +08:00
0576a790dd 实现 ReclassifyPenToNewBatch 2025-10-06 18:08:56 +08:00
5e49cd3f95 实现MovePigsIntoPen 2025-10-06 17:56:13 +08:00
efbe7d167c 实现AssignEmptyPensToBatch 2025-10-06 17:44:00 +08:00
51b776f393 跨群调栏没有调整猪的数量 2025-10-06 17:13:30 +08:00
189d532ac9 增加调栏数量检查 2025-10-06 16:31:24 +08:00
3b109d1547 买卖猪要调整猪栏存量 2025-10-06 16:19:48 +08:00
648a790cec 定义病猪子服务 2025-10-06 15:35:20 +08:00
1b026d6106 定义病猪用药两个repo 2025-10-06 15:26:44 +08:00
91e18c432c 提取修改猪群数量逻辑 2025-10-06 15:08:32 +08:00
59b6977367 调整model位置 2025-10-06 14:48:47 +08:00
c49844feea 实现交易管理器主服务入口 2025-10-06 14:32:52 +08:00
448b721af5 调整方法存放位置 2025-10-06 11:58:26 +08:00
759b31bce3 实现交易子服务 2025-10-06 11:42:56 +08:00
c76c976cc8 买卖猪记录表 2025-10-05 22:09:25 +08:00
1652df1533 病猪变化日志 2025-10-05 21:58:06 +08:00
b1e1dcdcad 增加类型 2025-10-05 21:25:13 +08:00
47c72dff3e 记录调猪人 2025-10-05 21:20:22 +08:00
811c6a09c5 清理文件 2025-10-05 18:32:18 +08:00
cb20732205 文件改名 2025-10-05 18:29:08 +08:00
2aa0f09079 创建批次时插入一条记录 2025-10-05 18:28:16 +08:00
9c35372720 修改方法命名 2025-10-05 18:00:50 +08:00
b6e68e861b 调整仓库方法归属 2025-10-05 17:46:03 +08:00
b3933b6d63 调整仓库方法归属 2025-10-05 17:42:27 +08:00
01327eb8d2 猪群管理聚合服务 增加调栏管理 2025-10-05 17:30:39 +08:00
6d080d250d 猪群管理聚合服务 2025-10-05 16:37:12 +08:00
740e14e6cc swag 2025-10-04 01:31:35 +08:00
8d9e4286b0 单独修改猪圈状态 2025-10-04 01:28:05 +08:00
5403be5e7d 增加日志 2025-10-04 01:02:40 +08:00
1bc36f5e10 移动业务逻辑 2025-10-04 00:58:29 +08:00
8bb0a54f18 实现修改批次绑定的猪栏 2025-10-04 00:47:27 +08:00
d03163a189 修改路由 2025-10-03 23:59:25 +08:00
9875994df8 修改路由 2025-10-03 23:58:37 +08:00
c27b5bd708 移动文件位置 2025-10-03 23:52:25 +08:00
4e17ddf638 实现猪批次增删改查 2025-10-03 23:42:14 +08:00
d273932693 重构dto 2025-10-03 23:02:43 +08:00
fadc1e2535 改名 2025-10-03 22:34:24 +08:00
c4fb237604 业务逻辑移动到服务层 2025-10-03 22:33:43 +08:00
645c92978b 增加删除检查 2025-10-03 22:23:31 +08:00
c50366f670 定义model 2025-10-03 22:17:28 +08:00
258e350c35 定义model 2025-10-03 22:00:44 +08:00
aced495cd6 定义model 2025-10-03 20:58:41 +08:00
25e9e07cc8 定义model 2025-10-03 20:46:04 +08:00
6cc6d719e1 定义model 2025-10-03 20:32:34 +08:00
8cbe313c89 实现 猪舍相关路由组 和 猪圈相关路由组 2025-10-03 18:27:53 +08:00
5754a1d94c 定义对应model 2025-10-03 17:23:44 +08:00
609aee2513 定义对应model 2025-10-03 16:56:03 +08:00
829f0a6253 调整目录结构 2025-10-02 00:18:13 +08:00
0b8b37511e 定义兽药模组 2025-10-01 20:40:35 +08:00
3cc88a5248 枚举改成中文 2025-10-01 20:39:59 +08:00
f814e682cf 改bug 2025-10-01 00:40:47 +08:00
981e523440 改bug 2025-09-30 22:42:07 +08:00
95c2c2e0c1 改bug 2025-09-30 22:32:30 +08:00
65a26b1880 允许创建小于六位的密码 2025-09-30 22:27:38 +08:00
077e866915 增加设备模板列表 2025-09-30 22:07:55 +08:00
108d496346 超表改造 2025-09-30 21:44:03 +08:00
5022a2be1f 添加区域主控相关路由 2025-09-30 15:25:07 +08:00
5d6fd315e3 Merge pull request 'issue_25' (#26) from issue_25 into main
Reviewed-on: #26
2025-09-30 00:33:33 +08:00
3722ec8031 注入依赖 2025-09-30 00:32:56 +08:00
441ce2c5ec 调整 ChirpStackListener 2025-09-30 00:30:46 +08:00
ab9842dc10 调整 Controller 2025-09-30 00:18:21 +08:00
56dbb680a7 调整 GeneralDeviceService 2025-09-30 00:07:16 +08:00
3a0e72a5c8 proto 2025-09-30 00:01:51 +08:00
e2be93565d proto 2025-09-29 23:54:09 +08:00
503296e574 调整 device和模板 2025-09-29 23:46:33 +08:00
35c2d03602 调整 device和模板 2025-09-29 23:46:28 +08:00
bc97c6bfed modbus rtu指令生成器 2025-09-29 23:27:40 +08:00
4bed3e51b2 调整 ReleaseFeedWeightTask 2025-09-29 22:26:58 +08:00
8392438dc5 调整 ValueDescriptor 2025-09-29 22:23:35 +08:00
4f730cf58f 调整 ChirpStackListener 2025-09-29 19:17:42 +08:00
ee8039b301 调整GeneralDeviceService 2025-09-29 18:13:19 +08:00
ccea087f6c 优化设备模板, 一个设备只能干一件事 2025-09-29 17:55:43 +08:00
8706d8c913 加强device仓库方法 2025-09-29 17:27:42 +08:00
1df1bf2e75 1. 增加自检
2. 去掉device的gin索引
2025-09-29 17:06:21 +08:00
facbbfe6a1 修改设备模型 2025-09-29 16:58:32 +08:00
f007e3b207 Merge pull request 'issue_20' (#24) from issue_20 into main
Reviewed-on: #24
2025-09-28 01:47:47 +08:00
b483415a9a ListUserHistory 实现 2025-09-28 01:48:15 +08:00
aaca5b1723 增加日志 2025-09-28 01:33:19 +08:00
72d8b45241 调整审计输出 2025-09-28 01:28:54 +08:00
17344cdb89 增加审计输出(初步) 2025-09-28 01:23:50 +08:00
7d527d9a67 加注释 2025-09-28 01:14:35 +08:00
fdbea5b7e9 优化代码位置 2025-09-28 01:10:04 +08:00
d995498199 优化代码 2025-09-28 01:02:29 +08:00
e3c0a972fb 响应状态改成自定义状态 2025-09-28 00:54:19 +08:00
3c8b91ff6a JSON转换放在内置函数里 2025-09-28 00:37:20 +08:00
1c7e13b965 初步实现审计 2025-09-28 00:13:47 +08:00
b177781fa1 支持用手机号等信息代替用户名登录 2025-09-27 23:28:06 +08:00
6d9d4ff91b 增加社交信息model 2025-09-27 23:22:27 +08:00
ea9cc3cbe4 实现登录校验中间件 2025-09-27 23:17:23 +08:00
18c4747de6 增加超表, 超过两天的数据全部压缩, 压缩可以释放索引减少内存使用 2025-09-27 23:05:48 +08:00
4496f2822c make swag 2025-09-27 22:25:49 +08:00
200a45a483 Merge pull request 'issue_18' (#19) from issue_18 into main
Reviewed-on: #19
2025-09-27 01:15:33 +08:00
25474f851e 优化代码 2025-09-27 01:09:20 +08:00
5d1b642cc8 校验device和task的配置的地方全部用内置方法 2025-09-27 01:03:58 +08:00
aed665b6b0 解析device和task的配置全部用内置方法处理 2025-09-27 00:58:22 +08:00
29fa23ba36 GeneralDeviceService 改为全局唯一使用靠注入 2025-09-27 00:30:47 +08:00
23b7f66d74 issue-18 优化代码(只保证编译通过没检查) 2025-09-26 22:50:08 +08:00
d9fe1683d2 issue-18初步实现 2025-09-26 15:26:21 +08:00
3b4de24e1d 增加proto定义 2025-09-25 20:16:07 +08:00
2342f388c0 Merge pull request 'issue_16' (#17) from issue_16 into main
Reviewed-on: #17
2025-09-25 11:28:28 +08:00
5a655ffc9e DelayTask 使用结构体代替map 2025-09-25 11:28:38 +08:00
97c750778a ReleaseFeedWeightTask 使用结构体代替map 2025-09-25 11:27:14 +08:00
50aac8d7e5 实现ReleaseFeedWeightTask 2025-09-25 11:17:13 +08:00
f6941fe002 优化超表分片逻辑, 一天一片 2025-09-25 10:34:35 +08:00
0d6d1db290 定义ReleaseFeedWeightTask并注入依赖 2025-09-25 09:44:32 +08:00
e1a1b29a0f 将GeneralDeviceService改成通用版, 随用随New 2025-09-25 09:27:29 +08:00
fe549fca4a 调整task工厂 2025-09-25 09:07:32 +08:00
0606be4711 更新 README.md 2025-09-25 00:36:08 +08:00
36d3f81d40 Merge pull request '删除计划时停止这个计划的执行,停止计划前判断一下是不是未启用的计划' (#15) from issue_6 into main
Reviewed-on: #15
2025-09-25 00:32:52 +08:00
5e019ecf73 删除计划时停止这个计划的执行
停止计划前判断一下是不是未启用的计划
2025-09-25 00:32:57 +08:00
6c8568f304 Merge pull request 'issue_9' (#14) from issue_9 into main
Reviewed-on: #14
2025-09-25 00:16:50 +08:00
e2e21601f4 记录任务下发历史和接收是否成功 2025-09-25 00:17:01 +08:00
6b931648dc 定义ack表 2025-09-24 23:08:59 +08:00
cf53cdfe28 记录温度湿度称重数据 2025-09-24 22:34:11 +08:00
21fb9c7e57 记录信号强度数据 2025-09-24 21:53:18 +08:00
f764ad8962 完善结构体定义 2025-09-24 20:43:40 +08:00
53dbe41d7b 处理 ChirpStack 的 log 事件 2025-09-24 20:26:26 +08:00
17e2c6471a 定义事件结构体和接收器 2025-09-24 19:13:15 +08:00
3a030f5bca 增加pprof 2025-09-24 18:09:29 +08:00
2070653f2f 优化索引 2025-09-24 16:48:41 +08:00
47b8c5bc65 增加 timescaledb 处理逻辑和gin索引 2025-09-24 16:34:16 +08:00
b668f3fbb5 定义一个配置记录是不是timescaledb 2025-09-24 16:06:05 +08:00
6520f2e9d7 更新计划时清零计数器 2025-09-23 22:38:04 +08:00
68b97a12a1 重启系统时清零计数器 2025-09-23 22:30:39 +08:00
1764ff5598 启动计划时清零计数器 2025-09-23 22:18:59 +08:00
9594d08e40 修正更新任务后待执行任务找不到被删除的任务的问题 2025-09-23 22:08:18 +08:00
6b4ef79c45 Merge pull request 'issue_11' (#13) from issue_11 into main
Reviewed-on: #13
2025-09-23 21:54:39 +08:00
28a43928aa 增加任务描述日志 2025-09-23 21:54:47 +08:00
15b4a9520a 修bug 2025-09-23 21:49:53 +08:00
8f3daef5cb Merge pull request 'issue_10' (#12) from issue_10 into main
Reviewed-on: #12
2025-09-23 21:35:53 +08:00
25d6855b38 修复空计划不会反复执行 2025-09-23 21:26:45 +08:00
08e326d56d 修复空计划不会反复执行 2025-09-23 21:14:37 +08:00
e711db94c0 自动判断content_type 2025-09-23 19:28:43 +08:00
b6a872b3b8 自动判断content_type 2025-09-23 18:11:21 +08:00
9e129a1ac0 调整任务进度跟踪器, 改为从数据库获取执行进度:
修bug
2025-09-23 17:55:33 +08:00
eda5c8dedb 调整任务进度跟踪器, 改为从数据库获取执行进度:
修bug
2025-09-23 17:28:33 +08:00
557a0d5d3e 调整任务进度跟踪器, 改为从数据库获取执行进度:
修bug
2025-09-23 17:21:14 +08:00
06f327518a 调整任务进度跟踪器, 改为从数据库获取执行进度:
调整任务调度器
2025-09-23 17:19:39 +08:00
db42560654 调整任务进度跟踪器, 改为从数据库获取执行进度:
提供仓库层api
2025-09-23 17:11:31 +08:00
83db3b2278 修复程序启动时执行历史没有正确刷新 2025-09-23 11:39:47 +08:00
05e789b707 1. 函数改名
2. 删掉没用文件
2025-09-23 11:08:18 +08:00
f5e862ee86 日志加颜色 2025-09-22 23:15:10 +08:00
fd2ac68a03 修复bug 2025-09-22 18:28:50 +08:00
806f1cf5ef 修复bug 2025-09-22 18:27:14 +08:00
a5d6c81f3a 修bug 2025-09-22 15:20:19 +08:00
4096499d28 实现关闭计划 2025-09-22 15:17:54 +08:00
bd2d1c6b63 实现启动计划 2025-09-22 14:21:08 +08:00
0a8e6793ef 改回cron解析五位表达式 2025-09-22 00:41:57 +08:00
d6eaf23289 完成的TODO 2025-09-21 23:09:25 +08:00
df583ef157 支持6位cron表达式 2025-09-21 17:01:26 +08:00
3b1d1580a1 修复gorm自动创建无用外键的问题 2025-09-21 14:22:48 +08:00
ca2dedb2e2 Merge pull request 'issue-5' (#8) from issue-5 into main
Reviewed-on: #8
2025-09-21 12:43:59 +08:00
769a0432c8 初始化待执行队列 2025-09-20 23:50:27 +08:00
74e42de7aa 重构AnalysisPlanTaskManager 2025-09-20 22:41:03 +08:00
b0eb135f44 重构AnalysisPlanTaskManager 2025-09-20 22:32:39 +08:00
056279bdc2 重构AnalysisPlanTaskManager 2025-09-20 22:32:00 +08:00
6711f55fba 重构AnalysisPlanTaskManager 2025-09-20 21:45:38 +08:00
e85d4f8ec3 重构AnalysisPlanTaskManager 2025-09-20 21:14:58 +08:00
40a892e09d todo 2025-09-20 18:11:48 +08:00
1f2d54d53e 修复bug 2025-09-20 17:11:04 +08:00
cb63437e0e 修复swagger 2025-09-19 23:51:13 +08:00
88e0fbfb64 修复swagger 2025-09-19 15:55:56 +08:00
3af1b4949f 允许创建/更新时指定执行次数 2025-09-19 13:35:41 +08:00
11502cb5f0 修改api接口定义 2025-09-19 13:18:05 +08:00
d94a18779e 修改model 2025-09-19 12:53:58 +08:00
46c7b96fed todo 2025-09-18 22:12:19 +08:00
3d2c99afaa Merge pull request '重构' (#4) from 重构 into main
Reviewed-on: #4
2025-09-18 21:51:16 +08:00
5a6153cacc todo 2025-09-18 21:50:51 +08:00
810049d62e 1. 增加任务调度器配置文件
2. 创建/更新计划会自动处理触发器
2025-09-17 23:01:15 +08:00
f7a5e4737d 1. 提取触发器逻辑
2. 创建/更新计划时自动生成对应触发器
2025-09-17 22:43:35 +08:00
ceba0c280e 任务调度器关于任务执行部分实现(没测没检查, 但应该实现完了) 2025-09-17 20:02:40 +08:00
e6047f6b6e 改注释 2025-09-17 17:04:16 +08:00
dde277c14d 实现task工厂 2025-09-17 16:55:56 +08:00
8b8c539e06 定义task接口和delay_task实现 2025-09-17 16:17:36 +08:00
4a24e1a08d task.go 改名 2025-09-17 15:56:08 +08:00
db75370873 实现意外获取Task后重新放回去 2025-09-17 15:47:58 +08:00
c750ef350d 实现意外获取Task后重新放回去 2025-09-17 15:45:40 +08:00
2402c206dc 实现task调度器同一时间只能运行同一个plan的同一个task 2025-09-17 14:58:41 +08:00
1e949aab69 task调度器该用ants线程池 2025-09-16 23:54:15 +08:00
23343a8558 重写task调度器之简单实现 2025-09-16 23:31:36 +08:00
3271f820d4 定义执行历史和执行队列model, 以及基础的增删改查功能 2025-09-16 23:11:07 +08:00
8980c29b07 移动文件位置 2025-09-16 17:14:17 +08:00
6271dc2e6a 移动文件位置 2025-09-16 17:13:51 +08:00
c9df4fd6f4 实现 GeneralDeviceService 2025-09-16 17:12:27 +08:00
2ac3dcda84 去掉ping功能, 异步状态下设备ping平台, 平台ping设备没用 2025-09-15 23:46:14 +08:00
8ede415443 todo 2025-09-15 23:45:36 +08:00
1b255090b0 生成proto代码 2025-09-15 23:45:15 +08:00
80a35c2f14 TODO 2025-09-15 23:05:00 +08:00
b7c67ea3c2 定义GeneralDeviceService 2025-09-15 22:25:05 +08:00
264bcf9cb7 todo 2025-09-15 22:24:10 +08:00
621c45f9c0 定义上行事件监听器 2025-09-15 22:01:00 +08:00
6131f38232 todo 2025-09-15 22:00:22 +08:00
4980495f90 定义 device service 2025-09-15 21:28:22 +08:00
b183ad660f 实现 chirp stack transport 的Send方法 2025-09-15 21:27:54 +08:00
78e885e4ce 移动文件位置 2025-09-15 21:26:31 +08:00
28cf997f1d 定义平台和区域主控间的消息格式 2025-09-15 21:26:18 +08:00
417e63fbcb 调整文件目录 2025-09-15 21:25:48 +08:00
4105d91741 调整gorm注解 2025-09-15 20:01:09 +08:00
3ecebc0d61 增加待处理事项 2025-09-15 20:00:57 +08:00
9869897349 调整包名 2025-09-15 20:00:41 +08:00
ff5b40bb56 变量改名 2025-09-15 18:57:47 +08:00
dedb429186 定义 ChirpStackTransport 2025-09-15 18:56:11 +08:00
3876162f1d 移动代码位置 2025-09-15 18:02:10 +08:00
d42ca300d3 用chirp_stack swagger文档生成代码 2025-09-15 17:52:51 +08:00
bed87ba9ce 导入chirp_stack配置和swagger文档 2025-09-15 17:33:25 +08:00
6dc6fc427c swag 2025-09-14 21:30:20 +08:00
784b02b571 实现 UpdatePlan 和单测 2025-09-14 17:39:44 +08:00
f11bd35b74 实现 DeletePlan 和单测 2025-09-14 17:02:36 +08:00
164c7a4923 实现ListPlans和单测 2025-09-14 16:52:59 +08:00
a5a4f6cbe2 改变量名 2025-09-14 16:42:54 +08:00
04242ab3d8 实现GetPlan单测 2025-09-14 16:40:28 +08:00
563571d4a5 实现GetPlan 2025-09-14 16:19:09 +08:00
b926f7d6a3 实现CreatePlan单测 2025-09-14 16:12:56 +08:00
389c2f9846 定义静音日志对象 2025-09-14 16:08:39 +08:00
55d32dad5f 实现CreatePlan接口 2025-09-14 15:56:55 +08:00
eb1be3f366 增加类型转换时顺序检测 2025-09-14 15:47:08 +08:00
4ddb2c5448 增加顺序修复 2025-09-14 15:18:35 +08:00
8ceff5c3c7 删除UpdatePlan的递归更新, 因为子计划的内容不应该被更改 2025-09-14 15:06:45 +08:00
352c9d9246 修复createPlan逻辑错误 2025-09-14 14:45:56 +08:00
67707c0d41 实现请求和响应与数据库模型转换 2025-09-14 14:13:40 +08:00
1dbef11a8c 定义plan_controller请求和响应 2025-09-14 14:00:42 +08:00
69bdc50b3e 将状态码改成自定义码 2025-09-14 13:36:41 +08:00
b3322fe367 将状态码改成自定义码 2025-09-14 13:23:16 +08:00
b0ce191aff 1. 删除计划单测
2. 增加makefile 生成swagger
2025-09-13 22:29:42 +08:00
fc0e8a36ce 实现删除计划 2025-09-13 22:21:23 +08:00
9fc9cda08e 1. 增加重复顺序校验
2. 增加测试用例
2025-09-13 21:14:22 +08:00
287c27a5ab 实现创建计划 2025-09-13 20:13:53 +08:00
bd22e452d3 补充测试用例 2025-09-13 19:48:13 +08:00
ec2595a751 补充测试用例 2025-09-13 17:49:43 +08:00
5e8ed88832 实现UpdatePlan单测 2025-09-13 17:38:02 +08:00
2228d8e879 实现UpdatePlan 2025-09-13 17:03:46 +08:00
01b11b6e42 增加TestGetPlanByID单测 2025-09-13 15:42:03 +08:00
cacb9a9b1d 修改变量名, 规避order关键字 2025-09-13 15:30:10 +08:00
16bc3b08bc 修复不同单测会使用同一个sqlite实例的情况 2025-09-13 15:29:36 +08:00
d169f9b9d1 增加获取表列表方法 2025-09-13 15:14:08 +08:00
fcea68c7c7 实现GetPlanByID 2025-09-13 15:00:31 +08:00
2b431377c1 实现GetPlanByID 2025-09-13 14:53:31 +08:00
986bdf15a6 定义接口 2025-09-13 14:09:22 +08:00
32e260ed73 查看计划简单信息和单测 2025-09-13 13:04:19 +08:00
4035172a4b 增加延时Task 2025-09-13 12:25:27 +08:00
2593097989 优化命名 2025-09-13 12:12:38 +08:00
6797db914a 初步创建计划相关model 2025-09-12 21:06:19 +08:00
52669e7055 调整swagger注解位置 2025-09-12 20:32:29 +08:00
0884e229fe 修改单测--目前单测全过 2025-09-12 20:28:57 +08:00
03a92ce3e9 增加测试用例 2025-09-12 19:20:33 +08:00
906d371df6 优化注释 2025-09-12 17:51:09 +08:00
9e9bf7b8a0 实现swagger 2025-09-12 17:43:42 +08:00
fe9b0db985 初步实现device_controller 2025-09-12 17:18:14 +08:00
549 changed files with 120166 additions and 2076 deletions

52
.air.toml Normal file
View File

@@ -0,0 +1,52 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "tmp\\main.exe"
cmd = "go build -o ./tmp/main.exe ."
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
silent = false
time = false
[misc]
clean_on_exit = false
[proxy]
app_port = 0
enabled = false
proxy_port = 0
[screen]
clear_on_rebuild = false
keep_scroll = true

1
.gitignore vendored
View File

@@ -24,3 +24,4 @@ vendor/
bin/ bin/
app_logs/ app_logs/
tmp/

52
.golangci.yml Normal file
View File

@@ -0,0 +1,52 @@
# .golangci.yml - 为你的项目量身定制的 linter 配置
linters-settings:
# 这里可以对特定的 linter 进行微调
errcheck:
# 检查未处理的错误,但可以排除一些常见的、我们确认无需处理的函数
exclude-functions:
- io/ioutil.ReadFile
- io.Copy
- io.WriteString
- os.Create
linters:
# 明确我们想要禁用的 linter
disable:
# --- 暂时禁用的“干扰项” ---
- godox # 禁用对 TODO, FIXME 注释的检查,让我们能专注于代码
# --- 暂时禁用的“风格/复杂度”检查器 ---
- gocyclo # 暂时不检查圈复杂度
- funlen # 暂时不检查函数长度
- lll # 暂时不检查行长度
- wsl # 检查多余的空格和换行,可以后期再处理
- gocritic # 这个检查器包含很多子项,有些可能过于严格,可以先禁用,或在下面精细配置
# 排除路径:分析这些文件但不报告问题(使用 regex 匹配)
exclusions:
paths:
# 排除 docs/ 目录(匹配路径以 docs/ 开头)
- '^docs/'
# 精细排除规则:用于特定文件/文本的 linter 排除
rules:
# 排除对 main.go 中 log.Fatalf 的抱怨(仅针对 goconst linter
- path: '^main\.go$'
text: "log.Fatalf"
linters:
- goconst
# 你也可以明确启用你认为最重要的检查器,形成一个“白名单”
# enable:
# - govet
# - errcheck
# - staticcheck
# - unused
# - gosimple
# - ineffassign
# - typecheck
run:
# 完全跳过测试文件分析(不解析、不报告任何问题)
tests: false

0
.swaggo Normal file
View File

18
AGENTS.md Normal file
View File

@@ -0,0 +1,18 @@
<!-- OPENSPEC:START -->
# OpenSpec Instructions
These instructions are for AI assistants working in this project.
Always open `@/openspec/AGENTS.md` when the request:
- Mentions planning or proposals (words like proposal, spec, change, plan)
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
- Sounds ambiguous and you need the authoritative spec before coding
Use `@/openspec/AGENTS.md` to learn:
- How to create and apply change proposals
- Spec format and conventions
- Project structure and guidelines
Keep this managed block so 'openspec update' can refresh the instructions.
<!-- OPENSPEC:END -->

View File

@@ -9,7 +9,11 @@ help:
@echo " run Run the application" @echo " run Run the application"
@echo " build Build the application" @echo " build Build the application"
@echo " clean Clean generated files" @echo " clean Clean generated files"
@echo " test Run all tests"
@echo " swag Generate swagger docs"
@echo " help Show this help message" @echo " help Show this help message"
@echo " proto Generate protobuf files"
@echo " lint Lint the code"
# 运行应用 # 运行应用
.PHONY: run .PHONY: run
@@ -25,3 +29,49 @@ build:
.PHONY: clean .PHONY: clean
clean: clean:
rm -f bin/pig-farm-controller rm -f bin/pig-farm-controller
# 运行所有测试
.PHONY: test
test:
go test --count=1 ./...
# 生成swagger文档
.PHONY: swag
swag:
if exist docs rmdir /s /q docs
swag init -g internal/app/api/api.go --parseInternal --parseDependency
# 生成protobuf文件
.PHONY: proto
proto:
protoc --go_out=internal/infra/transport/proto --go_opt=paths=source_relative --go-grpc_out=internal/infra/transport/proto --go-grpc_opt=paths=source_relative -Iinternal/infra/transport/proto internal/infra/transport/proto/device.proto
# 运行代码检查
.PHONY: lint
lint:
golangci-lint run ./...
# 测试模式(改动文件自动重编译重启)
.PHONY: dev
dev:
air
# 启用谷歌浏览器MCP服务器
.PHONY: mcp-chrome
mcp-chrome:
node "C:\nvm4w\nodejs\node_modules\chrome-devtools-mcp\build\src\index.js"
# 生成文件目录树
.PHONY: tree
# 定义要额外排除的生成代码目录
EXCLUDE_CONTEXT_PREFIX = internal/infra/transport/lora/chirp_stack_proto/
# 最终的文件清单会保存在这里
OUTPUT_FILE = project_structure.txt
# 使用 PowerShell 脚本块执行 Git 命令和二次过滤
tree:
@powershell -Command "git ls-files --exclude-standard | Select-String -NotMatch '$(EXCLUDE_CONTEXT_PREFIX)' | Out-File -Encoding UTF8 $(OUTPUT_FILE)"
@powershell -Command "Add-Content -Path $(OUTPUT_FILE) -Value '$(EXCLUDE_CONTEXT_PREFIX)' -Encoding UTF8"
@echo "The project file list has been generated to project_structure.txt"

View File

@@ -1,11 +1,17 @@
# 猪场管理系统 # 猪场管理系统
## 安装说明
### 推荐使用 TimescaleDB
TimescaleDB 是基于 PostgreSQL 的开源数据库, 专门为处理时序数据而设计的。可以应对后续传海量传感器数据
## 功能介绍 ## 功能介绍
### 一. 猪舍控制 ### 一. 猪舍控制
- [ ] 通过猪舍主控操作舍内所有设备(下料口, 风机, 水帘等) - [ ] 通过猪舍主控操作舍内所有设备(下料口, 风机, 水帘等)
- [ ] 通过猪舍主控采集舍内环境数据(温度, 湿度, 氨气浓度等) - [x] 通过猪舍主控采集舍内环境数据(温度, 湿度, 氨气浓度等)
- [ ] 监测猪舍主控和舍内设备运行状态 - [ ] 监测猪舍主控和舍内设备运行状态
- [ ] 根据监测数据自动调整舍内环境 - [ ] 根据监测数据自动调整舍内环境
- [ ] 环境异常自动报警(微信, 邮件, 短信) - [ ] 环境异常自动报警(微信, 邮件, 短信)
@@ -56,4 +62,4 @@
### 九. RESTful API ### 九. RESTful API
- [ ] 提供RESTful API接口, 方便其他系统对接 - [x] 提供RESTful API接口, 方便其他系统对接

View File

@@ -1,6 +0,0 @@
// TODO 列表
1. websocket不是安全的wss
2. 添加设备时应该激活一下设备状态采集
3. 设备Model缺少硬件地址
4. 如果同时有两条请求发给同一个设备, 会不会导致接收到错误的回复

16
TODO-List.txt Normal file
View File

@@ -0,0 +1,16 @@
// TODO 列表
// TODO 可以实现的问题
1. plan执行到一半时如果用户删掉里面的task, 后续调度器执行task时可能会找不到这个任务的细节
2. 目前调度器把所有任务都当成定时任务了, 手动和限制次数的没做(增加了model对应字段)
3. 系统启动时应该检查一遍执行历史库, 将所有显示为执行中的任务都修正为执行失败并报错
// TODO 暂时实现不了的问题
1. 目前设备都只对应一个地址, 但实际上如电磁两位五通阀等设备是需要用两个开关控制的
2. Task调度器目前只能一个任务一个任务执行, 但实际上有些任务需要并发执行, 如开启下料口时需要不断从料筒称重传感器读取数据
3. ListenHandler 的实现遇到问题只能panic, 没有处理错误
4. 暂时不考虑和区域主控间的同步消息, 假设所有消息都是异步的, 这可能导致无法知道指令是否执行成功
5. 如果系统停机时间很长, 待执行任务表中的任务过期了怎么办, 目前没有任务过期机制
6. 可以用TimescaleDB代替PGSQL, 优化传感器数据存储性能
已执行次数在停止后需要重置吗

115
config.example.yml Normal file
View File

@@ -0,0 +1,115 @@
# 应用基础配置
app:
name: "PigFarmController" # 应用名称
version: "1.0.0" # 应用版本
jwt_secret: "your_jwt_secret_key_here" # JWT 签名密钥,请务必修改为强密码
# 服务器配置
server:
port: 8080 # 服务器监听端口
mode: "debug" # 服务运行模式: debug, release, test
# 日志配置
log:
level: "info" # 日志级别: debug, info, warn, error, dpanic, panic, fatal
format: "console" # 日志输出格式: console, json
enable_file: true # 是否同时输出到文件
file_path: "app_logs/pig_farm_controller.log" # 日志文件路径
max_size: 100 # 单个日志文件最大大小 (MB)
max_backups: 7 # 最多保留的旧日志文件数量
max_age: 7 # 最多保留的旧日志文件天数
compress: true # 是否压缩旧日志文件
# 数据库配置
database:
host: "localhost" # 数据库主机地址
port: 5432 # 数据库端口
username: "postgres" # 数据库用户名
password: "your_db_password" # 数据库密码
dbname: "pig_farm_controller_db" # 数据库名称
sslmode: "disable" # SSL模式: disable, require, verify-ca, verify-full
is_timescaledb: false # 是否为 TimescaleDB
max_open_conns: 100 # 最大开放连接数
max_idle_conns: 10 # 最大空闲连接数
conn_max_lifetime: 300 # 连接最大生命周期 (秒)
# WebSocket配置
websocket:
timeout: 60 # WebSocket请求超时时间 (秒)
heartbeat_interval: 30 # 心跳检测间隔 (秒)
# 心跳配置
heartbeat:
interval: 10 # 心跳间隔 (秒)
concurrency: 5 # 请求并发数
# ChirpStack API 配置
chirp_stack:
api_host: "http://localhost:8080" # ChirpStack API 主机地址
api_token: "your_chirpstack_api_token" # ChirpStack API Token
fport: 10 # ChirpStack FPort
api_timeout: 10 # ChirpStack API请求超时时间(秒)
# 等待设备上行响应的超时时间(秒)。
# 对于LoRaWAN这种延迟较高的网络建议设置为5分钟 (300秒) 或更长。
collection_request_timeout: 300
# 任务调度配置
task:
interval: 5 # 任务调度间隔 (秒)
num_workers: 5 # 任务执行器并发工作数量
# Lora 配置
lora:
mode: "lora_mesh" # Lora 运行模式: lora_wan, lora_mesh
# Lora Mesh 配置
lora_mesh:
# 主节点串口
uart_port: "COM7"
# LoRa模块的通信波特率
baud_rate: 9600
# 等待LoRa模块AT指令响应的超时时间(ms)
timeout: 50
# LoRa Mesh 模块发送模式(EC: 透传; ED: 完整数据包)
# e.g.
# EC: 接收端只会接收到消息, 不会接收到请求头
# e.g. 发送: EC 05 02 01 48 65 6c 6c 6f
# (EC + 05(消息长度) + 0201(地址) + "Hello"(消息本体))
# 接收: 48 65 6c 6c 6f ("Hello")
# ED: 接收端会接收完整数据包,包含自定义协议头和地址信息。
# e.g. 发送: ED 05 12 34 01 00 01 02 03
# (ED(帧头) + 05(Length, 即 1(总包数)+1(当前包序号)+3(数据块)) + 12 34(目标地址) + 01(总包数) + 00(当前包序号) + 01 02 03(数据块))
# 接收: ED 05 12 34 01 00 01 02 03 56 78(56 78 是发送方地址,会自动拼接到消息末尾)
lora_mesh_mode: "ED"
# 单包最大用户数据数据长度, 模块限制240, 去掉两位自定义包头, 还剩238
max_chunk_size: 238
#分片重组超时时间(秒)。如果在一个分片到达后,超过这个时间
# 还没收到完整的包,则认为接收失败。
reassembly_timeout: 30
# 通知服务配置
notify:
primary: "日志" # 首选通知渠道: "邮件", "企业微信", "飞书", "日志" (如果其他渠道未启用,"日志" 会自动成为首选)
failureThreshold: 2 # 连续失败多少次后触发广播模式
smtp:
enabled: false # 是否启用 SMTP 邮件通知
host: "smtp.example.com" # SMTP 服务器地址
port: 587 # SMTP 服务器端口
username: "your_email@example.com" # 发件人邮箱地址
password: "your_email_password" # 发件人邮箱授权码或密码
sender: "PigFarm Alarm <no-reply@example.com>" # 发件人名称和地址
wechat:
enabled: false # 是否启用企业微信通知
corpID: "wwxxxxxxxxxxxx" # 企业ID (CorpID)
agentID: "1000001" # 应用ID (AgentID)
secret: "your_wechat_app_secret" # 应用密钥 (Secret)
lark:
enabled: false # 是否启用飞书通知
appID: "cli_xxxxxxxxxx" # 应用 ID
appSecret: "your_lark_app_secret" # 应用密钥
# 定时采集配置
collection:
interval: 1 # 采集间隔 (分钟)

View File

@@ -8,7 +8,7 @@ app:
# HTTP 服务配置 # HTTP 服务配置
server: server:
port: 8086 port: 8086
mode: "debug" # Gin 运行模式: "debug", "release", "test" mode: "release" # 服务运行模式: "debug", "release", "test"
# 日志配置 # 日志配置
log: log:
@@ -23,12 +23,13 @@ log:
# 数据库配置 (PostgreSQL) # 数据库配置 (PostgreSQL)
database: database:
host: "huangwc.com" host: "192.168.5.16"
port: 5432 port: 5431
username: "pig-farm-controller" username: "pig-farm-controller"
password: "pig-farm-controller" password: "pig-farm-controller"
dbname: "pig-farm-controller" dbname: "pig-farm-controller"
sslmode: "disable" # 在生产环境中建议使用 "require" sslmode: "disable" # 在生产环境中建议使用 "require"
is_timescaledb: true
max_open_conns: 25 # 最大开放连接数 max_open_conns: 25 # 最大开放连接数
max_idle_conns: 10 # 最大空闲连接数 max_idle_conns: 10 # 最大空闲连接数
conn_max_lifetime: 600 # 连接最大生命周期(秒) conn_max_lifetime: 600 # 连接最大生命周期(秒)
@@ -42,3 +43,51 @@ websocket:
heartbeat: heartbeat:
interval: 600 # 任务调度或心跳检查的默认间隔(秒) interval: 600 # 任务调度或心跳检查的默认间隔(秒)
concurrency: 2 # 执行任务的并发协程数 concurrency: 2 # 执行任务的并发协程数
# chirp_stack 配置文件
chirp_stack:
api_host: "http://192.168.5.16:8090" # ChirpStack API服务器地址
api_token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJjaGlycHN0YWNrIiwiaXNzIjoiY2hpcnBzdGFjayIsInN1YiI6IjU2ZWRhNWQ3LTM4NzgtNDAwMC05MWMzLWYwZDk3M2YwODhjNiIsInR5cCI6ImtleSJ9.NxBxTrhPAnezKMqAYZR_Uq2mGQjJRlmVzg1ZDFCyaHQ" # ChirpStack API密钥, 请求头中需要设置 Grpc-Metadata-Authorization: Bearer <YOUR_API_TOKEN>
fport: 1
api_timeout: 10 # ChirpStack API请求超时时间(秒)
# 等待设备上行响应的超时时间(秒)。
# 对于LoRaWAN这种延迟较高的网络建议设置为5分钟 (300秒) 或更长。
collection_request_timeout: 300
# 任务调度器配置
task:
interval: 3
num_workers: 5
# Lora 配置
lora:
mode: "lora_mesh" # "lora_wan" or "lora_mesh"
lora_mesh:
# 主节点串口
uart_port: "COM7"
# LoRa模块的通信波特率
baud_rate: 9600
# 等待LoRa模块AT指令响应的超时时间(ms)
timeout: 50
# LoRa Mesh 模块发送模式(EC: 透传; ED: 完整数据包)
# e.g.
# EC: 接收端只会接收到消息, 不会接收到请求头
# e.g. 发送: EC 05 02 01 48 65 6c 6c 6f
# (EC + 05(消息长度) + 0201(地址) + "Hello"(消息本体))
# 接收: 48 65 6c 6c 6f ("Hello")
# ED: 接收端会接收完整数据包,包含自定义协议头和地址信息。
# e.g. 发送: ED 05 12 34 01 00 01 02 03
# (ED(帧头) + 05(Length, 即 1(总包数)+1(当前包序号)+3(数据块)) + 12 34(目标地址) + 01(总包数) + 00(当前包序号) + 01 02 03(数据块))
# 接收: ED 05 12 34 01 00 01 02 03 56 78(56 78 是发送方地址,会自动拼接到消息末尾)
lora_mesh_mode: "ED"
# 单包最大用户数据数据长度, 模块限制240, 去掉两位自定义包头, 还剩238
max_chunk_size: 238
#分片重组超时时间(秒)。如果在一个分片到达后,超过这个时间
# 还没收到完整的包,则认为接收失败。
reassembly_timeout: 30
# 定时采集配置
collection:
interval: 1 # 采集间隔 (分钟)

View File

@@ -0,0 +1,34 @@
# 任务接口增加获取关联设备ID方法设计
## 1. 需求
为了在设备删除前进行验证需要为任务接口增加一个方法该方法能够直接返回指定任务配置中所有关联的设备ID列表。所有实现 `task` 接口的对象都必须实现此方法。
## 2. 新接口定义:`TaskDeviceIDResolver`
```go
// TaskDeviceIDResolver 定义了从任务配置中解析设备ID的方法
type TaskDeviceIDResolver interface {
// ResolveDeviceIDs 从任务配置中解析并返回所有关联的设备ID列表
// 返回值: uint数组每个字符串代表一个设备ID
ResolveDeviceIDs() ([]uint, error)
}
```
## 3. `task` 接口更新
`task` 接口将嵌入 `TaskDeviceIDResolver` 接口。
```go
// Task 接口(示例,具体结构可能不同)
type Task interface {
// ... 其他现有方法 ...
// 嵌入 TaskDeviceIDResolver 接口
TaskDeviceIDResolver
}
```
## 4. 实现要求
所有当前及未来实现 `Task` 接口的类型,都必须实现 `TaskDeviceIDResolver` 接口中定义的所有方法,即 `ResolveDeviceIDs` 方法。

View File

@@ -0,0 +1,41 @@
# 方案:删除设备前的使用校验
## 1. 目标
在删除设备前,检查该设备是否被任何任务关联。如果设备正在被使用,则禁止删除,并向用户返回明确的错误提示。
## 2. 核心思路
我们将遵循您项目清晰的分层架构,将“检查设备是否被任务使用”这一业务规则放在 **应用层** (`internal/app/service/`)
中进行协调。当上层请求删除设备时,应用服务会先调用仓库层查询 `device_tasks` 关联表,如果发现设备仍被任务关联,则会拒绝删除并返回一个明确的业务错误。
## 3. 实施步骤
### 3.1. 仓库层 (`DeviceRepository`)
- **动作**: 在 `internal/infra/repository/device_repository.go``DeviceRepository` 接口中,增加一个新方法
`IsDeviceInUse(deviceID uint) (bool, error)`
- **实现**: 在 `gormDeviceRepository` 中实现此方法。该方法将通过对 `models.DeviceTask` 模型执行 `Count`
操作来高效地判断是否存在 `device_id` 匹配的记录。这比查询完整记录性能更好。
### 3.2. 应用层 (`DeviceService`)
- **动作**:
1.`internal/app/service/device_service.go` 文件顶部定义一个新的错误变量 `ErrDeviceInUse`,例如
`var ErrDeviceInUse = errors.New("设备正在被一个或多个任务使用,无法删除")`
2. 修改该文件中的 `DeleteDevice` 方法。
- **实现**: 在 `DeleteDevice` 方法中,在调用 `s.deviceRepo.Delete()` 之前,先调用我们刚刚创建的
`s.deviceRepo.IsDeviceInUse()` 方法。如果返回 `true`,则立即返回 `ErrDeviceInUse` 错误,中断删除流程。
### 3.3. 表现层 (`DeviceController`)
- **动作**: 修改 `internal/app/controller/device/device_controller.go` 中的 `DeleteDevice` 方法。
- **实现**: 在错误处理逻辑中,增加一个 `case` 来专门捕获从服务层返回的 `service.ErrDeviceInUse`
错误。当捕获到此错误时,返回一个带有明确提示信息(如“设备正在被任务使用,无法删除”)和合适 HTTP 状态码(例如 `409 Conflict`)的错误响应。
## 4. 方案优势
- **职责清晰**: 业务流程的编排和校验逻辑被正确地放置在应用层,符合您项目清晰的分层架构。
- **高效查询**: 通过 `COUNT` 查询代替 `Find`,避免了不必要的数据加载,性能更佳。
- **代码内聚**: 与设备相关的数据库操作都统一封装在 `DeviceRepository` 中。
- **用户友好**: 通过在控制器层处理特定业务错误,可以给前端返回明确、可操作的错误信息。

View File

@@ -0,0 +1,111 @@
# 方案:维护设备与任务的关联关系
## 1. 目标
在对计划Plan及其包含的任务Task进行创建、更新、删除CRUD操作时同步维护 `device_tasks` 这张多对多关联表。
这是实现“删除设备前检查其是否被任务使用”这一需求的基础。
## 2. 核心挑战
1. **参数结构异构性**:不同类型的任务(`TaskType`),其设备 ID 存储在 `Parameters` (JSON) 字段中的 `key` 和数据结构(单个 ID
或 ID 数组)各不相同。
2. **分层架构原则**:解析 `Parameters` 以提取设备 ID 的逻辑属于 **业务规则**,需要找到一个合适的位置来封装它,以维持各层职责的清晰。
## 3. 方案设计
本方案旨在最大化地复用现有领域模型和逻辑,通过扩展 `TaskFactory` 来实现设备ID的解析从而保持了各领域模块的高内聚和低耦合。
### 3.1. 核心思路:复用领域对象与工厂
我们不移动任何结构体,也不在 `plan` 包中引入任何具体任务的实现细节。取而代之,我们利用现有的 `TaskFactory`
和各个任务领域对象自身的能力来解析参数。
每个具体的任务领域对象(如 `ReleaseFeedWeightTask`)最了解如何解析自己的 `Parameters`。因此我们将解析设备ID的责任完全交还给它们。
### 3.2. 扩展 `TaskFactory`
- **动作**:在 `plan.TaskFactory` 接口中增加一个新方法 `CreateTaskFromModel(*models.Task) (TaskDeviceIDResolver, error)`
- **目的**:此方法允许我们在非任务执行的场景下(例如,在增删改查计划时),仅根据数据库模型 `models.Task` 来创建一个临时的、轻量级的任务领域对象。
- **实现**:在 `internal/domain/task/task.go``taskFactory` 中实现此方法。它会根据传入的 `taskModel.Type``switch-case`
来调用相应的构造函数(如 `NewReleaseFeedWeightTask`)创建实例。
- **实现**
- **优势**
- **高内聚,低耦合**`plan` 包保持通用,无需了解任何具体任务的参数细节。参数定义和解析逻辑都保留在各自的 `task` 包内。
- **逻辑复用**:完美复用了您已在 `ReleaseFeedWeightTask` 中实现的 `ResolveDeviceIDs` 方法,避免了重复代码。
### 3.3. 调整领域服务层 (`PlanService`)
`PlanService` 将作为此业务用例的核心编排者。借助 `UnitOfWork` 模式,它可以在单个事务中协调多个仓库,完成数据准备和持久化。
- **职责**:在创建或更新计划的业务流程中,负责解析任务参数、准备设备关联数据,并调用仓库层完成持久化。
- **实现**
-`planServiceImpl` 注入 `repository.UnitOfWork``plan.TaskFactory`
-`CreatePlan``UpdatePlan` 方法中,使用 `unitOfWork.ExecuteInTransaction` 来包裹整个操作。
- 在事务闭包内,遍历计划中的所有任务 (`models.Task`)
1. 调用 `taskFactory.CreateTaskFromModel(taskModel)` 创建一个临时的任务领域对象。
2. 调用该领域对象的 `ResolveDeviceIDs()` 方法获取设备ID列表。
3. 使用事务性的 `DeviceRepository` 查询出设备实体。
4. 将查询到的设备实体列表填充到 `taskModel.Devices` 字段中。
- 最后,将填充好关联数据的 `plan` 对象传递给事务性的 `PlanRepository` 进行创建或更新。
- **优势**
- **职责清晰**`PlanService` 完整地拥有了“创建/更新计划”的业务逻辑,而仓库层则回归到纯粹的数据访问职责。
- **数据一致性**`UnitOfWork` 确保了从准备数据(查询设备)到最终持久化(创建计划和关联)的所有数据库操作都在一个原子事务中完成。
### 3.4. 调整仓库层 (`PlanRepository`)
仓库层被简化,回归其作为数据持久化网关的纯粹角色。
- **职责**:负责 `Plan` 及其直接子对象(`Task`, `SubPlan`)的 CRUD 操作。
- **实现**
- `CreatePlan``UpdatePlanMetadataAndStructure` 方法将被简化。它们不再需要任何特殊的关联处理逻辑(如 `Association().Replace()`)。
- 只需接收一个由 `PlanService` 准备好的、`task.Devices` 字段已被填充的 `plan` 对象。
-`CreatePlan` 中,调用 `tx.Create(plan)`GORM 会自动级联创建 `Plan``Task` 以及 `device_tasks` 中的关联记录。
-`UpdatePlanMetadataAndStructure``reconcileTasks` 逻辑中对于新创建的任务GORM 的 `tx.Create(task)` 同样会自动处理其设备关联。
### 3.5. 整体流程
**创建计划** 为例:
1. `PlanController` 调用 `PlanService.CreatePlan(plan)`
2. `PlanService` 调用 `unitOfWork.ExecuteInTransaction` 启动一个数据库事务。
3. 在事务闭包内,`PlanService` 遍历 `plan` 对象中的所有 `task`
4. 对于每一个 `task` 模型,调用 `taskFactory.CreateTaskFromModel(task)` 创建一个临时的领域对象。
5. 调用该领域对象的 `ResolveDeviceIDs()` 方法,获取其使用的设备 ID 列表。
6. 如果返回了设备 ID 列表,则使用事务性的 `DeviceRepository` 查询出 `[]models.Device` 实体。
7. 所有 `task` 的关联数据准备好后,调用事务性的 `PlanRepository.CreatePlan(plan)`。GORM 在创建 `plan``task` 的同时,会自动创建
`device_tasks` 表中的关联记录。
8. `UnitOfWork` 提交事务。
**更新计划** 的流程与创建类似,在 `UpdatePlanMetadataAndStructure` 方法中,由于会先删除旧任务再创建新任务,因此在创建新任务后执行相同的设备关联步骤。
**删除计划** 时,由于 `Task` 模型上配置了 `OnDelete:CASCADE`GORM 会自动删除关联的 `Task` 记录。同时GORM 的多对多删除逻辑会自动清理
`device_tasks` 表中与被删除任务相关的记录。因此 `DeletePlan` 方法无需修改。
## 4. 实施步骤
1. **扩展 `TaskFactory` 接口**
-`internal/domain/plan/task.go` 文件中,为 `TaskFactory` 接口添加
`CreateTaskFromModel(*models.Task) (TaskDeviceIDResolver, error)` 方法。
2. **实现 `TaskFactory` 新方法**
-`internal/domain/task/task.go` 文件中,为 `taskFactory` 结构体实现 `CreateTaskFromModel` 方法。
3. **修改 `PlanService`**
-`internal/domain/plan/plan_service.go` 中:
- 修改 `planServiceImpl` 结构体,增加 `unitOfWork repository.UnitOfWork``taskFactory TaskFactory` 字段。
- 修改 `NewPlanService` 构造函数,接收并注入这些新依赖。
- 重构 `CreatePlan``UpdatePlan` 方法,使用 `UnitOfWork` 包裹事务,并在其中实现数据准备和关联逻辑。
4. **修改 `PlanRepository`**
-`internal/infra/repository/plan_repository.go` 中:
- **简化 `CreatePlan` 和 `UpdatePlanMetadataAndStructure` 方法**。移除所有手动处理设备关联的代码(例如,如果之前有 `Association("Devices").Replace()` 等调用,则应删除)。
- 确保这两个方法的核心逻辑就是调用 GORM 的 `Create``Updates`,信任 GORM 会根据传入模型中已填充的 `Devices` 字段来自动维护多对多关联。
5. **修改依赖注入**
-`internal/core/component_initializers.go` (或类似的依赖注入入口文件) 中:
-`unitOfWork``taskFactory` 实例传递给 `plan.NewPlanService` 的构造函数。
## 5. 结论
此方案通过复用现有的领域对象和工厂模式,优雅地解决了设备关联维护的问题。它保持了清晰的架构分层和模块职责,在实现功能的同时,为项目未来的扩展和维护奠定了坚实、可扩展的基础。

View File

@@ -0,0 +1,103 @@
# 设备与任务多对多关联模型设计
## 需求背景
用户需要为系统中的“设备”和“任务”增加多对多关联,即一个设备可以执行多个任务,一个任务可以被多个设备执行。
## 现有模型分析
### `internal/infra/models/device.go`
`Device` 模型定义:
```go
type Device struct {
gorm.Model
Name string `gorm:"not null" json:"name"`
DeviceTemplateID uint `gorm:"not null;index" json:"device_template_id"`
DeviceTemplate DeviceTemplate `json:"device_template"`
AreaControllerID uint `gorm:"not null;index" json:"area_controller_id"`
AreaController AreaController `json:"area_controller"`
Location string `gorm:"index" json:"location"`
Properties datatypes.JSON `json:"properties"`
}
```
### `internal/infra/models/plan.go`
`Task` 模型定义:
```go
type Task struct {
ID int `gorm:"primarykey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
PlanID uint `gorm:"not null;index" json:"plan_id"`
Name string `gorm:"not null" json:"name"`
Description string `json:"description"`
ExecutionOrder int `gorm:"not null" json:"execution_order"`
Type TaskType `gorm:"not null" json:"type"`
Parameters datatypes.JSON `json:"parameters"`
}
```
## 方案设计
为了实现设备和任务的多对多关系,我们将引入一个中间关联模型 `DeviceTask`。考虑到 `Task` 模型定义在 `plan.go` 中,为了保持相关模型的内聚性,我们将 `DeviceTask` 模型也定义在 `internal/infra/models/plan.go` 文件中。
### 1. 在 `internal/infra/models/plan.go` 中新增 `DeviceTask` 关联模型
`DeviceTask` 模型将包含 `DeviceID``TaskID` 作为外键,以及 GORM 的标准模型字段。
```go
// DeviceTask 是设备和任务之间的关联模型,表示一个设备可以执行多个任务,一个任务可以被多个设备执行。
type DeviceTask struct {
gorm.Model
DeviceID uint `gorm:"not null;index"` // 设备ID
TaskID uint `gorm:"not null;index"` // 任务ID
// 可选:如果需要存储关联的额外信息,可以在这里添加字段,例如:
// Configuration datatypes.JSON `json:"configuration"` // 任务在特定设备上的配置
}
// TableName 自定义 GORM 使用的数据库表名
func (DeviceTask) TableName() string {
return "device_tasks"
}
```
### 2. 修改 `internal/infra/models/device.go`
`Device` 结构体中添加 `Tasks` 字段,通过 `gorm:"many2many:device_tasks;"` 标签声明与 `Task` 的多对多关系,并指定中间表名为 `device_tasks`
```go
// Device 代表系统中的所有普通设备
type Device struct {
gorm.Model
// ... 其他现有字段 ...
// Tasks 是与此设备关联的任务列表,通过 DeviceTask 关联表实现多对多关系
Tasks []Task `gorm:"many2many:device_tasks;" json:"tasks"`
}
```
### 3. 修改 `internal/infra/models/plan.go`
`Task` 结构体中添加 `Devices` 字段,通过 `gorm:"many2many:device_tasks;"` 标签声明与 `Device` 的多对多关系,并指定中间表名为 `device_tasks`
```go
// Task 代表计划中的一个任务,具有执行顺序
type Task struct {
// ... 其他现有字段 ...
// Devices 是与此任务关联的设备列表,通过 DeviceTask 关联表实现多对多关系
Devices []Device `gorm:"many2many:device_tasks;" json:"devices"`
}
```
## 总结
通过上述修改,我们将在数据库中创建一个名为 `device_tasks` 的中间表,用于存储 `Device``Task` 之间的关联关系。在 Go 代码层面,`Device``Task` 模型将能够直接通过 `Tasks``Devices` 字段进行多对多关系的查询和操作。

View File

@@ -0,0 +1,24 @@
# 需求
删除设备/设备模板/区域主控前进行校验
## issue
http://git.huangwc.com/pig/pig-farm-controller/issues/50
## 需求描述
1. 删除设备时检测是否被任务使用
2. 删除设备模板时检测是否被设备使用
3. 删除区域主控时检测是否被设备使用
# 实现
1. [重构计划领域](./plan_service_refactor.md)
2. [让任务可以提供自身使用设备](./add_get_device_id_configs_to_task.md)
3. [现有计划管理逻辑迁移](./plan_service_refactor_to_domain.md)
4. [增加设备任务关联表](./device_task_many_to_many_design.md)
5. [增加任务增删改查时对设备任务关联表的维护](./device_task_association_maintenance.md)
6. [删除设备时检查](./check_before_device_deletion.md)
7. [删除设备模板时检查和删除区域主控时检查](./refactor_deletion_check.md)
8. [优化设备服务方法的入参](./refactor_id_conversion.md)

View File

@@ -0,0 +1,83 @@
# 计划服务重构设计方案
## 1. 目标
`internal/domain/scheduler` 包重构为 `internal/domain/plan`,并创建一个新的 `Service` 对象,将原 `scheduler`
包中的核心调度逻辑集成到 `Service` 中作为一个子服务,统一由 `Service`
对外提供服务。此重构旨在提高代码的模块化、可维护性和可测试性,并为后续的“设备删除前校验”功能奠定基础。
## 2. 方案详情
### 2.1. 包重命名
*`internal/domain/scheduler` 目录重命名为 `internal/domain/plan`
* 修改 `internal/domain/plan` 目录下所有 Go 文件中的 `package scheduler``package plan`
* 更新 `internal/domain/plan` 目录下所有 Go 文件中所有引用
`git.huangwc.com/pig/pig-farm-controller/internal/domain/scheduler` 的导入路径为
`git.huangwc.com/pig/pig-farm-controller/internal/domain/plan`
### 2.2. `internal/domain/plan` 包内部结构调整
* **`internal/domain/plan/task.go`**:
* 保持不变。它定义了任务的接口和工厂,是领域内的核心抽象。
* **`internal/domain/plan/plan_execution_manager.go`**:
*`Scheduler` 结构体更名为 `ExecutionManagerImpl`。这个名称更准确地反映了它作为计划任务执行的协调者和管理者的具体实现。
*`NewScheduler` 构造函数更名为 `NewExecutionManagerImpl`
* 文件内部所有对 `Scheduler` 的引用都将更新为 `ExecutionManagerImpl`
* **`internal/domain/plan/analysis_plan_task_manager.go`**:
*`AnalysisPlanTaskManager` 结构体更名为 `AnalysisPlanTaskManagerImpl`
*`NewAnalysisPlanTaskManager` 构造函数更名为 `NewAnalysisPlanTaskManagerImpl`
* 文件内部所有对 `AnalysisPlanTaskManager` 的引用都将更新为 `AnalysisPlanTaskManagerImpl`
* **定义领域层接口**:
*`internal/domain/plan` 包中定义 `ExecutionManager` 接口,包含 `ExecutionManagerImpl` 对外暴露的所有公共方法。
*`internal/domain/plan` 包中定义 `AnalysisPlanTaskManager` 接口,包含 `AnalysisPlanTaskManagerImpl` 对外暴露的所有公共方法。
* `ExecutionManagerImpl``AnalysisPlanTaskManagerImpl` 将分别实现对应的接口。
### 2.3. 创建 `internal/domain/plan/plan_service.go`
* 创建新文件 `internal/domain/plan/plan_service.go`
* **定义领域服务接口**:
*`internal/domain/plan` 包中定义 `Service` 接口,该接口将聚合 `ExecutionManager``AnalysisPlanTaskManager`
的所有公共方法,并由 `planServiceImpl` 实现这些方法的委托。
* **实现领域服务**:
* 该文件将定义 `planServiceImpl` 结构体,并包含 `ExecutionManager` 接口和 `AnalysisPlanTaskManager` 接口的实例作为其依赖。
* 实现 `NewService` 构造函数,负责接收 `ExecutionManager` 接口和 `AnalysisPlanTaskManager` 接口的实例,并将其注入到
`planServiceImpl` 中。
* `planServiceImpl` 将对外提供高层次的 API这些 API 会协调调用其依赖的接口方法。例如:
* `Service.Start()` 方法会调用 `ExecutionManager` 接口的 `Start()` 方法。
* `Service.Stop()` 方法会调用 `ExecutionManager` 接口的 `Stop()` 方法。
* `Service.RefreshPlanTriggers()` 方法会调用 `AnalysisPlanTaskManager` 接口的 `Refresh()` 方法。
* `Service.CreateOrUpdateTrigger()` 方法会调用 `AnalysisPlanTaskManager` 接口的 `CreateOrUpdateTrigger()` 方法。
* `Service.EnsureAnalysisTaskDefinition()` 方法会调用 `AnalysisPlanTaskManager` 接口的
`EnsureAnalysisTaskDefinition()` 方法。
* 未来所有与计划相关的领域操作,都将通过 `Service` 接口进行。
### 2.4. 调整依赖注入和引用
* **查找并替换导入路径:** 使用 `grep` 命令查找整个项目中所有引用
`git.huangwc.com/pig/pig-farm-controller/internal/domain/scheduler` 的地方,并将其替换为
`git.huangwc.com/pig/pig-farm-controller/internal/domain/plan`
* **更新 `internal/core/component_initializers.go`**:
* 在初始化阶段,我们将创建 `plan.ExecutionManagerImpl``plan.AnalysisPlanTaskManagerImpl` 的具体实例。
* 然后,将这些具体实例作为 `plan.ExecutionManager` 接口和 `plan.AnalysisPlanTaskManager` 接口类型传递给
`plan.NewService` 构造函数,创建 `planServiceImpl` 实例。
* 最终,`plan.NewService` 返回 `plan.Service` 接口类型。
* 应用程序的其他部分将通过 `plan.Service` 接口来访问计划相关的逻辑,而不是直接访问底层的管理器或其具体实现。
## 3. 优势
* **职责分离清晰:** `internal/domain/plan` 包专注于计划领域的核心逻辑和管理,并提供统一的 `Service` 接口作为领域服务的入口。
* **符合领域驱动设计:** 领域层包含核心业务逻辑和管理器,应用层(如果需要)作为领域层的协调者。
* **与现有项目风格一致:** 借鉴 `domain/pig` 包的模式,提高了项目内部的一致性。
* **可测试性增强:** `Service` 可以更容易地进行单元测试,因为其依赖的接口可以被模拟。
* **可维护性提高:** 当计划相关的业务逻辑发生变化时,可以更精确地定位到需要修改的组件。
* **松耦合:** `Service` 不依赖于具体的实现,而是依赖于接口,提高了系统的灵活性和可扩展性。
## 4. 验证和测试
在完成所有修改后,需要运行项目并进行测试,确保调度器功能正常,没有引入新的错误。

View File

@@ -0,0 +1,179 @@
# 重构方案:将 `app/service/plan_service.go` 的核心逻辑迁移到 `domain/plan/plan_service.go`
## 目标:
* `app/service/plan_service.go` (应用服务层): 仅负责接收 DTO、将 DTO 转换为领域实体、调用 `domain/plan/plan_service` 的领域方法,并将领域方法返回的领域实体转换为 DTO 返回。
* `domain/plan/plan_service.go` (领域层): 封装所有与计划相关的业务逻辑、验证规则、状态管理以及对领域实体的查询操作。
## 详细步骤:
### 第一步:修改 `domain/plan/plan_service.go` (领域层)
1. **引入必要的依赖**:
* `repository.PlanRepository`:用于与计划数据存储交互。
* `repository.DeviceRepository`:如果计划逻辑中需要设备信息。
* `models.Plan`:领域实体。
* `errors``gorm.ErrRecordNotFound`:用于错误处理。
* `models.PlanTypeSystem`, `models.PlanStatusEnabled`, `models.PlanContentTypeSubPlans`, `models.PlanContentTypeTasks` 等常量。
* `git.huangwc.com/pig/pig-farm-controller/internal/infra/models`
* `git.huangwc.com/pig/pig-farm-controller/internal/infra/repository`
* `errors`
* `gorm.io/gorm`
2. **定义领域层错误**: 将 `app/service/plan_service.go` 中定义的错误(`ErrPlanNotFound`, `ErrPlanCannotBeModified` 等)迁移到 `domain/plan/plan_service.go`,并根据领域层的语义进行调整。
```go
var (
// ErrPlanNotFound 表示未找到计划
ErrPlanNotFound = errors.New("计划不存在")
// ErrPlanCannotBeModified 表示计划不允许修改
ErrPlanCannotBeModified = errors.New("系统计划不允许修改")
// ErrPlanCannotBeDeleted 表示计划不允许删除
ErrPlanCannotBeDeleted = errors.New("系统计划不允许删除")
// ErrPlanCannotBeStarted 表示计划不允许手动启动
ErrPlanCannotBeStarted = errors.New("系统计划不允许手动启动")
// ErrPlanAlreadyEnabled 表示计划已处于启动状态
ErrPlanAlreadyEnabled = errors.New("计划已处于启动状态,无需重复操作")
// ErrPlanNotEnabled 表示计划未处于启动状态
ErrPlanNotEnabled = errors.New("计划当前不是启用状态")
// ErrPlanCannotBeStopped 表示计划不允许停止
ErrPlanCannotBeStopped = errors.New("系统计划不允许停止")
)
```
3. **扩展 `plan.Service` 接口**:
* 将 `app/service/plan_service.go` 中 `PlanService` 接口的所有方法(`CreatePlan`, `GetPlanByID`, `ListPlans`, `UpdatePlan`, `DeletePlan`, `StartPlan`, `StopPlan`)添加到 `domain/plan/Service` 接口中。
* 这些方法的参数和返回值将直接使用领域实体(`*models.Plan`)或基本类型,而不是 DTO。例如
* `CreatePlan(plan *models.Plan) (*models.Plan, error)`
* `GetPlanByID(id uint) (*models.Plan, error)`
* `ListPlans(opts repository.ListPlansOptions, page, pageSize int) ([]models.Plan, int64, error)`
* `UpdatePlan(plan *models.Plan) (*models.Plan, error)`
* `DeletePlan(id uint) error`
* `StartPlan(id uint) error`
* `StopPlan(id uint) error`
4. **修改 `planServiceImpl` 结构体**:
* 添加 `planRepo repository.PlanRepository` 字段。
* 添加 `deviceRepo repository.DeviceRepository` 字段 (如果需要)。
* `analysisPlanTaskManager plan.AnalysisPlanTaskManager` 字段保持不变。
```go
type planServiceImpl struct {
executionManager ExecutionManager
taskManager AnalysisPlanTaskManager
planRepo repository.PlanRepository // 新增
// deviceRepo repository.DeviceRepository // 如果需要,新增
logger *logs.Logger
}
```
5. **实现 `plan.Service` 接口中的新方法**:
* 将 `app/service/plan_service.go` 中 `planService` 的所有业务逻辑方法(`CreatePlan`, `GetPlanByID`, `ListPlans`, `UpdatePlan`, `DeletePlan`, `StartPlan`, `StopPlan`)的实现,迁移到 `domain/plan/planServiceImpl` 中。
* **关键修改点**:
* **参数和返回值**: 确保这些方法现在接收和返回的是 `*models.Plan` 或其他领域实体,而不是 DTO。
* **业务逻辑**: 保留所有的业务规则、验证和状态管理逻辑。
* **依赖**: 这些方法将直接调用 `planRepo` 和 `analysisPlanTaskManager`。
* **日志**: 日志记录保持不变,但可能需要调整日志信息以反映领域层的上下文。
* **错误处理**: 错误处理逻辑保持不变,但现在将返回领域层定义的错误。
* **ContentType 自动判断**: `CreatePlan` 和 `UpdatePlan` 中的 `ContentType` 自动判断逻辑应该保留在领域层。
* **执行计数器重置**: `UpdatePlan` 和 `StartPlan` 中的执行计数器重置逻辑应该保留在领域层。
* **系统计划限制**: 对系统计划的修改、删除、启动、停止限制逻辑应该保留在领域层。
* **验证和重排顺序**: `models.Plan` 的 `ValidateExecutionOrder()` 和 `ReorderSteps()` 方法的调用应该在 `CreatePlan` 和 `UpdatePlan` 方法的领域层实现中进行,而不是在 DTO 转换函数中。
6. **修改 `NewPlanService` 函数**: 接收 `repository.PlanRepository` 和 `repository.DeviceRepository` (如果需要) 作为参数,并注入到 `planServiceImpl` 中。
```go
func NewPlanService(
executionManager ExecutionManager,
taskManager AnalysisPlanTaskManager,
planRepo repository.PlanRepository, // 新增
// deviceRepo repository.DeviceRepository, // 如果需要,新增
logger *logs.Logger,
) Service {
return &planServiceImpl{
executionManager: executionManager,
taskManager: taskManager,
planRepo: planRepo, // 注入
// deviceRepo: deviceRepo, // 注入
logger: logger,
}
}
```
### 第二步:修改 `app/service/plan_service.go` (应用服务层)
1. **修改 `planService` 结构体**:
* 移除 `planRepo repository.PlanRepository` 字段。
* 将 `analysisPlanTaskManager plan.AnalysisPlanTaskManager` 字段替换为 `domainPlanService plan.Service`。
```go
type planService struct {
logger *logs.Logger
// planRepo repository.PlanRepository // 移除
domainPlanService plan.Service // 替换为领域层的服务接口
// analysisPlanTaskManager plan.AnalysisPlanTaskManager // 移除,由 domainPlanService 内部持有
}
```
2. **修改 `NewPlanService` 函数**:
* 接收 `domainPlanService plan.Service` 作为参数。
* 将 `planRepo` 和 `analysisPlanTaskManager` 的注入替换为 `domainPlanService`。
```go
func NewPlanService(
logger *logs.Logger,
// planRepo repository.PlanRepository, // 移除
domainPlanService plan.Service, // 接收领域层服务
// analysisPlanTaskManager plan.AnalysisPlanTaskManager, // 移除
) PlanService {
return &planService{
logger: logger,
domainPlanService: domainPlanService, // 注入领域层服务
}
}
```
3. **修改 `PlanService` 接口**:
* 接口定义保持不变,仍然接收和返回 DTO。
4. **修改 `planService` 接口实现**:
* **`CreatePlan`**:
* 接收 `dto.CreatePlanRequest`。
* 使用 `dto.NewPlanFromCreateRequest` 将 DTO 转换为 `*models.Plan`。**注意:此时 `NewPlanFromCreateRequest` 不再包含 `ValidateExecutionOrder()` 和 `ReorderSteps()` 的调用。**
* 调用 `s.domainPlanService.CreatePlan(*models.Plan)`。
* 将返回的 `*models.Plan` 转换为 `dto.PlanResponse`。
* **`GetPlanByID`**:
* 调用 `s.domainPlanService.GetPlanByID(id)`。
* 将返回的 `*models.Plan` 转换为 `dto.PlanResponse`。
* **`ListPlans`**:
* 将 `dto.ListPlansQuery` 转换为 `repository.ListPlansOptions`。
* 调用 `s.domainPlanService.ListPlans(...)`。
* 将返回的 `[]models.Plan` 转换为 `[]dto.PlanResponse`。
* **`UpdatePlan`**:
* 使用 `dto.NewPlanFromUpdateRequest` 将 `dto.UpdatePlanRequest` 转换为 `*models.Plan`。**注意:此时 `NewPlanFromUpdateRequest` 不再包含 `ValidateExecutionOrder()` 和 `ReorderSteps()` 的调用。**
* 设置 `plan.ID = id`。
* 调用 `s.domainPlanService.UpdatePlan(*models.Plan)`。
* 将返回的 `*models.Plan` 转换为 `dto.PlanResponse`。
* **`DeletePlan`**:
* 调用 `s.domainPlanService.DeletePlan(id)`。
* **`StartPlan`**:
* 调用 `s.domainPlanService.StartPlan(id)`。
* **`StopPlan`**:
* 调用 `s.domainPlanService.StopPlan(id)`。
* **错误处理**: 应用服务层将捕获领域层返回的错误,并可能将其转换为更适合应用服务层或表示层的错误信息(例如,将领域层的 `ErrPlanNotFound` 转换为 `app/service` 层定义的 `ErrPlanNotFound`)。
### 第三步:修改 `internal/app/dto/plan_converter.go`
1. **移除 `NewPlanFromCreateRequest` 和 `NewPlanFromUpdateRequest` 中的领域逻辑**:
* 从 `NewPlanFromCreateRequest` 和 `NewPlanFromUpdateRequest` 函数中移除 `plan.ValidateExecutionOrder()` 和 `plan.ReorderSteps()` 的调用。这些逻辑应该由领域服务来处理。
### 第四步:更新 `main.go` 或其他依赖注入点
* 调整 `NewPlanService` 的调用,确保正确注入 `domain/plan/Service` 的实现。
## 风险与注意事项:
* **事务管理**: 如果领域层的方法需要事务,确保事务在领域层内部或由应用服务层协调。
* **错误映射**: 仔细处理领域层错误到应用服务层错误的映射,确保对外暴露的错误信息是恰当的。
* **循环依赖**: 确保 `domain` 层不依赖 `app` 层,`app` 层可以依赖 `domain` 层。
* **测试**: 重构后需要对所有相关功能进行全面的单元测试和集成测试。

View File

@@ -0,0 +1,196 @@
# 重构方案:将删除前关联检查逻辑迁移至 Service 层
## 1. 目标
将删除**区域主控 (AreaController)** 和**设备模板 (DeviceTemplate)** 时的关联设备检查逻辑,从 Repository数据访问层重构至 Service业务逻辑层。
## 2. 动机
当前实现中,关联检查逻辑位于 Repository 层的 `Delete` 方法内。这违反了分层架构的最佳实践。Repository 层应仅负责单纯的数据持久化操作(增删改查),而不应包含业务规则。
通过本次重构,我们将实现:
- **职责分离**: Service 层负责编排业务逻辑如“删除前必须检查关联”Repository 层回归其数据访问的单一职责。
- **代码清晰**: 业务流程在 Service 层一目了然,便于理解和维护。
- **可测试性增强**: 可以独立测试 Service 层的业务规则,而无需依赖数据库的事务或约束。
## 3. 详细实施步骤
### 第 1 步:在 Service 层定义业务错误
`internal/app/service/device_service.go` 文件中,导出两个新的错误变量,用于清晰地表达业务约束。
```go
// ErrAreaControllerInUse 表示区域主控正在被设备使用,无法删除
var ErrAreaControllerInUse = errors.New("区域主控正在被一个或多个设备使用,无法删除")
// ErrDeviceTemplateInUse 表示设备模板正在被设备使用,无法删除
var ErrDeviceTemplateInUse = errors.New("设备模板正在被一个或多个设备使用,无法删除")
```
### 第 2 步:调整 Repository 接口与实现
#### 2.1 `device_repository.go`
`DeviceRepository` 接口中增加一个方法,用于检查区域主控是否被使用,并在 `gormDeviceRepository` 中实现它。
```go
// internal/infra/repository/device_repository.go
type DeviceRepository interface {
// ... 其他方法
IsAreaControllerInUse(areaControllerID uint) (bool, error)
}
func (r *gormDeviceRepository) IsAreaControllerInUse(areaControllerID uint) (bool, error) {
var count int64
if err := r.db.Model(&models.Device{}).Where("area_controller_id = ?", areaControllerID).Count(&count).Error; err != nil {
return false, fmt.Errorf("检查区域主控使用情况失败: %w", err)
}
return count > 0, nil
}
```
#### 2.2 `area_controller_repository.go`
简化 `Delete` 方法,移除所有业务逻辑,使其成为一个纯粹的数据库删除操作。
```go
// internal/infra/repository/area_controller_repository.go
func (r *gormAreaControllerRepository) Delete(id uint) error {
// 移除原有的事务和关联检查
if err := r.db.Delete(&models.AreaController{}, id).Error; err != nil {
return fmt.Errorf("删除区域主控失败: %w", err)
}
return nil
}
```
#### 2.3 `device_template_repository.go`
同样,简化 `Delete` 方法。`IsInUse` 方法保持不变,因为它仍然是一个有用的查询。
```go
// internal/infra/repository/device_template_repository.go
func (r *gormDeviceTemplateRepository) Delete(id uint) error {
// 移除原有的关联检查逻辑
if err := r.db.Delete(&models.DeviceTemplate{}, id).Error; err != nil {
return fmt.Errorf("删除设备模板失败: %w", err)
}
return nil
}
```
### 第 3 步:在 Service 层实现业务逻辑
#### 3.1 `device_service.go` - 删除区域主控
修改 `DeleteAreaController` 方法,加入关联检查的业务逻辑。
```go
// internal/app/service/device_service.go
func (s *deviceService) DeleteAreaController(id string) error {
idUint, err := strconv.ParseUint(id, 10, 64)
if err != nil {
return fmt.Errorf("无效的ID格式: %w", err)
}
acID := uint(idUint)
// 1. 检查是否存在
_, err = s.areaControllerRepo.FindByID(acID)
if err != nil {
return err // 如果未找到gorm会返回 ErrRecordNotFound
}
// 2. 检查是否被使用(业务逻辑)
inUse, err := s.deviceRepo.IsAreaControllerInUse(acID)
if err != nil {
return err // 返回数据库检查错误
}
if inUse {
return ErrAreaControllerInUse // 返回业务错误
}
// 3. 执行删除
return s.areaControllerRepo.Delete(acID)
}
```
#### 3.2 `device_service.go` - 删除设备模板
修改 `DeleteDeviceTemplate` 方法,加入关联检查的业务逻辑。
```go
// internal/app/service/device_service.go
func (s *deviceService) DeleteDeviceTemplate(id string) error {
idUint, err := strconv.ParseUint(id, 10, 64)
if err != nil {
return fmt.Errorf("无效的ID格式: %w", err)
}
dtID := uint(idUint)
// 1. 检查是否存在
_, err = s.deviceTemplateRepo.FindByID(dtID)
if err != nil {
return err
}
// 2. 检查是否被使用(业务逻辑)
inUse, err := s.deviceTemplateRepo.IsInUse(dtID)
if err != nil {
return err
}
if inUse {
return ErrDeviceTemplateInUse // 返回业务错误
}
// 3. 执行删除
return s.deviceTemplateRepo.Delete(dtID)
}
```
### 第 4 步:在 Controller 层处理新的业务错误
#### 4.1 `device_controller.go` - 删除区域主控
`DeleteAreaController` 的错误处理中,增加对 `ErrAreaControllerInUse` 的捕获,并返回 `409 Conflict` 状态码。
```go
// internal/app/controller/device/device_controller.go
func (c *Controller) DeleteAreaController(ctx echo.Context) error {
// ...
if err := c.deviceService.DeleteAreaController(acID); err != nil {
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
// ...
case errors.Is(err, service.ErrAreaControllerInUse): // 新增
c.logger.Warnf("%s: 尝试删除正在被使用的主控, ID: %s", actionType, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), actionType, "主控正在被使用", acID)
default:
// ...
}
}
// ...
}
```
#### 4.2 `device_controller.go` - 删除设备模板
`DeleteDeviceTemplate` 的错误处理中,增加对 `ErrDeviceTemplateInUse` 的捕获,并返回 `409 Conflict` 状态码。
```go
// internal/app/controller/device/device_controller.go
func (c *Controller) DeleteDeviceTemplate(ctx echo.Context) error {
// ...
if err := c.deviceService.DeleteDeviceTemplate(dtID); err != nil {
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
// ...
case errors.Is(err, service.ErrDeviceTemplateInUse): // 新增
c.logger.Warnf("%s: 尝试删除正在被使用的模板, ID: %s", actionType, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), actionType, "模板正在被使用", dtID)
default:
// ...
}
}
// ...
}
```

View File

@@ -0,0 +1,106 @@
# 重构方案:将 ID 类型转换逻辑迁移至 Controller 层
## 1. 目标
将所有通过 URL 路径传入的 `id``string` 类型),其到 `uint` 类型的转换和验证逻辑,从 Service业务逻辑层统一迁移至 Controller控制器层。
## 2. 动机
当前实现中Controller 将从 URL 获取的 `string` 类型的 ID 直接传递给 Service 层,由 Service 层负责使用 `strconv.ParseUint` 进行类型转换。
这种模式存在以下问题:
- **职责不清**Service 层被迫处理了本应属于输入验证和转换的逻辑,而这部分工作更贴近 Controller 的职责。
- **Service 不纯粹**:业务核心逻辑与原始输入(字符串)耦合,降低了 Service 的可复用性。理想情况下Service 的接口应该只处理内部定义的、类型安全的数据。
- **延迟的错误处理**:对于一个无效的 ID如 "abc"),请求会穿透到 Service 层才会失败,而这种格式错误在 Controller 层就应该被拦截。
通过本次重构,我们将实现:
- **职责分离**Controller 负责处理 HTTP 请求的原始数据验证、转换Service 负责处理核心业务。
- **接口清晰**Service 层的所有方法将只接受类型安全的 `uint` 作为 ID使其意图更加明确。
- **快速失败**:无效的 ID 将在 Controller 层被立即拒绝,并返回 `400 Bad Request`,提高了系统的健壮性。
## 3. 详细实施步骤
### 第 1 步:修改 `device_service.go`
#### 3.1 修改 `DeviceService` 接口
所有接收 `id string` 参数的方法签名,全部修改为接收 `id uint`
**受影响的方法列表:**
- `GetDevice(id string)` -> `GetDevice(id uint)`
- `UpdateDevice(id string, ...)` -> `UpdateDevice(id uint, ...)`
- `DeleteDevice(id string)` -> `DeleteDevice(id uint)`
- `ManualControl(id string, ...)` -> `ManualControl(id uint, ...)`
- `GetAreaController(id string)` -> `GetAreaController(id uint)`
- `UpdateAreaController(id string, ...)` -> `UpdateAreaController(id uint, ...)`
- `DeleteAreaController(id string)` -> `DeleteAreaController(id uint)`
- `GetDeviceTemplate(id string)` -> `GetDeviceTemplate(id uint)`
- `UpdateDeviceTemplate(id string, ...)` -> `UpdateDeviceTemplate(id uint, ...)`
- `DeleteDeviceTemplate(id string)` -> `DeleteDeviceTemplate(id uint)`
#### 3.2 修改 `deviceService` 实现
`deviceService` 的方法实现中,移除所有 `strconv.ParseUint` 的调用,直接使用传入的 `uint` 类型的 ID。
**示例 (`DeleteDeviceTemplate`):**
**修改前:**
```go
func (s *deviceService) DeleteDeviceTemplate(id string) error {
idUint, err := strconv.ParseUint(id, 10, 64)
if err != nil {
return fmt.Errorf("无效的ID格式: %w", err)
}
dtID := uint(idUint)
// ... 业务逻辑
}
```
**修改后:**
```go
func (s *deviceService) DeleteDeviceTemplate(id uint) error {
// 直接使用 id
// ... 业务逻辑
}
```
### 第 2 步:修改 `device_controller.go`
在所有调用受影响 Service 方法的 Controller 方法中,增加 ID 的转换和错误处理逻辑。
**示例 (`DeleteDeviceTemplate`):**
**修改前:**
```go
func (c *Controller) DeleteDeviceTemplate(ctx echo.Context) error {
const actionType = "删除设备模板"
dtID := ctx.Param("id") // dtID is a string
if err := c.deviceService.DeleteDeviceTemplate(dtID); err != nil {
// ... 错误处理
}
// ... 成功处理
}
```
**修改后:**
```go
func (c *Controller) DeleteDeviceTemplate(ctx echo.Context) error {
const actionType = "删除设备模板"
idStr := ctx.Param("id")
// 在 Controller 层进行转换和验证
idUint, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.logger.Warnf("%s: 无效的ID格式: %s", actionType, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", actionType, "ID格式错误", idStr)
}
dtID := uint(idUint)
// 调用 Service传入 uint 类型的 ID
if err := c.deviceService.DeleteDeviceTemplate(dtID); err != nil {
// ... 错误处理 (保持不变)
}
// ... 成功处理
}
```
此模式将应用于所有受影响的 Controller 方法。

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

97
go.mod
View File

@@ -3,14 +3,22 @@ module git.huangwc.com/pig/pig-farm-controller
go 1.25 go 1.25
require ( require (
github.com/gin-gonic/gin v1.10.1 github.com/go-openapi/errors v0.22.2
github.com/go-openapi/runtime v0.28.0
github.com/go-openapi/strfmt v0.23.0
github.com/go-openapi/swag v0.25.1
github.com/go-openapi/validate v0.24.0
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/labstack/echo/v4 v4.13.4
github.com/panjf2000/ants/v2 v2.11.3
github.com/robfig/cron/v3 v3.0.1
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.6 github.com/swaggo/swag v1.16.6
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
go.uber.org/zap v1.27.0 go.uber.org/zap v1.27.0
golang.org/x/crypto v0.36.0 golang.org/x/crypto v0.43.0
google.golang.org/protobuf v1.36.9
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
gorm.io/datatypes v1.2.6 gorm.io/datatypes v1.2.6
@@ -22,25 +30,38 @@ require (
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect github.com/KyleBanks/depth v1.2.1 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/ghodss/yaml v1.0.0 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect github.com/go-logr/logr v1.4.1 // indirect
github.com/go-openapi/spec v0.20.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/swag v0.19.15 // indirect github.com/go-openapi/analysis v0.23.0 // indirect
github.com/go-openapi/jsonpointer v0.22.1 // indirect
github.com/go-openapi/jsonreference v0.21.2 // indirect
github.com/go-openapi/loads v0.22.0 // indirect
github.com/go-openapi/spec v0.22.0 // indirect
github.com/go-openapi/swag/cmdutils v0.25.1 // indirect
github.com/go-openapi/swag/conv v0.25.1 // indirect
github.com/go-openapi/swag/fileutils v0.25.1 // indirect
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
github.com/go-openapi/swag/loading v0.25.1 // indirect
github.com/go-openapi/swag/mangling v0.25.1 // indirect
github.com/go-openapi/swag/netutils v0.25.1 // indirect
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect
@@ -49,28 +70,42 @@ require (
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.6 // indirect github.com/mailru/easyjson v0.9.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/oklog/ulid v1.3.1 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/objx v0.5.2 // indirect
github.com/swaggo/echo-swagger v1.4.1 // indirect
github.com/swaggo/files/v2 v2.0.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.3.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
go.mongodb.org/mongo-driver v1.14.0 // indirect
go.opentelemetry.io/otel v1.24.0 // indirect
go.opentelemetry.io/otel/metric v1.24.0 // indirect
go.opentelemetry.io/otel/trace v1.24.0 // indirect
go.uber.org/multierr v1.10.0 // indirect go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.8.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.21.0 // indirect golang.org/x/arch v0.21.0 // indirect
golang.org/x/net v0.38.0 // indirect golang.org/x/mod v0.29.0 // indirect
golang.org/x/sync v0.12.0 // indirect golang.org/x/net v0.46.0 // indirect
golang.org/x/sys v0.31.0 // indirect golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.23.0 // indirect golang.org/x/sys v0.37.0 // indirect
golang.org/x/tools v0.26.0 // indirect golang.org/x/text v0.30.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect golang.org/x/time v0.11.0 // indirect
golang.org/x/tools v0.38.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/mysql v1.5.6 // indirect gorm.io/driver/mysql v1.5.6 // indirect
) )

263
go.sum
View File

@@ -2,61 +2,127 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU=
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/errors v0.22.2 h1:rdxhzcBUazEcGccKqbY1Y7NS8FDcMyIRr0934jrYnZg=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= github.com/go-openapi/errors v0.22.2/go.mod h1:+n/5UdIqdVnLIJ6Q9Se8HNGUXYaY6CN8ImWzfi/Gzp0=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-openapi/jsonpointer v0.22.0 h1:TmMhghgNef9YXxTu1tOopo+0BGEytxA+okbry0HjZsM=
github.com/go-openapi/jsonpointer v0.22.0/go.mod h1:xt3jV88UtExdIkkL7NloURjRQjbeUgcxFblMjq2iaiU=
github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
github.com/go-openapi/jsonreference v0.21.1 h1:bSKrcl8819zKiOgxkbVNRUBIr6Wwj9KYrDbMjRs0cDA=
github.com/go-openapi/jsonreference v0.21.1/go.mod h1:PWs8rO4xxTUqKGu+lEvvCxD5k2X7QYkKAepJyCmSTT8=
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
github.com/go-openapi/loads v0.22.0 h1:ECPGd4jX1U6NApCGG1We+uEozOAvXvJSF4nnwHZ8Aco=
github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5Stn1oF+rs=
github.com/go-openapi/runtime v0.28.0 h1:gpPPmWSNGo214l6n8hzdXYhPuJcGtziTOgUpvsFWGIQ=
github.com/go-openapi/runtime v0.28.0/go.mod h1:QN7OzcS+XuYmkQLw05akXk0jRH/eZ3kb18+1KwW9gyc=
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw=
github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0=
github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c=
github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4=
github.com/go-openapi/swag v0.24.1 h1:DPdYTZKo6AQCRqzwr/kGkxJzHhpKxZ9i/oX0zag+MF8=
github.com/go-openapi/swag v0.24.1/go.mod h1:sm8I3lCPlspsBBwUm1t5oZeWZS0s7m/A+Psg0ooRU0A=
github.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8=
github.com/go-openapi/swag v0.25.1/go.mod h1:bzONdGlT0fkStgGPd3bhZf1MnuPkf2YAys6h+jZipOo=
github.com/go-openapi/swag/cmdutils v0.24.0 h1:KlRCffHwXFI6E5MV9n8o8zBRElpY4uK4yWyAMWETo9I=
github.com/go-openapi/swag/cmdutils v0.24.0/go.mod h1:uxib2FAeQMByyHomTlsP8h1TtPd54Msu2ZDU/H5Vuf8=
github.com/go-openapi/swag/cmdutils v0.25.1 h1:nDke3nAFDArAa631aitksFGj2omusks88GF1VwdYqPY=
github.com/go-openapi/swag/cmdutils v0.25.1/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
github.com/go-openapi/swag/conv v0.24.0 h1:ejB9+7yogkWly6pnruRX45D1/6J+ZxRu92YFivx54ik=
github.com/go-openapi/swag/conv v0.24.0/go.mod h1:jbn140mZd7EW2g8a8Y5bwm8/Wy1slLySQQ0ND6DPc2c=
github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
github.com/go-openapi/swag/fileutils v0.24.0 h1:U9pCpqp4RUytnD689Ek/N1d2N/a//XCeqoH508H5oak=
github.com/go-openapi/swag/fileutils v0.24.0/go.mod h1:3SCrCSBHyP1/N+3oErQ1gP+OX1GV2QYFSnrTbzwli90=
github.com/go-openapi/swag/fileutils v0.25.1 h1:rSRXapjQequt7kqalKXdcpIegIShhTPXx7yw0kek2uU=
github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M=
github.com/go-openapi/swag/jsonname v0.24.0 h1:2wKS9bgRV/xB8c62Qg16w4AUiIrqqiniJFtZGi3dg5k=
github.com/go-openapi/swag/jsonname v0.24.0/go.mod h1:GXqrPzGJe611P7LG4QB9JKPtUZ7flE4DOVechNaDd7Q=
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
github.com/go-openapi/swag/jsonutils v0.24.0 h1:F1vE1q4pg1xtO3HTyJYRmEuJ4jmIp2iZ30bzW5XgZts=
github.com/go-openapi/swag/jsonutils v0.24.0/go.mod h1:vBowZtF5Z4DDApIoxcIVfR8v0l9oq5PpYRUuteVu6f0=
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
github.com/go-openapi/swag/loading v0.24.0 h1:ln/fWTwJp2Zkj5DdaX4JPiddFC5CHQpvaBKycOlceYc=
github.com/go-openapi/swag/loading v0.24.0/go.mod h1:gShCN4woKZYIxPxbfbyHgjXAhO61m88tmjy0lp/LkJk=
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
github.com/go-openapi/swag/mangling v0.24.0 h1:PGOQpViCOUroIeak/Uj/sjGAq9LADS3mOyjznmHy2pk=
github.com/go-openapi/swag/mangling v0.24.0/go.mod h1:Jm5Go9LHkycsz0wfoaBDkdc4CkpuSnIEf62brzyCbhc=
github.com/go-openapi/swag/mangling v0.25.1 h1:XzILnLzhZPZNtmxKaz/2xIGPQsBsvmCjrJOWGNz/ync=
github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ=
github.com/go-openapi/swag/netutils v0.24.0 h1:Bz02HRjYv8046Ycg/w80q3g9QCWeIqTvlyOjQPDjD8w=
github.com/go-openapi/swag/netutils v0.24.0/go.mod h1:WRgiHcYTnx+IqfMCtu0hy9oOaPR0HnPbmArSRN1SkZM=
github.com/go-openapi/swag/netutils v0.25.1 h1:2wFLYahe40tDUHfKT1GRC4rfa5T1B4GWZ+msEFA4Fl4=
github.com/go-openapi/swag/netutils v0.25.1/go.mod h1:CAkkvqnUJX8NV96tNhEQvKz8SQo2KF0f7LleiJwIeRE=
github.com/go-openapi/swag/stringutils v0.24.0 h1:i4Z/Jawf9EvXOLUbT97O0HbPUja18VdBxeadyAqS1FM=
github.com/go-openapi/swag/stringutils v0.24.0/go.mod h1:5nUXB4xA0kw2df5PRipZDslPJgJut+NjL7D25zPZ/4w=
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
github.com/go-openapi/swag/typeutils v0.24.0 h1:d3szEGzGDf4L2y1gYOSSLeK6h46F+zibnEas2Jm/wIw=
github.com/go-openapi/swag/typeutils v0.24.0/go.mod h1:q8C3Kmk/vh2VhpCLaoR2MVWOGP8y7Jc8l82qCTd1DYI=
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
github.com/go-openapi/swag/yamlutils v0.24.0 h1:bhw4894A7Iw6ne+639hsBNRHg9iZg/ISrOVr+sJGp4c=
github.com/go-openapi/swag/yamlutils v0.24.0/go.mod h1:DpKv5aYuaGm/sULePoeiG8uwMpZSfReo1HR3Ik0yaG8=
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58=
github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -76,39 +142,47 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/panjf2000/ants/v2 v2.11.3 h1:AfI0ngBoXJmYOpDh9m516vjqoUu2sLrIVgppI9TZVpg=
github.com/panjf2000/ants/v2 v2.11.3/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -117,93 +191,120 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggo/echo-swagger v1.4.1 h1:Yf0uPaJWp1uRtDloZALyLnvdBeoEL5Kc7DtnjzO/TUk=
github.com/swaggo/echo-swagger v1.4.1/go.mod h1:C8bSi+9yH2FLZsnhqMZLIZddpUxZdBYuNHbtaS1Hljc=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw=
github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM=
github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU=
github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0=
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU=
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd80=
go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c=
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw=
go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg=
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/arch v0.21.0 h1:iTC9o7+wP6cPWpDWkivCvQFGAHDQ59SrSxsLPcnkArw=
golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/datatypes v1.2.6 h1:KafLdXvFUhzNeL2ncm03Gl3eTLONQfNKZ+wJ+9Y4Nck= gorm.io/datatypes v1.2.6 h1:KafLdXvFUhzNeL2ncm03Gl3eTLONQfNKZ+wJ+9Y4Nck=
@@ -219,5 +320,3 @@ gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOze
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.30.5 h1:dvEfYwxL+i+xgCNSGGBT1lDjCzfELK8fHZxL3Ee9X0s= gorm.io/gorm v1.30.5 h1:dvEfYwxL+i+xgCNSGGBT1lDjCzfELK8fHZxL3Ee9X0s=
gorm.io/gorm v1.30.5/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gorm.io/gorm v1.30.5/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@@ -1,83 +1,113 @@
package api package api
// @title 猪场管理系统 API
// @version 1.0
// @description 这是一个用于管理猪场设备的后端服务。
// @contact.name Divano
// @contact.url http://www.example.com
// @contact.email divano@example.com
// @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
import ( import (
"context" "context"
"fmt" "fmt"
"net/http" "net/http"
"time" "time"
_ "git.huangwc.com/pig/pig-farm-controller/docs" // 引入 swag 生成的 docs
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller/device"
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller/management"
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller/monitor"
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller/plan"
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller/user" "git.huangwc.com/pig/pig-farm-controller/internal/app/controller/user"
"git.huangwc.com/pig/pig-farm-controller/internal/app/service/token" "git.huangwc.com/pig/pig-farm-controller/internal/app/service"
"git.huangwc.com/pig/pig-farm-controller/internal/app/webhook"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/audit"
domain_plan "git.huangwc.com/pig/pig-farm-controller/internal/domain/plan"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/token"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/config" "git.huangwc.com/pig/pig-farm-controller/internal/infra/config"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" "git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
"github.com/gin-gonic/gin"
_ "git.huangwc.com/pig/pig-farm-controller/docs" // 引入 swag 生成的 docs "github.com/labstack/echo/v4"
swaggerFiles "github.com/swaggo/files" "github.com/labstack/echo/v4/middleware"
ginSwagger "github.com/swaggo/gin-swagger"
) )
// API 结构体定义了 HTTP 服务器及其依赖 // API 结构体定义了 HTTP 服务器及其依赖
type API struct { type API struct {
engine *gin.Engine // Gin 引擎实例,用于处理 HTTP 请求 echo *echo.Echo // Echo 引擎实例,用于处理 HTTP 请求
logger *logs.Logger // 日志记录器,用于输出日志信息 logger *logs.Logger // 日志记录器,用于输出日志信息
userRepo repository.UserRepository // 用户数据仓库接口,用于用户数据操作 userRepo repository.UserRepository // 用户数据仓库接口,用于用户数据操作
tokenService token.TokenService // Token 服务接口,用于 JWT token 的生成和解析 tokenService token.Service // Token 服务接口,用于 JWT token 的生成和解析
auditService audit.Service // 审计服务,用于记录用户操作
httpServer *http.Server // 标准库的 HTTP 服务器实例,用于启动和停止服务 httpServer *http.Server // 标准库的 HTTP 服务器实例,用于启动和停止服务
config config.ServerConfig // API 服务器的配置,使用 infra/config 包中的 ServerConfig config config.ServerConfig // API 服务器的配置,使用 infra/config 包中的 ServerConfig
userController *user.Controller // 用户控制器实例 userController *user.Controller // 用户控制器实例
deviceController *device.Controller // 设备控制器实例
planController *plan.Controller // 计划控制器实例
pigFarmController *management.PigFarmController // 猪场管理控制器实例
pigBatchController *management.PigBatchController // 猪群控制器实例
monitorController *monitor.Controller // 数据监控控制器实例
listenHandler webhook.ListenHandler // 设备上行事件监听器
analysisTaskManager *domain_plan.AnalysisPlanTaskManager // 计划触发器管理器实例
} }
// NewAPI 创建并返回一个新的 API 实例 // NewAPI 创建并返回一个新的 API 实例
// 负责初始化 Gin 引擎、设置全局中间件,并注入所有必要的依赖。 // 负责初始化 Echo 引擎、设置全局中间件,并注入所有必要的依赖。
func NewAPI(cfg config.ServerConfig, logger *logs.Logger, userRepo repository.UserRepository, tokenService token.TokenService) *API { func NewAPI(cfg config.ServerConfig,
// 设置 Gin 模式,例如 gin.ReleaseMode (生产模式) 或 gin.DebugMode (开发模式) logger *logs.Logger,
// 从配置中获取 Gin 模式 userRepo repository.UserRepository,
gin.SetMode(cfg.Mode) pigFarmService service.PigFarmService,
pigBatchService service.PigBatchService,
monitorService service.MonitorService,
deviceService service.DeviceService,
planService service.PlanService,
userService service.UserService,
tokenService token.Service,
auditService audit.Service,
listenHandler webhook.ListenHandler,
) *API {
// 使用 echo.New() 创建一个 Echo 引擎实例
e := echo.New()
// 使用 gin.New() 创建一个 Gin 引擎实例,而不是 gin.Default() // 根据配置设置 Echo 的调试模式
// 这样可以手动添加所需的中间件,避免 gin.Default() 默认包含的 Logger 和 Recovery 中间件 e.Debug = cfg.Mode == "debug"
engine := gin.New()
// 添加 Gin Recovery 中间件,用于捕获 panic 并恢复,防止服务崩溃 // 添加 Echo Recovery 中间件,用于捕获 panic 并恢复,防止服务崩溃
// gin.Logger() 已移除,因为我们使用自定义的 logger // Echo 的 Logger 中间件默认会记录请求信息,如果需要自定义,可以替换
engine.Use(gin.Recovery()) e.Use(middleware.Recover())
// 初始化 API 结构体 // 初始化 API 结构体
api := &API{ api := &API{
engine: engine, echo: e,
logger: logger, logger: logger,
userRepo: userRepo, userRepo: userRepo,
tokenService: tokenService, tokenService: tokenService,
auditService: auditService,
config: cfg, config: cfg,
listenHandler: listenHandler,
// 在 NewAPI 中初始化用户控制器,并将其作为 API 结构体的成员 // 在 NewAPI 中初始化用户控制器,并将其作为 API 结构体的成员
userController: user.NewController(userRepo, logger, tokenService), userController: user.NewController(userService, logger),
// 在 NewAPI 中初始化设备控制器,并将其作为 API 结构体的成员
deviceController: device.NewController(deviceService, logger),
// 在 NewAPI 中初始化计划控制器,并将其作为 API 结构体的成员
planController: plan.NewController(logger, planService),
// 在 NewAPI 中初始化猪场管理控制器
pigFarmController: management.NewPigFarmController(logger, pigFarmService),
// 在 NewAPI 中初始化猪群控制器
pigBatchController: management.NewPigBatchController(logger, pigBatchService),
// 在 NewAPI 中初始化数据监控控制器
monitorController: monitor.NewController(monitorService, logger),
} }
api.setupRoutes() // 设置所有路由 api.setupRoutes() // 设置所有路由
return api return api
} }
// setupRoutes 设置所有 API 路由
// 在此方法中,使用已初始化的控制器实例将其路由注册到 Gin 引擎中。
func (a *API) setupRoutes() {
// 创建 /api/v1 路由组
v1 := a.engine.Group("/api/v1")
{
// 用户相关路由组
userGroup := v1.Group("/users")
{
userGroup.POST("", a.userController.CreateUser) // 注册创建用户接口 (POST /api/v1/users)
userGroup.POST("/login", a.userController.Login) // 注册用户登录接口 (POST /api/v1/users/login)
}
}
// 添加 Swagger UI 路由
a.engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
a.logger.Info("Swagger UI is available at /swagger/index.html")
}
// Start 启动 HTTP 服务器 // Start 启动 HTTP 服务器
// 接收一个地址字符串 (例如 ":8080"),并在一个新的 goroutine 中启动服务器, // 接收一个地址字符串 (例如 ":8080"),并在一个新的 goroutine 中启动服务器,
// 以便主线程可以继续执行其他任务(例如监听操作系统信号)。 // 以便主线程可以继续执行其他任务(例如监听操作系统信号)。
@@ -88,7 +118,7 @@ func (a *API) Start() {
// 初始化标准库的 http.Server 实例 // 初始化标准库的 http.Server 实例
a.httpServer = &http.Server{ a.httpServer = &http.Server{
Addr: addr, // 服务器监听的地址从配置中获取 Addr: addr, // 服务器监听的地址从配置中获取
Handler: a.engine, // 将 Gin 引擎作为 HTTP 请求的处理程序 Handler: a.echo, // 将 Echo 引擎作为 HTTP 请求的处理程序
} }
// 在独立的 goroutine 中启动服务器 // 在独立的 goroutine 中启动服务器

185
internal/app/api/router.go Normal file
View File

@@ -0,0 +1,185 @@
package api
import (
"net/http"
"net/http/pprof"
"git.huangwc.com/pig/pig-farm-controller/internal/app/middleware"
"github.com/labstack/echo/v4"
echoSwagger "github.com/swaggo/echo-swagger"
)
// setupRoutes 设置所有 API 路由
// 在此方法中,使用已初始化的控制器实例将其路由注册到 Echo 引擎中。
func (a *API) setupRoutes() {
a.logger.Info("开始初始化所有 API 路由")
// --- Public Routes ---
// 这些路由不需要身份验证
// 用户注册和登录
a.echo.POST("/api/v1/users", a.userController.CreateUser) // 注册新用户
a.echo.POST("/api/v1/users/login", a.userController.Login) // 用户登录
a.logger.Debug("公开接口注册成功:用户注册、登录")
// 注册 pprof 路由
pprofGroup := a.echo.Group("/debug/pprof")
{
pprofGroup.GET("/", echo.WrapHandler(http.HandlerFunc(pprof.Index))) // pprof 索引页
pprofGroup.GET("/cmdline", echo.WrapHandler(http.HandlerFunc(pprof.Cmdline))) // pprof 命令行参数
pprofGroup.GET("/profile", echo.WrapHandler(http.HandlerFunc(pprof.Profile))) // pprof CPU profile
pprofGroup.POST("/symbol", echo.WrapHandler(http.HandlerFunc(pprof.Symbol))) // pprof 符号查找 (POST)
pprofGroup.GET("/symbol", echo.WrapHandler(http.HandlerFunc(pprof.Symbol))) // pprof 符号查找 (GET)
pprofGroup.GET("/trace", echo.WrapHandler(http.HandlerFunc(pprof.Trace))) // pprof 跟踪
pprofGroup.GET("/allocs", echo.WrapHandler(pprof.Handler("allocs"))) // pprof 内存分配
pprofGroup.GET("/block", echo.WrapHandler(pprof.Handler("block"))) // pprof 阻塞
pprofGroup.GET("/goroutine", echo.WrapHandler(pprof.Handler("goroutine")))
pprofGroup.GET("/heap", echo.WrapHandler(pprof.Handler("heap"))) // pprof 堆内存
pprofGroup.GET("/mutex", echo.WrapHandler(pprof.Handler("mutex"))) // pprof 互斥锁
pprofGroup.GET("/threadcreate", echo.WrapHandler(pprof.Handler("threadcreate")))
}
a.logger.Debug("pprof 接口注册成功")
// 上行事件监听路由
a.echo.POST("/upstream", echo.WrapHandler(a.listenHandler.Handler())) // 处理设备上行事件
a.logger.Debug("上行事件监听接口注册成功")
// 添加 Swagger UI 路由, Swagger UI可在 /swagger/index.html 上找到
a.echo.GET("/swagger/*any", echoSwagger.WrapHandler) // Swagger UI 接口
a.logger.Debug("Swagger UI 接口注册成功")
// --- Authenticated Routes ---
// 所有在此注册的路由都需要通过 JWT 身份验证
authGroup := a.echo.Group("/api/v1")
authGroup.Use(middleware.AuthMiddleware(a.tokenService, a.userRepo)) // 1. 身份认证中间件
authGroup.Use(middleware.AuditLogMiddleware(a.auditService)) // 2. 审计日志中间件
{
// 用户相关路由组
userGroup := authGroup.Group("/users")
{
userGroup.POST("/:id/notifications/test", a.userController.SendTestNotification)
}
a.logger.Debug("用户相关接口注册成功 (需要认证和审计)")
// 设备相关路由组
deviceGroup := authGroup.Group("/devices")
{
deviceGroup.POST("", a.deviceController.CreateDevice) // 创建设备
deviceGroup.GET("", a.deviceController.ListDevices) // 获取设备列表
deviceGroup.GET("/:id", a.deviceController.GetDevice) // 获取单个设备
deviceGroup.PUT("/:id", a.deviceController.UpdateDevice) // 更新设备
deviceGroup.DELETE("/:id", a.deviceController.DeleteDevice) // 删除设备
deviceGroup.POST("/manual-control/:id", a.deviceController.ManualControl) // 手动控制设备
}
a.logger.Debug("设备相关接口注册成功 (需要认证和审计)")
// 区域主控相关路由组
areaControllerGroup := authGroup.Group("/area-controllers")
{
areaControllerGroup.POST("", a.deviceController.CreateAreaController) // 创建区域主控
areaControllerGroup.GET("", a.deviceController.ListAreaControllers) // 获取区域主控列表
areaControllerGroup.GET("/:id", a.deviceController.GetAreaController) // 获取单个区域主控
areaControllerGroup.PUT("/:id", a.deviceController.UpdateAreaController) // 更新区域主控
areaControllerGroup.DELETE("/:id", a.deviceController.DeleteAreaController) // 删除区域主控
}
a.logger.Debug("区域主控相关接口注册成功 (需要认证和审计)")
// 设备模板相关路由组
deviceTemplateGroup := authGroup.Group("/device-templates")
{
deviceTemplateGroup.POST("", a.deviceController.CreateDeviceTemplate) // 创建设备模板
deviceTemplateGroup.GET("", a.deviceController.ListDeviceTemplates) // 获取设备模板列表
deviceTemplateGroup.GET("/:id", a.deviceController.GetDeviceTemplate) // 获取单个设备模板
deviceTemplateGroup.PUT("/:id", a.deviceController.UpdateDeviceTemplate) // 更新设备模板
deviceTemplateGroup.DELETE("/:id", a.deviceController.DeleteDeviceTemplate) // 删除设备模板
}
a.logger.Debug("设备模板相关接口注册成功 (需要认证和审计)")
// 计划相关路由组
planGroup := authGroup.Group("/plans")
{
planGroup.POST("", a.planController.CreatePlan) // 创建计划
planGroup.GET("", a.planController.ListPlans) // 获取计划列表
planGroup.GET("/:id", a.planController.GetPlan) // 获取单个计划
planGroup.PUT("/:id", a.planController.UpdatePlan) // 更新计划
planGroup.DELETE("/:id", a.planController.DeletePlan) // 删除计划
planGroup.POST("/:id/start", a.planController.StartPlan) // 启动计划
planGroup.POST("/:id/stop", a.planController.StopPlan) // 停止计划
}
a.logger.Debug("计划相关接口注册成功 (需要认证和审计)")
// 猪舍相关路由组
pigHouseGroup := authGroup.Group("/pig-houses")
{
pigHouseGroup.POST("", a.pigFarmController.CreatePigHouse) // 创建猪舍
pigHouseGroup.GET("", a.pigFarmController.ListPigHouses) // 获取猪舍列表
pigHouseGroup.GET("/:id", a.pigFarmController.GetPigHouse) // 获取单个猪舍
pigHouseGroup.PUT("/:id", a.pigFarmController.UpdatePigHouse) // 更新猪舍
pigHouseGroup.DELETE("/:id", a.pigFarmController.DeletePigHouse) // 删除猪舍
}
a.logger.Debug("猪舍相关接口注册成功 (需要认证和审计)")
// 猪圈相关路由组
penGroup := authGroup.Group("/pens")
{
penGroup.POST("", a.pigFarmController.CreatePen) // 创建猪圈
penGroup.GET("", a.pigFarmController.ListPens) // 获取猪圈列表
penGroup.GET("/:id", a.pigFarmController.GetPen) // 获取单个猪圈
penGroup.PUT("/:id", a.pigFarmController.UpdatePen) // 更新猪圈
penGroup.DELETE("/:id", a.pigFarmController.DeletePen) // 删除猪圈
penGroup.PUT("/:id/status", a.pigFarmController.UpdatePenStatus) // 更新猪圈状态
}
a.logger.Debug("猪圈相关接口注册成功 (需要认证和审计)")
// 猪群相关路由组
pigBatchGroup := authGroup.Group("/pig-batches")
{
pigBatchGroup.POST("", a.pigBatchController.CreatePigBatch) // 创建猪群
pigBatchGroup.GET("", a.pigBatchController.ListPigBatches) // 获取猪群列表
pigBatchGroup.GET("/:id", a.pigBatchController.GetPigBatch) // 获取单个猪群
pigBatchGroup.PUT("/:id", a.pigBatchController.UpdatePigBatch) // 更新猪群
pigBatchGroup.DELETE("/:id", a.pigBatchController.DeletePigBatch) // 删除猪群
pigBatchGroup.POST("/assign-pens/:id", a.pigBatchController.AssignEmptyPensToBatch) // 为猪群分配空栏
pigBatchGroup.POST("/reclassify-pen/:fromBatchID", a.pigBatchController.ReclassifyPenToNewBatch) // 将猪栏划拨到新群
pigBatchGroup.DELETE("/remove-pen/:penID/:batchID", a.pigBatchController.RemoveEmptyPenFromBatch) // 从猪群移除空栏
pigBatchGroup.POST("/move-pigs-into-pen/:id", a.pigBatchController.MovePigsIntoPen) // 将猪只从“虚拟库存”移入指定猪栏
pigBatchGroup.POST("/sell-pigs/:id", a.pigBatchController.SellPigs) // 处理卖猪业务
pigBatchGroup.POST("/buy-pigs/:id", a.pigBatchController.BuyPigs) // 处理买猪业务
pigBatchGroup.POST("/transfer-across-batches/:sourceBatchID", a.pigBatchController.TransferPigsAcrossBatches) // 跨猪群调栏
pigBatchGroup.POST("/transfer-within-batch/:id", a.pigBatchController.TransferPigsWithinBatch) // 群内调栏
pigBatchGroup.POST("/record-sick-pigs/:id", a.pigBatchController.RecordSickPigs) // 记录新增病猪事件
pigBatchGroup.POST("/record-sick-pig-recovery/:id", a.pigBatchController.RecordSickPigRecovery) // 记录病猪康复事件
pigBatchGroup.POST("/record-sick-pig-death/:id", a.pigBatchController.RecordSickPigDeath) // 记录病猪死亡事件
pigBatchGroup.POST("/record-sick-pig-cull/:id", a.pigBatchController.RecordSickPigCull) // 记录病猪淘汰事件
pigBatchGroup.POST("/record-death/:id", a.pigBatchController.RecordDeath) // 记录正常猪只死亡事件
pigBatchGroup.POST("/record-cull/:id", a.pigBatchController.RecordCull) // 记录正常猪只淘汰事件
}
a.logger.Debug("猪群相关接口注册成功 (需要认证和审计)")
// 数据监控相关路由组
monitorGroup := authGroup.Group("/monitor")
{
monitorGroup.GET("/sensor-data", a.monitorController.ListSensorData)
monitorGroup.GET("/device-command-logs", a.monitorController.ListDeviceCommandLogs)
monitorGroup.GET("/plan-execution-logs", a.monitorController.ListPlanExecutionLogs)
monitorGroup.GET("/task-execution-logs", a.monitorController.ListTaskExecutionLogs)
monitorGroup.GET("/pending-collections", a.monitorController.ListPendingCollections)
monitorGroup.GET("/user-action-logs", a.monitorController.ListUserActionLogs)
monitorGroup.GET("/raw-material-purchases", a.monitorController.ListRawMaterialPurchases)
monitorGroup.GET("/raw-material-stock-logs", a.monitorController.ListRawMaterialStockLogs)
monitorGroup.GET("/feed-usage-records", a.monitorController.ListFeedUsageRecords)
monitorGroup.GET("/medication-logs", a.monitorController.ListMedicationLogs)
monitorGroup.GET("/pig-batch-logs", a.monitorController.ListPigBatchLogs)
monitorGroup.GET("/weighing-batches", a.monitorController.ListWeighingBatches)
monitorGroup.GET("/weighing-records", a.monitorController.ListWeighingRecords)
monitorGroup.GET("/pig-transfer-logs", a.monitorController.ListPigTransferLogs)
monitorGroup.GET("/pig-sick-logs", a.monitorController.ListPigSickLogs)
monitorGroup.GET("/pig-purchases", a.monitorController.ListPigPurchases)
monitorGroup.GET("/pig-sales", a.monitorController.ListPigSales)
monitorGroup.GET("/notifications", a.monitorController.ListNotifications)
}
a.logger.Debug("数据监控相关接口注册成功 (需要认证和审计)")
}
a.logger.Debug("所有接口注册成功")
}

View File

@@ -0,0 +1,47 @@
package controller
import (
"errors"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"github.com/labstack/echo/v4"
)
var (
// ErrUserNotFoundInContext 表示在 context 中未找到用户信息。
ErrUserNotFoundInContext = errors.New("context中未找到用户信息")
// ErrInvalidUserType 表示从 context 中获取的用户信息类型不正确。
ErrInvalidUserType = errors.New("context中用户信息类型不正确")
)
// GetOperatorIDFromContext 从 echo.Context 中提取操作者ID。
// 假设操作者ID是由 AuthMiddleware 存储到 context 中的 *models.User 对象的 ID 字段。
func GetOperatorIDFromContext(c echo.Context) (uint, error) {
userVal := c.Get(models.ContextUserKey.String())
if userVal == nil {
return 0, ErrUserNotFoundInContext
}
user, ok := userVal.(*models.User)
if !ok {
return 0, ErrInvalidUserType
}
return user.ID, nil
}
// GetOperatorFromContext 从 echo.Context 中提取操作者。
// 假设操作者是由 AuthMiddleware 存储到 context 中的 *models.User 对象的字段。
func GetOperatorFromContext(c echo.Context) (*models.User, error) {
userVal := c.Get(models.ContextUserKey.String())
if userVal == nil {
return nil, ErrUserNotFoundInContext
}
user, ok := userVal.(*models.User)
if !ok {
return nil, ErrInvalidUserType
}
return user, nil
}

View File

@@ -0,0 +1,552 @@
package device
import (
"errors"
"strconv"
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller"
"git.huangwc.com/pig/pig-farm-controller/internal/app/dto"
"git.huangwc.com/pig/pig-farm-controller/internal/app/service"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
)
// Controller 设备控制器,封装了所有与设备和区域主控相关的业务逻辑
type Controller struct {
deviceService service.DeviceService
logger *logs.Logger
}
// NewController 创建一个新的设备控制器实例
func NewController(
deviceService service.DeviceService,
logger *logs.Logger,
) *Controller {
return &Controller{
deviceService: deviceService,
logger: logger,
}
}
// --- Controller Methods: Devices ---
// CreateDevice godoc
// @Summary 创建新设备
// @Description 根据提供的信息创建一个新设备
// @Tags 设备管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param device body dto.CreateDeviceRequest true "设备信息"
// @Success 200 {object} controller.Response{data=dto.DeviceResponse}
// @Router /api/v1/devices [post]
func (c *Controller) CreateDevice(ctx echo.Context) error {
const actionType = "创建设备"
var req dto.CreateDeviceRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
resp, err := c.deviceService.CreateDevice(&req)
if err != nil {
c.logger.Errorf("%s: 服务层创建失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建设备失败: "+err.Error(), actionType, "服务层创建失败", req)
}
c.logger.Infof("%s: 设备创建成功, ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "设备创建成功", resp, actionType, "设备创建成功", resp)
}
// GetDevice godoc
// @Summary 获取设备信息
// @Description 根据设备ID获取单个设备的详细信息
// @Tags 设备管理
// @Security BearerAuth
// @Produce json
// @Param id path string true "设备ID"
// @Success 200 {object} controller.Response{data=dto.DeviceResponse}
// @Router /api/v1/devices/{id} [get]
func (c *Controller) GetDevice(ctx echo.Context) error {
const actionType = "获取设备"
deviceID := ctx.Param("id")
id, err := strconv.ParseUint(deviceID, 10, 64)
if err != nil {
c.logger.Errorf("%s: 无效的ID: %s", actionType, deviceID)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID: "+deviceID, actionType, "无效的ID", deviceID)
}
resp, err := c.deviceService.GetDevice(uint(id))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.logger.Warnf("%s: 设备不存在, ID: %s", actionType, deviceID)
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备未找到", actionType, "设备不存在", deviceID)
}
c.logger.Errorf("%s: 服务层获取失败: %v, ID: %s", actionType, err, deviceID)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备信息失败: "+err.Error(), actionType, "服务层获取失败", deviceID)
}
c.logger.Infof("%s: 获取设备信息成功, ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取设备信息成功", resp, actionType, "获取设备信息成功", resp)
}
// ListDevices godoc
// @Summary 获取设备列表
// @Description 获取系统中所有设备的列表
// @Tags 设备管理
// @Security BearerAuth
// @Produce json
// @Success 200 {object} controller.Response{data=[]dto.DeviceResponse}
// @Router /api/v1/devices [get]
func (c *Controller) ListDevices(ctx echo.Context) error {
const actionType = "获取设备列表"
resp, err := c.deviceService.ListDevices()
if err != nil {
c.logger.Errorf("%s: 服务层获取列表失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备列表失败: "+err.Error(), actionType, "服务层获取列表失败", nil)
}
c.logger.Infof("%s: 获取设备列表成功, 数量: %d", actionType, len(resp))
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取设备列表成功", resp, actionType, "获取设备列表成功", resp)
}
// UpdateDevice godoc
// @Summary 更新设备信息
// @Description 根据设备ID更新一个已存在的设备信息
// @Tags 设备管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path string true "设备ID"
// @Param device body dto.UpdateDeviceRequest true "要更新的设备信息"
// @Success 200 {object} controller.Response{data=dto.DeviceResponse}
// @Router /api/v1/devices/{id} [put]
func (c *Controller) UpdateDevice(ctx echo.Context) error {
const actionType = "更新设备"
deviceID := ctx.Param("id")
var req dto.UpdateDeviceRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
id, err := strconv.ParseUint(deviceID, 10, 64)
if err != nil {
c.logger.Errorf("%s: 无效的ID: %s", actionType, deviceID)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID: "+deviceID, actionType, "无效的ID", deviceID)
}
resp, err := c.deviceService.UpdateDevice(uint(id), &req)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.logger.Warnf("%s: 设备不存在, ID: %s", actionType, deviceID)
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备未找到", actionType, "设备不存在", deviceID)
}
c.logger.Errorf("%s: 服务层更新失败: %v, ID: %s", actionType, err, deviceID)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新设备失败: "+err.Error(), actionType, "服务层更新失败", deviceID)
}
c.logger.Infof("%s: 设备更新成功, ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "设备更新成功", resp, actionType, "设备更新成功", resp)
}
// DeleteDevice godoc
// @Summary 删除设备
// @Description 根据设备ID删除一个设备软删除
// @Tags 设备管理
// @Security BearerAuth
// @Produce json
// @Param id path string true "设备ID"
// @Success 200 {object} controller.Response
// @Router /api/v1/devices/{id} [delete]
func (c *Controller) DeleteDevice(ctx echo.Context) error {
const actionType = "删除设备"
deviceID := ctx.Param("id")
id, err := strconv.ParseUint(deviceID, 10, 64)
if err != nil {
c.logger.Errorf("%s: 无效的ID: %s", actionType, deviceID)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID: "+deviceID, actionType, "无效的ID", deviceID)
}
if err := c.deviceService.DeleteDevice(uint(id)); err != nil {
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
c.logger.Warnf("%s: 设备不存在, ID: %s", actionType, deviceID)
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备未找到", actionType, "设备不存在", deviceID)
case errors.Is(err, service.ErrDeviceInUse):
c.logger.Warnf("%s: 尝试删除正在被使用的设备, ID: %s", actionType, deviceID)
// 返回 409 Conflict 状态码,表示请求与服务器当前状态冲突
return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), actionType, "设备正在被使用", deviceID)
default:
c.logger.Errorf("%s: 服务层删除失败: %v, ID: %s", actionType, err, deviceID)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除设备失败: "+err.Error(), actionType, "服务层删除失败", deviceID)
}
}
c.logger.Infof("%s: 设备删除成功, ID: %s", actionType, deviceID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "设备删除成功", nil, actionType, "设备删除成功", deviceID)
}
// ManualControl godoc
// @Summary 手动控制设备
// @Description 根据设备ID和指定的动作开启或关闭来手动控制设备
// @Tags 设备管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path string true "设备ID"
// @Param manualControl body dto.ManualControlDeviceRequest true "手动控制指令"
// @Success 200 {object} controller.Response
// @Router /api/v1/devices/manual-control/{id} [post]
func (c *Controller) ManualControl(ctx echo.Context) error {
const actionType = "手动控制设备"
deviceID := ctx.Param("id")
var req dto.ManualControlDeviceRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
id, err := strconv.ParseUint(deviceID, 10, 64)
if err != nil {
c.logger.Errorf("%s: 无效的ID: %s", actionType, deviceID)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID: "+deviceID, actionType, "无效的ID", deviceID)
}
if err := c.deviceService.ManualControl(uint(id), &req); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.logger.Warnf("%s: 设备不存在, ID: %s", actionType, deviceID)
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备未找到", actionType, "设备不存在", deviceID)
}
c.logger.Errorf("%s: 服务层手动控制失败: %v, ID: %s", actionType, err, deviceID)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "手动控制失败: "+err.Error(), actionType, "服务层手动控制失败", deviceID)
}
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "指令已发送", nil, actionType, "指令发送成功", nil)
}
// --- Controller Methods: Area Controllers ---
// CreateAreaController godoc
// @Summary 创建新区域主控
// @Description 根据提供的信息创建一个新区域主控
// @Tags 区域主控管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param areaController body dto.CreateAreaControllerRequest true "区域主控信息"
// @Success 200 {object} controller.Response{data=dto.AreaControllerResponse}
// @Router /api/v1/area-controllers [post]
func (c *Controller) CreateAreaController(ctx echo.Context) error {
const actionType = "创建区域主控"
var req dto.CreateAreaControllerRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
resp, err := c.deviceService.CreateAreaController(&req)
if err != nil {
c.logger.Errorf("%s: 服务层创建失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建区域主控失败: "+err.Error(), actionType, "服务层创建失败", req)
}
c.logger.Infof("%s: 区域主控创建成功, ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "区域主控创建成功", resp, actionType, "区域主控创建成功", resp)
}
// GetAreaController godoc
// @Summary 获取区域主控信息
// @Description 根据ID获取单个区域主控的详细信息
// @Tags 区域主控管理
// @Security BearerAuth
// @Produce json
// @Param id path string true "区域主控ID"
// @Success 200 {object} controller.Response{data=dto.AreaControllerResponse}
// @Router /api/v1/area-controllers/{id} [get]
func (c *Controller) GetAreaController(ctx echo.Context) error {
const actionType = "获取区域主控"
acID := ctx.Param("id")
id, err := strconv.ParseUint(acID, 10, 64)
if err != nil {
c.logger.Errorf("%s: 无效的ID: %s", actionType, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID: "+acID, actionType, "无效的ID", acID)
}
resp, err := c.deviceService.GetAreaController(uint(id))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.logger.Warnf("%s: 区域主控不存在, ID: %s", actionType, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "区域主控未找到", actionType, "区域主控不存在", acID)
}
c.logger.Errorf("%s: 服务层获取失败: %v, ID: %s", actionType, err, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取区域主控信息失败: "+err.Error(), actionType, "服务层获取失败", acID)
}
c.logger.Infof("%s: 获取区域主控信息成功, ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取区域主控信息成功", resp, actionType, "获取区域主控信息成功", resp)
}
// ListAreaControllers godoc
// @Summary 获取所有区域主控列表
// @Description 获取系统中所有区域主控的列表
// @Tags 区域主控管理
// @Security BearerAuth
// @Produce json
// @Success 200 {object} controller.Response{data=[]dto.AreaControllerResponse}
// @Router /api/v1/area-controllers [get]
func (c *Controller) ListAreaControllers(ctx echo.Context) error {
const actionType = "获取区域主控列表"
resp, err := c.deviceService.ListAreaControllers()
if err != nil {
c.logger.Errorf("%s: 服务层获取列表失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取区域主控列表失败: "+err.Error(), actionType, "服务层获取列表失败", nil)
}
c.logger.Infof("%s: 获取区域主控列表成功, 数量: %d", actionType, len(resp))
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取区域主控列表成功", resp, actionType, "获取区域主控列表成功", resp)
}
// UpdateAreaController godoc
// @Summary 更新区域主控信息
// @Description 根据ID更新一个已存在的区域主控信息
// @Tags 区域主控管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path string true "区域主控ID"
// @Param areaController body dto.UpdateAreaControllerRequest true "要更新的区域主控信息"
// @Success 200 {object} controller.Response{data=dto.AreaControllerResponse}
// @Router /api/v1/area-controllers/{id} [put]
func (c *Controller) UpdateAreaController(ctx echo.Context) error {
const actionType = "更新区域主控"
acID := ctx.Param("id")
var req dto.UpdateAreaControllerRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
id, err := strconv.ParseUint(acID, 10, 64)
if err != nil {
c.logger.Errorf("%s: 无效的ID: %s", actionType, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID: "+acID, actionType, "无效的ID", acID)
}
resp, err := c.deviceService.UpdateAreaController(uint(id), &req)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.logger.Warnf("%s: 区域主控不存在, ID: %s", actionType, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "区域主控未找到", actionType, "区域主控不存在", acID)
}
c.logger.Errorf("%s: 服务层更新失败: %v, ID: %s", actionType, err, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新区域主控失败: "+err.Error(), actionType, "服务层更新失败", acID)
}
c.logger.Infof("%s: 区域主控更新成功, ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "区域主控更新成功", resp, actionType, "区域主控更新成功", resp)
}
// DeleteAreaController godoc
// @Summary 删除区域主控
// @Description 根据ID删除一个区域主控软删除
// @Tags 区域主控管理
// @Security BearerAuth
// @Produce json
// @Param id path string true "区域主控ID"
// @Success 200 {object} controller.Response
// @Router /api/v1/area-controllers/{id} [delete]
func (c *Controller) DeleteAreaController(ctx echo.Context) error {
const actionType = "删除区域主控"
acID := ctx.Param("id")
id, err := strconv.ParseUint(acID, 10, 64)
if err != nil {
c.logger.Errorf("%s: 无效的ID: %s", actionType, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID: "+acID, actionType, "无效的ID", acID)
}
if err := c.deviceService.DeleteAreaController(uint(id)); err != nil {
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
c.logger.Warnf("%s: 区域主控不存在, ID: %s", actionType, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "区域主控未找到", actionType, "区域主控不存在", acID)
case errors.Is(err, service.ErrAreaControllerInUse):
c.logger.Warnf("%s: 尝试删除正在被使用的主控, ID: %s", actionType, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), actionType, "主控正在被使用", acID)
default:
c.logger.Errorf("%s: 服务层删除失败: %v, ID: %s", actionType, err, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除区域主控失败: "+err.Error(), actionType, "服务层删除失败", acID)
}
}
c.logger.Infof("%s: 区域主控删除成功, ID: %s", actionType, acID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "区域主控删除成功", nil, actionType, "区域主控删除成功", acID)
}
// --- Controller Methods: Device Templates ---
// CreateDeviceTemplate godoc
// @Summary 创建新设备模板
// @Description 根据提供的信息创建一个新设备模板
// @Tags 设备模板管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param deviceTemplate body dto.CreateDeviceTemplateRequest true "设备模板信息"
// @Success 200 {object} controller.Response{data=dto.DeviceTemplateResponse}
// @Router /api/v1/device-templates [post]
func (c *Controller) CreateDeviceTemplate(ctx echo.Context) error {
const actionType = "创建设备模板"
var req dto.CreateDeviceTemplateRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
resp, err := c.deviceService.CreateDeviceTemplate(&req)
if err != nil {
c.logger.Errorf("%s: 服务层创建失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建设备模板失败: "+err.Error(), actionType, "服务层创建失败", req)
}
c.logger.Infof("%s: 设备模板创建成功, ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "设备模板创建成功", resp, actionType, "设备模板创建成功", resp)
}
// GetDeviceTemplate godoc
// @Summary 获取设备模板信息
// @Description 根据设备模板ID获取单个设备模板的详细信息
// @Tags 设备模板管理
// @Security BearerAuth
// @Produce json
// @Param id path string true "设备模板ID"
// @Success 200 {object} controller.Response{data=dto.DeviceTemplateResponse}
// @Router /api/v1/device-templates/{id} [get]
func (c *Controller) GetDeviceTemplate(ctx echo.Context) error {
const actionType = "获取设备模板"
dtID := ctx.Param("id")
id, err := strconv.ParseUint(dtID, 10, 64)
if err != nil {
c.logger.Errorf("%s: 无效的ID: %s", actionType, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID: "+dtID, actionType, "无效的ID", dtID)
}
resp, err := c.deviceService.GetDeviceTemplate(uint(id))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.logger.Warnf("%s: 设备模板不存在, ID: %s", actionType, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备模板未找到", actionType, "设备模板不存在", dtID)
}
c.logger.Errorf("%s: 服务层获取失败: %v, ID: %s", actionType, err, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备模板信息失败: "+err.Error(), actionType, "服务层获取失败", dtID)
}
c.logger.Infof("%s: 获取设备模板信息成功, ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取设备模板信息成功", resp, actionType, "获取设备模板信息成功", resp)
}
// ListDeviceTemplates godoc
// @Summary 获取设备模板列表
// @Description 获取系统中所有设备模板的列表
// @Tags 设备模板管理
// @Security BearerAuth
// @Produce json
// @Success 200 {object} controller.Response{data=[]dto.DeviceTemplateResponse}
// @Router /api/v1/device-templates [get]
func (c *Controller) ListDeviceTemplates(ctx echo.Context) error {
const actionType = "获取设备模板列表"
resp, err := c.deviceService.ListDeviceTemplates()
if err != nil {
c.logger.Errorf("%s: 服务层获取列表失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备模板列表失败: "+err.Error(), actionType, "服务层获取列表失败", nil)
}
c.logger.Infof("%s: 获取设备模板列表成功, 数量: %d", actionType, len(resp))
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取设备模板列表成功", resp, actionType, "获取设备模板列表成功", resp)
}
// UpdateDeviceTemplate godoc
// @Summary 更新设备模板信息
// @Description 根据设备模板ID更新一个已存在的设备模板信息
// @Tags 设备模板管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path string true "设备模板ID"
// @Param deviceTemplate body dto.UpdateDeviceTemplateRequest true "要更新的设备模板信息"
// @Success 200 {object} controller.Response{data=dto.DeviceTemplateResponse}
// @Router /api/v1/device-templates/{id} [put]
func (c *Controller) UpdateDeviceTemplate(ctx echo.Context) error {
const actionType = "更新设备模板"
dtID := ctx.Param("id")
var req dto.UpdateDeviceTemplateRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
id, err := strconv.ParseUint(dtID, 10, 64)
if err != nil {
c.logger.Errorf("%s: 无效的ID: %s", actionType, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID: "+dtID, actionType, "无效的ID", dtID)
}
resp, err := c.deviceService.UpdateDeviceTemplate(uint(id), &req)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.logger.Warnf("%s: 设备模板不存在, ID: %s", actionType, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备模板未找到", actionType, "设备模板不存在", dtID)
}
c.logger.Errorf("%s: 服务层更新失败: %v, ID: %s", actionType, err, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新设备模板失败: "+err.Error(), actionType, "服务层更新失败", dtID)
}
c.logger.Infof("%s: 设备模板更新成功, ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "设备模板更新成功", resp, actionType, "设备模板更新成功", resp)
}
// DeleteDeviceTemplate godoc
// @Summary 删除设备模板
// @Description 根据设备模板ID删除一个设备模板软删除
// @Tags 设备模板管理
// @Security BearerAuth
// @Produce json
// @Param id path string true "设备模板ID"
// @Success 200 {object} controller.Response
// @Router /api/v1/device-templates/{id} [delete]
func (c *Controller) DeleteDeviceTemplate(ctx echo.Context) error {
const actionType = "删除设备模板"
dtID := ctx.Param("id")
id, err := strconv.ParseUint(dtID, 10, 64)
if err != nil {
c.logger.Errorf("%s: 无效的ID: %s", actionType, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID: "+dtID, actionType, "无效的ID", dtID)
}
if err := c.deviceService.DeleteDeviceTemplate(uint(id)); err != nil {
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
c.logger.Warnf("%s: 设备模板不存在, ID: %s", actionType, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备模板未找到", actionType, "设备模板不存在", dtID)
case errors.Is(err, service.ErrDeviceTemplateInUse):
c.logger.Warnf("%s: 尝试删除正在被使用的模板, ID: %s", actionType, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), actionType, "模板正在被使用", dtID)
default:
c.logger.Errorf("%s: 服务层删除失败: %v, ID: %s", actionType, err, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除设备模板失败: "+err.Error(), actionType, "服务层删除失败", dtID)
}
}
c.logger.Infof("%s: 设备模板删除成功, ID: %s", actionType, dtID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "设备模板删除成功", nil, actionType, "设备模板删除成功", dtID)
}

View File

@@ -0,0 +1,227 @@
package management
import (
"errors"
"fmt"
"strconv"
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller"
"git.huangwc.com/pig/pig-farm-controller/internal/app/service"
"github.com/labstack/echo/v4"
)
// mapAndSendError 统一映射服务层错误并发送响应。
// 这个函数将服务层返回的错误转换为控制器层应返回的HTTP状态码和审计信息。
func mapAndSendError(c *PigBatchController, ctx echo.Context, action string, err error, id uint) error {
if errors.Is(err, service.ErrPigBatchNotFound) ||
errors.Is(err, service.ErrPenNotFound) ||
errors.Is(err, service.ErrPenNotAssociatedWithBatch) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), action, err.Error(), id)
} else if errors.Is(err, service.ErrInvalidOperation) ||
errors.Is(err, service.ErrPigBatchActive) ||
errors.Is(err, service.ErrPigBatchNotActive) ||
errors.Is(err, service.ErrPenOccupiedByOtherBatch) ||
errors.Is(err, service.ErrPenStatusInvalidForAllocation) ||
errors.Is(err, service.ErrPenNotEmpty) {
return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), action, err.Error(), id)
} else {
c.logger.Errorf("操作[%s]业务逻辑失败: %v", action, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, fmt.Sprintf("操作失败: %v", err), action, err.Error(), id)
}
}
// idExtractorFunc 定义了一个函数类型用于从echo.Context中提取主ID。
type idExtractorFunc func(ctx echo.Context) (uint, error)
// extractOperatorAndPrimaryID 封装了从echo.Context中提取操作员ID和主ID的通用逻辑。
// 它负责处理ID提取过程中的错误并发送相应的HTTP响应。
//
// 参数:
//
// c: *PigBatchController - 控制器实例,用于访问其日志。
// ctx: echo.Context - Echo上下文。
// action: string - 当前操作的描述,用于日志和审计。
// idExtractor: idExtractorFunc - 可选函数用于从ctx中提取主ID。如果为nil则尝试从":id"路径参数中提取。
//
// 返回值:
//
// operatorID: uint - 提取到的操作员ID。
// primaryID: uint - 提取到的主ID。
// err: error - 如果ID提取失败或发送错误响应则返回错误。
func extractOperatorAndPrimaryID(
c *PigBatchController,
ctx echo.Context,
action string,
idExtractor idExtractorFunc,
) (operatorID uint, primaryID uint, err error) {
// 1. 获取操作员ID
operatorID, err = controller.GetOperatorIDFromContext(ctx)
if err != nil {
return 0, 0, controller.SendErrorWithAudit(ctx, controller.CodeUnauthorized, "未授权", action, "无法获取操作员ID", nil)
}
// 2. 提取主ID
if idExtractor != nil {
primaryID, err = idExtractor(ctx)
if err != nil {
return 0, 0, controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", err.Error())
}
} else { // 默认从 ":id" 路径参数提取
idParam := ctx.Param("id")
if idParam == "" { // 有些端点可能没有 "id" 参数,例如列表或创建操作
// 如果没有ID参数且没有自定义提取器primaryID保持为0这对于某些操作是可接受的
} else {
parsedID, err := strconv.ParseUint(idParam, 10, 32)
if err != nil {
return 0, 0, controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", idParam)
}
primaryID = uint(parsedID)
}
}
return operatorID, primaryID, nil
}
// handleAPIRequest 封装了控制器中处理带有请求体和路径参数的API请求的通用逻辑。
// 它负责请求体绑定、操作员ID获取、服务层调用、错误映射和响应发送。
func handleAPIRequest[Req any](
c *PigBatchController,
ctx echo.Context,
action string,
reqDTO Req,
serviceExecutor func(ctx echo.Context, operatorID uint, primaryID uint, req Req) error,
successMsg string,
idExtractor idExtractorFunc,
) error {
// 1. 绑定请求体
if err := ctx.Bind(&reqDTO); err != nil {
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", reqDTO)
}
// 2. 提取操作员ID和主ID
operatorID, primaryID, err := extractOperatorAndPrimaryID(c, ctx, action, idExtractor)
if err != nil {
return err // 错误已在 extractOperatorAndPrimaryID 中处理
}
// 3. 执行服务层逻辑
err = serviceExecutor(ctx, operatorID, primaryID, reqDTO)
if err != nil {
return mapAndSendError(c, ctx, action, err, primaryID)
}
// 4. 发送成功响应
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, successMsg, nil, action, successMsg, primaryID)
}
// handleNoBodyAPIRequest 封装了处理不带请求体但有路径参数和操作员ID的API请求的通用逻辑。
func handleNoBodyAPIRequest(
c *PigBatchController,
ctx echo.Context,
action string,
serviceExecutor func(ctx echo.Context, operatorID uint, primaryID uint) error,
successMsg string,
idExtractor idExtractorFunc,
) error {
// 1. 提取操作员ID和主ID
operatorID, primaryID, err := extractOperatorAndPrimaryID(c, ctx, action, idExtractor)
if err != nil {
return err // 错误已在 extractOperatorAndPrimaryID 中处理
}
// 2. 执行服务层逻辑
err = serviceExecutor(ctx, operatorID, primaryID)
if err != nil {
return mapAndSendError(c, ctx, action, err, primaryID)
}
// 3. 发送成功响应
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, successMsg, nil, action, successMsg, primaryID)
}
// handleAPIRequestWithResponse 封装了控制器中处理带有请求体、路径参数并返回响应DTO的API请求的通用逻辑。
func handleAPIRequestWithResponse[Req any, Resp any](
c *PigBatchController,
ctx echo.Context,
action string,
reqDTO Req,
serviceExecutor func(ctx echo.Context, operatorID uint, primaryID uint, req Req) (Resp, error), // serviceExecutor现在返回Resp
successMsg string,
idExtractor idExtractorFunc,
) error {
// 1. 绑定请求体
if err := ctx.Bind(&reqDTO); err != nil {
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, fmt.Sprintf("无效的请求体: %v", err), action, fmt.Sprintf("请求体绑定失败: %v", err), reqDTO)
}
// 2. 提取操作员ID和主ID
operatorID, primaryID, err := extractOperatorAndPrimaryID(c, ctx, action, idExtractor)
if err != nil {
return err // 错误已在 extractOperatorAndPrimaryID 中处理
}
// 3. 执行服务层逻辑
respDTO, err := serviceExecutor(ctx, operatorID, primaryID, reqDTO)
if err != nil {
return mapAndSendError(c, ctx, action, err, primaryID)
}
// 4. 发送成功响应
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, successMsg, respDTO, action, successMsg, primaryID)
}
// handleNoBodyAPIRequestWithResponse 封装了处理不带请求体但有路径参数和操作员ID并返回响应DTO的API请求的通用逻辑。
func handleNoBodyAPIRequestWithResponse[Resp any](
c *PigBatchController,
ctx echo.Context,
action string,
serviceExecutor func(ctx echo.Context, operatorID uint, primaryID uint) (Resp, error), // serviceExecutor现在返回Resp
successMsg string,
idExtractor idExtractorFunc,
) error {
// 1. 提取操作员ID和主ID
operatorID, primaryID, err := extractOperatorAndPrimaryID(c, ctx, action, idExtractor)
if err != nil {
return err // 错误已在 extractOperatorAndPrimaryID 中处理
}
// 2. 执行服务层逻辑
respDTO, err := serviceExecutor(ctx, operatorID, primaryID)
if err != nil {
return mapAndSendError(c, ctx, action, err, primaryID)
}
// 3. 发送成功响应
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, successMsg, respDTO, action, successMsg, primaryID)
}
// handleQueryAPIRequestWithResponse 封装了处理带有查询参数并返回响应DTO的API请求的通用逻辑。
func handleQueryAPIRequestWithResponse[Query any, Resp any](
c *PigBatchController,
ctx echo.Context,
action string,
queryDTO Query,
serviceExecutor func(ctx echo.Context, operatorID uint, query Query) (Resp, error), // serviceExecutor现在接收queryDTO
successMsg string,
) error {
// 1. 绑定查询参数
if err := ctx.Bind(&queryDTO); err != nil {
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数", action, "查询参数绑定失败", queryDTO)
}
// 2. 获取操作员ID
operatorID, err := controller.GetOperatorIDFromContext(ctx)
if err != nil {
return controller.SendErrorWithAudit(ctx, controller.CodeUnauthorized, "未授权", action, "无法获取操作员ID", nil)
}
// 3. 执行服务层逻辑
respDTO, err := serviceExecutor(ctx, operatorID, queryDTO)
if err != nil {
// 对于列表查询通常没有primaryID所以传递0
return mapAndSendError(c, ctx, action, err, 0)
}
// 4. 发送成功响应
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, successMsg, respDTO, action, successMsg, nil)
}

View File

@@ -0,0 +1,260 @@
package management
import (
"strconv"
"git.huangwc.com/pig/pig-farm-controller/internal/app/dto"
"git.huangwc.com/pig/pig-farm-controller/internal/app/service"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"github.com/labstack/echo/v4"
)
// PigBatchController 负责处理猪批次相关的API请求
type PigBatchController struct {
logger *logs.Logger
service service.PigBatchService
}
// NewPigBatchController 创建一个新的 PigBatchController 实例
func NewPigBatchController(logger *logs.Logger, service service.PigBatchService) *PigBatchController {
return &PigBatchController{
logger: logger,
service: service,
}
}
// CreatePigBatch godoc
// @Summary 创建猪批次
// @Description 创建一个新的猪批次
// @Tags 猪群管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param body body dto.PigBatchCreateDTO true "猪批次信息"
// @Success 201 {object} controller.Response{data=dto.PigBatchResponseDTO} "创建成功"
// @Router /api/v1/pig-batches [post]
func (c *PigBatchController) CreatePigBatch(ctx echo.Context) error {
const action = "创建猪批次"
var req dto.PigBatchCreateDTO
return handleAPIRequestWithResponse(
c, ctx, action, &req,
func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.PigBatchCreateDTO) (*dto.PigBatchResponseDTO, error) {
// 对于创建操作primaryID通常不从路径中获取而是由服务层生成
return c.service.CreatePigBatch(operatorID, req)
},
"创建成功",
nil, // 无需自定义ID提取器primaryID将为0
)
}
// GetPigBatch godoc
// @Summary 获取单个猪批次
// @Description 根据ID获取单个猪批次信息
// @Tags 猪群管理
// @Security BearerAuth
// @Produce json
// @Param id path int true "猪批次ID"
// @Success 200 {object} controller.Response{data=dto.PigBatchResponseDTO} "获取成功"
// @Router /api/v1/pig-batches/{id} [get]
func (c *PigBatchController) GetPigBatch(ctx echo.Context) error {
const action = "获取猪批次"
return handleNoBodyAPIRequestWithResponse(
c, ctx, action,
func(ctx echo.Context, operatorID uint, primaryID uint) (*dto.PigBatchResponseDTO, error) {
return c.service.GetPigBatch(primaryID)
},
"获取成功",
nil, // 默认从 ":id" 路径参数提取ID
)
}
// UpdatePigBatch godoc
// @Summary 更新猪批次
// @Description 更新一个已存在的猪批次信息
// @Tags 猪群管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path int true "猪批次ID"
// @Param body body dto.PigBatchUpdateDTO true "猪批次信息"
// @Success 200 {object} controller.Response{data=dto.PigBatchResponseDTO} "更新成功"
// @Router /api/v1/pig-batches/{id} [put]
func (c *PigBatchController) UpdatePigBatch(ctx echo.Context) error {
const action = "更新猪批次"
var req dto.PigBatchUpdateDTO
return handleAPIRequestWithResponse(
c, ctx, action, &req,
func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.PigBatchUpdateDTO) (*dto.PigBatchResponseDTO, error) {
return c.service.UpdatePigBatch(primaryID, req)
},
"更新成功",
nil, // 默认从 ":id" 路径参数提取ID
)
}
// DeletePigBatch godoc
// @Summary 删除猪批次
// @Description 根据ID删除一个猪批次
// @Tags 猪群管理
// @Security BearerAuth
// @Produce json
// @Param id path int true "猪批次ID"
// @Success 200 {object} controller.Response "删除成功"
// @Router /api/v1/pig-batches/{id} [delete]
func (c *PigBatchController) DeletePigBatch(ctx echo.Context) error {
const action = "删除猪批次"
return handleNoBodyAPIRequest(
c, ctx, action,
func(ctx echo.Context, operatorID uint, primaryID uint) error {
return c.service.DeletePigBatch(primaryID)
},
"删除成功",
nil, // 默认从 ":id" 路径参数提取ID
)
}
// ListPigBatches godoc
// @Summary 获取猪批次列表
// @Description 获取所有猪批次的列表,支持按活跃状态筛选
// @Tags 猪群管理
// @Security BearerAuth
// @Produce json
// @Param is_active query bool false "是否活跃 (true/false)"
// @Success 200 {object} controller.Response{data=[]dto.PigBatchResponseDTO} "获取成功"
// @Router /api/v1/pig-batches [get]
func (c *PigBatchController) ListPigBatches(ctx echo.Context) error {
const action = "获取猪批次列表"
var query dto.PigBatchQueryDTO
return handleQueryAPIRequestWithResponse(
c, ctx, action, &query,
func(ctx echo.Context, operatorID uint, query *dto.PigBatchQueryDTO) ([]*dto.PigBatchResponseDTO, error) {
return c.service.ListPigBatches(query.IsActive)
},
"获取成功",
)
}
// AssignEmptyPensToBatch godoc
// @Summary 为猪批次分配空栏
// @Description 将一个或多个空闲猪栏分配给指定的猪批次
// @Tags 猪群管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path int true "猪批次ID"
// @Param body body dto.AssignEmptyPensToBatchRequest true "待分配的猪栏ID列表"
// @Success 200 {object} controller.Response "分配成功"
// @Router /api/v1/pig-batches/assign-pens/{id} [post]
func (c *PigBatchController) AssignEmptyPensToBatch(ctx echo.Context) error {
const action = "为猪批次分配空栏"
var req dto.AssignEmptyPensToBatchRequest
return handleAPIRequest(
c, ctx, action, &req,
func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.AssignEmptyPensToBatchRequest) error {
return c.service.AssignEmptyPensToBatch(primaryID, req.PenIDs, operatorID)
},
"分配成功",
nil, // 默认从 ":id" 路径参数提取ID
)
}
// ReclassifyPenToNewBatch godoc
// @Summary 将猪栏划拨到新批次
// @Description 将一个猪栏(连同其中的猪只)从一个批次整体划拨到另一个批次
// @Tags 猪群管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param fromBatchID path int true "源猪批次ID"
// @Param body body dto.ReclassifyPenToNewBatchRequest true "划拨请求信息 (包含目标批次ID、猪栏ID和备注)"
// @Success 200 {object} controller.Response "划拨成功"
// @Router /api/v1/pig-batches/reclassify-pen/{fromBatchID} [post]
func (c *PigBatchController) ReclassifyPenToNewBatch(ctx echo.Context) error {
const action = "划拨猪栏到新批次"
var req dto.ReclassifyPenToNewBatchRequest
return handleAPIRequest(
c, ctx, action, &req,
func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.ReclassifyPenToNewBatchRequest) error {
// primaryID 在这里是 fromBatchID
return c.service.ReclassifyPenToNewBatch(primaryID, req.ToBatchID, req.PenID, operatorID, req.Remarks)
},
"划拨成功",
func(ctx echo.Context) (uint, error) { // 自定义ID提取器从 ":fromBatchID" 路径参数提取
idParam := ctx.Param("fromBatchID")
parsedID, err := strconv.ParseUint(idParam, 10, 32)
if err != nil {
return 0, err
}
return uint(parsedID), nil
},
)
}
// RemoveEmptyPenFromBatch godoc
// @Summary 从猪批次移除空栏
// @Description 将一个空闲猪栏从指定的猪批次中移除
// @Tags 猪群管理
// @Security BearerAuth
// @Produce json
// @Param batchID path int true "猪批次ID"
// @Param penID path int true "待移除的猪栏ID"
// @Success 200 {object} controller.Response "移除成功"
// @Router /api/v1/pig-batches/remove-pen/{penID}/{batchID} [delete]
func (c *PigBatchController) RemoveEmptyPenFromBatch(ctx echo.Context) error {
const action = "从猪批次移除空栏"
return handleNoBodyAPIRequest(
c, ctx, action,
func(ctx echo.Context, operatorID uint, primaryID uint) error {
// primaryID 在这里是 batchID
penIDParam := ctx.Param("penID")
parsedPenID, err := strconv.ParseUint(penIDParam, 10, 32)
if err != nil {
return err // 返回错误,因为 penID 格式无效
}
return c.service.RemoveEmptyPenFromBatch(primaryID, uint(parsedPenID))
},
"移除成功",
func(ctx echo.Context) (uint, error) { // 自定义ID提取器从 ":batchID" 路径参数提取
idParam := ctx.Param("batchID")
parsedID, err := strconv.ParseUint(idParam, 10, 32)
if err != nil {
return 0, err
}
return uint(parsedID), nil
},
)
}
// MovePigsIntoPen godoc
// @Summary 将猪只从“虚拟库存”移入指定猪栏
// @Description 将指定数量的猪只从批次的“虚拟库存”移入一个已分配的猪栏
// @Tags 猪群管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path int true "猪批次ID"
// @Param body body dto.MovePigsIntoPenRequest true "移入猪只请求信息 (包含目标猪栏ID、数量和备注)"
// @Success 200 {object} controller.Response "移入成功"
// @Router /api/v1/pig-batches/move-pigs-into-pen/{id} [post]
func (c *PigBatchController) MovePigsIntoPen(ctx echo.Context) error {
const action = "将猪只移入猪栏"
var req dto.MovePigsIntoPenRequest
return handleAPIRequest(
c, ctx, action, &req,
func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.MovePigsIntoPenRequest) error {
return c.service.MovePigsIntoPen(primaryID, req.ToPenID, req.Quantity, operatorID, req.Remarks)
},
"移入成功",
nil, // 默认从 ":id" 路径参数提取ID
)
}

View File

@@ -0,0 +1,156 @@
package management
import (
"git.huangwc.com/pig/pig-farm-controller/internal/app/dto"
"github.com/labstack/echo/v4"
)
// RecordSickPigs godoc
// @Summary 记录新增病猪事件
// @Description 记录猪批次中新增病猪的数量、治疗地点和发生时间
// @Tags 猪群管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path int true "猪批次ID"
// @Param body body dto.RecordSickPigsRequest true "记录病猪请求信息"
// @Success 200 {object} controller.Response "记录成功"
// @Router /api/v1/pig-batches/record-sick-pigs/{id} [post]
func (c *PigBatchController) RecordSickPigs(ctx echo.Context) error {
const action = "记录新增病猪事件"
var req dto.RecordSickPigsRequest
return handleAPIRequest(
c, ctx, action, &req,
func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.RecordSickPigsRequest) error {
return c.service.RecordSickPigs(operatorID, primaryID, req.PenID, req.Quantity, req.TreatmentLocation, req.HappenedAt, req.Remarks)
},
"记录成功",
nil, // 默认从 ":id" 路径参数提取ID
)
}
// RecordSickPigRecovery godoc
// @Summary 记录病猪康复事件
// @Description 记录猪批次中病猪康复的数量、治疗地点和发生时间
// @Tags 猪群管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path int true "猪批次ID"
// @Param body body dto.RecordSickPigRecoveryRequest true "记录病猪康复请求信息"
// @Success 200 {object} controller.Response "记录成功"
// @Router /api/v1/pig-batches/record-sick-pig-recovery/{id} [post]
func (c *PigBatchController) RecordSickPigRecovery(ctx echo.Context) error {
const action = "记录病猪康复事件"
var req dto.RecordSickPigRecoveryRequest
return handleAPIRequest(
c, ctx, action, &req,
func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.RecordSickPigRecoveryRequest) error {
return c.service.RecordSickPigRecovery(operatorID, primaryID, req.PenID, req.Quantity, req.TreatmentLocation, req.HappenedAt, req.Remarks)
},
"记录成功",
nil, // 默认从 ":id" 路径参数提取ID
)
}
// RecordSickPigDeath godoc
// @Summary 记录病猪死亡事件
// @Description 记录猪批次中病猪死亡的数量、治疗地点和发生时间
// @Tags 猪群管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path int true "猪批次ID"
// @Param body body dto.RecordSickPigDeathRequest true "记录病猪死亡请求信息"
// @Success 200 {object} controller.Response "记录成功"
// @Router /api/v1/pig-batches/record-sick-pig-death/{id} [post]
func (c *PigBatchController) RecordSickPigDeath(ctx echo.Context) error {
const action = "记录病猪死亡事件"
var req dto.RecordSickPigDeathRequest
return handleAPIRequest(
c, ctx, action, &req,
func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.RecordSickPigDeathRequest) error {
return c.service.RecordSickPigDeath(operatorID, primaryID, req.PenID, req.Quantity, req.TreatmentLocation, req.HappenedAt, req.Remarks)
},
"记录成功",
nil, // 默认从 ":id" 路径参数提取ID
)
}
// RecordSickPigCull godoc
// @Summary 记录病猪淘汰事件
// @Description 记录猪批次中病猪淘汰的数量、治疗地点和发生时间
// @Tags 猪群管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path int true "猪批次ID"
// @Param body body dto.RecordSickPigCullRequest true "记录病猪淘汰请求信息"
// @Success 200 {object} controller.Response "记录成功"
// @Router /api/v1/pig-batches/record-sick-pig-cull/{id} [post]
func (c *PigBatchController) RecordSickPigCull(ctx echo.Context) error {
const action = "记录病猪淘汰事件"
var req dto.RecordSickPigCullRequest
return handleAPIRequest(
c, ctx, action, &req,
func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.RecordSickPigCullRequest) error {
return c.service.RecordSickPigCull(operatorID, primaryID, req.PenID, req.Quantity, req.TreatmentLocation, req.HappenedAt, req.Remarks)
},
"记录成功",
nil, // 默认从 ":id" 路径参数提取ID
)
}
// RecordDeath godoc
// @Summary 记录正常猪只死亡事件
// @Description 记录猪批次中正常猪只死亡的数量和发生时间
// @Tags 猪群管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path int true "猪批次ID"
// @Param body body dto.RecordDeathRequest true "记录正常猪只死亡请求信息"
// @Success 200 {object} controller.Response "记录成功"
// @Router /api/v1/pig-batches/record-death/{id} [post]
func (c *PigBatchController) RecordDeath(ctx echo.Context) error {
const action = "记录正常猪只死亡事件"
var req dto.RecordDeathRequest
return handleAPIRequest(
c, ctx, action, &req,
func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.RecordDeathRequest) error {
return c.service.RecordDeath(operatorID, primaryID, req.PenID, req.Quantity, req.HappenedAt, req.Remarks)
},
"记录成功",
nil, // 默认从 ":id" 路径参数提取ID
)
}
// RecordCull godoc
// @Summary 记录正常猪只淘汰事件
// @Description 记录猪批次中正常猪只淘汰的数量和发生时间
// @Tags 猪群管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path int true "猪批次ID"
// @Param body body dto.RecordCullRequest true "记录正常猪只淘汰请求信息"
// @Success 200 {object} controller.Response "记录成功"
// @Router /api/v1/pig-batches/record-cull/{id} [post]
func (c *PigBatchController) RecordCull(ctx echo.Context) error {
const action = "记录正常猪只淘汰事件"
var req dto.RecordCullRequest
return handleAPIRequest(
c, ctx, action, &req,
func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.RecordCullRequest) error {
return c.service.RecordCull(operatorID, primaryID, req.PenID, req.Quantity, req.HappenedAt, req.Remarks)
},
"记录成功",
nil, // 默认从 ":id" 路径参数提取ID
)
}

View File

@@ -0,0 +1,56 @@
package management
import (
"git.huangwc.com/pig/pig-farm-controller/internal/app/dto"
"github.com/labstack/echo/v4"
)
// SellPigs godoc
// @Summary 处理卖猪的业务逻辑
// @Description 记录猪批次中的猪只出售事件
// @Tags 猪群管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path int true "猪批次ID"
// @Param body body dto.SellPigsRequest true "卖猪请求信息"
// @Success 200 {object} controller.Response "卖猪成功"
// @Router /api/v1/pig-batches/sell-pigs/{id} [post]
func (c *PigBatchController) SellPigs(ctx echo.Context) error {
const action = "卖猪"
var req dto.SellPigsRequest
return handleAPIRequest(
c, ctx, action, &req,
func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.SellPigsRequest) error {
return c.service.SellPigs(primaryID, req.PenID, req.Quantity, req.UnitPrice, req.TotalPrice, req.TraderName, req.TradeDate, req.Remarks, operatorID)
},
"卖猪成功",
nil, // 默认从 ":id" 路径参数提取ID
)
}
// BuyPigs godoc
// @Summary 处理买猪的业务逻辑
// @Description 记录猪批次中的猪只购买事件
// @Tags 猪群管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path int true "猪批次ID"
// @Param body body dto.BuyPigsRequest true "买猪请求信息"
// @Success 200 {object} controller.Response "买猪成功"
// @Router /api/v1/pig-batches/buy-pigs/{id} [post]
func (c *PigBatchController) BuyPigs(ctx echo.Context) error {
const action = "买猪"
var req dto.BuyPigsRequest
return handleAPIRequest(
c, ctx, action, &req,
func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.BuyPigsRequest) error {
return c.service.BuyPigs(primaryID, req.PenID, req.Quantity, req.UnitPrice, req.TotalPrice, req.TraderName, req.TradeDate, req.Remarks, operatorID)
},
"买猪成功",
nil, // 默认从 ":id" 路径参数提取ID
)
}

View File

@@ -0,0 +1,67 @@
package management
import (
"strconv"
"git.huangwc.com/pig/pig-farm-controller/internal/app/dto"
"github.com/labstack/echo/v4"
)
// TransferPigsAcrossBatches godoc
// @Summary 跨猪群调栏
// @Description 将指定数量的猪只从一个猪群的猪栏调动到另一个猪群的猪栏
// @Tags 猪群管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param sourceBatchID path int true "源猪批次ID"
// @Param body body dto.TransferPigsAcrossBatchesRequest true "跨群调栏请求信息"
// @Success 200 {object} controller.Response "调栏成功"
// @Router /api/v1/pig-batches/transfer-across-batches/{sourceBatchID} [post]
func (c *PigBatchController) TransferPigsAcrossBatches(ctx echo.Context) error {
const action = "跨猪群调栏"
var req dto.TransferPigsAcrossBatchesRequest
return handleAPIRequest(
c, ctx, action, &req,
func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.TransferPigsAcrossBatchesRequest) error {
// primaryID 在这里是 sourceBatchID
return c.service.TransferPigsAcrossBatches(primaryID, req.DestBatchID, req.FromPenID, req.ToPenID, req.Quantity, operatorID, req.Remarks)
},
"调栏成功",
func(ctx echo.Context) (uint, error) { // 自定义ID提取器从 ":sourceBatchID" 路径参数提取
idParam := ctx.Param("sourceBatchID")
parsedID, err := strconv.ParseUint(idParam, 10, 32)
if err != nil {
return 0, err
}
return uint(parsedID), nil
},
)
}
// TransferPigsWithinBatch godoc
// @Summary 群内调栏
// @Description 将指定数量的猪只在同一个猪群的不同猪栏间调动
// @Tags 猪群管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path int true "猪批次ID"
// @Param body body dto.TransferPigsWithinBatchRequest true "群内调栏请求信息"
// @Success 200 {object} controller.Response "调栏成功"
// @Router /api/v1/pig-batches/transfer-within-batch/{id} [post]
func (c *PigBatchController) TransferPigsWithinBatch(ctx echo.Context) error {
const action = "群内调栏"
var req dto.TransferPigsWithinBatchRequest
return handleAPIRequest(
c, ctx, action, &req,
func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.TransferPigsWithinBatchRequest) error {
// primaryID 在这里是 batchID
return c.service.TransferPigsWithinBatch(primaryID, req.FromPenID, req.ToPenID, req.Quantity, operatorID, req.Remarks)
},
"调栏成功",
nil, // 默认从 ":id" 路径参数提取ID
)
}

View File

@@ -0,0 +1,353 @@
package management
import (
"errors"
"strconv"
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller"
"git.huangwc.com/pig/pig-farm-controller/internal/app/dto"
"git.huangwc.com/pig/pig-farm-controller/internal/app/service"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"github.com/labstack/echo/v4"
)
// --- 控制器定义 ---
// PigFarmController 负责处理猪舍和猪栏相关的API请求
type PigFarmController struct {
logger *logs.Logger
service service.PigFarmService
}
// NewPigFarmController 创建一个新的 PigFarmController 实例
func NewPigFarmController(logger *logs.Logger, service service.PigFarmService) *PigFarmController {
return &PigFarmController{
logger: logger,
service: service,
}
}
// --- 猪舍 (PigHouse) API 实现 ---
// CreatePigHouse godoc
// @Summary 创建猪舍
// @Description 根据提供的信息创建一个新猪舍
// @Tags 猪场管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param body body dto.CreatePigHouseRequest true "猪舍信息"
// @Success 201 {object} controller.Response{data=dto.PigHouseResponse} "创建成功"
// @Router /api/v1/pig-houses [post]
func (c *PigFarmController) CreatePigHouse(ctx echo.Context) error {
const action = "创建猪舍"
var req dto.CreatePigHouseRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", action, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", req)
}
house, err := c.service.CreatePigHouse(req.Name, req.Description)
if err != nil {
c.logger.Errorf("%s: 业务逻辑失败: %v", action, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建猪舍失败", action, "业务逻辑失败", req)
}
return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "创建成功", house, action, "创建成功", house)
}
// GetPigHouse godoc
// @Summary 获取单个猪舍
// @Description 根据ID获取单个猪舍信息
// @Tags 猪场管理
// @Security BearerAuth
// @Produce json
// @Param id path int true "猪舍ID"
// @Success 200 {object} controller.Response{data=dto.PigHouseResponse} "获取成功"
// @Router /api/v1/pig-houses/{id} [get]
func (c *PigFarmController) GetPigHouse(ctx echo.Context) error {
const action = "获取猪舍"
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id"))
}
house, err := c.service.GetPigHouseByID(uint(id))
if err != nil {
if errors.Is(err, service.ErrHouseNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪舍不存在", action, "猪舍不存在", id)
}
c.logger.Errorf("%s: 业务逻辑失败: %v", action, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪舍失败", action, "业务逻辑失败", id)
}
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取成功", house, action, "获取成功", house)
}
// ListPigHouses godoc
// @Summary 获取猪舍列表
// @Description 获取所有猪舍的列表
// @Tags 猪场管理
// @Security BearerAuth
// @Produce json
// @Success 200 {object} controller.Response{data=[]dto.PigHouseResponse} "获取成功"
// @Router /api/v1/pig-houses [get]
func (c *PigFarmController) ListPigHouses(ctx echo.Context) error {
const action = "获取猪舍列表"
houses, err := c.service.ListPigHouses()
if err != nil {
c.logger.Errorf("%s: 业务逻辑失败: %v", action, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取列表失败", action, "业务逻辑失败", nil)
}
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取成功", houses, action, "获取成功", houses)
}
// UpdatePigHouse godoc
// @Summary 更新猪舍
// @Description 更新一个已存在的猪舍信息
// @Tags 猪场管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path int true "猪舍ID"
// @Param body body dto.UpdatePigHouseRequest true "猪舍信息"
// @Success 200 {object} controller.Response{data=dto.PigHouseResponse} "更新成功"
// @Router /api/v1/pig-houses/{id} [put]
func (c *PigFarmController) UpdatePigHouse(ctx echo.Context) error {
const action = "更新猪舍"
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id"))
}
var req dto.UpdatePigHouseRequest
if err := ctx.Bind(&req); err != nil {
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", req)
}
house, err := c.service.UpdatePigHouse(uint(id), req.Name, req.Description)
if err != nil {
if errors.Is(err, service.ErrHouseNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪舍不存在", action, "猪舍不存在", id)
}
c.logger.Errorf("%s: 业务逻辑失败: %v", action, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新失败", action, "业务逻辑失败", req)
}
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "更新成功", house, action, "更新成功", house)
}
// DeletePigHouse godoc
// @Summary 删除猪舍
// @Description 根据ID删除一个猪舍
// @Tags 猪场管理
// @Security BearerAuth
// @Produce json
// @Param id path int true "猪舍ID"
// @Success 200 {object} controller.Response "删除成功"
// @Router /api/v1/pig-houses/{id} [delete]
func (c *PigFarmController) DeletePigHouse(ctx echo.Context) error {
const action = "删除猪舍"
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id"))
}
if err := c.service.DeletePigHouse(uint(id)); err != nil {
if errors.Is(err, service.ErrHouseNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪舍不存在", action, "猪舍不存在", id)
}
// 检查是否是业务逻辑错误
if errors.Is(err, service.ErrHouseContainsPens) {
return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), action, err.Error(), id)
}
c.logger.Errorf("%s: 业务逻辑失败: %v", action, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除失败", action, "业务逻辑失败", id)
}
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "删除成功", nil, action, "删除成功", id)
}
// --- 猪栏 (Pen) API 实现 ---
// CreatePen godoc
// @Summary 创建猪栏
// @Description 创建一个新的猪栏
// @Tags 猪场管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param body body dto.CreatePenRequest true "猪栏信息"
// @Success 201 {object} controller.Response{data=dto.PenResponse} "创建成功"
// @Router /api/v1/pens [post]
func (c *PigFarmController) CreatePen(ctx echo.Context) error {
const action = "创建猪栏"
var req dto.CreatePenRequest
if err := ctx.Bind(&req); err != nil {
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", req)
}
pen, err := c.service.CreatePen(req.PenNumber, req.HouseID, req.Capacity)
if err != nil {
// 检查是否是业务逻辑错误
if errors.Is(err, service.ErrHouseNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), action, err.Error(), req)
}
c.logger.Errorf("%s: 业务逻辑失败: %v", action, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建猪栏失败", action, "业务逻辑失败", req)
}
return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "创建成功", pen, action, "创建成功", pen)
}
// GetPen godoc
// @Summary 获取单个猪栏
// @Description 根据ID获取单个猪栏信息
// @Tags 猪场管理
// @Security BearerAuth
// @Produce json
// @Param id path int true "猪栏ID"
// @Success 200 {object} controller.Response{data=dto.PenResponse} "获取成功"
// @Router /api/v1/pens/{id} [get]
func (c *PigFarmController) GetPen(ctx echo.Context) error {
const action = "获取猪栏"
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id"))
}
pen, err := c.service.GetPenByID(uint(id))
if err != nil {
if errors.Is(err, service.ErrPenNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪栏不存在", action, "猪栏不存在", id)
}
c.logger.Errorf("%s: 业务逻辑失败: %v", action, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪栏失败", action, "业务逻辑失败", id)
}
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取成功", pen, action, "获取成功", pen)
}
// ListPens godoc
// @Summary 获取猪栏列表
// @Description 获取所有猪栏的列表
// @Tags 猪场管理
// @Security BearerAuth
// @Produce json
// @Success 200 {object} controller.Response{data=[]dto.PenResponse} "获取成功"
// @Router /api/v1/pens [get]
func (c *PigFarmController) ListPens(ctx echo.Context) error {
const action = "获取猪栏列表"
pens, err := c.service.ListPens()
if err != nil {
c.logger.Errorf("%s: 业务逻辑失败: %v", action, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取列表失败", action, "业务逻辑失败", nil)
}
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取成功", pens, action, "获取成功", pens)
}
// UpdatePen godoc
// @Summary 更新猪栏
// @Description 更新一个已存在的猪栏信息
// @Tags 猪场管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path int true "猪栏ID"
// @Param body body dto.UpdatePenRequest true "猪栏信息"
// @Success 200 {object} controller.Response{data=dto.PenResponse} "更新成功"
// @Router /api/v1/pens/{id} [put]
func (c *PigFarmController) UpdatePen(ctx echo.Context) error {
const action = "更新猪栏"
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id"))
}
var req dto.UpdatePenRequest
if err := ctx.Bind(&req); err != nil {
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", req)
}
pen, err := c.service.UpdatePen(uint(id), req.PenNumber, req.HouseID, req.Capacity, req.Status)
if err != nil {
if errors.Is(err, service.ErrPenNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪栏不存在", action, "猪栏不存在", id)
}
// 其他业务逻辑错误可以在这里添加处理
c.logger.Errorf("%s: 业务逻辑失败: %v", action, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新失败", action, "业务逻辑失败", req)
}
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "更新成功", pen, action, "更新成功", pen)
}
// DeletePen godoc
// @Summary 删除猪栏
// @Description 根据ID删除一个猪栏
// @Tags 猪场管理
// @Security BearerAuth
// @Produce json
// @Param id path int true "猪栏ID"
// @Success 200 {object} controller.Response "删除成功"
// @Router /api/v1/pens/{id} [delete]
func (c *PigFarmController) DeletePen(ctx echo.Context) error {
const action = "删除猪栏"
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id"))
}
if err := c.service.DeletePen(uint(id)); err != nil {
if errors.Is(err, service.ErrPenNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪栏不存在", action, "猪栏不存在", id)
}
// 检查是否是业务逻辑错误
if errors.Is(err, service.ErrPenInUse) {
return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), action, err.Error(), id)
}
c.logger.Errorf("%s: 业务逻辑失败: %v", action, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除失败", action, "业务逻辑失败", id)
}
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "删除成功", nil, action, "删除成功", id)
}
// UpdatePenStatus godoc
// @Summary 更新猪栏状态
// @Description 更新指定猪栏的当前状态
// @Tags 猪场管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path int true "猪栏ID"
// @Param body body dto.UpdatePenStatusRequest true "新的猪栏状态"
// @Success 200 {object} controller.Response{data=dto.PenResponse} "更新成功"
// @Router /api/v1/pens/{id}/status [put]
func (c *PigFarmController) UpdatePenStatus(ctx echo.Context) error {
const action = "更新猪栏状态"
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id"))
}
var req dto.UpdatePenStatusRequest
if err := ctx.Bind(&req); err != nil {
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", req)
}
pen, err := c.service.UpdatePenStatus(uint(id), req.Status)
if err != nil {
if errors.Is(err, service.ErrPenNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), action, err.Error(), id)
} else if errors.Is(err, service.ErrPenStatusInvalidForOccupiedPen) || errors.Is(err, service.ErrPenStatusInvalidForUnoccupiedPen) {
return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), action, err.Error(), id)
}
c.logger.Errorf("%s: 业务逻辑失败: %v", action, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新猪栏状态失败", action, err.Error(), id)
}
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "更新成功", pen, action, "更新成功", pen)
}

View File

@@ -0,0 +1,620 @@
package monitor
import (
"errors"
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller"
"git.huangwc.com/pig/pig-farm-controller/internal/app/dto"
"git.huangwc.com/pig/pig-farm-controller/internal/app/service"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
"github.com/labstack/echo/v4"
)
// Controller 监控控制器,封装了所有与数据监控相关的业务逻辑
type Controller struct {
monitorService service.MonitorService
logger *logs.Logger
}
// NewController 创建一个新的监控控制器实例
func NewController(monitorService service.MonitorService, logger *logs.Logger) *Controller {
return &Controller{
monitorService: monitorService,
logger: logger,
}
}
// ListSensorData godoc
// @Summary 获取传感器数据列表
// @Description 根据提供的过滤条件,分页获取传感器数据
// @Tags 数据监控
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListSensorDataRequest true "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListSensorDataResponse}
// @Router /api/v1/monitor/sensor-data [get]
func (c *Controller) ListSensorData(ctx echo.Context) error {
const actionType = "获取传感器数据列表"
var req dto.ListSensorDataRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req)
}
resp, err := c.monitorService.ListSensorData(&req)
if err != nil {
if errors.Is(err, repository.ErrInvalidPagination) {
c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req)
}
c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取传感器数据失败: "+err.Error(), actionType, "服务层查询失败", req)
}
c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取传感器数据成功", resp, actionType, "获取传感器数据成功", req)
}
// ListDeviceCommandLogs godoc
// @Summary 获取设备命令日志列表
// @Description 根据提供的过滤条件,分页获取设备命令日志
// @Tags 数据监控
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListDeviceCommandLogRequest true "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListDeviceCommandLogResponse}
// @Router /api/v1/monitor/device-command-logs [get]
func (c *Controller) ListDeviceCommandLogs(ctx echo.Context) error {
const actionType = "获取设备命令日志列表"
var req dto.ListDeviceCommandLogRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req)
}
resp, err := c.monitorService.ListDeviceCommandLogs(&req)
if err != nil {
if errors.Is(err, repository.ErrInvalidPagination) {
c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req)
}
c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备命令日志失败: "+err.Error(), actionType, "服务层查询失败", req)
}
c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取设备命令日志成功", resp, actionType, "获取设备命令日志成功", req)
}
// ListPlanExecutionLogs godoc
// @Summary 获取计划执行日志列表
// @Description 根据提供的过滤条件,分页获取计划执行日志
// @Tags 数据监控
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListPlanExecutionLogRequest true "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListPlanExecutionLogResponse}
// @Router /api/v1/monitor/plan-execution-logs [get]
func (c *Controller) ListPlanExecutionLogs(ctx echo.Context) error {
const actionType = "获取计划执行日志列表"
var req dto.ListPlanExecutionLogRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req)
}
resp, err := c.monitorService.ListPlanExecutionLogs(&req)
if err != nil {
if errors.Is(err, repository.ErrInvalidPagination) {
c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req)
}
c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划执行日志失败: "+err.Error(), actionType, "服务层查询失败", req)
}
c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取计划执行日志成功", resp, actionType, "获取计划执行日志成功", req)
}
// ListTaskExecutionLogs godoc
// @Summary 获取任务执行日志列表
// @Description 根据提供的过滤条件,分页获取任务执行日志
// @Tags 数据监控
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListTaskExecutionLogRequest true "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListTaskExecutionLogResponse}
// @Router /api/v1/monitor/task-execution-logs [get]
func (c *Controller) ListTaskExecutionLogs(ctx echo.Context) error {
const actionType = "获取任务执行日志列表"
var req dto.ListTaskExecutionLogRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req)
}
resp, err := c.monitorService.ListTaskExecutionLogs(&req)
if err != nil {
if errors.Is(err, repository.ErrInvalidPagination) {
c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req)
}
c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取任务执行日志失败: "+err.Error(), actionType, "服务层查询失败", req)
}
c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取任务执行日志成功", resp, actionType, "获取任务执行日志成功", req)
}
// ListPendingCollections godoc
// @Summary 获取待采集请求列表
// @Description 根据提供的过滤条件,分页获取待采集请求
// @Tags 数据监控
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListPendingCollectionRequest true "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListPendingCollectionResponse}
// @Router /api/v1/monitor/pending-collections [get]
func (c *Controller) ListPendingCollections(ctx echo.Context) error {
const actionType = "获取待采集请求列表"
var req dto.ListPendingCollectionRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req)
}
resp, err := c.monitorService.ListPendingCollections(&req)
if err != nil {
if errors.Is(err, repository.ErrInvalidPagination) {
c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req)
}
c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取待采集请求失败: "+err.Error(), actionType, "服务层查询失败", req)
}
c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取待采集请求成功", resp, actionType, "获取待采集请求成功", req)
}
// ListUserActionLogs godoc
// @Summary 获取用户操作日志列表
// @Description 根据提供的过滤条件,分页获取用户操作日志
// @Tags 数据监控
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListUserActionLogRequest true "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListUserActionLogResponse}
// @Router /api/v1/monitor/user-action-logs [get]
func (c *Controller) ListUserActionLogs(ctx echo.Context) error {
const actionType = "获取用户操作日志列表"
var req dto.ListUserActionLogRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req)
}
resp, err := c.monitorService.ListUserActionLogs(&req)
if err != nil {
if errors.Is(err, repository.ErrInvalidPagination) {
c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req)
}
c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取用户操作日志失败: "+err.Error(), actionType, "服务层查询失败", req)
}
c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取用户操作日志成功", resp, actionType, "获取用户操作日志成功", req)
}
// ListRawMaterialPurchases godoc
// @Summary 获取原料采购记录列表
// @Description 根据提供的过滤条件,分页获取原料采购记录
// @Tags 数据监控
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListRawMaterialPurchaseRequest true "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListRawMaterialPurchaseResponse}
// @Router /api/v1/monitor/raw-material-purchases [get]
func (c *Controller) ListRawMaterialPurchases(ctx echo.Context) error {
const actionType = "获取原料采购记录列表"
var req dto.ListRawMaterialPurchaseRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req)
}
resp, err := c.monitorService.ListRawMaterialPurchases(&req)
if err != nil {
if errors.Is(err, repository.ErrInvalidPagination) {
c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req)
}
c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取原料采购记录失败: "+err.Error(), actionType, "服务层查询失败", req)
}
c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取原料采购记录成功", resp, actionType, "获取原料采购记录成功", req)
}
// ListRawMaterialStockLogs godoc
// @Summary 获取原料库存日志列表
// @Description 根据提供的过滤条件,分页获取原料库存日志
// @Tags 数据监控
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListRawMaterialStockLogRequest true "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListRawMaterialStockLogResponse}
// @Router /api/v1/monitor/raw-material-stock-logs [get]
func (c *Controller) ListRawMaterialStockLogs(ctx echo.Context) error {
const actionType = "获取原料库存日志列表"
var req dto.ListRawMaterialStockLogRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req)
}
resp, err := c.monitorService.ListRawMaterialStockLogs(&req)
if err != nil {
if errors.Is(err, repository.ErrInvalidPagination) {
c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req)
}
c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取原料库存日志失败: "+err.Error(), actionType, "服务层查询失败", req)
}
c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取原料库存日志成功", resp, actionType, "获取原料库存日志成功", req)
}
// ListFeedUsageRecords godoc
// @Summary 获取饲料使用记录列表
// @Description 根据提供的过滤条件,分页获取饲料使用记录
// @Tags 数据监控
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListFeedUsageRecordRequest true "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListFeedUsageRecordResponse}
// @Router /api/v1/monitor/feed-usage-records [get]
func (c *Controller) ListFeedUsageRecords(ctx echo.Context) error {
const actionType = "获取饲料使用记录列表"
var req dto.ListFeedUsageRecordRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req)
}
resp, err := c.monitorService.ListFeedUsageRecords(&req)
if err != nil {
if errors.Is(err, repository.ErrInvalidPagination) {
c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req)
}
c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取饲料使用记录失败: "+err.Error(), actionType, "服务层查询失败", req)
}
c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取饲料使用记录成功", resp, actionType, "获取饲料使用记录成功", req)
}
// ListMedicationLogs godoc
// @Summary 获取用药记录列表
// @Description 根据提供的过滤条件,分页获取用药记录
// @Tags 数据监控
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListMedicationLogRequest true "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListMedicationLogResponse}
// @Router /api/v1/monitor/medication-logs [get]
func (c *Controller) ListMedicationLogs(ctx echo.Context) error {
const actionType = "获取用药记录列表"
var req dto.ListMedicationLogRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req)
}
resp, err := c.monitorService.ListMedicationLogs(&req)
if err != nil {
if errors.Is(err, repository.ErrInvalidPagination) {
c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req)
}
c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取用药记录失败: "+err.Error(), actionType, "服务层查询失败", req)
}
c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取用药记录成功", resp, actionType, "获取用药记录成功", req)
}
// ListPigBatchLogs godoc
// @Summary 获取猪批次日志列表
// @Description 根据提供的过滤条件,分页获取猪批次日志
// @Tags 数据监控
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListPigBatchLogRequest true "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListPigBatchLogResponse}
// @Router /api/v1/monitor/pig-batch-logs [get]
func (c *Controller) ListPigBatchLogs(ctx echo.Context) error {
const actionType = "获取猪批次日志列表"
var req dto.ListPigBatchLogRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req)
}
resp, err := c.monitorService.ListPigBatchLogs(&req)
if err != nil {
if errors.Is(err, repository.ErrInvalidPagination) {
c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req)
}
c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪批次日志失败: "+err.Error(), actionType, "服务层查询失败", req)
}
c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取猪批次日志成功", resp, actionType, "获取猪批次日志成功", req)
}
// ListWeighingBatches godoc
// @Summary 获取批次称重记录列表
// @Description 根据提供的过滤条件,分页获取批次称重记录
// @Tags 数据监控
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListWeighingBatchRequest true "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListWeighingBatchResponse}
// @Router /api/v1/monitor/weighing-batches [get]
func (c *Controller) ListWeighingBatches(ctx echo.Context) error {
const actionType = "获取批次称重记录列表"
var req dto.ListWeighingBatchRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req)
}
resp, err := c.monitorService.ListWeighingBatches(&req)
if err != nil {
if errors.Is(err, repository.ErrInvalidPagination) {
c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req)
}
c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取批次称重记录失败: "+err.Error(), actionType, "服务层查询失败", req)
}
c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取批次称重记录成功", resp, actionType, "获取批次称重记录成功", req)
}
// ListWeighingRecords godoc
// @Summary 获取单次称重记录列表
// @Description 根据提供的过滤条件,分页获取单次称重记录
// @Tags 数据监控
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListWeighingRecordRequest true "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListWeighingRecordResponse}
// @Router /api/v1/monitor/weighing-records [get]
func (c *Controller) ListWeighingRecords(ctx echo.Context) error {
const actionType = "获取单次称重记录列表"
var req dto.ListWeighingRecordRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req)
}
resp, err := c.monitorService.ListWeighingRecords(&req)
if err != nil {
if errors.Is(err, repository.ErrInvalidPagination) {
c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req)
}
c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取单次称重记录失败: "+err.Error(), actionType, "服务层查询失败", req)
}
c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取单次称重记录成功", resp, actionType, "获取单次称重记录成功", req)
}
// ListPigTransferLogs godoc
// @Summary 获取猪只迁移日志列表
// @Description 根据提供的过滤条件,分页获取猪只迁移日志
// @Tags 数据监控
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListPigTransferLogRequest true "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListPigTransferLogResponse}
// @Router /api/v1/monitor/pig-transfer-logs [get]
func (c *Controller) ListPigTransferLogs(ctx echo.Context) error {
const actionType = "获取猪只迁移日志列表"
var req dto.ListPigTransferLogRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req)
}
resp, err := c.monitorService.ListPigTransferLogs(&req)
if err != nil {
if errors.Is(err, repository.ErrInvalidPagination) {
c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req)
}
c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪只迁移日志失败: "+err.Error(), actionType, "服务层查询失败", req)
}
c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取猪只迁移日志成功", resp, actionType, "获取猪只迁移日志成功", req)
}
// ListPigSickLogs godoc
// @Summary 获取病猪日志列表
// @Description 根据提供的过滤条件,分页获取病猪日志
// @Tags 数据监控
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListPigSickLogRequest true "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListPigSickLogResponse}
// @Router /api/v1/monitor/pig-sick-logs [get]
func (c *Controller) ListPigSickLogs(ctx echo.Context) error {
const actionType = "获取病猪日志列表"
var req dto.ListPigSickLogRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req)
}
resp, err := c.monitorService.ListPigSickLogs(&req)
if err != nil {
if errors.Is(err, repository.ErrInvalidPagination) {
c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req)
}
c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取病猪日志失败: "+err.Error(), actionType, "服务层查询失败", req)
}
c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取病猪日志成功", resp, actionType, "获取病猪日志成功", req)
}
// ListPigPurchases godoc
// @Summary 获取猪只采购记录列表
// @Description 根据提供的过滤条件,分页获取猪只采购记录
// @Tags 数据监控
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListPigPurchaseRequest true "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListPigPurchaseResponse}
// @Router /api/v1/monitor/pig-purchases [get]
func (c *Controller) ListPigPurchases(ctx echo.Context) error {
const actionType = "获取猪只采购记录列表"
var req dto.ListPigPurchaseRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req)
}
resp, err := c.monitorService.ListPigPurchases(&req)
if err != nil {
if errors.Is(err, repository.ErrInvalidPagination) {
c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req)
}
c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪只采购记录失败: "+err.Error(), actionType, "服务层查询失败", req)
}
c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取猪只采购记录成功", resp, actionType, "获取猪只采购记录成功", req)
}
// ListPigSales godoc
// @Summary 获取猪只售卖记录列表
// @Description 根据提供的过滤条件,分页获取猪只售卖记录
// @Tags 数据监控
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListPigSaleRequest true "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListPigSaleResponse}
// @Router /api/v1/monitor/pig-sales [get]
func (c *Controller) ListPigSales(ctx echo.Context) error {
const actionType = "获取猪只售卖记录列表"
var req dto.ListPigSaleRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req)
}
resp, err := c.monitorService.ListPigSales(&req)
if err != nil {
if errors.Is(err, repository.ErrInvalidPagination) {
c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req)
}
c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪只售卖记录失败: "+err.Error(), actionType, "服务层查询失败", req)
}
c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取猪只售卖记录成功", resp, actionType, "获取猪只售卖记录成功", req)
}
// ListNotifications godoc
// @Summary 批量查询通知
// @Description 根据提供的过滤条件,分页获取通知列表
// @Tags 数据监控
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListNotificationRequest true "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListNotificationResponse}
// @Router /api/v1/monitor/notifications [get]
func (c *Controller) ListNotifications(ctx echo.Context) error {
const actionType = "批量查询通知"
var req dto.ListNotificationRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req)
}
resp, err := c.monitorService.ListNotifications(&req)
if err != nil {
if errors.Is(err, repository.ErrInvalidPagination) {
c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req)
}
c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "批量查询通知失败: "+err.Error(), actionType, "服务层查询失败", req)
}
c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "批量查询通知成功", resp, actionType, "批量查询通知成功", req)
}

View File

@@ -0,0 +1,284 @@
package plan
import (
"errors"
"strconv"
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller"
"git.huangwc.com/pig/pig-farm-controller/internal/app/dto"
"git.huangwc.com/pig/pig-farm-controller/internal/app/service"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/plan"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"github.com/labstack/echo/v4"
)
// --- 控制器定义 ---
// Controller 定义了计划相关的控制器
type Controller struct {
logger *logs.Logger
planService service.PlanService
}
// NewController 创建一个新的 Controller 实例
func NewController(logger *logs.Logger, planService service.PlanService) *Controller {
return &Controller{
logger: logger,
planService: planService,
}
}
// --- 接口方法实现 ---
// CreatePlan godoc
// @Summary 创建计划
// @Description 创建一个新的计划,包括其基本信息和所有关联的子计划/任务。
// @Tags 计划管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param plan body dto.CreatePlanRequest true "计划信息"
// @Success 200 {object} controller.Response{data=dto.PlanResponse} "业务码为201代表创建成功"
// @Router /api/v1/plans [post]
func (c *Controller) CreatePlan(ctx echo.Context) error {
var req dto.CreatePlanRequest
const actionType = "创建计划"
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
// 调用服务层创建计划
resp, err := c.planService.CreatePlan(&req)
if err != nil {
c.logger.Errorf("%s: 服务层创建计划失败: %v", actionType, err)
// 根据服务层返回的错误类型转换为相应的HTTP状态码
if errors.Is(err, plan.ErrPlanNotFound) { // 修改为 plan.ErrPlanNotFound
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "计划数据校验失败或关联计划不存在", req)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建计划失败: "+err.Error(), actionType, "服务层创建计划失败", req)
}
// 使用统一的成功响应函数
c.logger.Infof("%s: 计划创建成功, ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "计划创建成功", resp, actionType, "计划创建成功", resp)
}
// GetPlan godoc
// @Summary 获取计划详情
// @Description 根据计划ID获取单个计划的详细信息。
// @Tags 计划管理
// @Security BearerAuth
// @Produce json
// @Param id path int true "计划ID"
// @Success 200 {object} controller.Response{data=dto.PlanResponse} "业务码为200代表成功获取"
// @Router /api/v1/plans/{id} [get]
func (c *Controller) GetPlan(ctx echo.Context) error {
const actionType = "获取计划详情"
// 1. 从 URL 路径中获取 ID
idStr := ctx.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.logger.Errorf("%s: 计划ID格式错误: %v, ID: %s", actionType, err, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的计划ID格式", actionType, "计划ID格式错误", idStr)
}
// 调用服务层获取计划详情
resp, err := c.planService.GetPlanByID(uint(id))
if err != nil {
c.logger.Errorf("%s: 服务层获取计划详情失败: %v, ID: %d", actionType, err, id)
if errors.Is(err, plan.ErrPlanNotFound) { // 修改为 plan.ErrPlanNotFound
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "计划不存在", id)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划详情失败: "+err.Error(), actionType, "服务层获取计划详情失败", id)
}
// 4. 发送成功响应
c.logger.Infof("%s: 获取计划详情成功, ID: %d", actionType, id)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取计划详情成功", resp, actionType, "获取计划详情成功", resp)
}
// ListPlans godoc
// @Summary 获取计划列表
// @Description 获取所有计划的列表,支持按类型过滤和分页
// @Tags 计划管理
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListPlansQuery false "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListPlansResponse} "业务码为200代表成功获取列表"
// @Router /api/v1/plans [get]
func (c *Controller) ListPlans(ctx echo.Context) error {
const actionType = "获取计划列表"
var query dto.ListPlansQuery
if err := ctx.Bind(&query); err != nil {
c.logger.Errorf("%s: 查询参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "查询参数绑定失败", query)
}
// 调用服务层获取计划列表
resp, err := c.planService.ListPlans(&query)
if err != nil {
c.logger.Errorf("%s: 服务层获取计划列表失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划列表失败: "+err.Error(), actionType, "服务层获取计划列表失败", nil)
}
c.logger.Infof("%s: 获取计划列表成功, 数量: %d", actionType, len(resp.Plans))
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取计划列表成功", resp, actionType, "获取计划列表成功", resp)
}
// UpdatePlan godoc
// @Summary 更新计划
// @Description 根据计划ID更新计划的详细信息。系统计划不允许修改。
// @Tags 计划管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path int true "计划ID"
// @Param plan body dto.UpdatePlanRequest true "更新后的计划信息"
// @Success 200 {object} controller.Response{data=dto.PlanResponse} "业务码为200代表更新成功"
// @Router /api/v1/plans/{id} [put]
func (c *Controller) UpdatePlan(ctx echo.Context) error {
const actionType = "更新计划"
// 1. 从 URL 路径中获取 ID
idStr := ctx.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.logger.Errorf("%s: 计划ID格式错误: %v, ID: %s", actionType, err, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的计划ID格式", actionType, "计划ID格式错误", idStr)
}
// 2. 绑定请求体
var req dto.UpdatePlanRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
// 调用服务层更新计划
resp, err := c.planService.UpdatePlan(uint(id), &req)
if err != nil {
c.logger.Errorf("%s: 服务层更新计划失败: %v, ID: %d", actionType, err, id)
if errors.Is(err, plan.ErrPlanNotFound) { // 修改为 plan.ErrPlanNotFound
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "计划不存在", id)
} else if errors.Is(err, plan.ErrPlanCannotBeModified) { // 修改为 plan.ErrPlanCannotBeModified
return controller.SendErrorWithAudit(ctx, controller.CodeForbidden, err.Error(), actionType, "系统计划不允许修改", id)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新计划失败: "+err.Error(), actionType, "服务层更新计划失败", req)
}
// 9. 发送成功响应
c.logger.Infof("%s: 计划更新成功, ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "计划更新成功", resp, actionType, "计划更新成功", resp)
}
// DeletePlan godoc
// @Summary 删除计划
// @Description 根据计划ID删除计划。软删除系统计划不允许删除。
// @Tags 计划管理
// @Security BearerAuth
// @Produce json
// @Param id path int true "计划ID"
// @Success 200 {object} controller.Response "业务码为200代表删除成功"
// @Router /api/v1/plans/{id} [delete]
func (c *Controller) DeletePlan(ctx echo.Context) error {
const actionType = "删除计划"
// 1. 从 URL 路径中获取 ID
idStr := ctx.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.logger.Errorf("%s: 计划ID格式错误: %v, ID: %s", actionType, err, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的计划ID格式", actionType, "计划ID格式错误", idStr)
}
// 调用服务层删除计划
err = c.planService.DeletePlan(uint(id))
if err != nil {
c.logger.Errorf("%s: 服务层删除计划失败: %v, ID: %d", actionType, err, id)
if errors.Is(err, plan.ErrPlanNotFound) { // 修改为 plan.ErrPlanNotFound
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "计划不存在", id)
} else if errors.Is(err, plan.ErrPlanCannotBeDeleted) { // 修改为 plan.ErrPlanCannotBeDeleted
return controller.SendErrorWithAudit(ctx, controller.CodeForbidden, err.Error(), actionType, "系统计划不允许删除", id)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除计划失败: "+err.Error(), actionType, "服务层删除计划失败", id)
}
// 6. 发送成功响应
c.logger.Infof("%s: 计划删除成功, ID: %d", actionType, id)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "计划删除成功", nil, actionType, "计划删除成功", id)
}
// StartPlan godoc
// @Summary 启动计划
// @Description 根据计划ID启动一个计划的执行。系统计划不允许手动启动。
// @Tags 计划管理
// @Security BearerAuth
// @Produce json
// @Param id path int true "计划ID"
// @Success 200 {object} controller.Response "业务码为200代表成功启动计划"
// @Router /api/v1/plans/{id}/start [post]
func (c *Controller) StartPlan(ctx echo.Context) error {
const actionType = "启动计划"
// 1. 从 URL 路径中获取 ID
idStr := ctx.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.logger.Errorf("%s: 计划ID格式错误: %v, ID: %s", actionType, err, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的计划ID格式", actionType, "计划ID格式错误", idStr)
}
// 调用服务层启动计划
err = c.planService.StartPlan(uint(id))
if err != nil {
c.logger.Errorf("%s: 服务层启动计划失败: %v, ID: %d", actionType, err, id)
if errors.Is(err, plan.ErrPlanNotFound) { // 修改为 plan.ErrPlanNotFound
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "计划不存在", id)
} else if errors.Is(err, plan.ErrPlanCannotBeStarted) { // 修改为 plan.ErrPlanCannotBeStarted
return controller.SendErrorWithAudit(ctx, controller.CodeForbidden, err.Error(), actionType, "系统计划不允许手动启动", id)
} else if errors.Is(err, plan.ErrPlanAlreadyEnabled) { // 修改为 plan.ErrPlanAlreadyEnabled
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, err.Error(), actionType, "计划已处于启动状态", id)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "启动计划失败: "+err.Error(), actionType, "服务层启动计划失败", id)
}
// 6. 发送成功响应
c.logger.Infof("%s: 计划已成功启动, ID: %d", actionType, id)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "计划已成功启动", nil, actionType, "计划已成功启动", id)
}
// StopPlan godoc
// @Summary 停止计划
// @Description 根据计划ID停止一个正在执行的计划。系统计划不能被停止。
// @Tags 计划管理
// @Security BearerAuth
// @Produce json
// @Param id path int true "计划ID"
// @Success 200 {object} controller.Response "业务码为200代表成功停止计划"
// @Router /api/v1/plans/{id}/stop [post]
func (c *Controller) StopPlan(ctx echo.Context) error {
const actionType = "停止计划"
// 1. 从 URL 路径中获取 ID
idStr := ctx.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.logger.Errorf("%s: 计划ID格式错误: %v, ID: %s", actionType, err, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的计划ID格式", actionType, "计划ID格式错误", idStr)
}
// 调用服务层停止计划
err = c.planService.StopPlan(uint(id))
if err != nil {
c.logger.Errorf("%s: 服务层停止计划失败: %v, ID: %d", actionType, err, id)
if errors.Is(err, plan.ErrPlanNotFound) { // 修改为 plan.ErrPlanNotFound
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "计划不存在", id)
} else if errors.Is(err, plan.ErrPlanCannotBeStopped) { // 修改为 plan.ErrPlanCannotBeStopped
return controller.SendErrorWithAudit(ctx, controller.CodeForbidden, err.Error(), actionType, "系统计划不允许停止", id)
} else if errors.Is(err, plan.ErrPlanNotEnabled) { // 修改为 plan.ErrPlanNotEnabled
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, err.Error(), actionType, "计划未启用", id)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "停止计划失败: "+err.Error(), actionType, "服务层停止计划失败", id)
}
// 6. 发送成功响应
c.logger.Infof("%s: 计划已成功停止, ID: %d", actionType, id)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "计划已成功停止", nil, actionType, "计划已成功停止", id)
}

View File

@@ -3,28 +3,107 @@ package controller
import ( import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"github.com/labstack/echo/v4"
) )
// --- 业务状态码 ---
type ResponseCode int
const (
// 成功状态码 (2000-2999)
CodeSuccess ResponseCode = 2000 // 操作成功
CodeCreated ResponseCode = 2001 // 创建成功
// 客户端错误状态码 (4000-4999)
CodeBadRequest ResponseCode = 4000 // 请求参数错误
CodeUnauthorized ResponseCode = 4001 // 未授权
CodeForbidden ResponseCode = 4003 // 禁止访问
CodeNotFound ResponseCode = 4004 // 资源未找到
CodeConflict ResponseCode = 4009 // 资源冲突
// 服务器错误状态码 (5000-5999)
CodeInternalError ResponseCode = 5000 // 服务器内部错误
CodeServiceUnavailable ResponseCode = 5003 // 服务不可用
)
// --- 通用响应结构 ---
// Response 定义统一的API响应结构体 // Response 定义统一的API响应结构体
type Response struct { type Response struct {
Code int `json:"code"` // 业务状态码 Code ResponseCode `json:"code"` // 业务状态码
Message string `json:"message"` // 提示信息 Message string `json:"message"` // 提示信息
Data interface{} `json:"data"` // 业务数据 Data interface{} `json:"data,omitempty"` // 业务数据, omitempty表示如果为空则不序列化
} }
// SendResponse 发送统一格式的JSON响应 // SendResponse 发送统一格式的JSON响应 (基础函数,不带审计)
// httpStatus 参数现在将几乎总是 http.StatusOK业务状态码通过 Response.Code 传递 // 所有的业务API都应该使用这个函数返回以确保HTTP状态码始终为200 OK。
func SendResponse(ctx *gin.Context, code int, message string, data interface{}) { func SendResponse(c echo.Context, code ResponseCode, message string, data interface{}) error {
ctx.JSON(http.StatusOK, Response{ return c.JSON(http.StatusOK, Response{
Code: code, Code: code,
Message: message, Message: message,
Data: data, Data: data,
}) })
} }
// SendErrorResponse 发送统一格式的错误响应 // SendErrorResponse 发送统一格式的错误响应 (基础函数,不带审计)
// 错误响应通常不包含业务数据,因此 data 参数固定为 nil // HTTP状态码为200 OK通过业务码表示错误。
func SendErrorResponse(ctx *gin.Context, code int, message string) { func SendErrorResponse(c echo.Context, code ResponseCode, message string) error {
SendResponse(ctx, code, message, nil) return SendResponse(c, code, message, nil)
}
// SendErrorWithStatus 发送带有指定HTTP状态码的错误响应。
// 这个函数主要用于中间件或特殊场景如认证失败在这些场景下需要返回非200的HTTP状态码。
func SendErrorWithStatus(c echo.Context, httpStatus int, code ResponseCode, message string) error {
return c.JSON(httpStatus, Response{
Code: code,
Message: message,
})
}
// --- 带审计功能的响应函数 ---
// setAuditDetails 是一个内部辅助函数,用于在 echo.Context 中统一设置所有业务相关的审计信息。
func setAuditDetails(c echo.Context, actionType, description string, targetResource interface{}, status models.AuditStatus, resultDetails string) {
// 只有当 actionType 不为空时,才设置审计信息,这作为触发审计的标志
if actionType != "" {
c.Set(models.ContextAuditActionType.String(), actionType)
c.Set(models.ContextAuditDescription.String(), description)
c.Set(models.ContextAuditTargetResource.String(), targetResource)
c.Set(models.ContextAuditStatus.String(), status)
c.Set(models.ContextAuditResultDetails.String(), resultDetails)
}
}
// SendSuccessWithAudit 发送成功的响应,并设置审计日志所需的信息。
// 这是控制器中用于记录成功操作并返回响应的首选函数。
func SendSuccessWithAudit(
c echo.Context, // Echo上下文用于处理HTTP请求和响应
code ResponseCode, // 业务状态码,表示操作结果
message string, // 提示信息,向用户展示操作结果的文本描述
data interface{}, // 业务数据,操作成功后返回的具体数据
actionType string, // 审计操作类型,例如"创建用户", "更新配置"
description string, // 审计描述,对操作的详细说明
targetResource interface{}, // 审计目标资源,被操作的资源对象或其标识
) error {
// 1. 设置审计信息
setAuditDetails(c, actionType, description, targetResource, models.AuditStatusSuccess, "")
// 2. 发送响应
return SendResponse(c, code, message, data)
}
// SendErrorWithAudit 发送失败的响应,并设置审计日志所需的信息。
// 这是控制器中用于记录失败操作并返回响应的首选函数。
func SendErrorWithAudit(
c echo.Context, // Echo上下文用于处理HTTP请求和响应
code ResponseCode, // 业务状态码,表示操作结果
message string, // 提示信息,向用户展示操作结果的文本描述
actionType string, // 审计操作类型,例如"登录失败", "删除失败"
description string, // 审计描述,对操作的详细说明
targetResource interface{}, // 审计目标资源,被操作的资源对象或其标识
) error {
// 1. 设置审计信息
setAuditDetails(c, actionType, description, targetResource, models.AuditStatusFailed, message)
// 2. 发送响应
return SendErrorResponse(c, code, message)
} }

View File

@@ -1,57 +1,33 @@
package user package user
import ( import (
"net/http" "strconv"
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller" "git.huangwc.com/pig/pig-farm-controller/internal/app/controller"
"git.huangwc.com/pig/pig-farm-controller/internal/app/service/token" "git.huangwc.com/pig/pig-farm-controller/internal/app/dto"
"git.huangwc.com/pig/pig-farm-controller/internal/app/service"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" "github.com/labstack/echo/v4"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
// Controller 用户控制器 // Controller 用户控制器
type Controller struct { type Controller struct {
userRepo repository.UserRepository userService service.UserService
logger *logs.Logger logger *logs.Logger
tokenService token.TokenService // 注入 token 服务
} }
// NewController 创建用户控制器实例 // NewController 创建用户控制器实例
func NewController(userRepo repository.UserRepository, logger *logs.Logger, tokenService token.TokenService) *Controller { func NewController(
userService service.UserService,
logger *logs.Logger,
) *Controller {
return &Controller{ return &Controller{
userRepo: userRepo, userService: userService,
logger: logger, logger: logger,
tokenService: tokenService,
} }
} }
// CreateUserRequest 定义创建用户请求的结构体 // --- Controller Methods ---
type CreateUserRequest struct {
Username string `json:"username" binding:"required" example:"newuser"`
Password string `json:"password" binding:"required,min=6" example:"password123"`
}
// LoginRequest 定义登录请求的结构体
type LoginRequest struct {
Username string `json:"username" binding:"required" example:"testuser"`
Password string `json:"password" binding:"required" example:"password123"`
}
// CreateUserResponse 定义创建用户成功响应的结构体
type CreateUserResponse struct {
Username string `json:"username" example:"newuser"`
ID uint `json:"id" example:"1"`
}
// LoginResponse 定义登录成功响应的结构体
type LoginResponse struct {
Username string `json:"username" example:"testuser"`
ID uint `json:"id" example:"1"`
Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."`
}
// CreateUser godoc // CreateUser godoc
// @Summary 创建新用户 // @Summary 创建新用户
@@ -59,93 +35,86 @@ type LoginResponse struct {
// @Tags 用户管理 // @Tags 用户管理
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param user body CreateUserRequest true "用户信息" // @Param user body dto.CreateUserRequest true "用户信息"
// @Success 200 {object} controller.Response{data=CreateUserResponse} "用户创建成功" // @Success 200 {object} controller.Response{data=dto.CreateUserResponse} "业务码为201代表创建成功"
// @Failure 400 {object} controller.Response "请求参数错误" // @Router /api/v1/users [post]
// @Failure 409 {object} controller.Response "用户名已存在" func (c *Controller) CreateUser(ctx echo.Context) error {
// @Failure 500 {object} controller.Response "服务器内部错误" var req dto.CreateUserRequest
// @Router /users [post] if err := ctx.Bind(&req); err != nil {
func (c *Controller) CreateUser(ctx *gin.Context) {
var req CreateUserRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
c.logger.Errorf("创建用户: 参数绑定失败: %v", err) c.logger.Errorf("创建用户: 参数绑定失败: %v", err)
controller.SendErrorResponse(ctx, http.StatusBadRequest, err.Error()) return controller.SendErrorResponse(ctx, controller.CodeBadRequest, err.Error())
return
} }
user := &models.User{ resp, err := c.userService.CreateUser(&req)
Username: req.Username, if err != nil {
Password: req.Password, // 密码会在 BeforeSave 钩子中哈希 c.logger.Errorf("创建用户: 服务层调用失败: %v", err)
return controller.SendErrorResponse(ctx, controller.CodeInternalError, err.Error())
} }
if err := c.userRepo.Create(user); err != nil { return controller.SendResponse(ctx, controller.CodeCreated, "用户创建成功", resp)
c.logger.Errorf("创建用户: 创建用户失败: %v", err)
// 尝试查询用户,以判断是否是用户名重复导致的错误
_, findErr := c.userRepo.FindByUsername(req.Username)
if findErr == nil { // 如果能找到用户,说明是用户名重复
controller.SendErrorResponse(ctx, http.StatusConflict, "用户名已存在")
return
}
// 其他创建失败的情况
controller.SendErrorResponse(ctx, http.StatusInternalServerError, "创建用户失败")
return
}
controller.SendResponse(ctx, http.StatusOK, "用户创建成功", CreateUserResponse{
Username: user.Username,
ID: user.ID,
})
} }
// Login godoc // Login godoc
// @Summary 用户登录 // @Summary 用户登录
// @Description 用户使用用户名和密码登录,成功后返回 JWT 令牌。 // @Description 用户可以使用用户名、邮箱、手机号、微信号或飞书账号进行登录,成功后返回 JWT 令牌。
// @Tags 用户管理 // @Tags 用户管理
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param credentials body LoginRequest true "登录凭证" // @Param credentials body dto.LoginRequest true "登录凭证"
// @Success 200 {object} controller.Response{data=LoginResponse} "登录成功" // @Success 200 {object} controller.Response{data=dto.LoginResponse} "业务码为200代表登录成功"
// @Failure 400 {object} controller.Response "请求参数错误" // @Router /api/v1/users/login [post]
// @Failure 401 {object} controller.Response "用户名或密码不正确" func (c *Controller) Login(ctx echo.Context) error {
// @Failure 500 {object} controller.Response "服务器内部错误" var req dto.LoginRequest
// @Router /users/login [post] if err := ctx.Bind(&req); err != nil {
func (c *Controller) Login(ctx *gin.Context) {
var req LoginRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
c.logger.Errorf("登录: 参数绑定失败: %v", err) c.logger.Errorf("登录: 参数绑定失败: %v", err)
controller.SendErrorResponse(ctx, http.StatusBadRequest, err.Error()) return controller.SendErrorResponse(ctx, controller.CodeBadRequest, err.Error())
return
} }
user, err := c.userRepo.FindByUsername(req.Username) resp, err := c.userService.Login(&req)
if err != nil { if err != nil {
if err == gorm.ErrRecordNotFound { c.logger.Errorf("登录: 服务层调用失败: %v", err)
controller.SendErrorResponse(ctx, http.StatusUnauthorized, "用户名或密码不正确") return controller.SendErrorResponse(ctx, controller.CodeUnauthorized, err.Error())
return
}
c.logger.Errorf("登录: 查询用户失败: %v", err)
controller.SendErrorResponse(ctx, http.StatusInternalServerError, "登录失败")
return
} }
if !user.CheckPassword(req.Password) { return controller.SendResponse(ctx, controller.CodeSuccess, "登录成功", resp)
controller.SendErrorResponse(ctx, http.StatusUnauthorized, "用户名或密码不正确") }
return
} // SendTestNotification godoc
// @Summary 发送测试通知
// 登录成功,生成 JWT token // @Description 为指定用户发送一条特定渠道的测试消息,以验证其配置是否正确。
tokenString, err := c.tokenService.GenerateToken(user.ID) // @Tags 用户管理
if err != nil { // @Security BearerAuth
c.logger.Errorf("登录: 生成令牌失败: %v", err) // @Accept json
controller.SendErrorResponse(ctx, http.StatusInternalServerError, "登录失败,无法生成认证信息") // @Produce json
return // @Param id path int true "用户ID"
} // @Param body body dto.SendTestNotificationRequest true "请求体"
// @Success 200 {object} controller.Response{data=string} "成功响应"
controller.SendResponse(ctx, http.StatusOK, "登录成功", LoginResponse{ // @Router /api/v1/users/{id}/notifications/test [post]
Username: user.Username, func (c *Controller) SendTestNotification(ctx echo.Context) error {
ID: user.ID, const actionType = "发送测试通知"
Token: tokenString,
}) // 1. 从 URL 中获取用户 ID
userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
c.logger.Errorf("%s: 无效的用户ID格式: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的用户ID格式", actionType, "无效的用户ID格式", ctx.Param("id"))
}
// 2. 从请求体 (JSON Body) 中获取要测试的通知类型
var req dto.SendTestNotificationRequest
if err := ctx.Bind(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "请求体格式错误或缺少 'type' 字段: "+err.Error(), actionType, "请求体绑定失败", req)
}
// 3. 调用服务层
err = c.userService.SendTestNotification(uint(userID), &req)
if err != nil {
c.logger.Errorf("%s: 服务层调用失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "发送测试消息失败: "+err.Error(), actionType, "服务层调用失败", map[string]interface{}{"userID": userID, "type": req.Type})
}
// 4. 返回成功响应
c.logger.Infof("%s: 成功为用户 %d 发送类型为 %s 的测试消息", actionType, userID, req.Type)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "测试消息已发送,请检查您的接收端。", nil, actionType, "测试消息发送成功", map[string]interface{}{"userID": userID, "type": req.Type})
} }

View File

@@ -1,444 +0,0 @@
package user_test
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller/user"
"git.huangwc.com/pig/pig-farm-controller/internal/app/service/token"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gorm.io/gorm"
)
// MockUserRepository 是 UserRepository 接口的模拟实现
type MockUserRepository struct {
mock.Mock
}
// Create 模拟 UserRepository 的 Create 方法
func (m *MockUserRepository) Create(user *models.User) error {
args := m.Called(user)
return args.Error(0)
}
// FindByUsername 模拟 UserRepository 的 FindByUsername 方法
// 返回类型改回 *models.User
func (m *MockUserRepository) FindByUsername(username string) (*models.User, error) {
args := m.Called(username)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.User), args.Error(1)
}
// FindByID 模拟 UserRepository 的 FindByID 方法
func (m *MockUserRepository) FindByID(id uint) (*models.User, error) {
args := m.Called(id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.User), args.Error(1)
}
// MockTokenService 是 token.TokenService 接口的模拟实现
type MockTokenService struct {
mock.Mock
}
// GenerateToken 模拟 TokenService 的 GenerateToken 方法
func (m *MockTokenService) GenerateToken(userID uint) (string, error) {
args := m.Called(userID)
return args.String(0), args.Error(1)
}
// ParseToken 模拟 TokenService 的 ParseToken 方法
func (m *MockTokenService) ParseToken(tokenString string) (*token.Claims, error) {
args := m.Called(tokenString)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*token.Claims), args.Error(1)
}
// TestCreateUser 测试 CreateUser 方法
func TestCreateUser(t *testing.T) {
gin.SetMode(gin.TestMode) // 设置 Gin 为测试模式
// 创建一个不输出日志的真实 logs.Logger 实例
discardSyncer := zapcore.AddSync(io.Discard)
encoderConfig := zap.NewProductionEncoderConfig()
encoder := zapcore.NewConsoleEncoder(encoderConfig)
core := zapcore.NewCore(encoder, discardSyncer, zap.DebugLevel) // 设置为 DebugLevel 以确保所有日志都被处理(并丢弃)
zapLogger := zap.New(core)
sugaredLogger := zapLogger.Sugar()
silentLogger := &logs.Logger{SugaredLogger: sugaredLogger}
tests := []struct {
name string
requestBody user.CreateUserRequest
mockRepoSetup func(*MockUserRepository)
expectedResponse map[string]interface{}
}{
{
name: "成功创建用户",
requestBody: user.CreateUserRequest{
Username: "testuser",
Password: "password123",
},
mockRepoSetup: func(m *MockUserRepository) {
// 模拟 Create 成功
m.On("Create", mock.AnythingOfType("*models.User")).Return(nil).Once()
// 在成功创建用户的路径下FindByUsername 不会被调用,因此这里不需要设置其期望
},
expectedResponse: map[string]interface{}{
"code": float64(http.StatusOK),
"message": "用户创建成功",
"data": map[string]interface{}{
"username": "testuser",
"id": mock.Anything, // ID 是动态生成的,我们只检查存在
},
},
},
{
name: "请求参数绑定失败_密码过短",
requestBody: user.CreateUserRequest{
Username: "testuser2",
Password: "123", // 密码少于6位
},
mockRepoSetup: func(m *MockUserRepository) {
// 不会调用 Create 或 FindByUsername
},
expectedResponse: map[string]interface{}{
"code": float64(http.StatusBadRequest),
"message": "Key: 'CreateUserRequest.Password' Error:Field validation for 'Password' failed on the 'min' tag",
"data": nil,
},
},
{
name: "请求参数绑定失败_缺少用户名",
requestBody: user.CreateUserRequest{
Password: "password123",
},
mockRepoSetup: func(m *MockUserRepository) {
// 不会调用 Create 或 FindByUsername
},
expectedResponse: map[string]interface{}{
"code": float64(http.StatusBadRequest),
"message": "Key: 'CreateUserRequest.Username' Error:Field validation for 'Username' failed on the 'required' tag",
"data": nil,
},
},
{
name: "用户名已存在",
requestBody: user.CreateUserRequest{
Username: "existinguser",
Password: "password123",
},
mockRepoSetup: func(m *MockUserRepository) {
// 模拟 Create 失败,因为用户名已存在
m.On("Create", mock.AnythingOfType("*models.User")).Return(errors.New("duplicate entry")).Once()
// 模拟 FindByUsername 找到用户,确认是用户名重复
m.On("FindByUsername", "existinguser").Return(&models.User{Username: "existinguser"}, nil).Once()
},
expectedResponse: map[string]interface{}{
"code": float64(http.StatusConflict),
"message": "用户名已存在",
"data": nil,
},
},
{
name: "创建用户失败_通用数据库错误",
requestBody: user.CreateUserRequest{
Username: "db_error_user",
Password: "password123",
},
mockRepoSetup: func(m *MockUserRepository) {
// 模拟 Create 失败,通用数据库错误
m.On("Create", mock.AnythingOfType("*models.User")).Return(errors.New("database error")).Once()
// 模拟 FindByUsername 找不到用户,确认不是用户名重复
m.On("FindByUsername", "db_error_user").Return(nil, gorm.ErrRecordNotFound).Once()
},
expectedResponse: map[string]interface{}{
"code": float64(http.StatusInternalServerError),
"message": "创建用户失败",
"data": nil,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 初始化 Gin 上下文和记录器
w := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(w)
ctx.Request = httptest.NewRequest(http.MethodPost, "/users", nil) // URL 路径不重要,因为我们不测试路由
// 设置请求体
jsonBody, _ := json.Marshal(tt.requestBody)
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(jsonBody))
ctx.Request.Header.Set("Content-Type", "application/json")
// 创建 Mock UserRepository
mockRepo := new(MockUserRepository)
// 设置 Mock UserRepository 行为
tt.mockRepoSetup(mockRepo)
// 创建控制器实例,使用静默日志器
controller := user.NewController(mockRepo, silentLogger, nil) // tokenService 在 CreateUser 中未使用,设为 nil
// 调用被测试的方法
controller.CreateUser(ctx)
// 解析响应体
var responseBody map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &responseBody)
assert.NoError(t, err)
// 断言响应体中的 code 字段
assert.Equal(t, tt.expectedResponse["code"], responseBody["code"])
// 断言响应内容 (除了 code 字段)
// 对于成功的创建ID 是动态的,需要特殊处理
if tt.expectedResponse["code"] == float64(http.StatusOK) {
assert.NotNil(t, responseBody["data"].(map[string]interface{})["id"])
// 移除 ID 字段以便进行通用断言
delete(responseBody["data"].(map[string]interface{}), "id")
delete(tt.expectedResponse["data"].(map[string]interface{}), "id")
}
// 移除 code 字段以便进行通用断言
delete(responseBody, "code")
delete(tt.expectedResponse, "code")
assert.Equal(t, tt.expectedResponse, responseBody)
// 验证 Mock 期望是否都已满足
mockRepo.AssertExpectations(t)
})
}
}
// TestLogin 测试 Login 方法
func TestLogin(t *testing.T) {
// 设置release模式阻止废话日志
gin.SetMode(gin.ReleaseMode)
// 创建一个不输出日志的真实 logs.Logger 实例
discardSyncer := zapcore.AddSync(io.Discard)
encoderConfig := zap.NewProductionEncoderConfig()
encoder := zapcore.NewConsoleEncoder(encoderConfig)
core := zapcore.NewCore(encoder, discardSyncer, zap.DebugLevel) // 设置为 DebugLevel 以确保所有日志都被处理(并丢弃)
zapLogger := zap.New(core)
sugaredLogger := zapLogger.Sugar()
silentLogger := &logs.Logger{SugaredLogger: sugaredLogger}
tests := []struct {
name string
requestBody user.LoginRequest
mockRepoSetup func(*MockUserRepository)
mockTokenServiceSetup func(*MockTokenService)
expectedResponse map[string]interface{}
}{
{
name: "成功登录",
requestBody: user.LoginRequest{
Username: "loginuser",
Password: "correctpassword",
},
mockRepoSetup: func(m *MockUserRepository) {
mockUser := &models.User{
Model: gorm.Model{ID: 1},
Username: "loginuser",
Password: "correctpassword", // 明文密码BeforeCreate 会哈希它
}
// 调用 BeforeCreate 钩子来哈希密码
_ = mockUser.BeforeCreate(nil)
m.On("FindByUsername", "loginuser").Return(mockUser, nil).Once()
},
mockTokenServiceSetup: func(m *MockTokenService) {
m.On("GenerateToken", uint(1)).Return("mocked_token", nil).Once()
},
expectedResponse: map[string]interface{}{
"code": float64(http.StatusOK),
"message": "登录成功",
"data": map[string]interface{}{
"username": "loginuser",
"id": float64(1),
"token": "mocked_token",
},
},
},
{
name: "请求参数绑定失败_缺少用户名",
requestBody: user.LoginRequest{
Username: "", // 缺少用户名
Password: "password",
},
mockRepoSetup: func(m *MockUserRepository) {},
mockTokenServiceSetup: func(m *MockTokenService) {},
expectedResponse: map[string]interface{}{
"code": float64(http.StatusBadRequest),
"message": "Key: 'LoginRequest.Username' Error:Field validation for 'Username' failed on the 'required' tag",
"data": nil,
},
},
{
name: "请求参数绑定失败_缺少密码",
requestBody: user.LoginRequest{
Username: "testuser",
Password: "", // 缺少密码
},
mockRepoSetup: func(m *MockUserRepository) {},
mockTokenServiceSetup: func(m *MockTokenService) {},
expectedResponse: map[string]interface{}{
"code": float64(http.StatusBadRequest),
"message": "Key: 'LoginRequest.Password' Error:Field validation for 'Password' failed on the 'required' tag",
"data": nil,
},
},
{
name: "用户不存在",
requestBody: user.LoginRequest{
Username: "nonexistent",
Password: "anypassword",
},
mockRepoSetup: func(m *MockUserRepository) {
m.On("FindByUsername", "nonexistent").Return(nil, gorm.ErrRecordNotFound).Once()
},
mockTokenServiceSetup: func(m *MockTokenService) {},
expectedResponse: map[string]interface{}{
"code": float64(http.StatusUnauthorized),
"message": "用户名或密码不正确",
"data": nil,
},
},
{
name: "查询用户失败_通用数据库错误",
requestBody: user.LoginRequest{
Username: "dberroruser",
Password: "password",
},
mockRepoSetup: func(m *MockUserRepository) {
m.On("FindByUsername", "dberroruser").Return(nil, errors.New("database connection error")).Once()
},
mockTokenServiceSetup: func(m *MockTokenService) {}, expectedResponse: map[string]interface{}{
"code": float64(http.StatusInternalServerError),
"message": "登录失败",
"data": nil,
},
},
{
name: "密码不正确",
requestBody: user.LoginRequest{
Username: "loginuser",
Password: "wrongpassword",
},
mockRepoSetup: func(m *MockUserRepository) {
mockUser := &models.User{
Model: gorm.Model{ID: 1},
Username: "loginuser",
Password: "correctpassword", // 明文密码BeforeCreate 会哈希它
}
// 调用 BeforeCreate 钩子来哈希密码
_ = mockUser.BeforeCreate(nil)
m.On("FindByUsername", "loginuser").Return(mockUser, nil).Once()
},
mockTokenServiceSetup: func(m *MockTokenService) {},
expectedResponse: map[string]interface{}{
"code": float64(http.StatusUnauthorized),
"message": "用户名或密码不正确",
"data": nil,
},
},
{
name: "生成Token失败",
requestBody: user.LoginRequest{
Username: "loginuser",
Password: "correctpassword",
},
mockRepoSetup: func(m *MockUserRepository) {
mockUser := &models.User{
Model: gorm.Model{ID: 1},
Username: "loginuser",
Password: "correctpassword", // 明文密码BeforeCreate 会哈希它
}
// 调用 BeforeCreate 钩子来哈希密码
_ = mockUser.BeforeCreate(nil)
m.On("FindByUsername", "loginuser").Return(mockUser, nil).Once()
},
mockTokenServiceSetup: func(m *MockTokenService) {
m.On("GenerateToken", uint(1)).Return("", errors.New("jwt error")).Once()
},
expectedResponse: map[string]interface{}{
"code": float64(http.StatusInternalServerError),
"message": "登录失败,无法生成认证信息",
"data": nil,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 初始化 Gin 上下文和记录器
w := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(w)
ctx.Request = httptest.NewRequest(http.MethodPost, "/login", nil) // URL 路径不重要,因为我们不测试路由
// 设置请求体
jsonBody, _ := json.Marshal(tt.requestBody)
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(jsonBody))
ctx.Request.Header.Set("Content-Type", "application/json")
// 创建 Mock
mockRepo := new(MockUserRepository)
mockTokenService := new(MockTokenService)
// 设置 Mock 行为
tt.mockRepoSetup(mockRepo)
tt.mockTokenServiceSetup(mockTokenService)
// 创建控制器实例
controller := user.NewController(mockRepo, silentLogger, mockTokenService)
// 调用被测试的方法
controller.Login(ctx)
// 解析响应体
var responseBody map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &responseBody)
assert.NoError(t, err)
// 断言响应体中的 code 字段
assert.Equal(t, tt.expectedResponse["code"], responseBody["code"])
// 断言响应内容 (除了 code 字段)
if tt.expectedResponse["code"] == float64(http.StatusOK) {
assert.NotNil(t, responseBody["data"].(map[string]interface{})["id"])
assert.NotNil(t, responseBody["data"].(map[string]interface{})["token"])
// 移除 ID 和 Token 字段以便进行通用断言
delete(responseBody["data"].(map[string]interface{}), "id")
delete(tt.expectedResponse["data"].(map[string]interface{}), "id")
delete(responseBody["data"].(map[string]interface{}), "token")
delete(tt.expectedResponse["data"].(map[string]interface{}), "token")
}
// 移除 code 字段以便进行通用断言
delete(responseBody, "code")
delete(tt.expectedResponse, "code")
assert.Equal(t, tt.expectedResponse, responseBody)
// 验证 Mock 期望是否都已满足
mockRepo.AssertExpectations(t)
mockTokenService.AssertExpectations(t)
})
}
}

View File

@@ -0,0 +1,142 @@
package dto
import (
"encoding/json"
"fmt"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
)
// NewDeviceResponse 从数据库模型创建一个新的设备响应 DTO
func NewDeviceResponse(device *models.Device) (*DeviceResponse, error) {
if device == nil {
return nil, nil
}
var props map[string]interface{}
if len(device.Properties) > 0 && string(device.Properties) != "null" {
if err := device.ParseProperties(&props); err != nil {
return nil, fmt.Errorf("解析设备属性失败 (ID: %d): %w", device.ID, err)
}
}
// 确保 DeviceTemplate 和 AreaController 已预加载
deviceTemplateName := ""
if device.DeviceTemplate.ID != 0 {
deviceTemplateName = device.DeviceTemplate.Name
}
areaControllerName := ""
if device.AreaController.ID != 0 {
areaControllerName = device.AreaController.Name
}
return &DeviceResponse{
ID: device.ID,
Name: device.Name,
DeviceTemplateID: device.DeviceTemplateID,
DeviceTemplateName: deviceTemplateName,
AreaControllerID: device.AreaControllerID,
AreaControllerName: areaControllerName,
Location: device.Location,
Properties: props,
CreatedAt: device.CreatedAt.Format(time.RFC3339),
UpdatedAt: device.UpdatedAt.Format(time.RFC3339),
}, nil
}
// NewListDeviceResponse 从数据库模型切片创建一个新的设备列表响应 DTO 切片
func NewListDeviceResponse(devices []*models.Device) ([]*DeviceResponse, error) {
list := make([]*DeviceResponse, 0, len(devices))
for _, device := range devices {
resp, err := NewDeviceResponse(device)
if err != nil {
return nil, err
}
list = append(list, resp)
}
return list, nil
}
// NewAreaControllerResponse 从数据库模型创建一个新的区域主控响应 DTO
func NewAreaControllerResponse(ac *models.AreaController) (*AreaControllerResponse, error) {
if ac == nil {
return nil, nil
}
var props map[string]interface{}
if len(ac.Properties) > 0 && string(ac.Properties) != "null" {
if err := json.Unmarshal(ac.Properties, &props); err != nil {
return nil, fmt.Errorf("解析区域主控属性失败 (ID: %d): %w", ac.ID, err)
}
}
return &AreaControllerResponse{
ID: ac.ID,
Name: ac.Name,
NetworkID: ac.NetworkID,
Location: ac.Location,
Status: ac.Status,
Properties: props,
CreatedAt: ac.CreatedAt.Format(time.RFC3339),
UpdatedAt: ac.UpdatedAt.Format(time.RFC3339),
}, nil
}
// NewListAreaControllerResponse 从数据库模型切片创建一个新的区域主控列表响应 DTO 切片
func NewListAreaControllerResponse(acs []*models.AreaController) ([]*AreaControllerResponse, error) {
list := make([]*AreaControllerResponse, 0, len(acs))
for _, ac := range acs {
resp, err := NewAreaControllerResponse(ac)
if err != nil {
return nil, err
}
list = append(list, resp)
}
return list, nil
}
// NewDeviceTemplateResponse 从数据库模型创建一个新的设备模板响应 DTO
func NewDeviceTemplateResponse(dt *models.DeviceTemplate) (*DeviceTemplateResponse, error) {
if dt == nil {
return nil, nil
}
var commands map[string]interface{}
if err := dt.ParseCommands(&commands); err != nil {
return nil, fmt.Errorf("解析设备模板命令失败 (ID: %d): %w", dt.ID, err)
}
var values []models.ValueDescriptor
if dt.Category == models.CategorySensor {
if err := dt.ParseValues(&values); err != nil {
return nil, fmt.Errorf("解析设备模板值描述符失败 (ID: %d): %w", dt.ID, err)
}
}
return &DeviceTemplateResponse{
ID: dt.ID,
Name: dt.Name,
Manufacturer: dt.Manufacturer,
Description: dt.Description,
Category: dt.Category,
Commands: commands,
Values: values,
CreatedAt: dt.CreatedAt.Format(time.RFC3339),
UpdatedAt: dt.UpdatedAt.Format(time.RFC3339),
}, nil
}
// NewListDeviceTemplateResponse 从数据库模型切片创建一个新的设备模板列表响应 DTO 切片
func NewListDeviceTemplateResponse(dts []*models.DeviceTemplate) ([]*DeviceTemplateResponse, error) {
list := make([]*DeviceTemplateResponse, 0, len(dts))
for _, dt := range dts {
resp, err := NewDeviceTemplateResponse(dt)
if err != nil {
return nil, err
}
list = append(list, resp)
}
return list, nil
}

View File

@@ -0,0 +1,102 @@
package dto
import "git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
// CreateDeviceRequest 定义了创建设备时需要传入的参数
type CreateDeviceRequest struct {
Name string `json:"name" validate:"required"`
DeviceTemplateID uint `json:"device_template_id" validate:"required"`
AreaControllerID uint `json:"area_controller_id" validate:"required"`
Location string `json:"location,omitempty" validate:"omitempty"`
Properties map[string]interface{} `json:"properties,omitempty" validate:"omitempty"`
}
// UpdateDeviceRequest 定义了更新设备时需要传入的参数
type UpdateDeviceRequest struct {
Name string `json:"name" validate:"required"`
DeviceTemplateID uint `json:"device_template_id" validate:"required"`
AreaControllerID uint `json:"area_controller_id" validate:"required"`
Location string `json:"location,omitempty" validate:"omitempty"`
Properties map[string]interface{} `json:"properties,omitempty" validate:"omitempty"`
}
// ManualControlDeviceRequest 定义了手动控制设备时需要传入的参数
type ManualControlDeviceRequest struct {
// Action 不传表示这是一个传感器, 会触发一次采集
Action *string `json:"action"`
}
// CreateAreaControllerRequest 定义了创建区域主控时需要传入的参数
type CreateAreaControllerRequest struct {
Name string `json:"name" validate:"required"`
NetworkID string `json:"network_id" validate:"required"`
Location string `json:"location,omitempty" validate:"omitempty"`
Properties map[string]interface{} `json:"properties,omitempty" validate:"omitempty"`
}
// UpdateAreaControllerRequest 定义了更新区域主控时需要传入的参数
type UpdateAreaControllerRequest struct {
Name string `json:"name" validate:"required"`
NetworkID string `json:"network_id" validate:"required"`
Location string `json:"location,omitempty" validate:"omitempty"`
Properties map[string]interface{} `json:"properties,omitempty" validate:"omitempty"`
}
// CreateDeviceTemplateRequest 定义了创建设备模板时需要传入的参数
type CreateDeviceTemplateRequest struct {
Name string `json:"name" validate:"required"`
Manufacturer string `json:"manufacturer,omitempty" validate:"omitempty"`
Description string `json:"description,omitempty" validate:"omitempty"`
Category models.DeviceCategory `json:"category" validate:"required"`
Commands map[string]interface{} `json:"commands" validate:"required"`
Values []models.ValueDescriptor `json:"values,omitempty" validate:"omitempty,dive"`
}
// UpdateDeviceTemplateRequest 定义了更新设备模板时需要传入的参数
type UpdateDeviceTemplateRequest struct {
Name string `json:"name" validate:"required"`
Manufacturer string `json:"manufacturer,omitempty" validate:"omitempty"`
Description string `json:"description,omitempty" validate:"omitempty"`
Category models.DeviceCategory `json:"category" validate:"required"`
Commands map[string]interface{} `json:"commands" validate:"required"`
Values []models.ValueDescriptor `json:"values,omitempty" validate:"omitempty,dive"`
}
// DeviceResponse 定义了返回给客户端的单个设备信息的结构
type DeviceResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
DeviceTemplateID uint `json:"device_template_id"`
DeviceTemplateName string `json:"device_template_name"`
AreaControllerID uint `json:"area_controller_id"`
AreaControllerName string `json:"area_controller_name"`
Location string `json:"location"`
Properties map[string]interface{} `json:"properties"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// AreaControllerResponse 定义了返回给客户端的单个区域主控信息的结构
type AreaControllerResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
NetworkID string `json:"network_id"`
Location string `json:"location"`
Status string `json:"status"`
Properties map[string]interface{} `json:"properties"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// DeviceTemplateResponse 定义了返回给客户端的单个设备模板信息的结构
type DeviceTemplateResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Manufacturer string `json:"manufacturer"`
Description string `json:"description"`
Category models.DeviceCategory `json:"category"`
Commands map[string]interface{} `json:"commands"`
Values []models.ValueDescriptor `json:"values"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}

View File

@@ -0,0 +1,489 @@
package dto
import (
"encoding/json"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
)
// NewListSensorDataResponse 从模型数据创建列表响应 DTO
func NewListSensorDataResponse(data []models.SensorData, total int64, page, pageSize int) *ListSensorDataResponse {
dtos := make([]SensorDataDTO, len(data))
for i, item := range data {
dtos[i] = SensorDataDTO{
Time: item.Time,
DeviceID: item.DeviceID,
RegionalControllerID: item.RegionalControllerID,
SensorType: item.SensorType,
Data: json.RawMessage(item.Data),
}
}
return &ListSensorDataResponse{
List: dtos,
Pagination: PaginationDTO{
Total: total,
Page: page,
PageSize: pageSize,
},
}
}
// NewListDeviceCommandLogResponse 从模型数据创建列表响应 DTO
func NewListDeviceCommandLogResponse(data []models.DeviceCommandLog, total int64, page, pageSize int) *ListDeviceCommandLogResponse {
dtos := make([]DeviceCommandLogDTO, len(data))
for i, item := range data {
dtos[i] = DeviceCommandLogDTO{
MessageID: item.MessageID,
DeviceID: item.DeviceID,
SentAt: item.SentAt,
AcknowledgedAt: item.AcknowledgedAt,
ReceivedSuccess: item.ReceivedSuccess,
}
}
return &ListDeviceCommandLogResponse{
List: dtos,
Pagination: PaginationDTO{
Total: total,
Page: page,
PageSize: pageSize,
},
}
}
// NewListPlanExecutionLogResponse 从模型数据创建列表响应 DTO
func NewListPlanExecutionLogResponse(planLogs []models.PlanExecutionLog, plans []models.Plan, total int64, page, pageSize int) *ListPlanExecutionLogResponse {
planId2Name := make(map[uint]string)
for _, plan := range plans {
planId2Name[plan.ID] = plan.Name
}
dtos := make([]PlanExecutionLogDTO, len(planLogs))
for i, item := range planLogs {
dtos[i] = PlanExecutionLogDTO{
ID: item.ID,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
PlanID: item.PlanID,
PlanName: planId2Name[item.PlanID],
Status: item.Status,
StartedAt: item.StartedAt,
EndedAt: item.EndedAt,
Error: item.Error,
}
}
return &ListPlanExecutionLogResponse{
List: dtos,
Pagination: PaginationDTO{
Total: total,
Page: page,
PageSize: pageSize,
},
}
}
// NewListTaskExecutionLogResponse 从模型数据创建列表响应 DTO
func NewListTaskExecutionLogResponse(data []models.TaskExecutionLog, total int64, page, pageSize int) *ListTaskExecutionLogResponse {
dtos := make([]TaskExecutionLogDTO, len(data))
for i, item := range data {
dtos[i] = TaskExecutionLogDTO{
ID: item.ID,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
PlanExecutionLogID: item.PlanExecutionLogID,
TaskID: item.TaskID,
Task: TaskDTO{
ID: uint(item.Task.ID),
Name: item.Task.Name,
Description: item.Task.Description,
},
Status: item.Status,
Output: item.Output,
StartedAt: item.StartedAt,
EndedAt: item.EndedAt,
}
}
return &ListTaskExecutionLogResponse{
List: dtos,
Pagination: PaginationDTO{
Total: total,
Page: page,
PageSize: pageSize,
},
}
}
// NewListPendingCollectionResponse 从模型数据创建列表响应 DTO
func NewListPendingCollectionResponse(data []models.PendingCollection, total int64, page, pageSize int) *ListPendingCollectionResponse {
dtos := make([]PendingCollectionDTO, len(data))
for i, item := range data {
dtos[i] = PendingCollectionDTO{
CorrelationID: item.CorrelationID,
DeviceID: item.DeviceID,
CommandMetadata: item.CommandMetadata,
Status: item.Status,
FulfilledAt: item.FulfilledAt,
CreatedAt: item.CreatedAt,
}
}
return &ListPendingCollectionResponse{
List: dtos,
Pagination: PaginationDTO{
Total: total,
Page: page,
PageSize: pageSize,
},
}
}
// NewListUserActionLogResponse 从模型数据创建列表响应 DTO
func NewListUserActionLogResponse(data []models.UserActionLog, total int64, page, pageSize int) *ListUserActionLogResponse {
dtos := make([]UserActionLogDTO, len(data))
for i, item := range data {
dtos[i] = UserActionLogDTO{
ID: item.ID,
Time: item.Time,
UserID: item.UserID,
Username: item.Username,
SourceIP: item.SourceIP,
ActionType: item.ActionType,
TargetResource: json.RawMessage(item.TargetResource),
Description: item.Description,
Status: item.Status,
HTTPPath: item.HTTPPath,
HTTPMethod: item.HTTPMethod,
ResultDetails: item.ResultDetails,
}
}
return &ListUserActionLogResponse{
List: dtos,
Pagination: PaginationDTO{
Total: total,
Page: page,
PageSize: pageSize,
},
}
}
// NewListRawMaterialPurchaseResponse 从模型数据创建列表响应 DTO
func NewListRawMaterialPurchaseResponse(data []models.RawMaterialPurchase, total int64, page, pageSize int) *ListRawMaterialPurchaseResponse {
dtos := make([]RawMaterialPurchaseDTO, len(data))
for i, item := range data {
dtos[i] = RawMaterialPurchaseDTO{
ID: item.ID,
RawMaterialID: item.RawMaterialID,
RawMaterial: RawMaterialDTO{
ID: item.RawMaterial.ID,
Name: item.RawMaterial.Name,
},
Supplier: item.Supplier,
Amount: item.Amount,
UnitPrice: item.UnitPrice,
TotalPrice: item.TotalPrice,
PurchaseDate: item.PurchaseDate,
CreatedAt: item.CreatedAt,
}
}
return &ListRawMaterialPurchaseResponse{
List: dtos,
Pagination: PaginationDTO{
Total: total,
Page: page,
PageSize: pageSize,
},
}
}
// NewListRawMaterialStockLogResponse 从模型数据创建列表响应 DTO
func NewListRawMaterialStockLogResponse(data []models.RawMaterialStockLog, total int64, page, pageSize int) *ListRawMaterialStockLogResponse {
dtos := make([]RawMaterialStockLogDTO, len(data))
for i, item := range data {
dtos[i] = RawMaterialStockLogDTO{
ID: item.ID,
RawMaterialID: item.RawMaterialID,
ChangeAmount: item.ChangeAmount,
SourceType: item.SourceType,
SourceID: item.SourceID,
HappenedAt: item.HappenedAt,
Remarks: item.Remarks,
}
}
return &ListRawMaterialStockLogResponse{
List: dtos,
Pagination: PaginationDTO{
Total: total,
Page: page,
PageSize: pageSize,
},
}
}
// NewListFeedUsageRecordResponse 从模型数据创建列表响应 DTO
func NewListFeedUsageRecordResponse(data []models.FeedUsageRecord, total int64, page, pageSize int) *ListFeedUsageRecordResponse {
dtos := make([]FeedUsageRecordDTO, len(data))
for i, item := range data {
dtos[i] = FeedUsageRecordDTO{
ID: item.ID,
PenID: item.PenID,
Pen: PenDTO{
ID: item.Pen.ID,
Name: item.Pen.PenNumber,
},
FeedFormulaID: item.FeedFormulaID,
FeedFormula: FeedFormulaDTO{
ID: item.FeedFormula.ID,
Name: item.FeedFormula.Name,
},
Amount: item.Amount,
RecordedAt: item.RecordedAt,
OperatorID: item.OperatorID,
Remarks: item.Remarks,
}
}
return &ListFeedUsageRecordResponse{
List: dtos,
Pagination: PaginationDTO{
Total: total,
Page: page,
PageSize: pageSize,
},
}
}
// NewListMedicationLogResponse 从模型数据创建列表响应 DTO
func NewListMedicationLogResponse(data []models.MedicationLog, total int64, page, pageSize int) *ListMedicationLogResponse {
dtos := make([]MedicationLogDTO, len(data))
for i, item := range data {
dtos[i] = MedicationLogDTO{
ID: item.ID,
PigBatchID: item.PigBatchID,
MedicationID: item.MedicationID,
Medication: MedicationDTO{
ID: item.Medication.ID,
Name: item.Medication.Name,
},
DosageUsed: item.DosageUsed,
TargetCount: item.TargetCount,
Reason: item.Reason,
Description: item.Description,
OperatorID: item.OperatorID,
HappenedAt: item.HappenedAt,
}
}
return &ListMedicationLogResponse{
List: dtos,
Pagination: PaginationDTO{
Total: total,
Page: page,
PageSize: pageSize,
},
}
}
// NewListPigBatchLogResponse 从模型数据创建列表响应 DTO
func NewListPigBatchLogResponse(data []models.PigBatchLog, total int64, page, pageSize int) *ListPigBatchLogResponse {
dtos := make([]PigBatchLogDTO, len(data))
for i, item := range data {
dtos[i] = PigBatchLogDTO{
ID: item.ID,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
PigBatchID: item.PigBatchID,
ChangeType: item.ChangeType,
ChangeCount: item.ChangeCount,
Reason: item.Reason,
BeforeCount: item.BeforeCount,
AfterCount: item.AfterCount,
OperatorID: item.OperatorID,
HappenedAt: item.HappenedAt,
}
}
return &ListPigBatchLogResponse{
List: dtos,
Pagination: PaginationDTO{
Total: total,
Page: page,
PageSize: pageSize,
},
}
}
// NewListWeighingBatchResponse 从模型数据创建列表响应 DTO
func NewListWeighingBatchResponse(data []models.WeighingBatch, total int64, page, pageSize int) *ListWeighingBatchResponse {
dtos := make([]WeighingBatchDTO, len(data))
for i, item := range data {
dtos[i] = WeighingBatchDTO{
ID: item.ID,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
WeighingTime: item.WeighingTime,
Description: item.Description,
PigBatchID: item.PigBatchID,
}
}
return &ListWeighingBatchResponse{
List: dtos,
Pagination: PaginationDTO{
Total: total,
Page: page,
PageSize: pageSize,
},
}
}
// NewListWeighingRecordResponse 从模型数据创建列表响应 DTO
func NewListWeighingRecordResponse(data []models.WeighingRecord, total int64, page, pageSize int) *ListWeighingRecordResponse {
dtos := make([]WeighingRecordDTO, len(data))
for i, item := range data {
dtos[i] = WeighingRecordDTO{
ID: item.ID,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
Weight: item.Weight,
WeighingBatchID: item.WeighingBatchID,
PenID: item.PenID,
OperatorID: item.OperatorID,
Remark: item.Remark,
WeighingTime: item.WeighingTime,
}
}
return &ListWeighingRecordResponse{
List: dtos,
Pagination: PaginationDTO{
Total: total,
Page: page,
PageSize: pageSize,
},
}
}
// NewListPigTransferLogResponse 从模型数据创建列表响应 DTO
func NewListPigTransferLogResponse(data []models.PigTransferLog, total int64, page, pageSize int) *ListPigTransferLogResponse {
dtos := make([]PigTransferLogDTO, len(data))
for i, item := range data {
// 注意PigTransferLog 的 ID, CreatedAt, UpdatedAt 字段是 gorm.Model 嵌入的
dtos[i] = PigTransferLogDTO{
ID: item.ID,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
TransferTime: item.TransferTime,
PigBatchID: item.PigBatchID,
PenID: item.PenID,
Quantity: item.Quantity,
Type: item.Type,
CorrelationID: item.CorrelationID,
OperatorID: item.OperatorID,
Remarks: item.Remarks,
}
}
return &ListPigTransferLogResponse{
List: dtos,
Pagination: PaginationDTO{
Total: total,
Page: page,
PageSize: pageSize,
},
}
}
// NewListPigSickLogResponse 从模型数据创建列表响应 DTO
func NewListPigSickLogResponse(data []models.PigSickLog, total int64, page, pageSize int) *ListPigSickLogResponse {
dtos := make([]PigSickLogDTO, len(data))
for i, item := range data {
dtos[i] = PigSickLogDTO{
ID: item.ID,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
PigBatchID: item.PigBatchID,
PenID: item.PenID,
ChangeCount: item.ChangeCount,
Reason: item.Reason,
BeforeCount: item.BeforeCount,
AfterCount: item.AfterCount,
Remarks: item.Remarks,
TreatmentLocation: item.TreatmentLocation,
OperatorID: item.OperatorID,
HappenedAt: item.HappenedAt,
}
}
return &ListPigSickLogResponse{
List: dtos,
Pagination: PaginationDTO{
Total: total,
Page: page,
PageSize: pageSize,
},
}
}
// NewListPigPurchaseResponse 从模型数据创建列表响应 DTO
func NewListPigPurchaseResponse(data []models.PigPurchase, total int64, page, pageSize int) *ListPigPurchaseResponse {
dtos := make([]PigPurchaseDTO, len(data))
for i, item := range data {
dtos[i] = PigPurchaseDTO{
ID: item.ID,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
PigBatchID: item.PigBatchID,
PurchaseDate: item.PurchaseDate,
Supplier: item.Supplier,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
TotalPrice: item.TotalPrice,
Remarks: item.Remarks,
OperatorID: item.OperatorID,
}
}
return &ListPigPurchaseResponse{
List: dtos,
Pagination: PaginationDTO{
Total: total,
Page: page,
PageSize: pageSize,
},
}
}
// NewListPigSaleResponse 从模型数据创建列表响应 DTO
func NewListPigSaleResponse(data []models.PigSale, total int64, page, pageSize int) *ListPigSaleResponse {
dtos := make([]PigSaleDTO, len(data))
for i, item := range data {
dtos[i] = PigSaleDTO{
ID: item.ID,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
PigBatchID: item.PigBatchID,
SaleDate: item.SaleDate,
Buyer: item.Buyer,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
TotalPrice: item.TotalPrice,
Remarks: item.Remarks,
OperatorID: item.OperatorID,
}
}
return &ListPigSaleResponse{
List: dtos,
Pagination: PaginationDTO{
Total: total,
Page: page,
PageSize: pageSize,
},
}
}

View File

@@ -0,0 +1,609 @@
package dto
import (
"encoding/json"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
)
// --- General ---
// PaginationDTO 定义了分页信息的标准结构
type PaginationDTO struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// --- SensorData ---
// ListSensorDataRequest 定义了获取传感器数据列表的请求参数
type ListSensorDataRequest struct {
Page int `json:"page" query:"page"`
PageSize int `json:"page_size" query:"page_size"`
DeviceID *uint `json:"device_id" query:"device_id"`
SensorType *string `json:"sensor_type" query:"sensor_type"`
StartTime *time.Time `json:"start_time" query:"start_time"`
EndTime *time.Time `json:"end_time" query:"end_time"`
OrderBy string `json:"order_by" query:"order_by"`
}
// SensorDataDTO 是用于API响应的传感器数据结构
type SensorDataDTO struct {
Time time.Time `json:"time"`
DeviceID uint `json:"device_id"`
RegionalControllerID uint `json:"regional_controller_id"`
SensorType models.SensorType `json:"sensor_type"`
Data json.RawMessage `json:"data"`
}
// ListSensorDataResponse 是获取传感器数据列表的响应结构
type ListSensorDataResponse struct {
List []SensorDataDTO `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}
// --- DeviceCommandLog ---
// ListDeviceCommandLogRequest 定义了获取设备命令日志列表的请求参数
type ListDeviceCommandLogRequest struct {
Page int `json:"page" query:"page"`
PageSize int `json:"page_size" query:"page_size"`
DeviceID *uint `json:"device_id" query:"device_id"`
ReceivedSuccess *bool `json:"received_success" query:"received_success"`
StartTime *time.Time `json:"start_time" query:"start_time"`
EndTime *time.Time `json:"end_time" query:"end_time"`
OrderBy string `json:"order_by" query:"order_by"`
}
// DeviceCommandLogDTO 是用于API响应的设备命令日志结构
type DeviceCommandLogDTO struct {
MessageID string `json:"message_id"`
DeviceID uint `json:"device_id"`
SentAt time.Time `json:"sent_at"`
AcknowledgedAt *time.Time `json:"acknowledged_at"`
ReceivedSuccess bool `json:"received_success"`
}
// ListDeviceCommandLogResponse 是获取设备命令日志列表的响应结构
type ListDeviceCommandLogResponse struct {
List []DeviceCommandLogDTO `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}
// --- PlanExecutionLog ---
// ListPlanExecutionLogRequest 定义了获取计划执行日志列表的请求参数
type ListPlanExecutionLogRequest struct {
Page int `json:"page" query:"page"`
PageSize int `json:"page_size" query:"page_size"`
PlanID *uint `json:"plan_id" query:"plan_id"`
Status *string `json:"status" query:"status"`
StartTime *time.Time `json:"start_time" query:"start_time"`
EndTime *time.Time `json:"end_time" query:"end_time"`
OrderBy string `json:"order_by" query:"order_by"`
}
// PlanExecutionLogDTO 是用于API响应的计划执行日志结构
type PlanExecutionLogDTO struct {
ID uint `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PlanID uint `json:"plan_id"`
PlanName string `json:"plan_name"`
Status models.ExecutionStatus `json:"status"`
StartedAt time.Time `json:"started_at"`
EndedAt time.Time `json:"ended_at"`
Error string `json:"error"`
}
// ListPlanExecutionLogResponse 是获取计划执行日志列表的响应结构
type ListPlanExecutionLogResponse struct {
List []PlanExecutionLogDTO `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}
// --- TaskExecutionLog ---
// ListTaskExecutionLogRequest 定义了获取任务执行日志列表的请求参数
type ListTaskExecutionLogRequest struct {
Page int `json:"page" query:"page"`
PageSize int `json:"page_size" query:"page_size"`
PlanExecutionLogID *uint `json:"plan_execution_log_id" query:"plan_execution_log_id"`
TaskID *int `json:"task_id" query:"task_id"`
Status *string `json:"status" query:"status"`
StartTime *time.Time `json:"start_time" query:"start_time"`
EndTime *time.Time `json:"end_time" query:"end_time"`
OrderBy string `json:"order_by" query:"order_by"`
}
// TaskDTO 是用于API响应的简化版任务结构
type TaskDTO struct {
ID uint `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
// TaskExecutionLogDTO 是用于API响应的任务执行日志结构
type TaskExecutionLogDTO struct {
ID uint `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PlanExecutionLogID uint `json:"plan_execution_log_id"`
TaskID int `json:"task_id"`
Task TaskDTO `json:"task"` // 嵌套的任务信息
Status models.ExecutionStatus `json:"status"`
Output string `json:"output"`
StartedAt time.Time `json:"started_at"`
EndedAt time.Time `json:"ended_at"`
}
// ListTaskExecutionLogResponse 是获取任务执行日志列表的响应结构
type ListTaskExecutionLogResponse struct {
List []TaskExecutionLogDTO `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}
// --- PendingCollection ---
// ListPendingCollectionRequest 定义了获取待采集请求列表的请求参数
type ListPendingCollectionRequest struct {
Page int `json:"page" query:"page"`
PageSize int `json:"page_size" query:"page_size"`
DeviceID *uint `json:"device_id" query:"device_id"`
Status *string `json:"status" query:"status"`
StartTime *time.Time `json:"start_time" query:"start_time"`
EndTime *time.Time `json:"end_time" query:"end_time"`
OrderBy string `json:"order_by" query:"order_by"`
}
// PendingCollectionDTO 是用于API响应的待采集请求结构
type PendingCollectionDTO struct {
CorrelationID string `json:"correlation_id"`
DeviceID uint `json:"device_id"`
CommandMetadata models.UintArray `json:"command_metadata"`
Status models.PendingCollectionStatus `json:"status"`
FulfilledAt *time.Time `json:"fulfilled_at"`
CreatedAt time.Time `json:"created_at"`
}
// ListPendingCollectionResponse 是获取待采集请求列表的响应结构
type ListPendingCollectionResponse struct {
List []PendingCollectionDTO `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}
// --- UserActionLog ---
// ListUserActionLogRequest 定义了获取用户操作日志列表的请求参数
type ListUserActionLogRequest struct {
Page int `json:"page" query:"page"`
PageSize int `json:"page_size" query:"page_size"`
UserID *uint `json:"user_id" query:"user_id"`
Username *string `json:"username" query:"username"`
ActionType *string `json:"action_type" query:"action_type"`
Status *string `json:"status" query:"status"`
StartTime *time.Time `json:"start_time" query:"start_time"`
EndTime *time.Time `json:"end_time" query:"end_time"`
OrderBy string `json:"order_by" query:"order_by"`
}
// UserActionLogDTO 是用于API响应的用户操作日志结构
type UserActionLogDTO struct {
ID uint `json:"id"`
Time time.Time `json:"time"`
UserID uint `json:"user_id"`
Username string `json:"username"`
SourceIP string `json:"source_ip"`
ActionType string `json:"action_type"`
TargetResource json.RawMessage `json:"target_resource"`
Description string `json:"description"`
Status models.AuditStatus `json:"status"`
HTTPPath string `json:"http_path"`
HTTPMethod string `json:"http_method"`
ResultDetails string `json:"result_details"`
}
// ListUserActionLogResponse 是获取用户操作日志列表的响应结构
type ListUserActionLogResponse struct {
List []UserActionLogDTO `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}
// --- RawMaterialPurchase ---
// ListRawMaterialPurchaseRequest 定义了获取原料采购列表的请求参数
type ListRawMaterialPurchaseRequest struct {
Page int `json:"page" query:"page"`
PageSize int `json:"page_size" query:"page_size"`
RawMaterialID *uint `json:"raw_material_id" query:"raw_material_id"`
Supplier *string `json:"supplier" query:"supplier"`
StartTime *time.Time `json:"start_time" query:"start_time"`
EndTime *time.Time `json:"end_time" query:"end_time"`
OrderBy string `json:"order_by" query:"order_by"`
}
// RawMaterialDTO 是用于API响应的简化版原料结构
type RawMaterialDTO struct {
ID uint `json:"id"`
Name string `json:"name"`
}
// RawMaterialPurchaseDTO 是用于API响应的原料采购结构
type RawMaterialPurchaseDTO struct {
ID uint `json:"id"`
RawMaterialID uint `json:"raw_material_id"`
RawMaterial RawMaterialDTO `json:"raw_material"`
Supplier string `json:"supplier"`
Amount float64 `json:"amount"`
UnitPrice float64 `json:"unit_price"`
TotalPrice float64 `json:"total_price"`
PurchaseDate time.Time `json:"purchase_date"`
CreatedAt time.Time `json:"created_at"`
}
// ListRawMaterialPurchaseResponse 是获取原料采购列表的响应结构
type ListRawMaterialPurchaseResponse struct {
List []RawMaterialPurchaseDTO `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}
// --- RawMaterialStockLog ---
// ListRawMaterialStockLogRequest 定义了获取原料库存日志列表的请求参数
type ListRawMaterialStockLogRequest struct {
Page int `json:"page" query:"page"`
PageSize int `json:"page_size" query:"page_size"`
RawMaterialID *uint `json:"raw_material_id" query:"raw_material_id"`
SourceType *string `json:"source_type" query:"source_type"`
SourceID *uint `json:"source_id" query:"source_id"`
StartTime *time.Time `json:"start_time" query:"start_time"`
EndTime *time.Time `json:"end_time" query:"end_time"`
OrderBy string `json:"order_by" query:"order_by"`
}
// RawMaterialStockLogDTO 是用于API响应的原料库存日志结构
type RawMaterialStockLogDTO struct {
ID uint `json:"id"`
RawMaterialID uint `json:"raw_material_id"`
ChangeAmount float64 `json:"change_amount"`
SourceType models.StockLogSourceType `json:"source_type"`
SourceID uint `json:"source_id"`
HappenedAt time.Time `json:"happened_at"`
Remarks string `json:"remarks"`
}
// ListRawMaterialStockLogResponse 是获取原料库存日志列表的响应结构
type ListRawMaterialStockLogResponse struct {
List []RawMaterialStockLogDTO `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}
// --- FeedUsageRecord ---
// ListFeedUsageRecordRequest 定义了获取饲料使用记录列表的请求参数
type ListFeedUsageRecordRequest struct {
Page int `json:"page" query:"page"`
PageSize int `json:"page_size" query:"page_size"`
PenID *uint `json:"pen_id" query:"pen_id"`
FeedFormulaID *uint `json:"feed_formula_id" query:"feed_formula_id"`
OperatorID *uint `json:"operator_id" query:"operator_id"`
StartTime *time.Time `json:"start_time" query:"start_time"`
EndTime *time.Time `json:"end_time" query:"end_time"`
OrderBy string `json:"order_by" query:"order_by"`
}
// PenDTO 是用于API响应的简化版猪栏结构
type PenDTO struct {
ID uint `json:"id"`
Name string `json:"name"`
}
// FeedFormulaDTO 是用于API响应的简化版饲料配方结构
type FeedFormulaDTO struct {
ID uint `json:"id"`
Name string `json:"name"`
}
// FeedUsageRecordDTO 是用于API响应的饲料使用记录结构
type FeedUsageRecordDTO struct {
ID uint `json:"id"`
PenID uint `json:"pen_id"`
Pen PenDTO `json:"pen"`
FeedFormulaID uint `json:"feed_formula_id"`
FeedFormula FeedFormulaDTO `json:"feed_formula"`
Amount float64 `json:"amount"`
RecordedAt time.Time `json:"recorded_at"`
OperatorID uint `json:"operator_id"`
Remarks string `json:"remarks"`
}
// ListFeedUsageRecordResponse 是获取饲料使用记录列表的响应结构
type ListFeedUsageRecordResponse struct {
List []FeedUsageRecordDTO `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}
// --- MedicationLog ---
// ListMedicationLogRequest 定义了获取用药记录列表的请求参数
type ListMedicationLogRequest struct {
Page int `json:"page" query:"page"`
PageSize int `json:"page_size" query:"page_size"`
PigBatchID *uint `json:"pig_batch_id" query:"pig_batch_id"`
MedicationID *uint `json:"medication_id" query:"medication_id"`
Reason *string `json:"reason" query:"reason"`
OperatorID *uint `json:"operator_id" query:"operator_id"`
StartTime *time.Time `json:"start_time" query:"start_time"`
EndTime *time.Time `json:"end_time" query:"end_time"`
OrderBy string `json:"order_by" query:"order_by"`
}
// MedicationDTO 是用于API响应的简化版药品结构
type MedicationDTO struct {
ID uint `json:"id"`
Name string `json:"name"`
}
// MedicationLogDTO 是用于API响应的用药记录结构
type MedicationLogDTO struct {
ID uint `json:"id"`
PigBatchID uint `json:"pig_batch_id"`
MedicationID uint `json:"medication_id"`
Medication MedicationDTO `json:"medication"`
DosageUsed float64 `json:"dosage_used"`
TargetCount int `json:"target_count"`
Reason models.MedicationReasonType `json:"reason"`
Description string `json:"description"`
OperatorID uint `json:"operator_id"`
HappenedAt time.Time `json:"happened_at"`
}
// ListMedicationLogResponse 是获取用药记录列表的响应结构
type ListMedicationLogResponse struct {
List []MedicationLogDTO `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}
// --- PigBatchLog ---
// ListPigBatchLogRequest 定义了获取猪批次日志列表的请求参数
type ListPigBatchLogRequest struct {
Page int `json:"page" query:"page"`
PageSize int `json:"page_size" query:"page_size"`
PigBatchID *uint `json:"pig_batch_id" query:"pig_batch_id"`
ChangeType *string `json:"change_type" query:"change_type"`
OperatorID *uint `json:"operator_id" query:"operator_id"`
StartTime *time.Time `json:"start_time" query:"start_time"`
EndTime *time.Time `json:"end_time" query:"end_time"`
OrderBy string `json:"order_by" query:"order_by"`
}
// PigBatchLogDTO 是用于API响应的猪批次日志结构
type PigBatchLogDTO struct {
ID uint `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PigBatchID uint `json:"pig_batch_id"`
ChangeType models.LogChangeType `json:"change_type"`
ChangeCount int `json:"change_count"`
Reason string `json:"reason"`
BeforeCount int `json:"before_count"`
AfterCount int `json:"after_count"`
OperatorID uint `json:"operator_id"`
HappenedAt time.Time `json:"happened_at"`
}
// ListPigBatchLogResponse 是获取猪批次日志列表的响应结构
type ListPigBatchLogResponse struct {
List []PigBatchLogDTO `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}
// --- WeighingBatch ---
// ListWeighingBatchRequest 定义了获取批次称重记录列表的请求参数
type ListWeighingBatchRequest struct {
Page int `json:"page" query:"page"`
PageSize int `json:"page_size" query:"page_size"`
PigBatchID *uint `json:"pig_batch_id" query:"pig_batch_id"`
StartTime *time.Time `json:"start_time" query:"start_time"`
EndTime *time.Time `json:"end_time" query:"end_time"`
OrderBy string `json:"order_by" query:"order_by"`
}
// WeighingBatchDTO 是用于API响应的批次称重记录结构
type WeighingBatchDTO struct {
ID uint `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
WeighingTime time.Time `json:"weighing_time"`
Description string `json:"description"`
PigBatchID uint `json:"pig_batch_id"`
}
// ListWeighingBatchResponse 是获取批次称重记录列表的响应结构
type ListWeighingBatchResponse struct {
List []WeighingBatchDTO `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}
// --- WeighingRecord ---
// ListWeighingRecordRequest 定义了获取单次称重记录列表的请求参数
type ListWeighingRecordRequest struct {
Page int `json:"page" query:"page"`
PageSize int `json:"page_size" query:"page_size"`
WeighingBatchID *uint `json:"weighing_batch_id" query:"weighing_batch_id"`
PenID *uint `json:"pen_id" query:"pen_id"`
OperatorID *uint `json:"operator_id" query:"operator_id"`
StartTime *time.Time `json:"start_time" query:"start_time"`
EndTime *time.Time `json:"end_time" query:"end_time"`
OrderBy string `json:"order_by" query:"order_by"`
}
// WeighingRecordDTO 是用于API响应的单次称重记录结构
type WeighingRecordDTO struct {
ID uint `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Weight float64 `json:"weight"`
WeighingBatchID uint `json:"weighing_batch_id"`
PenID uint `json:"pen_id"`
OperatorID uint `json:"operator_id"`
Remark string `json:"remark"`
WeighingTime time.Time `json:"weighing_time"`
}
// ListWeighingRecordResponse 是获取单次称重记录列表的响应结构
type ListWeighingRecordResponse struct {
List []WeighingRecordDTO `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}
// --- PigTransferLog ---
// ListPigTransferLogRequest 定义了获取猪只迁移日志列表的请求参数
type ListPigTransferLogRequest struct {
Page int `json:"page" query:"page"`
PageSize int `json:"page_size" query:"page_size"`
PigBatchID *uint `json:"pig_batch_id" query:"pig_batch_id"`
PenID *uint `json:"pen_id" query:"pen_id"`
TransferType *string `json:"transfer_type" query:"transfer_type"`
OperatorID *uint `json:"operator_id" query:"operator_id"`
CorrelationID *string `json:"correlation_id" query:"correlation_id"`
StartTime *time.Time `json:"start_time" query:"start_time"`
EndTime *time.Time `json:"end_time" query:"end_time"`
OrderBy string `json:"order_by" query:"order_by"`
}
// PigTransferLogDTO 是用于API响应的猪只迁移日志结构
type PigTransferLogDTO struct {
ID uint `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
TransferTime time.Time `json:"transfer_time"`
PigBatchID uint `json:"pig_batch_id"`
PenID uint `json:"pen_id"`
Quantity int `json:"quantity"`
Type models.PigTransferType `json:"type"`
CorrelationID string `json:"correlation_id"`
OperatorID uint `json:"operator_id"`
Remarks string `json:"remarks"`
}
// ListPigTransferLogResponse 是获取猪只迁移日志列表的响应结构
type ListPigTransferLogResponse struct {
List []PigTransferLogDTO `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}
// --- PigSickLog ---
// ListPigSickLogRequest 定义了获取病猪日志列表的请求参数
type ListPigSickLogRequest struct {
Page int `json:"page" query:"page"`
PageSize int `json:"page_size" query:"page_size"`
PigBatchID *uint `json:"pig_batch_id" query:"pig_batch_id"`
PenID *uint `json:"pen_id" query:"pen_id"`
Reason *string `json:"reason" query:"reason"`
TreatmentLocation *string `json:"treatment_location" query:"treatment_location"`
OperatorID *uint `json:"operator_id" query:"operator_id"`
StartTime *time.Time `json:"start_time" query:"start_time"`
EndTime *time.Time `json:"end_time" query:"end_time"`
OrderBy string `json:"order_by" query:"order_by"`
}
// PigSickLogDTO 是用于API响应的病猪日志结构
type PigSickLogDTO struct {
ID uint `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PigBatchID uint `json:"pig_batch_id"`
PenID uint `json:"pen_id"`
ChangeCount int `json:"change_count"`
Reason models.PigBatchSickPigReasonType `json:"reason"`
BeforeCount int `json:"before_count"`
AfterCount int `json:"after_count"`
Remarks string `json:"remarks"`
TreatmentLocation models.PigBatchSickPigTreatmentLocation `json:"treatment_location"`
OperatorID uint `json:"operator_id"`
HappenedAt time.Time `json:"happened_at"`
}
// ListPigSickLogResponse 是获取病猪日志列表的响应结构
type ListPigSickLogResponse struct {
List []PigSickLogDTO `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}
// --- PigPurchase ---
// ListPigPurchaseRequest 定义了获取猪只采购记录列表的请求参数
type ListPigPurchaseRequest struct {
Page int `json:"page" query:"page"`
PageSize int `json:"page_size" query:"page_size"`
PigBatchID *uint `json:"pig_batch_id" query:"pig_batch_id"`
Supplier *string `json:"supplier" query:"supplier"`
OperatorID *uint `json:"operator_id" query:"operator_id"`
StartTime *time.Time `json:"start_time" query:"start_time"`
EndTime *time.Time `json:"end_time" query:"end_time"`
OrderBy string `json:"order_by" query:"order_by"`
}
// PigPurchaseDTO 是用于API响应的猪只采购记录结构
type PigPurchaseDTO struct {
ID uint `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PigBatchID uint `json:"pig_batch_id"`
PurchaseDate time.Time `json:"purchase_date"`
Supplier string `json:"supplier"`
Quantity int `json:"quantity"`
UnitPrice float64 `json:"unit_price"`
TotalPrice float64 `json:"total_price"`
Remarks string `json:"remarks"`
OperatorID uint `json:"operator_id"`
}
// ListPigPurchaseResponse 是获取猪只采购记录列表的响应结构
type ListPigPurchaseResponse struct {
List []PigPurchaseDTO `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}
// --- PigSale ---
// ListPigSaleRequest 定义了获取猪只销售记录列表的请求参数
type ListPigSaleRequest struct {
Page int `json:"page" query:"page"`
PageSize int `json:"page_size" query:"page_size"`
PigBatchID *uint `json:"pig_batch_id" query:"pig_batch_id"`
Buyer *string `json:"buyer" query:"buyer"`
OperatorID *uint `json:"operator_id" query:"operator_id"`
StartTime *time.Time `json:"start_time" query:"start_time"`
EndTime *time.Time `json:"end_time" query:"end_time"`
OrderBy string `json:"order_by" query:"order_by"`
}
// PigSaleDTO 是用于API响应的猪只销售记录结构
type PigSaleDTO struct {
ID uint `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PigBatchID uint `json:"pig_batch_id"`
SaleDate time.Time `json:"sale_date"`
Buyer string `json:"buyer"`
Quantity int `json:"quantity"`
UnitPrice float64 `json:"unit_price"`
TotalPrice float64 `json:"total_price"`
Remarks string `json:"remarks"`
OperatorID uint `json:"operator_id"`
}
// ListPigSaleResponse 是获取猪只销售记录列表的响应结构
type ListPigSaleResponse struct {
List []PigSaleDTO `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}

View File

@@ -0,0 +1,36 @@
package dto
import (
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"go.uber.org/zap/zapcore"
)
// NewListNotificationResponse 从模型数据创建通知列表响应 DTO
func NewListNotificationResponse(data []models.Notification, total int64, page, pageSize int) *ListNotificationResponse {
dtos := make([]NotificationDTO, len(data))
for i, item := range data {
dtos[i] = NotificationDTO{
ID: item.ID,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
NotifierType: item.NotifierType,
UserID: item.UserID,
Title: item.Title,
Message: item.Message,
Level: zapcore.Level(item.Level),
AlarmTimestamp: item.AlarmTimestamp,
ToAddress: item.ToAddress,
Status: item.Status,
ErrorMessage: item.ErrorMessage,
}
}
return &ListNotificationResponse{
List: dtos,
Pagination: PaginationDTO{
Total: total,
Page: page,
PageSize: pageSize,
},
}
}

View File

@@ -0,0 +1,50 @@
package dto
import (
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/notify"
"go.uber.org/zap/zapcore"
)
// SendTestNotificationRequest 定义了发送测试通知请求的 JSON 结构
type SendTestNotificationRequest struct {
// Type 指定要测试的通知渠道
Type notify.NotifierType `json:"type" validate:"required"`
}
// ListNotificationRequest 定义了获取通知列表的请求参数
type ListNotificationRequest struct {
Page int `json:"page" query:"page"`
PageSize int `json:"page_size" query:"page_size"`
UserID *uint `json:"user_id" query:"user_id"`
NotifierType *notify.NotifierType `json:"notifier_type" query:"notifier_type"`
Status *models.NotificationStatus `json:"status" query:"status"`
Level *zapcore.Level `json:"level" query:"level"`
StartTime *time.Time `json:"start_time" query:"start_time"`
EndTime *time.Time `json:"end_time" query:"end_time"`
OrderBy string `json:"order_by" query:"order_by"`
}
// NotificationDTO 是用于API响应的通知结构
type NotificationDTO struct {
ID uint `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
NotifierType notify.NotifierType `json:"notifier_type"`
UserID uint `json:"user_id"`
Title string `json:"title"`
Message string `json:"message"`
Level zapcore.Level `json:"level"`
AlarmTimestamp time.Time `json:"alarm_timestamp"`
ToAddress string `json:"to_address"`
Status models.NotificationStatus `json:"status"`
ErrorMessage string `json:"error_message"`
}
// ListNotificationResponse 是获取通知列表的响应结构
type ListNotificationResponse struct {
List []NotificationDTO `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}

View File

@@ -0,0 +1,162 @@
package dto
import (
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
)
// PigBatchCreateDTO 定义了创建猪批次的请求结构
type PigBatchCreateDTO struct {
BatchNumber string `json:"batch_number" validate:"required"` // 批次编号,必填
OriginType models.PigBatchOriginType `json:"origin_type" validate:"required"` // 批次来源,必填
StartDate time.Time `json:"start_date" validate:"required"` // 批次开始日期,必填
InitialCount int `json:"initial_count" validate:"required,min=1"` // 初始数量必填最小为1
Status models.PigBatchStatus `json:"status" validate:"required"` // 批次状态,必填
}
// PigBatchUpdateDTO 定义了更新猪批次的请求结构
type PigBatchUpdateDTO struct {
BatchNumber *string `json:"batch_number"` // 批次编号,可选
OriginType *models.PigBatchOriginType `json:"origin_type"` // 批次来源,可选
StartDate *time.Time `json:"start_date"` // 批次开始日期,可选
EndDate *time.Time `json:"end_date"` // 批次结束日期,可选
InitialCount *int `json:"initial_count"` // 初始数量,可选
Status *models.PigBatchStatus `json:"status"` // 批次状态,可选
}
// PigBatchQueryDTO 定义了查询猪批次的请求结构
type PigBatchQueryDTO struct {
IsActive *bool `json:"is_active" query:"is_active"` // 是否活跃可选用于URL查询参数
}
// PigBatchResponseDTO 定义了猪批次信息的响应结构
type PigBatchResponseDTO struct {
ID uint `json:"id"` // 批次ID
BatchNumber string `json:"batch_number"` // 批次编号
OriginType models.PigBatchOriginType `json:"origin_type"` // 批次来源
StartDate time.Time `json:"start_date"` // 批次开始日期
EndDate time.Time `json:"end_date"` // 批次结束日期
InitialCount int `json:"initial_count"` // 初始数量
Status models.PigBatchStatus `json:"status"` // 批次状态
IsActive bool `json:"is_active"` // 是否活跃
CurrentTotalQuantity int `json:"current_total_quantity"` // 当前总数
CurrentTotalPigsInPens int `json:"current_total_pigs_in_pens"` // 当前存栏总数
CreateTime time.Time `json:"create_time"` // 创建时间
UpdateTime time.Time `json:"update_time"` // 更新时间
}
// AssignEmptyPensToBatchRequest 用于为猪批次分配空栏的请求体
type AssignEmptyPensToBatchRequest struct {
PenIDs []uint `json:"pen_ids" validate:"required,min=1,dive" example:"1,2,3"` // 待分配的猪栏ID列表
}
// ReclassifyPenToNewBatchRequest 用于将猪栏划拨到新批次的请求体
type ReclassifyPenToNewBatchRequest struct {
ToBatchID uint `json:"to_batch_id" validate:"required"` // 目标猪批次ID
PenID uint `json:"pen_id" validate:"required"` // 待划拨的猪栏ID
Remarks string `json:"remarks"` // 备注
}
// RemoveEmptyPenFromBatchRequest 用于从猪批次移除空栏的请求体
type RemoveEmptyPenFromBatchRequest struct {
PenID uint `json:"pen_id" validate:"required"` // 待移除的猪栏ID
}
// MovePigsIntoPenRequest 用于将猪只从“虚拟库存”移入指定猪栏的请求体
type MovePigsIntoPenRequest struct {
ToPenID uint `json:"to_pen_id" validate:"required"` // 目标猪栏ID
Quantity int `json:"quantity" validate:"required,min=1"` // 移入猪只数量
Remarks string `json:"remarks"` // 备注
}
// SellPigsRequest 用于处理卖猪的请求体
type SellPigsRequest struct {
PenID uint `json:"pen_id" validate:"required"` // 猪栏ID
Quantity int `json:"quantity" validate:"required,min=1"` // 卖出猪只数量
UnitPrice float64 `json:"unit_price" validate:"required,min=0"` // 单价
TotalPrice float64 `json:"total_price" validate:"required,min=0"` // 总价
TraderName string `json:"trader_name" validate:"required"` // 交易方名称
TradeDate time.Time `json:"trade_date" validate:"required"` // 交易日期
Remarks string `json:"remarks"` // 备注
}
// BuyPigsRequest 用于处理买猪的请求体
type BuyPigsRequest struct {
PenID uint `json:"pen_id" validate:"required"` // 猪栏ID
Quantity int `json:"quantity" validate:"required,min=1"` // 买入猪只数量
UnitPrice float64 `json:"unit_price" validate:"required,min=0"` // 单价
TotalPrice float64 `json:"total_price" validate:"required,min=0"` // 总价
TraderName string `json:"trader_name" validate:"required"` // 交易方名称
TradeDate time.Time `json:"trade_date" validate:"required"` // 交易日期
Remarks string `json:"remarks"` // 备注
}
// TransferPigsAcrossBatchesRequest 用于跨猪群调栏的请求体
type TransferPigsAcrossBatchesRequest struct {
DestBatchID uint `json:"dest_batch_id" validate:"required"` // 目标猪批次ID
FromPenID uint `json:"from_pen_id" validate:"required"` // 源猪栏ID
ToPenID uint `json:"to_pen_id" validate:"required"` // 目标猪栏ID
Quantity uint `json:"quantity" validate:"required,min=1"` // 调栏猪只数量
Remarks string `json:"remarks"` // 备注
}
// TransferPigsWithinBatchRequest 用于群内调栏的请求体
type TransferPigsWithinBatchRequest struct {
FromPenID uint `json:"from_pen_id" validate:"required"` // 源猪栏ID
ToPenID uint `json:"to_pen_id" validate:"required"` // 目标猪栏ID
Quantity uint `json:"quantity" validate:"required,min=1"` // 调栏猪只数量
Remarks string `json:"remarks"` // 备注
}
// RecordSickPigsRequest 用于记录新增病猪事件的请求体
type RecordSickPigsRequest struct {
PenID uint `json:"pen_id" validate:"required"` // 猪栏ID
Quantity int `json:"quantity" validate:"required,min=1"` // 病猪数量
TreatmentLocation models.PigBatchSickPigTreatmentLocation `json:"treatment_location" validate:"required"` // 治疗地点
HappenedAt time.Time `json:"happened_at" validate:"required"` // 发生时间
Remarks string `json:"remarks"` // 备注
}
// RecordSickPigRecoveryRequest 用于记录病猪康复事件的请求体
type RecordSickPigRecoveryRequest struct {
PenID uint `json:"pen_id" validate:"required"` // 猪栏ID
Quantity int `json:"quantity" validate:"required,min=1"` // 康复猪数量
TreatmentLocation models.PigBatchSickPigTreatmentLocation `json:"treatment_location" validate:"required"` // 治疗地点
HappenedAt time.Time `json:"happened_at" validate:"required"` // 发生时间
Remarks string `json:"remarks"` // 备注
}
// RecordSickPigDeathRequest 用于记录病猪死亡事件的请求体
type RecordSickPigDeathRequest struct {
PenID uint `json:"pen_id" validate:"required"` // 猪栏ID
Quantity int `json:"quantity" validate:"required,min=1"` // 死亡猪数量
TreatmentLocation models.PigBatchSickPigTreatmentLocation `json:"treatment_location" validate:"required"` // 治疗地点
HappenedAt time.Time `json:"happened_at" validate:"required"` // 发生时间
Remarks string `json:"remarks"` // 备注
}
// RecordSickPigCullRequest 用于记录病猪淘汰事件的请求体
type RecordSickPigCullRequest struct {
PenID uint `json:"pen_id" validate:"required"` // 猪栏ID
Quantity int `json:"quantity" validate:"required,min=1"` // 淘汰猪数量
TreatmentLocation models.PigBatchSickPigTreatmentLocation `json:"treatment_location" validate:"required"` // 治疗地点
HappenedAt time.Time `json:"happened_at" validate:"required"` // 发生时间
Remarks string `json:"remarks"` // 备注
}
// RecordDeathRequest 用于记录正常猪只死亡事件的请求体
type RecordDeathRequest struct {
PenID uint `json:"pen_id" validate:"required"` // 猪栏ID
Quantity int `json:"quantity" validate:"required,min=1"` // 死亡猪数量
HappenedAt time.Time `json:"happened_at" validate:"required"` // 发生时间
Remarks string `json:"remarks"` // 备注
}
// RecordCullRequest 用于记录正常猪只淘汰事件的请求体
type RecordCullRequest struct {
PenID uint `json:"pen_id" validate:"required"` // 猪栏ID
Quantity int `json:"quantity" validate:"required,min=1"` // 淘汰猪数量
HappenedAt time.Time `json:"happened_at" validate:"required"` // 发生时间
Remarks string `json:"remarks"` // 备注
}

View File

@@ -0,0 +1,53 @@
package dto
import "git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
// PigHouseResponse 定义了猪舍信息的响应结构
type PigHouseResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
// PenResponse 定义了猪栏信息的响应结构
type PenResponse struct {
ID uint `json:"id"`
PenNumber string `json:"pen_number"`
HouseID uint `json:"house_id"`
Capacity int `json:"capacity"`
Status models.PenStatus `json:"status"`
PigBatchID *uint `json:"pig_batch_id,omitempty"`
CurrentPigCount int `json:"current_pig_count"`
}
// CreatePigHouseRequest 定义了创建猪舍的请求结构
type CreatePigHouseRequest struct {
Name string `json:"name" validate:"required"`
Description string `json:"description"`
}
// UpdatePigHouseRequest 定义了更新猪舍的请求结构
type UpdatePigHouseRequest struct {
Name string `json:"name" validate:"required"`
Description string `json:"description"`
}
// CreatePenRequest 定义了创建猪栏的请求结构
type CreatePenRequest struct {
PenNumber string `json:"pen_number" validate:"required"`
HouseID uint `json:"house_id" validate:"required"`
Capacity int `json:"capacity" validate:"required"`
}
// UpdatePenRequest 定义了更新猪栏的请求结构
type UpdatePenRequest struct {
PenNumber string `json:"pen_number" validate:"required"`
HouseID uint `json:"house_id" validate:"required"`
Capacity int `json:"capacity" validate:"required"`
Status models.PenStatus `json:"status" validate:"required,oneof=空闲 使用中 病猪栏 康复栏 清洗消毒 维修中"` // 添加oneof校验
}
// UpdatePenStatusRequest 定义了更新猪栏状态的请求结构
type UpdatePenStatusRequest struct {
Status models.PenStatus `json:"status" validate:"required,oneof=空闲 使用中 病猪栏 康复栏 清洗消毒 维修中" example:"病猪栏"`
}

View File

@@ -0,0 +1,209 @@
package dto
import (
"encoding/json"
"fmt"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
)
// NewPlanToResponse 将Plan模型转换为PlanResponse
func NewPlanToResponse(plan *models.Plan) (*PlanResponse, error) {
if plan == nil {
return nil, nil
}
response := &PlanResponse{
ID: plan.ID,
Name: plan.Name,
Description: plan.Description,
PlanType: plan.PlanType,
ExecutionType: plan.ExecutionType,
Status: plan.Status,
ExecuteNum: plan.ExecuteNum,
ExecuteCount: plan.ExecuteCount,
CronExpression: plan.CronExpression,
ContentType: plan.ContentType,
}
// 转换子计划
if plan.ContentType == models.PlanContentTypeSubPlans {
response.SubPlans = make([]SubPlanResponse, len(plan.SubPlans))
for i, subPlan := range plan.SubPlans {
subPlanResp, err := SubPlanToResponse(&subPlan)
if err != nil {
return nil, err
}
response.SubPlans[i] = subPlanResp
}
}
// 转换任务
if plan.ContentType == models.PlanContentTypeTasks {
response.Tasks = make([]TaskResponse, len(plan.Tasks))
for i, task := range plan.Tasks {
taskResp, err := TaskToResponse(&task)
if err != nil {
return nil, err
}
response.Tasks[i] = taskResp
}
}
return response, nil
}
// NewPlanFromCreateRequest 将CreatePlanRequest转换为Plan模型
func NewPlanFromCreateRequest(req *CreatePlanRequest) (*models.Plan, error) {
if req == nil {
return nil, nil
}
plan := &models.Plan{
Name: req.Name,
Description: req.Description,
ExecutionType: req.ExecutionType,
ExecuteNum: req.ExecuteNum,
CronExpression: req.CronExpression,
// ContentType 和 PlanType 在控制器中设置,此处不再处理
}
// 处理子计划 (通过ID引用)
if req.SubPlanIDs != nil {
subPlanSlice := req.SubPlanIDs
plan.SubPlans = make([]models.SubPlan, len(subPlanSlice))
for i, childPlanID := range subPlanSlice {
plan.SubPlans[i] = models.SubPlan{
ChildPlanID: childPlanID,
ExecutionOrder: i, // 默认执行顺序
}
}
}
// 处理任务
if req.Tasks != nil {
taskSlice := req.Tasks
plan.Tasks = make([]models.Task, len(taskSlice))
for i, taskReq := range taskSlice {
task, err := TaskFromRequest(&taskReq)
if err != nil {
return nil, err
}
plan.Tasks[i] = task
}
}
return plan, nil
}
// NewPlanFromUpdateRequest 将UpdatePlanRequest转换为Plan模型
func NewPlanFromUpdateRequest(req *UpdatePlanRequest) (*models.Plan, error) {
if req == nil {
return nil, nil
}
plan := &models.Plan{
Name: req.Name,
Description: req.Description,
ExecutionType: req.ExecutionType,
ExecuteNum: req.ExecuteNum,
CronExpression: req.CronExpression,
// ContentType 和 PlanType 在控制器中设置,此处不再处理
}
// 处理子计划 (通过ID引用)
if req.SubPlanIDs != nil {
subPlanSlice := req.SubPlanIDs
plan.SubPlans = make([]models.SubPlan, len(subPlanSlice))
for i, childPlanID := range subPlanSlice {
plan.SubPlans[i] = models.SubPlan{
ChildPlanID: childPlanID,
ExecutionOrder: i, // 默认执行顺序
}
}
}
// 处理任务
if req.Tasks != nil {
taskSlice := req.Tasks
plan.Tasks = make([]models.Task, len(taskSlice))
for i, taskReq := range taskSlice {
task, err := TaskFromRequest(&taskReq)
if err != nil {
return nil, err
}
plan.Tasks[i] = task
}
}
return plan, nil
}
// SubPlanToResponse 将SubPlan模型转换为SubPlanResponse
func SubPlanToResponse(subPlan *models.SubPlan) (SubPlanResponse, error) {
if subPlan == nil {
return SubPlanResponse{}, nil
}
response := SubPlanResponse{
ID: subPlan.ID,
ParentPlanID: subPlan.ParentPlanID,
ChildPlanID: subPlan.ChildPlanID,
ExecutionOrder: subPlan.ExecutionOrder,
}
// 如果有完整的子计划数据,也进行转换
if subPlan.ChildPlan != nil {
childPlanResp, err := NewPlanToResponse(subPlan.ChildPlan)
if err != nil {
return SubPlanResponse{}, err
}
response.ChildPlan = childPlanResp
}
return response, nil
}
// TaskToResponse 将Task模型转换为TaskResponse
func TaskToResponse(task *models.Task) (TaskResponse, error) {
if task == nil {
return TaskResponse{}, nil
}
var params map[string]interface{}
if len(task.Parameters) > 0 && string(task.Parameters) != "null" {
if err := task.ParseParameters(&params); err != nil {
return TaskResponse{}, fmt.Errorf("parsing task parameters failed (ID: %d): %w", task.ID, err)
}
}
return TaskResponse{
ID: task.ID,
PlanID: task.PlanID,
Name: task.Name,
Description: task.Description,
ExecutionOrder: task.ExecutionOrder,
Type: task.Type,
Parameters: params,
}, nil
}
// TaskFromRequest 将TaskRequest转换为Task模型
func TaskFromRequest(req *TaskRequest) (models.Task, error) {
if req == nil {
return models.Task{}, nil
}
paramsJSON, err := json.Marshal(req.Parameters)
if err != nil {
return models.Task{}, fmt.Errorf("serializing task parameters failed: %w", err)
}
return models.Task{
Name: req.Name,
Description: req.Description,
ExecutionOrder: req.ExecutionOrder,
Type: req.Type,
Parameters: paramsJSON,
}, nil
}

View File

@@ -0,0 +1,86 @@
package dto
import (
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
)
// ListPlansQuery 定义了获取计划列表时的查询参数
type ListPlansQuery struct {
PlanType repository.PlanTypeFilter `json:"plan_type" query:"planType"` // 计划类型
Page int `json:"page" query:"page"` // 页码
PageSize int `json:"page_size" query:"page_size"` // 每页大小
}
// CreatePlanRequest 定义创建计划请求的结构体
type CreatePlanRequest struct {
Name string `json:"name" validate:"required" example:"猪舍温度控制计划"`
Description string `json:"description" example:"根据温度自动调节风扇和加热器"`
ExecutionType models.PlanExecutionType `json:"execution_type" validate:"required" example:"自动"`
ExecuteNum uint `json:"execute_num,omitempty" validate:"omitempty,min=0" example:"10"`
CronExpression string `json:"cron_expression" validate:"omitempty,cron" example:"0 0 6 * * *"`
SubPlanIDs []uint `json:"sub_plan_ids,omitempty" validate:"omitempty,dive"`
Tasks []TaskRequest `json:"tasks,omitempty" validate:"omitempty,dive"`
}
// PlanResponse 定义计划详情响应的结构体
type PlanResponse struct {
ID uint `json:"id" example:"1"`
Name string `json:"name" example:"猪舍温度控制计划"`
Description string `json:"description" example:"根据温度自动调节风扇和加热器"`
PlanType models.PlanType `json:"plan_type" example:"自定义任务"`
ExecutionType models.PlanExecutionType `json:"execution_type" example:"自动"`
Status models.PlanStatus `json:"status" example:"已启用"`
ExecuteNum uint `json:"execute_num" example:"10"`
ExecuteCount uint `json:"execute_count" example:"0"`
CronExpression string `json:"cron_expression" example:"0 0 6 * * *"`
ContentType models.PlanContentType `json:"content_type" example:"任务"`
SubPlans []SubPlanResponse `json:"sub_plans,omitempty"`
Tasks []TaskResponse `json:"tasks,omitempty"`
}
// ListPlansResponse 定义获取计划列表响应的结构体
type ListPlansResponse struct {
Plans []PlanResponse `json:"plans"`
Total int64 `json:"total" example:"100"`
}
// UpdatePlanRequest 定义更新计划请求的结构体
type UpdatePlanRequest struct {
Name string `json:"name" example:"猪舍温度控制计划V2"`
Description string `json:"description" example:"更新后的描述"`
ExecutionType models.PlanExecutionType `json:"execution_type" validate:"required" example:"自动"`
ExecuteNum uint `json:"execute_num,omitempty" validate:"omitempty,min=0" example:"10"`
CronExpression string `json:"cron_expression" validate:"omitempty,cron" example:"0 0 6 * * *"`
SubPlanIDs []uint `json:"sub_plan_ids,omitempty" validate:"omitempty,dive"`
Tasks []TaskRequest `json:"tasks,omitempty" validate:"omitempty,dive"`
}
// SubPlanResponse 定义子计划响应结构体
type SubPlanResponse struct {
ID uint `json:"id" example:"1"`
ParentPlanID uint `json:"parent_plan_id" example:"1"`
ChildPlanID uint `json:"child_plan_id" example:"2"`
ExecutionOrder int `json:"execution_order" example:"1"`
ChildPlan *PlanResponse `json:"child_plan,omitempty"`
}
// TaskRequest 定义任务请求结构体
type TaskRequest struct {
Name string `json:"name" example:"打开风扇"`
Description string `json:"description" example:"打开1号风扇"`
ExecutionOrder int `json:"execution_order" example:"1"`
Type models.TaskType `json:"type" example:"等待"`
Parameters map[string]interface{} `json:"parameters,omitempty"`
}
// TaskResponse 定义任务响应结构体
type TaskResponse struct {
ID int `json:"id" example:"1"`
PlanID uint `json:"plan_id" example:"1"`
Name string `json:"name" example:"打开风扇"`
Description string `json:"description" example:"打开1号风扇"`
ExecutionOrder int `json:"execution_order" example:"1"`
Type models.TaskType `json:"type" example:"等待"`
Parameters map[string]interface{} `json:"parameters,omitempty"`
}

View File

@@ -0,0 +1,43 @@
package dto
// CreateUserRequest 定义创建用户请求的结构体
type CreateUserRequest struct {
Username string `json:"username" validate:"required" example:"newuser"`
Password string `json:"password" validate:"required" example:"password123"`
}
// LoginRequest 定义登录请求的结构体
type LoginRequest struct {
// Identifier 可以是用户名、邮箱、手机号、微信号或飞书账号
Identifier string `json:"identifier" validate:"required" example:"testuser"`
Password string `json:"password" validate:"required" example:"password123"`
}
// CreateUserResponse 定义创建用户成功响应的结构体
type CreateUserResponse struct {
Username string `json:"username" example:"newuser"`
ID uint `json:"id" example:"1"`
}
// LoginResponse 定义登录成功响应的结构体
type LoginResponse struct {
Username string `json:"username" example:"testuser"`
ID uint `json:"id" example:"1"`
Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."`
}
// HistoryResponse 定义单条操作历史的响应结构体
type HistoryResponse struct {
UserID uint `json:"user_id" example:"101"`
Username string `json:"username" example:"testuser"`
ActionType string `json:"action_type" example:"更新设备"`
Description string `json:"description" example:"设备更新成功"`
TargetResource interface{} `json:"target_resource"`
Time string `json:"time"`
}
// ListHistoryResponse 定义操作历史列表的响应结构体
type ListHistoryResponse struct {
History []HistoryResponse `json:"history"`
Total int64 `json:"total" example:"100"`
}

View File

@@ -0,0 +1,59 @@
package middleware
import (
"git.huangwc.com/pig/pig-farm-controller/internal/domain/audit"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"github.com/labstack/echo/v4"
)
// AuditLogMiddleware 创建一个Echo中间件用于在请求结束后记录用户操作审计日志。
// 它依赖于控制器通过调用 SendSuccessWithAudit 或 SendErrorWithAudit 在上下文中设置的审计信息。
func AuditLogMiddleware(auditService audit.Service) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// 首先执行请求链中的后续处理程序(即业务控制器)
err := next(c)
// --- 在这里,请求已经处理完毕 ---
// 从上下文中尝试获取由控制器设置的业务审计信息
actionType, exists := c.Get(models.ContextAuditActionType.String()).(string)
if !exists || actionType == "" {
// 如果上下文中没有 actionType说明此接口无需记录审计日志直接返回
return err
}
// 从 Context 中获取用户对象
var user *models.User
if userCtx := c.Get(models.ContextUserKey.String()); userCtx != nil {
user, _ = userCtx.(*models.User)
}
// 构建 RequestContext
reqCtx := audit.RequestContext{
ClientIP: c.RealIP(),
HTTPPath: c.Request().URL.Path,
HTTPMethod: c.Request().Method,
}
// 直接从上下文中获取所有其他审计信息
description, _ := c.Get(models.ContextAuditDescription.String()).(string)
targetResource := c.Get(models.ContextAuditTargetResource.String())
status, _ := c.Get(models.ContextAuditStatus.String()).(models.AuditStatus)
resultDetails, _ := c.Get(models.ContextAuditResultDetails.String()).(string)
// 调用审计服务记录日志(异步)
auditService.LogAction(
user,
reqCtx,
actionType,
description,
targetResource,
status,
resultDetails,
)
return err
}
}
}

View File

@@ -0,0 +1,60 @@
// Package middleware 存放中间件
package middleware
import (
"errors"
"net/http"
"strings"
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/token"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
)
// AuthMiddleware 创建一个Echo中间件用于JWT身份验证
// 它依赖于 TokenService 来解析和验证 token并使用 UserRepository 来获取完整的用户信息
func AuthMiddleware(tokenService token.Service, userRepo repository.UserRepository) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// 从 Authorization header 获取 token
authHeader := c.Request().Header.Get("Authorization")
if authHeader == "" {
return controller.SendErrorWithStatus(c, http.StatusUnauthorized, controller.CodeUnauthorized, "请求未包含授权标头")
}
// 授权标头的格式应为 "Bearer <token>"
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
return controller.SendErrorWithStatus(c, http.StatusUnauthorized, controller.CodeUnauthorized, "授权标头格式不正确")
}
tokenString := parts[1]
// 解析和验证 token
claims, err := tokenService.ParseToken(tokenString)
if err != nil {
return controller.SendErrorWithStatus(c, http.StatusUnauthorized, controller.CodeUnauthorized, "无效的Token")
}
// 根据 token 中的用户ID从数据库中获取完整的用户信息
user, err := userRepo.FindByID(claims.UserID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// Token有效但对应的用户已不存在
return controller.SendErrorWithStatus(c, http.StatusUnauthorized, controller.CodeUnauthorized, "授权用户不存在")
}
// 其他数据库查询错误
return controller.SendErrorWithStatus(c, http.StatusInternalServerError, controller.CodeInternalError, "获取用户信息失败")
}
// 将完整的用户对象存储在 context 中,以便后续的处理函数使用
c.Set(models.ContextUserKey.String(), user)
// 继续处理请求链中的下一个处理程序
return next(c)
}
}
}

View File

@@ -0,0 +1,389 @@
package service
import (
"encoding/json"
"errors"
"fmt"
"git.huangwc.com/pig/pig-farm-controller/internal/app/dto"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/device"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
)
var (
// ErrDeviceInUse 表示设备正在被任务使用,无法删除
ErrDeviceInUse = errors.New("设备正在被一个或多个任务使用,无法删除")
// ErrAreaControllerInUse 表示区域主控正在被设备使用,无法删除
ErrAreaControllerInUse = errors.New("区域主控正在被一个或多个设备使用,无法删除")
// ErrDeviceTemplateInUse 表示设备模板正在被设备使用,无法删除
ErrDeviceTemplateInUse = errors.New("设备模板正在被一个或多个设备使用,无法删除")
)
// DeviceService 定义了应用层的设备服务接口,用于协调设备相关的业务逻辑。
// DeviceService 定义了应用层的设备服务接口,用于协调设备相关的业务逻辑。
type DeviceService interface {
CreateDevice(req *dto.CreateDeviceRequest) (*dto.DeviceResponse, error)
GetDevice(id uint) (*dto.DeviceResponse, error)
ListDevices() ([]*dto.DeviceResponse, error)
UpdateDevice(id uint, req *dto.UpdateDeviceRequest) (*dto.DeviceResponse, error)
DeleteDevice(id uint) error
ManualControl(id uint, req *dto.ManualControlDeviceRequest) error
CreateAreaController(req *dto.CreateAreaControllerRequest) (*dto.AreaControllerResponse, error)
GetAreaController(id uint) (*dto.AreaControllerResponse, error)
ListAreaControllers() ([]*dto.AreaControllerResponse, error)
UpdateAreaController(id uint, req *dto.UpdateAreaControllerRequest) (*dto.AreaControllerResponse, error)
DeleteAreaController(id uint) error
CreateDeviceTemplate(req *dto.CreateDeviceTemplateRequest) (*dto.DeviceTemplateResponse, error)
GetDeviceTemplate(id uint) (*dto.DeviceTemplateResponse, error)
ListDeviceTemplates() ([]*dto.DeviceTemplateResponse, error)
UpdateDeviceTemplate(id uint, req *dto.UpdateDeviceTemplateRequest) (*dto.DeviceTemplateResponse, error)
DeleteDeviceTemplate(id uint) error
}
// deviceService 是 DeviceService 接口的具体实现。
type deviceService struct {
deviceRepo repository.DeviceRepository
areaControllerRepo repository.AreaControllerRepository
deviceTemplateRepo repository.DeviceTemplateRepository
deviceDomainSvc device.Service // 依赖领域服务
}
// NewDeviceService 创建一个新的 DeviceService 实例。
func NewDeviceService(
deviceRepo repository.DeviceRepository,
areaControllerRepo repository.AreaControllerRepository,
deviceTemplateRepo repository.DeviceTemplateRepository,
deviceDomainSvc device.Service,
) DeviceService {
return &deviceService{
deviceRepo: deviceRepo,
areaControllerRepo: areaControllerRepo,
deviceTemplateRepo: deviceTemplateRepo,
deviceDomainSvc: deviceDomainSvc,
}
}
// --- Devices ---
func (s *deviceService) CreateDevice(req *dto.CreateDeviceRequest) (*dto.DeviceResponse, error) {
propertiesJSON, err := json.Marshal(req.Properties)
if err != nil {
return nil, err // Consider wrapping this error for better context
}
device := &models.Device{
Name: req.Name,
DeviceTemplateID: req.DeviceTemplateID,
AreaControllerID: req.AreaControllerID,
Location: req.Location,
Properties: propertiesJSON,
}
if err := device.SelfCheck(); err != nil {
return nil, err
}
if err := s.deviceRepo.Create(device); err != nil {
return nil, err
}
createdDevice, err := s.deviceRepo.FindByID(device.ID)
if err != nil {
return nil, err
}
return dto.NewDeviceResponse(createdDevice)
}
func (s *deviceService) GetDevice(id uint) (*dto.DeviceResponse, error) {
device, err := s.deviceRepo.FindByID(id)
if err != nil {
return nil, err
}
return dto.NewDeviceResponse(device)
}
func (s *deviceService) ListDevices() ([]*dto.DeviceResponse, error) {
devices, err := s.deviceRepo.ListAll()
if err != nil {
return nil, err
}
return dto.NewListDeviceResponse(devices)
}
func (s *deviceService) UpdateDevice(id uint, req *dto.UpdateDeviceRequest) (*dto.DeviceResponse, error) {
existingDevice, err := s.deviceRepo.FindByID(id)
if err != nil {
return nil, err
}
propertiesJSON, err := json.Marshal(req.Properties)
if err != nil {
return nil, err
}
existingDevice.Name = req.Name
existingDevice.DeviceTemplateID = req.DeviceTemplateID
existingDevice.AreaControllerID = req.AreaControllerID
existingDevice.Location = req.Location
existingDevice.Properties = propertiesJSON
if err := existingDevice.SelfCheck(); err != nil {
return nil, err
}
if err := s.deviceRepo.Update(existingDevice); err != nil {
return nil, err
}
updatedDevice, err := s.deviceRepo.FindByID(existingDevice.ID)
if err != nil {
return nil, err
}
return dto.NewDeviceResponse(updatedDevice)
}
func (s *deviceService) DeleteDevice(id uint) error {
// 检查设备是否存在
_, err := s.deviceRepo.FindByID(id)
if err != nil {
return err // 如果未找到,会返回 gorm.ErrRecordNotFound
}
// 在删除前检查设备是否被任务使用
inUse, err := s.deviceRepo.IsDeviceInUse(id)
if err != nil {
// 如果检查过程中发生数据库错误,则返回错误
return fmt.Errorf("检查设备使用情况失败: %w", err)
}
if inUse {
// 如果设备正在被使用,则返回特定的业务错误
return ErrDeviceInUse
}
// 只有在未被使用时,才执行删除操作
return s.deviceRepo.Delete(id)
}
func (s *deviceService) ManualControl(id uint, req *dto.ManualControlDeviceRequest) error {
dev, err := s.deviceRepo.FindByID(id)
if err != nil {
return err
}
if req.Action == nil {
return s.deviceDomainSvc.Collect(dev.AreaControllerID, []*models.Device{dev})
} else {
action := device.DeviceActionStart
switch *req.Action {
case "off":
action = device.DeviceActionStop
case "on":
action = device.DeviceActionStart
default:
return errors.New("invalid action")
}
return s.deviceDomainSvc.Switch(dev, action)
}
}
// --- Area Controllers ---
func (s *deviceService) CreateAreaController(req *dto.CreateAreaControllerRequest) (*dto.AreaControllerResponse, error) {
propertiesJSON, err := json.Marshal(req.Properties)
if err != nil {
return nil, err
}
ac := &models.AreaController{
Name: req.Name,
NetworkID: req.NetworkID,
Location: req.Location,
Properties: propertiesJSON,
}
if err := ac.SelfCheck(); err != nil {
return nil, err
}
if err := s.areaControllerRepo.Create(ac); err != nil {
return nil, err
}
return dto.NewAreaControllerResponse(ac)
}
func (s *deviceService) GetAreaController(id uint) (*dto.AreaControllerResponse, error) {
ac, err := s.areaControllerRepo.FindByID(id)
if err != nil {
return nil, err
}
return dto.NewAreaControllerResponse(ac)
}
func (s *deviceService) ListAreaControllers() ([]*dto.AreaControllerResponse, error) {
acs, err := s.areaControllerRepo.ListAll()
if err != nil {
return nil, err
}
return dto.NewListAreaControllerResponse(acs)
}
func (s *deviceService) UpdateAreaController(id uint, req *dto.UpdateAreaControllerRequest) (*dto.AreaControllerResponse, error) {
existingAC, err := s.areaControllerRepo.FindByID(id)
if err != nil {
return nil, err
}
propertiesJSON, err := json.Marshal(req.Properties)
if err != nil {
return nil, err
}
existingAC.Name = req.Name
existingAC.NetworkID = req.NetworkID
existingAC.Location = req.Location
existingAC.Properties = propertiesJSON
if err := existingAC.SelfCheck(); err != nil {
return nil, err
}
if err := s.areaControllerRepo.Update(existingAC); err != nil {
return nil, err
}
return dto.NewAreaControllerResponse(existingAC)
}
func (s *deviceService) DeleteAreaController(id uint) error {
// 1. 检查是否存在
_, err := s.areaControllerRepo.FindByID(id)
if err != nil {
return err // 如果未找到gorm会返回 ErrRecordNotFound
}
// 2. 检查是否被使用(业务逻辑)
inUse, err := s.deviceRepo.IsAreaControllerInUse(id)
if err != nil {
return err // 返回数据库检查错误
}
if inUse {
return ErrAreaControllerInUse // 返回业务错误
}
// 3. 执行删除
return s.areaControllerRepo.Delete(id)
}
// --- Device Templates ---
func (s *deviceService) CreateDeviceTemplate(req *dto.CreateDeviceTemplateRequest) (*dto.DeviceTemplateResponse, error) {
commandsJSON, err := json.Marshal(req.Commands)
if err != nil {
return nil, err
}
valuesJSON, err := json.Marshal(req.Values)
if err != nil {
return nil, err
}
deviceTemplate := &models.DeviceTemplate{
Name: req.Name,
Manufacturer: req.Manufacturer,
Description: req.Description,
Category: req.Category,
Commands: commandsJSON,
Values: valuesJSON,
}
if err := deviceTemplate.SelfCheck(); err != nil {
return nil, err
}
if err := s.deviceTemplateRepo.Create(deviceTemplate); err != nil {
return nil, err
}
return dto.NewDeviceTemplateResponse(deviceTemplate)
}
func (s *deviceService) GetDeviceTemplate(id uint) (*dto.DeviceTemplateResponse, error) {
deviceTemplate, err := s.deviceTemplateRepo.FindByID(id)
if err != nil {
return nil, err
}
return dto.NewDeviceTemplateResponse(deviceTemplate)
}
func (s *deviceService) ListDeviceTemplates() ([]*dto.DeviceTemplateResponse, error) {
deviceTemplates, err := s.deviceTemplateRepo.ListAll()
if err != nil {
return nil, err
}
return dto.NewListDeviceTemplateResponse(deviceTemplates)
}
func (s *deviceService) UpdateDeviceTemplate(id uint, req *dto.UpdateDeviceTemplateRequest) (*dto.DeviceTemplateResponse, error) {
existingDeviceTemplate, err := s.deviceTemplateRepo.FindByID(id)
if err != nil {
return nil, err
}
commandsJSON, err := json.Marshal(req.Commands)
if err != nil {
return nil, err
}
valuesJSON, err := json.Marshal(req.Values)
if err != nil {
return nil, err
}
existingDeviceTemplate.Name = req.Name
existingDeviceTemplate.Manufacturer = req.Manufacturer
existingDeviceTemplate.Description = req.Description
existingDeviceTemplate.Category = req.Category
existingDeviceTemplate.Commands = commandsJSON
existingDeviceTemplate.Values = valuesJSON
if err := existingDeviceTemplate.SelfCheck(); err != nil {
return nil, err
}
if err := s.deviceTemplateRepo.Update(existingDeviceTemplate); err != nil {
return nil, err
}
return dto.NewDeviceTemplateResponse(existingDeviceTemplate)
}
func (s *deviceService) DeleteDeviceTemplate(id uint) error {
// 1. 检查是否存在
_, err := s.deviceTemplateRepo.FindByID(id)
if err != nil {
return err
}
// 2. 检查是否被使用(业务逻辑)
inUse, err := s.deviceTemplateRepo.IsInUse(id)
if err != nil {
return err
}
if inUse {
return ErrDeviceTemplateInUse // 返回业务错误
}
// 3. 执行删除
return s.deviceTemplateRepo.Delete(id)
}

View File

@@ -0,0 +1,474 @@
package service
import (
"git.huangwc.com/pig/pig-farm-controller/internal/app/dto"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
)
// MonitorService 定义了监控相关的业务逻辑服务接口
type MonitorService interface {
ListSensorData(req *dto.ListSensorDataRequest) (*dto.ListSensorDataResponse, error)
ListDeviceCommandLogs(req *dto.ListDeviceCommandLogRequest) (*dto.ListDeviceCommandLogResponse, error)
ListPlanExecutionLogs(req *dto.ListPlanExecutionLogRequest) (*dto.ListPlanExecutionLogResponse, error)
ListTaskExecutionLogs(req *dto.ListTaskExecutionLogRequest) (*dto.ListTaskExecutionLogResponse, error)
ListPendingCollections(req *dto.ListPendingCollectionRequest) (*dto.ListPendingCollectionResponse, error)
ListUserActionLogs(req *dto.ListUserActionLogRequest) (*dto.ListUserActionLogResponse, error)
ListRawMaterialPurchases(req *dto.ListRawMaterialPurchaseRequest) (*dto.ListRawMaterialPurchaseResponse, error)
ListRawMaterialStockLogs(req *dto.ListRawMaterialStockLogRequest) (*dto.ListRawMaterialStockLogResponse, error)
ListFeedUsageRecords(req *dto.ListFeedUsageRecordRequest) (*dto.ListFeedUsageRecordResponse, error)
ListMedicationLogs(req *dto.ListMedicationLogRequest) (*dto.ListMedicationLogResponse, error)
ListPigBatchLogs(req *dto.ListPigBatchLogRequest) (*dto.ListPigBatchLogResponse, error)
ListWeighingBatches(req *dto.ListWeighingBatchRequest) (*dto.ListWeighingBatchResponse, error)
ListWeighingRecords(req *dto.ListWeighingRecordRequest) (*dto.ListWeighingRecordResponse, error)
ListPigTransferLogs(req *dto.ListPigTransferLogRequest) (*dto.ListPigTransferLogResponse, error)
ListPigSickLogs(req *dto.ListPigSickLogRequest) (*dto.ListPigSickLogResponse, error)
ListPigPurchases(req *dto.ListPigPurchaseRequest) (*dto.ListPigPurchaseResponse, error)
ListPigSales(req *dto.ListPigSaleRequest) (*dto.ListPigSaleResponse, error)
ListNotifications(req *dto.ListNotificationRequest) (*dto.ListNotificationResponse, error)
}
// monitorService 是 MonitorService 接口的具体实现
type monitorService struct {
sensorDataRepo repository.SensorDataRepository
deviceCommandLogRepo repository.DeviceCommandLogRepository
executionLogRepo repository.ExecutionLogRepository
planRepository repository.PlanRepository
pendingCollectionRepo repository.PendingCollectionRepository
userActionLogRepo repository.UserActionLogRepository
rawMaterialRepo repository.RawMaterialRepository
medicationRepo repository.MedicationLogRepository
pigBatchRepo repository.PigBatchRepository
pigBatchLogRepo repository.PigBatchLogRepository
pigTransferLogRepo repository.PigTransferLogRepository
pigSickLogRepo repository.PigSickLogRepository
pigTradeRepo repository.PigTradeRepository
notificationRepo repository.NotificationRepository
}
// NewMonitorService 创建一个新的 MonitorService 实例
func NewMonitorService(
sensorDataRepo repository.SensorDataRepository,
deviceCommandLogRepo repository.DeviceCommandLogRepository,
executionLogRepo repository.ExecutionLogRepository,
planRepository repository.PlanRepository,
pendingCollectionRepo repository.PendingCollectionRepository,
userActionLogRepo repository.UserActionLogRepository,
rawMaterialRepo repository.RawMaterialRepository,
medicationRepo repository.MedicationLogRepository,
pigBatchRepo repository.PigBatchRepository,
pigBatchLogRepo repository.PigBatchLogRepository,
pigTransferLogRepo repository.PigTransferLogRepository,
pigSickLogRepo repository.PigSickLogRepository,
pigTradeRepo repository.PigTradeRepository,
notificationRepo repository.NotificationRepository,
) MonitorService {
return &monitorService{
sensorDataRepo: sensorDataRepo,
deviceCommandLogRepo: deviceCommandLogRepo,
executionLogRepo: executionLogRepo,
planRepository: planRepository,
pendingCollectionRepo: pendingCollectionRepo,
userActionLogRepo: userActionLogRepo,
rawMaterialRepo: rawMaterialRepo,
medicationRepo: medicationRepo,
pigBatchRepo: pigBatchRepo,
pigBatchLogRepo: pigBatchLogRepo,
pigTransferLogRepo: pigTransferLogRepo,
pigSickLogRepo: pigSickLogRepo,
pigTradeRepo: pigTradeRepo,
notificationRepo: notificationRepo,
}
}
// ListSensorData 负责处理查询传感器数据列表的业务逻辑
func (s *monitorService) ListSensorData(req *dto.ListSensorDataRequest) (*dto.ListSensorDataResponse, error) {
opts := repository.SensorDataListOptions{
DeviceID: req.DeviceID,
OrderBy: req.OrderBy,
StartTime: req.StartTime,
EndTime: req.EndTime,
}
if req.SensorType != nil {
sensorType := models.SensorType(*req.SensorType)
opts.SensorType = &sensorType
}
data, total, err := s.sensorDataRepo.List(opts, req.Page, req.PageSize)
if err != nil {
return nil, err
}
return dto.NewListSensorDataResponse(data, total, req.Page, req.PageSize), nil
}
// ListDeviceCommandLogs 负责处理查询设备命令日志列表的业务逻辑
func (s *monitorService) ListDeviceCommandLogs(req *dto.ListDeviceCommandLogRequest) (*dto.ListDeviceCommandLogResponse, error) {
opts := repository.DeviceCommandLogListOptions{
DeviceID: req.DeviceID,
ReceivedSuccess: req.ReceivedSuccess,
OrderBy: req.OrderBy,
StartTime: req.StartTime,
EndTime: req.EndTime,
}
data, total, err := s.deviceCommandLogRepo.List(opts, req.Page, req.PageSize)
if err != nil {
return nil, err
}
return dto.NewListDeviceCommandLogResponse(data, total, req.Page, req.PageSize), nil
}
// ListPlanExecutionLogs 负责处理查询计划执行日志列表的业务逻辑
func (s *monitorService) ListPlanExecutionLogs(req *dto.ListPlanExecutionLogRequest) (*dto.ListPlanExecutionLogResponse, error) {
opts := repository.PlanExecutionLogListOptions{
PlanID: req.PlanID,
OrderBy: req.OrderBy,
StartTime: req.StartTime,
EndTime: req.EndTime,
}
if req.Status != nil {
status := models.ExecutionStatus(*req.Status)
opts.Status = &status
}
planLogs, total, err := s.executionLogRepo.ListPlanExecutionLogs(opts, req.Page, req.PageSize)
if err != nil {
return nil, err
}
planIds := make([]uint, 0, len(planLogs))
for _, datum := range planLogs {
has := false
for _, id := range planIds {
if id == datum.PlanID {
has = true
break
}
}
if !has {
planIds = append(planIds, datum.PlanID)
}
}
plans, err := s.planRepository.GetPlansByIDs(planIds)
if err != nil {
return nil, err
}
return dto.NewListPlanExecutionLogResponse(planLogs, plans, total, req.Page, req.PageSize), nil
}
// ListTaskExecutionLogs 负责处理查询任务执行日志列表的业务逻辑
func (s *monitorService) ListTaskExecutionLogs(req *dto.ListTaskExecutionLogRequest) (*dto.ListTaskExecutionLogResponse, error) {
opts := repository.TaskExecutionLogListOptions{
PlanExecutionLogID: req.PlanExecutionLogID,
TaskID: req.TaskID,
OrderBy: req.OrderBy,
StartTime: req.StartTime,
EndTime: req.EndTime,
}
if req.Status != nil {
status := models.ExecutionStatus(*req.Status)
opts.Status = &status
}
data, total, err := s.executionLogRepo.ListTaskExecutionLogs(opts, req.Page, req.PageSize)
if err != nil {
return nil, err
}
return dto.NewListTaskExecutionLogResponse(data, total, req.Page, req.PageSize), nil
}
// ListPendingCollections 负责处理查询待采集请求列表的业务逻辑
func (s *monitorService) ListPendingCollections(req *dto.ListPendingCollectionRequest) (*dto.ListPendingCollectionResponse, error) {
opts := repository.PendingCollectionListOptions{
DeviceID: req.DeviceID,
OrderBy: req.OrderBy,
StartTime: req.StartTime,
EndTime: req.EndTime,
}
if req.Status != nil {
status := models.PendingCollectionStatus(*req.Status)
opts.Status = &status
}
data, total, err := s.pendingCollectionRepo.List(opts, req.Page, req.PageSize)
if err != nil {
return nil, err
}
return dto.NewListPendingCollectionResponse(data, total, req.Page, req.PageSize), nil
}
// ListUserActionLogs 负责处理查询用户操作日志列表的业务逻辑
func (s *monitorService) ListUserActionLogs(req *dto.ListUserActionLogRequest) (*dto.ListUserActionLogResponse, error) {
opts := repository.UserActionLogListOptions{
UserID: req.UserID,
Username: req.Username,
ActionType: req.ActionType,
OrderBy: req.OrderBy,
StartTime: req.StartTime,
EndTime: req.EndTime,
}
if req.Status != nil {
status := models.AuditStatus(*req.Status)
opts.Status = &status
}
data, total, err := s.userActionLogRepo.List(opts, req.Page, req.PageSize)
if err != nil {
return nil, err
}
return dto.NewListUserActionLogResponse(data, total, req.Page, req.PageSize), nil
}
// ListRawMaterialPurchases 负责处理查询原料采购记录列表的业务逻辑
func (s *monitorService) ListRawMaterialPurchases(req *dto.ListRawMaterialPurchaseRequest) (*dto.ListRawMaterialPurchaseResponse, error) {
opts := repository.RawMaterialPurchaseListOptions{
RawMaterialID: req.RawMaterialID,
Supplier: req.Supplier,
OrderBy: req.OrderBy,
StartTime: req.StartTime,
EndTime: req.EndTime,
}
data, total, err := s.rawMaterialRepo.ListRawMaterialPurchases(opts, req.Page, req.PageSize)
if err != nil {
return nil, err
}
return dto.NewListRawMaterialPurchaseResponse(data, total, req.Page, req.PageSize), nil
}
// ListRawMaterialStockLogs 负责处理查询原料库存日志列表的业务逻辑
func (s *monitorService) ListRawMaterialStockLogs(req *dto.ListRawMaterialStockLogRequest) (*dto.ListRawMaterialStockLogResponse, error) {
opts := repository.RawMaterialStockLogListOptions{
RawMaterialID: req.RawMaterialID,
SourceID: req.SourceID,
OrderBy: req.OrderBy,
StartTime: req.StartTime,
EndTime: req.EndTime,
}
if req.SourceType != nil {
sourceType := models.StockLogSourceType(*req.SourceType)
opts.SourceType = &sourceType
}
data, total, err := s.rawMaterialRepo.ListRawMaterialStockLogs(opts, req.Page, req.PageSize)
if err != nil {
return nil, err
}
return dto.NewListRawMaterialStockLogResponse(data, total, req.Page, req.PageSize), nil
}
// ListFeedUsageRecords 负责处理查询饲料使用记录列表的业务逻辑
func (s *monitorService) ListFeedUsageRecords(req *dto.ListFeedUsageRecordRequest) (*dto.ListFeedUsageRecordResponse, error) {
opts := repository.FeedUsageRecordListOptions{
PenID: req.PenID,
FeedFormulaID: req.FeedFormulaID,
OperatorID: req.OperatorID,
OrderBy: req.OrderBy,
StartTime: req.StartTime,
EndTime: req.EndTime,
}
data, total, err := s.rawMaterialRepo.ListFeedUsageRecords(opts, req.Page, req.PageSize)
if err != nil {
return nil, err
}
return dto.NewListFeedUsageRecordResponse(data, total, req.Page, req.PageSize), nil
}
// ListMedicationLogs 负责处理查询用药记录列表的业务逻辑
func (s *monitorService) ListMedicationLogs(req *dto.ListMedicationLogRequest) (*dto.ListMedicationLogResponse, error) {
opts := repository.MedicationLogListOptions{
PigBatchID: req.PigBatchID,
MedicationID: req.MedicationID,
OperatorID: req.OperatorID,
OrderBy: req.OrderBy,
StartTime: req.StartTime,
EndTime: req.EndTime,
}
if req.Reason != nil {
reason := models.MedicationReasonType(*req.Reason)
opts.Reason = &reason
}
data, total, err := s.medicationRepo.ListMedicationLogs(opts, req.Page, req.PageSize)
if err != nil {
return nil, err
}
return dto.NewListMedicationLogResponse(data, total, req.Page, req.PageSize), nil
}
// ListPigBatchLogs 负责处理查询猪批次日志列表的业务逻辑
func (s *monitorService) ListPigBatchLogs(req *dto.ListPigBatchLogRequest) (*dto.ListPigBatchLogResponse, error) {
opts := repository.PigBatchLogListOptions{
PigBatchID: req.PigBatchID,
OperatorID: req.OperatorID,
OrderBy: req.OrderBy,
StartTime: req.StartTime,
EndTime: req.EndTime,
}
if req.ChangeType != nil {
changeType := models.LogChangeType(*req.ChangeType)
opts.ChangeType = &changeType
}
data, total, err := s.pigBatchLogRepo.List(opts, req.Page, req.PageSize)
if err != nil {
return nil, err
}
return dto.NewListPigBatchLogResponse(data, total, req.Page, req.PageSize), nil
}
// ListWeighingBatches 负责处理查询批次称重记录列表的业务逻辑
func (s *monitorService) ListWeighingBatches(req *dto.ListWeighingBatchRequest) (*dto.ListWeighingBatchResponse, error) {
opts := repository.WeighingBatchListOptions{
PigBatchID: req.PigBatchID,
OrderBy: req.OrderBy,
StartTime: req.StartTime,
EndTime: req.EndTime,
}
data, total, err := s.pigBatchRepo.ListWeighingBatches(opts, req.Page, req.PageSize)
if err != nil {
return nil, err
}
return dto.NewListWeighingBatchResponse(data, total, req.Page, req.PageSize), nil
}
// ListWeighingRecords 负责处理查询单次称重记录列表的业务逻辑
func (s *monitorService) ListWeighingRecords(req *dto.ListWeighingRecordRequest) (*dto.ListWeighingRecordResponse, error) {
opts := repository.WeighingRecordListOptions{
WeighingBatchID: req.WeighingBatchID,
PenID: req.PenID,
OperatorID: req.OperatorID,
OrderBy: req.OrderBy,
StartTime: req.StartTime,
EndTime: req.EndTime,
}
data, total, err := s.pigBatchRepo.ListWeighingRecords(opts, req.Page, req.PageSize)
if err != nil {
return nil, err
}
return dto.NewListWeighingRecordResponse(data, total, req.Page, req.PageSize), nil
}
// ListPigTransferLogs 负责处理查询猪只迁移日志列表的业务逻辑
func (s *monitorService) ListPigTransferLogs(req *dto.ListPigTransferLogRequest) (*dto.ListPigTransferLogResponse, error) {
opts := repository.PigTransferLogListOptions{
PigBatchID: req.PigBatchID,
PenID: req.PenID,
OperatorID: req.OperatorID,
CorrelationID: req.CorrelationID,
OrderBy: req.OrderBy,
StartTime: req.StartTime,
EndTime: req.EndTime,
}
if req.TransferType != nil {
transferType := models.PigTransferType(*req.TransferType)
opts.TransferType = &transferType
}
data, total, err := s.pigTransferLogRepo.ListPigTransferLogs(opts, req.Page, req.PageSize)
if err != nil {
return nil, err
}
return dto.NewListPigTransferLogResponse(data, total, req.Page, req.PageSize), nil
}
// ListPigSickLogs 负责处理查询病猪日志列表的业务逻辑
func (s *monitorService) ListPigSickLogs(req *dto.ListPigSickLogRequest) (*dto.ListPigSickLogResponse, error) {
opts := repository.PigSickLogListOptions{
PigBatchID: req.PigBatchID,
PenID: req.PenID,
OperatorID: req.OperatorID,
OrderBy: req.OrderBy,
StartTime: req.StartTime,
EndTime: req.EndTime,
}
if req.Reason != nil {
reason := models.PigBatchSickPigReasonType(*req.Reason)
opts.Reason = &reason
}
if req.TreatmentLocation != nil {
treatmentLocation := models.PigBatchSickPigTreatmentLocation(*req.TreatmentLocation)
opts.TreatmentLocation = &treatmentLocation
}
data, total, err := s.pigSickLogRepo.ListPigSickLogs(opts, req.Page, req.PageSize)
if err != nil {
return nil, err
}
return dto.NewListPigSickLogResponse(data, total, req.Page, req.PageSize), nil
}
// ListPigPurchases 负责处理查询猪只采购记录列表的业务逻辑
func (s *monitorService) ListPigPurchases(req *dto.ListPigPurchaseRequest) (*dto.ListPigPurchaseResponse, error) {
opts := repository.PigPurchaseListOptions{
PigBatchID: req.PigBatchID,
Supplier: req.Supplier,
OperatorID: req.OperatorID,
OrderBy: req.OrderBy,
StartTime: req.StartTime,
EndTime: req.EndTime,
}
data, total, err := s.pigTradeRepo.ListPigPurchases(opts, req.Page, req.PageSize)
if err != nil {
return nil, err
}
return dto.NewListPigPurchaseResponse(data, total, req.Page, req.PageSize), nil
}
// ListPigSales 负责处理查询猪只销售记录列表的业务逻辑
func (s *monitorService) ListPigSales(req *dto.ListPigSaleRequest) (*dto.ListPigSaleResponse, error) {
opts := repository.PigSaleListOptions{
PigBatchID: req.PigBatchID,
Buyer: req.Buyer,
OperatorID: req.OperatorID,
OrderBy: req.OrderBy,
StartTime: req.StartTime,
EndTime: req.EndTime,
}
data, total, err := s.pigTradeRepo.ListPigSales(opts, req.Page, req.PageSize)
if err != nil {
return nil, err
}
return dto.NewListPigSaleResponse(data, total, req.Page, req.PageSize), nil
}
// ListNotifications 负责处理查询通知列表的业务逻辑
func (s *monitorService) ListNotifications(req *dto.ListNotificationRequest) (*dto.ListNotificationResponse, error) {
opts := repository.NotificationListOptions{
UserID: req.UserID,
NotifierType: req.NotifierType,
Level: req.Level,
StartTime: req.StartTime,
EndTime: req.EndTime,
OrderBy: req.OrderBy,
Status: req.Status,
}
data, total, err := s.notificationRepo.List(opts, req.Page, req.PageSize)
if err != nil {
return nil, err
}
return dto.NewListNotificationResponse(data, total, req.Page, req.PageSize), nil
}

View File

@@ -0,0 +1,348 @@
package service
import (
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/app/dto"
domain_pig "git.huangwc.com/pig/pig-farm-controller/internal/domain/pig"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
)
// PigBatchService 接口定义保持不变,继续作为应用层对外的契约。
type PigBatchService interface {
CreatePigBatch(operatorID uint, dto *dto.PigBatchCreateDTO) (*dto.PigBatchResponseDTO, error)
GetPigBatch(id uint) (*dto.PigBatchResponseDTO, error)
UpdatePigBatch(id uint, dto *dto.PigBatchUpdateDTO) (*dto.PigBatchResponseDTO, error)
DeletePigBatch(id uint) error
ListPigBatches(isActive *bool) ([]*dto.PigBatchResponseDTO, error)
// Pig Pen Management
AssignEmptyPensToBatch(batchID uint, penIDs []uint, operatorID uint) error
ReclassifyPenToNewBatch(fromBatchID uint, toBatchID uint, penID uint, operatorID uint, remarks string) error
RemoveEmptyPenFromBatch(batchID uint, penID uint) error
MovePigsIntoPen(batchID uint, toPenID uint, quantity int, operatorID uint, remarks string) error
// Trade Sub-service
SellPigs(batchID uint, penID uint, quantity int, unitPrice float64, tatalPrice float64, traderName string, tradeDate time.Time, remarks string, operatorID uint) error
BuyPigs(batchID uint, penID uint, quantity int, unitPrice float64, tatalPrice float64, traderName string, tradeDate time.Time, remarks string, operatorID uint) error
// Transfer Sub-service
TransferPigsAcrossBatches(sourceBatchID uint, destBatchID uint, fromPenID uint, toPenID uint, quantity uint, operatorID uint, remarks string) error
TransferPigsWithinBatch(batchID uint, fromPenID uint, toPenID uint, quantity uint, operatorID uint, remarks string) error
// Sick Pig Management
RecordSickPigs(operatorID uint, batchID uint, penID uint, quantity int, treatmentLocation models.PigBatchSickPigTreatmentLocation, happenedAt time.Time, remarks string) error
RecordSickPigRecovery(operatorID uint, batchID uint, penID uint, quantity int, treatmentLocation models.PigBatchSickPigTreatmentLocation, happenedAt time.Time, remarks string) error
RecordSickPigDeath(operatorID uint, batchID uint, penID uint, quantity int, treatmentLocation models.PigBatchSickPigTreatmentLocation, happenedAt time.Time, remarks string) error
RecordSickPigCull(operatorID uint, batchID uint, penID uint, quantity int, treatmentLocation models.PigBatchSickPigTreatmentLocation, happenedAt time.Time, remarks string) error
// Normal Pig Management
RecordDeath(operatorID uint, batchID uint, penID uint, quantity int, happenedAt time.Time, remarks string) error
RecordCull(operatorID uint, batchID uint, penID uint, quantity int, happenedAt time.Time, remarks string) error
}
// pigBatchService 的实现现在依赖于领域服务接口。
type pigBatchService struct {
logger *logs.Logger
domainService domain_pig.PigBatchService // 依赖注入领域服务
}
// NewPigBatchService 构造函数被修改,以注入领域服务。
func NewPigBatchService(domainService domain_pig.PigBatchService, logger *logs.Logger) PigBatchService {
return &pigBatchService{
logger: logger,
domainService: domainService,
}
}
// toPigBatchResponseDTO 负责将领域模型转换为应用层DTO这个职责保留在应用层。
func (s *pigBatchService) toPigBatchResponseDTO(batch *models.PigBatch, currentTotalQuantity, currentTotalPigsInPens int) *dto.PigBatchResponseDTO {
if batch == nil {
return nil
}
return &dto.PigBatchResponseDTO{
ID: batch.ID,
BatchNumber: batch.BatchNumber,
OriginType: batch.OriginType,
StartDate: batch.StartDate,
EndDate: batch.EndDate,
InitialCount: batch.InitialCount,
Status: batch.Status,
IsActive: batch.IsActive(),
CurrentTotalQuantity: currentTotalQuantity,
CurrentTotalPigsInPens: currentTotalPigsInPens,
CreateTime: batch.CreatedAt,
UpdateTime: batch.UpdatedAt,
}
}
// CreatePigBatch 现在将请求委托给领域服务处理。
func (s *pigBatchService) CreatePigBatch(operatorID uint, dto *dto.PigBatchCreateDTO) (*dto.PigBatchResponseDTO, error) {
// 1. DTO -> 领域模型
batch := &models.PigBatch{
BatchNumber: dto.BatchNumber,
OriginType: dto.OriginType,
StartDate: dto.StartDate,
InitialCount: dto.InitialCount,
Status: dto.Status,
}
// 2. 调用领域服务
createdBatch, err := s.domainService.CreatePigBatch(operatorID, batch)
if err != nil {
s.logger.Errorf("应用层: 创建猪批次失败: %v", err)
return nil, MapDomainError(err)
}
// 3. 领域模型 -> DTO
return s.toPigBatchResponseDTO(createdBatch, dto.InitialCount, 0), nil
}
// GetPigBatch 从领域服务获取数据并转换为DTO同时处理错误转换。
func (s *pigBatchService) GetPigBatch(id uint) (*dto.PigBatchResponseDTO, error) {
batch, err := s.domainService.GetPigBatch(id)
if err != nil {
s.logger.Warnf("应用层: 获取猪批次失败, ID: %d, 错误: %v", id, err)
return nil, MapDomainError(err)
}
currentTotalQuantity, err := s.domainService.GetCurrentPigQuantity(id)
if err != nil {
s.logger.Warnf("应用层: 获取猪批次总数失败, ID: %d, 错误: %v", id, err)
return nil, MapDomainError(err)
}
currentTotalPigsInPens, err := s.domainService.GetTotalPigsInPensForBatch(id)
if err != nil {
s.logger.Warnf("应用层: 获取猪批次存栏总数失败, ID: %d, 错误: %v", id, err)
return nil, MapDomainError(err)
}
return s.toPigBatchResponseDTO(batch, currentTotalQuantity, currentTotalPigsInPens), nil
}
// UpdatePigBatch 协调获取、更新和保存的流程,并处理错误转换。
func (s *pigBatchService) UpdatePigBatch(id uint, dto *dto.PigBatchUpdateDTO) (*dto.PigBatchResponseDTO, error) {
// 1. 先获取最新的领域模型
existingBatch, err := s.domainService.GetPigBatch(id)
if err != nil {
s.logger.Warnf("应用层: 更新猪批次失败,获取原批次信息错误, ID: %d, 错误: %v", id, err)
return nil, MapDomainError(err)
}
// 2. 将DTO中的变更应用到模型上
if dto.BatchNumber != nil {
existingBatch.BatchNumber = *dto.BatchNumber
}
if dto.OriginType != nil {
existingBatch.OriginType = *dto.OriginType
}
if dto.StartDate != nil {
existingBatch.StartDate = *dto.StartDate
}
if dto.EndDate != nil {
existingBatch.EndDate = *dto.EndDate
}
if dto.InitialCount != nil {
existingBatch.InitialCount = *dto.InitialCount
}
if dto.Status != nil {
existingBatch.Status = *dto.Status
}
// 3. 调用领域服务执行更新
updatedBatch, err := s.domainService.UpdatePigBatch(existingBatch)
if err != nil {
s.logger.Errorf("应用层: 更新猪批次失败, ID: %d, 错误: %v", id, err)
return nil, MapDomainError(err)
}
// 4. 填充猪群信息
currentTotalQuantity, err := s.domainService.GetCurrentPigQuantity(id)
if err != nil {
s.logger.Warnf("应用层: 获取猪批次总数失败, ID: %d, 错误: %v", id, err)
return nil, MapDomainError(err)
}
currentTotalPigsInPens, err := s.domainService.GetTotalPigsInPensForBatch(id)
if err != nil {
s.logger.Warnf("应用层: 获取猪批次存栏总数失败, ID: %d, 错误: %v", id, err)
return nil, MapDomainError(err)
}
// 5. 转换并返回结果
return s.toPigBatchResponseDTO(updatedBatch, currentTotalQuantity, currentTotalPigsInPens), nil
}
// DeletePigBatch 将删除操作委托给领域服务,并转换领域错误为应用层错误。
func (s *pigBatchService) DeletePigBatch(id uint) error {
err := s.domainService.DeletePigBatch(id)
if err != nil {
s.logger.Errorf("应用层: 删除猪批次失败, ID: %d, 错误: %v", id, err)
return MapDomainError(err)
}
return nil
}
// ListPigBatches 从领域服务获取列表并进行转换。
func (s *pigBatchService) ListPigBatches(isActive *bool) ([]*dto.PigBatchResponseDTO, error) {
batches, err := s.domainService.ListPigBatches(isActive)
if err != nil {
s.logger.Errorf("应用层: 批量查询猪批次失败: %v", err)
return nil, MapDomainError(err)
}
var responseDTOs []*dto.PigBatchResponseDTO
for _, batch := range batches {
currentTotalQuantity, err := s.domainService.GetCurrentPigQuantity(batch.ID)
if err != nil {
s.logger.Warnf("应用层: 获取猪批次总数失败, ID: %d, 错误: %v", batch.ID, err)
return nil, MapDomainError(err)
}
currentTotalPigsInPens, err := s.domainService.GetTotalPigsInPensForBatch(batch.ID)
if err != nil {
s.logger.Warnf("应用层: 获取猪批次存栏总数失败, ID: %d, 错误: %v", batch.ID, err)
return nil, MapDomainError(err)
}
responseDTOs = append(responseDTOs, s.toPigBatchResponseDTO(batch, currentTotalQuantity, currentTotalPigsInPens))
}
return responseDTOs, nil
}
// AssignEmptyPensToBatch 委托给领域服务
func (s *pigBatchService) AssignEmptyPensToBatch(batchID uint, penIDs []uint, operatorID uint) error {
err := s.domainService.AssignEmptyPensToBatch(batchID, penIDs, operatorID)
if err != nil {
s.logger.Errorf("应用层: 为猪批次分配空栏失败, 批次ID: %d, 错误: %v", batchID, err)
return MapDomainError(err)
}
return nil
}
// ReclassifyPenToNewBatch 委托给领域服务
func (s *pigBatchService) ReclassifyPenToNewBatch(fromBatchID uint, toBatchID uint, penID uint, operatorID uint, remarks string) error {
err := s.domainService.ReclassifyPenToNewBatch(fromBatchID, toBatchID, penID, operatorID, remarks)
if err != nil {
s.logger.Errorf("应用层: 划拨猪栏到新批次失败, 源批次ID: %d, 错误: %v", fromBatchID, err)
return MapDomainError(err)
}
return nil
}
// RemoveEmptyPenFromBatch 委托给领域服务
func (s *pigBatchService) RemoveEmptyPenFromBatch(batchID uint, penID uint) error {
err := s.domainService.RemoveEmptyPenFromBatch(batchID, penID)
if err != nil {
s.logger.Errorf("应用层: 从猪批次移除空栏失败, 批次ID: %d, 猪栏ID: %d, 错误: %v", batchID, penID, err)
return MapDomainError(err)
}
return nil
}
// MovePigsIntoPen 委托给领域服务
func (s *pigBatchService) MovePigsIntoPen(batchID uint, toPenID uint, quantity int, operatorID uint, remarks string) error {
err := s.domainService.MovePigsIntoPen(batchID, toPenID, quantity, operatorID, remarks)
if err != nil {
s.logger.Errorf("应用层: 将猪只移入猪栏失败, 批次ID: %d, 目标猪栏ID: %d, 错误: %v", batchID, toPenID, err)
return MapDomainError(err)
}
return nil
}
// SellPigs 委托给领域服务
func (s *pigBatchService) SellPigs(batchID uint, penID uint, quantity int, unitPrice float64, tatalPrice float64, traderName string, tradeDate time.Time, remarks string, operatorID uint) error {
err := s.domainService.SellPigs(batchID, penID, quantity, unitPrice, tatalPrice, traderName, tradeDate, remarks, operatorID)
if err != nil {
s.logger.Errorf("应用层: 卖猪失败, 批次ID: %d, 错误: %v", batchID, err)
return MapDomainError(err)
}
return nil
}
// BuyPigs 委托给领域服务
func (s *pigBatchService) BuyPigs(batchID uint, penID uint, quantity int, unitPrice float64, tatalPrice float64, traderName string, tradeDate time.Time, remarks string, operatorID uint) error {
err := s.domainService.BuyPigs(batchID, penID, quantity, unitPrice, tatalPrice, traderName, tradeDate, remarks, operatorID)
if err != nil {
s.logger.Errorf("应用层: 买猪失败, 批次ID: %d, 错误: %v", batchID, err)
return MapDomainError(err)
}
return nil
}
// TransferPigsAcrossBatches 委托给领域服务
func (s *pigBatchService) TransferPigsAcrossBatches(sourceBatchID uint, destBatchID uint, fromPenID uint, toPenID uint, quantity uint, operatorID uint, remarks string) error {
err := s.domainService.TransferPigsAcrossBatches(sourceBatchID, destBatchID, fromPenID, toPenID, quantity, operatorID, remarks)
if err != nil {
s.logger.Errorf("应用层: 跨群调栏失败, 源批次ID: %d, 错误: %v", sourceBatchID, err)
return MapDomainError(err)
}
return nil
}
// TransferPigsWithinBatch 委托给领域服务
func (s *pigBatchService) TransferPigsWithinBatch(batchID uint, fromPenID uint, toPenID uint, quantity uint, operatorID uint, remarks string) error {
err := s.domainService.TransferPigsWithinBatch(batchID, fromPenID, toPenID, quantity, operatorID, remarks)
if err != nil {
s.logger.Errorf("应用层: 群内调栏失败, 批次ID: %d, 错误: %v", batchID, err)
return MapDomainError(err)
}
return nil
}
// RecordSickPigs 委托给领域服务
func (s *pigBatchService) RecordSickPigs(operatorID uint, batchID uint, penID uint, quantity int, treatmentLocation models.PigBatchSickPigTreatmentLocation, happenedAt time.Time, remarks string) error {
err := s.domainService.RecordSickPigs(operatorID, batchID, penID, quantity, treatmentLocation, happenedAt, remarks)
if err != nil {
s.logger.Errorf("应用层: 记录病猪事件失败, 批次ID: %d, 错误: %v", batchID, err)
return MapDomainError(err)
}
return nil
}
// RecordSickPigRecovery 委托给领域服务
func (s *pigBatchService) RecordSickPigRecovery(operatorID uint, batchID uint, penID uint, quantity int, treatmentLocation models.PigBatchSickPigTreatmentLocation, happenedAt time.Time, remarks string) error {
err := s.domainService.RecordSickPigRecovery(operatorID, batchID, penID, quantity, treatmentLocation, happenedAt, remarks)
if err != nil {
s.logger.Errorf("应用层: 记录病猪康复事件失败, 批次ID: %d, 错误: %v", batchID, err)
return MapDomainError(err)
}
return nil
}
// RecordSickPigDeath 委托给领域服务
func (s *pigBatchService) RecordSickPigDeath(operatorID uint, batchID uint, penID uint, quantity int, treatmentLocation models.PigBatchSickPigTreatmentLocation, happenedAt time.Time, remarks string) error {
err := s.domainService.RecordSickPigDeath(operatorID, batchID, penID, quantity, treatmentLocation, happenedAt, remarks)
if err != nil {
s.logger.Errorf("应用层: 记录病猪死亡事件失败, 批次ID: %d, 错误: %v", batchID, err)
return MapDomainError(err)
}
return nil
}
// RecordSickPigCull 委托给领域服务
func (s *pigBatchService) RecordSickPigCull(operatorID uint, batchID uint, penID uint, quantity int, treatmentLocation models.PigBatchSickPigTreatmentLocation, happenedAt time.Time, remarks string) error {
err := s.domainService.RecordSickPigCull(operatorID, batchID, penID, quantity, treatmentLocation, happenedAt, remarks)
if err != nil {
s.logger.Errorf("应用层: 记录病猪淘汰事件失败, 批次ID: %d, 错误: %v", batchID, err)
return MapDomainError(err)
}
return nil
}
// RecordDeath 委托给领域服务
func (s *pigBatchService) RecordDeath(operatorID uint, batchID uint, penID uint, quantity int, happenedAt time.Time, remarks string) error {
err := s.domainService.RecordDeath(operatorID, batchID, penID, quantity, happenedAt, remarks)
if err != nil {
s.logger.Errorf("应用层: 记录正常猪只死亡事件失败, 批次ID: %d, 错误: %v", batchID, err)
return MapDomainError(err)
}
return nil
}
// RecordCull 委托给领域服务
func (s *pigBatchService) RecordCull(operatorID uint, batchID uint, penID uint, quantity int, happenedAt time.Time, remarks string) error {
err := s.domainService.RecordCull(operatorID, batchID, penID, quantity, happenedAt, remarks)
if err != nil {
s.logger.Errorf("应用层: 记录正常猪只淘汰事件失败, 批次ID: %d, 错误: %v", batchID, err)
return MapDomainError(err)
}
return nil
}

View File

@@ -0,0 +1,376 @@
package service
import (
"errors"
"fmt"
"git.huangwc.com/pig/pig-farm-controller/internal/app/dto"
domain_pig "git.huangwc.com/pig/pig-farm-controller/internal/domain/pig"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
"gorm.io/gorm"
)
// PigFarmService 提供了猪场资产管理的业务逻辑
type PigFarmService interface {
// PigHouse methods
CreatePigHouse(name, description string) (*dto.PigHouseResponse, error)
GetPigHouseByID(id uint) (*dto.PigHouseResponse, error)
ListPigHouses() ([]dto.PigHouseResponse, error)
UpdatePigHouse(id uint, name, description string) (*dto.PigHouseResponse, error)
DeletePigHouse(id uint) error
// Pen methods
CreatePen(penNumber string, houseID uint, capacity int) (*dto.PenResponse, error)
GetPenByID(id uint) (*dto.PenResponse, error)
ListPens() ([]*dto.PenResponse, error)
UpdatePen(id uint, penNumber string, houseID uint, capacity int, status models.PenStatus) (*dto.PenResponse, error)
DeletePen(id uint) error
// UpdatePenStatus 更新猪栏状态
UpdatePenStatus(id uint, newStatus models.PenStatus) (*dto.PenResponse, error)
}
type pigFarmService struct {
logger *logs.Logger
farmRepository repository.PigFarmRepository
penRepository repository.PigPenRepository
batchRepository repository.PigBatchRepository
pigBatchService domain_pig.PigBatchService // Add domain PigBatchService dependency
uow repository.UnitOfWork // 工作单元,用于事务管理
}
// NewPigFarmService 创建一个新的 PigFarmService 实例
func NewPigFarmService(farmRepository repository.PigFarmRepository,
penRepository repository.PigPenRepository,
batchRepository repository.PigBatchRepository,
pigBatchService domain_pig.PigBatchService,
uow repository.UnitOfWork,
logger *logs.Logger) PigFarmService {
return &pigFarmService{
logger: logger,
farmRepository: farmRepository,
penRepository: penRepository,
batchRepository: batchRepository,
pigBatchService: pigBatchService,
uow: uow,
}
}
// --- PigHouse Implementation ---
func (s *pigFarmService) CreatePigHouse(name, description string) (*dto.PigHouseResponse, error) {
house := &models.PigHouse{
Name: name,
Description: description,
}
err := s.farmRepository.CreatePigHouse(house)
if err != nil {
return nil, err
}
return &dto.PigHouseResponse{
ID: house.ID,
Name: house.Name,
Description: house.Description,
}, nil
}
func (s *pigFarmService) GetPigHouseByID(id uint) (*dto.PigHouseResponse, error) {
house, err := s.farmRepository.GetPigHouseByID(id)
if err != nil {
return nil, err
}
return &dto.PigHouseResponse{
ID: house.ID,
Name: house.Name,
Description: house.Description,
}, nil
}
func (s *pigFarmService) ListPigHouses() ([]dto.PigHouseResponse, error) {
houses, err := s.farmRepository.ListPigHouses()
if err != nil {
return nil, err
}
var resp []dto.PigHouseResponse
for _, house := range houses {
resp = append(resp, dto.PigHouseResponse{
ID: house.ID,
Name: house.Name,
Description: house.Description,
})
}
return resp, nil
}
func (s *pigFarmService) UpdatePigHouse(id uint, name, description string) (*dto.PigHouseResponse, error) {
house := &models.PigHouse{
Model: gorm.Model{ID: id},
Name: name,
Description: description,
}
rowsAffected, err := s.farmRepository.UpdatePigHouse(house)
if err != nil {
return nil, err
}
if rowsAffected == 0 {
return nil, ErrHouseNotFound
}
// 返回更新后的完整信息
updatedHouse, err := s.farmRepository.GetPigHouseByID(id)
if err != nil {
return nil, err
}
return &dto.PigHouseResponse{
ID: updatedHouse.ID,
Name: updatedHouse.Name,
Description: updatedHouse.Description,
}, nil
}
func (s *pigFarmService) DeletePigHouse(id uint) error {
// 业务逻辑:检查猪舍是否包含猪栏
penCount, err := s.farmRepository.CountPensInHouse(id)
if err != nil {
return err
}
if penCount > 0 {
return ErrHouseContainsPens
}
// 调用仓库层进行删除
rowsAffected, err := s.farmRepository.DeletePigHouse(id)
if err != nil {
return err
}
if rowsAffected == 0 {
return ErrHouseNotFound
}
return nil
}
// --- Pen Implementation ---
func (s *pigFarmService) CreatePen(penNumber string, houseID uint, capacity int) (*dto.PenResponse, error) {
// 业务逻辑:验证所属猪舍是否存在
_, err := s.farmRepository.GetPigHouseByID(houseID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrHouseNotFound
}
return nil, err
}
pen := &models.Pen{
PenNumber: penNumber,
HouseID: houseID,
Capacity: capacity,
Status: models.PenStatusEmpty,
}
err = s.penRepository.CreatePen(pen)
if err != nil {
return nil, err
}
return &dto.PenResponse{
ID: pen.ID,
PenNumber: pen.PenNumber,
HouseID: pen.HouseID,
Capacity: pen.Capacity,
Status: pen.Status,
}, nil
}
func (s *pigFarmService) GetPenByID(id uint) (*dto.PenResponse, error) {
pen, err := s.penRepository.GetPenByID(id)
if err != nil {
return nil, err
}
currentPigCount, err := s.pigBatchService.GetCurrentPigsInPen(id)
if err != nil {
s.logger.Errorf("获取猪栏 %d 存栏量失败: %v", id, err)
currentPigCount = 0 // 如果获取计数时出错则默认为0
}
response := &dto.PenResponse{
ID: pen.ID,
PenNumber: pen.PenNumber,
HouseID: pen.HouseID,
Capacity: pen.Capacity,
Status: pen.Status,
CurrentPigCount: currentPigCount,
}
if pen.PigBatchID != nil {
response.PigBatchID = pen.PigBatchID
}
return response, nil
}
func (s *pigFarmService) ListPens() ([]*dto.PenResponse, error) {
pens, err := s.penRepository.ListPens()
if err != nil {
return nil, err
}
var response []*dto.PenResponse
for _, pen := range pens {
currentPigCount, err := s.pigBatchService.GetCurrentPigsInPen(pen.ID)
if err != nil {
s.logger.Errorf("获取猪栏 %d 存栏量失败: %v", pen.ID, err)
currentPigCount = 0 // 如果获取计数时出错则默认为0
}
penResponse := &dto.PenResponse{
ID: pen.ID,
PenNumber: pen.PenNumber,
HouseID: pen.HouseID,
Capacity: pen.Capacity,
Status: pen.Status,
CurrentPigCount: currentPigCount,
}
if pen.PigBatchID != nil {
penResponse.PigBatchID = pen.PigBatchID
}
response = append(response, penResponse)
}
return response, nil
}
func (s *pigFarmService) UpdatePen(id uint, penNumber string, houseID uint, capacity int, status models.PenStatus) (*dto.PenResponse, error) {
// 业务逻辑:验证所属猪舍是否存在
_, err := s.farmRepository.GetPigHouseByID(houseID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrHouseNotFound
}
return nil, err
}
pen := &models.Pen{
Model: gorm.Model{ID: id},
PenNumber: penNumber,
HouseID: houseID,
Capacity: capacity,
Status: status,
}
rowsAffected, err := s.penRepository.UpdatePen(pen)
if err != nil {
return nil, err
}
if rowsAffected == 0 {
return nil, ErrPenNotFound
}
// 返回更新后的完整信息
updatedPen, err := s.penRepository.GetPenByID(id)
if err != nil {
return nil, err
}
return &dto.PenResponse{
ID: updatedPen.ID,
PenNumber: updatedPen.PenNumber,
HouseID: updatedPen.HouseID,
Capacity: updatedPen.Capacity,
Status: updatedPen.Status,
PigBatchID: updatedPen.PigBatchID,
}, nil
}
func (s *pigFarmService) DeletePen(id uint) error {
// 业务逻辑:检查猪栏是否被活跃批次使用
pen, err := s.penRepository.GetPenByID(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPenNotFound // 猪栏不存在
}
return err
}
// 检查猪栏是否关联了活跃批次
// 注意pen.PigBatchID 是指针类型,需要检查是否为 nil
if pen.PigBatchID != nil && *pen.PigBatchID != 0 {
pigBatch, err := s.batchRepository.GetPigBatchByID(*pen.PigBatchID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
// 如果批次活跃,则不能删除猪栏
if pigBatch != nil && pigBatch.IsActive() {
return ErrPenInUse
}
}
// 调用仓库层进行删除
rowsAffected, err := s.penRepository.DeletePen(id)
if err != nil {
return err
}
if rowsAffected == 0 {
return ErrPenNotFound
}
return nil
}
// UpdatePenStatus 更新猪栏状态
func (s *pigFarmService) UpdatePenStatus(id uint, newStatus models.PenStatus) (*dto.PenResponse, error) {
var updatedPen *models.Pen
err := s.uow.ExecuteInTransaction(func(tx *gorm.DB) error {
pen, err := s.penRepository.GetPenByIDTx(tx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPenNotFound
}
s.logger.Errorf("更新猪栏状态失败: 获取猪栏 %d 信息错误: %v", id, err)
return fmt.Errorf("获取猪栏 %d 信息失败: %w", id, err)
}
// 业务逻辑:根据猪栏的 PigBatchID 和当前状态,判断是否允许设置为 newStatus
if pen.PigBatchID != nil && *pen.PigBatchID != 0 { // 猪栏已被批次使用
if newStatus == models.PenStatusEmpty { // 猪栏已被批次使用,不能直接设置为空闲
return ErrPenStatusInvalidForOccupiedPen
}
} else { // 猪栏未被批次使用 (PigBatchID == nil)
if newStatus == models.PenStatusOccupied { // 猪栏未被批次使用,不能设置为使用中
return ErrPenStatusInvalidForUnoccupiedPen
}
}
// 如果新状态与旧状态相同,则无需更新
if pen.Status == newStatus {
updatedPen = pen // 返回原始猪栏,因为没有实际更新
return nil
}
updates := map[string]interface{}{
"status": newStatus,
}
if err := s.penRepository.UpdatePenFieldsTx(tx, id, updates); err != nil {
s.logger.Errorf("更新猪栏 %d 状态失败: %v", id, err)
return fmt.Errorf("更新猪栏 %d 状态失败: %w", id, err)
}
// 获取更新后的猪栏信息
updatedPen, err = s.penRepository.GetPenByIDTx(tx, id)
if err != nil {
s.logger.Errorf("更新猪栏状态后获取猪栏 %d 信息失败: %v", id, err)
return fmt.Errorf("更新猪栏状态后获取猪栏 %d 信息失败: %w", id, err)
}
return nil
})
if err != nil {
return nil, err
}
return &dto.PenResponse{
ID: updatedPen.ID,
PenNumber: updatedPen.PenNumber,
HouseID: updatedPen.HouseID,
Capacity: updatedPen.Capacity,
Status: updatedPen.Status,
PigBatchID: updatedPen.PigBatchID,
}, nil
}

View File

@@ -0,0 +1,55 @@
package service
import (
"errors"
domain_pig "git.huangwc.com/pig/pig-farm-controller/internal/domain/pig"
)
var (
ErrHouseContainsPens = errors.New("无法删除包含猪栏的猪舍")
ErrHouseNotFound = errors.New("指定的猪舍不存在")
ErrPenInUse = errors.New("猪栏正在被活跃批次使用,无法删除")
ErrPenNotFound = errors.New("指定的猪栏不存在")
ErrPenStatusInvalidForOccupiedPen = errors.New("猪栏已被批次使用,无法设置为非使用中状态")
ErrPenStatusInvalidForUnoccupiedPen = errors.New("猪栏未被批次使用,无法设置为使用中状态")
ErrPigBatchNotFound = errors.New("指定的猪批次不存在")
ErrPigBatchActive = errors.New("活跃的猪批次不能被删除")
ErrPigBatchNotActive = errors.New("猪批次不处于活跃状态,无法修改关联猪栏")
ErrPenOccupiedByOtherBatch = errors.New("猪栏已被其他批次使用")
ErrPenStatusInvalidForAllocation = errors.New("猪栏状态不允许分配")
ErrPenNotAssociatedWithBatch = errors.New("猪栏未与该批次关联")
ErrPenNotEmpty = errors.New("猪栏内仍有猪只")
ErrInvalidOperation = errors.New("非法操作")
)
// MapDomainError 将领域层的错误转换为应用服务层的公共错误。
func MapDomainError(err error) error {
if err == nil {
return nil
}
switch {
case errors.Is(err, domain_pig.ErrPigBatchNotFound):
return ErrPigBatchNotFound
case errors.Is(err, domain_pig.ErrPigBatchActive):
return ErrPigBatchActive
case errors.Is(err, domain_pig.ErrPigBatchNotActive):
return ErrPigBatchNotActive
case errors.Is(err, domain_pig.ErrPenOccupiedByOtherBatch):
return ErrPenOccupiedByOtherBatch
case errors.Is(err, domain_pig.ErrPenStatusInvalidForAllocation):
return ErrPenStatusInvalidForAllocation
case errors.Is(err, domain_pig.ErrPenNotAssociatedWithBatch):
return ErrPenNotAssociatedWithBatch
case errors.Is(err, domain_pig.ErrPenNotFound):
return ErrPenNotFound
case errors.Is(err, domain_pig.ErrPenNotEmpty):
return ErrPenNotEmpty
case errors.Is(err, domain_pig.ErrInvalidOperation):
return ErrInvalidOperation
// 可以添加更多领域错误到应用层错误的映射
default:
return err // 对于未知的领域错误,直接返回
}
}

View File

@@ -0,0 +1,205 @@
package service
import (
"errors"
"git.huangwc.com/pig/pig-farm-controller/internal/app/dto"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/plan"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
)
// PlanService 定义了计划相关的应用服务接口
type PlanService interface {
// CreatePlan 创建一个新的计划
CreatePlan(req *dto.CreatePlanRequest) (*dto.PlanResponse, error)
// GetPlanByID 根据ID获取计划详情
GetPlanByID(id uint) (*dto.PlanResponse, error)
// ListPlans 获取计划列表,支持过滤和分页
ListPlans(query *dto.ListPlansQuery) (*dto.ListPlansResponse, error)
// UpdatePlan 更新计划
UpdatePlan(id uint, req *dto.UpdatePlanRequest) (*dto.PlanResponse, error)
// DeletePlan 删除计划(软删除)
DeletePlan(id uint) error
// StartPlan 启动计划
StartPlan(id uint) error
// StopPlan 停止计划
StopPlan(id uint) error
}
// planService 是 PlanService 接口的实现
type planService struct {
logger *logs.Logger
domainPlanService plan.Service // 替换为领域层的服务接口
}
// NewPlanService 创建一个新的 PlanService 实例
func NewPlanService(
logger *logs.Logger,
domainPlanService plan.Service, // 接收领域层服务
) PlanService {
return &planService{
logger: logger,
domainPlanService: domainPlanService, // 注入领域层服务
}
}
// CreatePlan 创建一个新的计划
func (s *planService) CreatePlan(req *dto.CreatePlanRequest) (*dto.PlanResponse, error) {
const actionType = "应用服务层:创建计划"
// 使用 DTO 转换函数将请求转换为领域实体
planToCreate, err := dto.NewPlanFromCreateRequest(req)
if err != nil {
s.logger.Errorf("%s: 计划数据校验失败: %v", actionType, err)
return nil, err
}
// 调用领域服务创建计划
createdPlan, err := s.domainPlanService.CreatePlan(planToCreate)
if err != nil {
s.logger.Errorf("%s: 领域服务创建计划失败: %v", actionType, err)
return nil, err // 直接返回领域层错误
}
// 将领域实体转换为响应 DTO
resp, err := dto.NewPlanToResponse(createdPlan)
if err != nil {
s.logger.Errorf("%s: 序列化响应失败: %v, Plan: %+v", actionType, err, createdPlan)
return nil, errors.New("计划创建成功,但响应生成失败")
}
s.logger.Infof("%s: 计划创建成功, ID: %d", actionType, createdPlan.ID)
return resp, nil
}
// GetPlanByID 根据ID获取计划详情
func (s *planService) GetPlanByID(id uint) (*dto.PlanResponse, error) {
const actionType = "应用服务层:获取计划详情"
// 调用领域服务获取计划
plan, err := s.domainPlanService.GetPlanByID(id)
if err != nil {
s.logger.Errorf("%s: 领域服务获取计划详情失败: %v, ID: %d", actionType, err, id)
return nil, err // 直接返回领域层错误
}
// 将领域实体转换为响应 DTO
resp, err := dto.NewPlanToResponse(plan)
if err != nil {
s.logger.Errorf("%s: 序列化响应失败: %v, Plan: %+v", actionType, err, plan)
return nil, errors.New("获取计划详情失败: 内部数据格式错误")
}
s.logger.Infof("%s: 获取计划详情成功, ID: %d", actionType, id)
return resp, nil
}
// ListPlans 获取计划列表,支持过滤和分页
func (s *planService) ListPlans(query *dto.ListPlansQuery) (*dto.ListPlansResponse, error) {
const actionType = "应用服务层:获取计划列表"
// 将 DTO 查询参数转换为领域层可接受的选项
opts := repository.ListPlansOptions{PlanType: query.PlanType}
// 调用领域服务获取计划列表
plans, total, err := s.domainPlanService.ListPlans(opts, query.Page, query.PageSize)
if err != nil {
s.logger.Errorf("%s: 领域服务获取计划列表失败: %v", actionType, err)
return nil, err // 直接返回领域层错误
}
// 将领域实体列表转换为响应 DTO 列表
planResponses := make([]dto.PlanResponse, 0, len(plans))
for _, p := range plans {
resp, err := dto.NewPlanToResponse(&p)
if err != nil {
s.logger.Errorf("%s: 序列化单个计划响应失败: %v, Plan: %+v", actionType, err, p)
// 这里选择跳过有问题的计划,并记录错误,而不是中断整个列表的返回
continue
}
planResponses = append(planResponses, *resp)
}
resp := &dto.ListPlansResponse{
Plans: planResponses,
Total: total,
}
s.logger.Infof("%s: 获取计划列表成功, 数量: %d", actionType, len(planResponses))
return resp, nil
}
// UpdatePlan 更新计划
func (s *planService) UpdatePlan(id uint, req *dto.UpdatePlanRequest) (*dto.PlanResponse, error) {
const actionType = "应用服务层:更新计划"
// 使用 DTO 转换函数将请求转换为领域实体
planToUpdate, err := dto.NewPlanFromUpdateRequest(req)
if err != nil {
s.logger.Errorf("%s: 计划数据校验失败: %v", actionType, err)
return nil, err
}
planToUpdate.ID = id // 确保ID被设置
// 调用领域服务更新计划
updatedPlan, err := s.domainPlanService.UpdatePlan(planToUpdate)
if err != nil {
s.logger.Errorf("%s: 领域服务更新计划失败: %v, ID: %d", actionType, err, id)
return nil, err // 直接返回领域层错误
}
// 将领域实体转换为响应 DTO
resp, err := dto.NewPlanToResponse(updatedPlan)
if err != nil {
s.logger.Errorf("%s: 序列化响应失败: %v, Updated Plan: %+v", actionType, err, updatedPlan)
return nil, errors.New("计划更新成功,但响应生成失败")
}
s.logger.Infof("%s: 计划更新成功, ID: %d", actionType, updatedPlan.ID)
return resp, nil
}
// DeletePlan 删除计划(软删除)
func (s *planService) DeletePlan(id uint) error {
const actionType = "应用服务层:删除计划"
// 调用领域服务删除计划
err := s.domainPlanService.DeletePlan(id)
if err != nil {
s.logger.Errorf("%s: 领域服务删除计划失败: %v, ID: %d", actionType, err, id)
return err // 直接返回领域层错误
}
s.logger.Infof("%s: 计划删除成功, ID: %d", actionType, id)
return nil
}
// StartPlan 启动计划
func (s *planService) StartPlan(id uint) error {
const actionType = "应用服务层:启动计划"
// 调用领域服务启动计划
err := s.domainPlanService.StartPlan(id)
if err != nil {
s.logger.Errorf("%s: 领域服务启动计划失败: %v, ID: %d", actionType, err, id)
return err // 直接返回领域层错误
}
s.logger.Infof("%s: 计划已成功启动, ID: %d", actionType, id)
return nil
}
// StopPlan 停止计划
func (s *planService) StopPlan(id uint) error {
const actionType = "应用服务层:停止计划"
// 调用领域服务停止计划
err := s.domainPlanService.StopPlan(id)
if err != nil {
s.logger.Errorf("%s: 领域服务停止计划失败: %v, ID: %d", actionType, err, id)
return err // 直接返回领域层错误
}
s.logger.Infof("%s: 计划已成功停止, ID: %d", actionType, id)
return nil
}

View File

@@ -1,95 +0,0 @@
package token_test
import (
"testing"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/app/service/token"
"github.com/golang-jwt/jwt/v5"
)
func TestGenerateToken(t *testing.T) {
// 使用一个测试密钥初始化 TokenService
testSecret := []byte("test_secret_key")
service := token.NewTokenService(testSecret)
userID := uint(123)
tokenString, err := service.GenerateToken(userID)
if err != nil {
t.Fatalf("生成令牌失败: %v", err)
}
if tokenString == "" {
t.Fatal("生成的令牌字符串为空")
}
// 解析 token 以确保其有效性及声明
claims, err := service.ParseToken(tokenString)
if err != nil {
t.Fatalf("生成后解析令牌失败: %v", err)
}
if claims.UserID != userID {
t.Errorf("期望用户ID %d, 实际为 %d", userID, claims.UserID)
}
// 检查 token 是否未过期 (在合理范围内)
if claims.ExpiresAt == nil || claims.ExpiresAt.Time.Before(time.Now().Add(-time.Minute)) {
t.Errorf("令牌过期时间无效或已过期")
}
if claims.Issuer != "pig-farm-controller" {
t.Errorf("期望签发者 \"pig-farm-controller\", 实际为 \"%s\"", claims.Issuer)
}
}
func TestParseToken(t *testing.T) {
// 使用一个测试密钥初始化 TokenService
testSecret := []byte("test_secret_key")
service := token.NewTokenService(testSecret)
userID := uint(456)
// 生成一个有效的 token 用于解析测试
validToken, err := service.GenerateToken(userID)
if err != nil {
t.Fatalf("为解析测试生成有效令牌失败: %v", err)
}
// 测试用例 1: 有效 token
claims, err := service.ParseToken(validToken)
if err != nil {
t.Errorf("解析有效令牌失败: %v", err)
}
if claims.UserID != userID {
t.Errorf("解析有效令牌时期望用户ID %d, 实际为 %d", userID, claims.UserID)
}
// 测试用例 2: 无效 token (例如, 错误的签名)
invalidToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsImV4cCI6MTY3ODkwNTYwMCwiaXNzIjoicGlnLWZhcm0tY29udHJvbGxlciJ9.invalid_signature_here"
_, err = service.ParseToken(invalidToken)
if err == nil {
t.Error("解析无效令牌意外成功")
}
// 测试用例 3: 过期 token (创建一个过期时间在过去的 token)
expiredClaims := token.Claims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)), // 1 小时前
Issuer: "pig-farm-controller",
},
}
expiredTokenClaims := jwt.NewWithClaims(jwt.SigningMethodHS256, expiredClaims)
expiredTokenString, err := expiredTokenClaims.SignedString(testSecret)
if err != nil {
t.Fatalf("生成过期令牌失败: %v", err)
}
_, err = service.ParseToken(expiredTokenString)
if err == nil {
t.Error("解析过期令牌意外成功")
}
}

View File

@@ -0,0 +1,110 @@
package service
import (
"errors"
"git.huangwc.com/pig/pig-farm-controller/internal/app/dto"
domain_notify "git.huangwc.com/pig/pig-farm-controller/internal/domain/notify"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/token"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
"gorm.io/gorm"
)
// UserService 定义用户服务接口
type UserService interface {
CreateUser(req *dto.CreateUserRequest) (*dto.CreateUserResponse, error)
Login(req *dto.LoginRequest) (*dto.LoginResponse, error)
SendTestNotification(userID uint, req *dto.SendTestNotificationRequest) error
}
// userService 实现了 UserService 接口
type userService struct {
userRepo repository.UserRepository
tokenService token.Service
notifyService domain_notify.Service
logger *logs.Logger
}
// NewUserService 创建并返回一个新的 UserService 实例
func NewUserService(
userRepo repository.UserRepository,
tokenService token.Service,
notifyService domain_notify.Service,
logger *logs.Logger,
) UserService {
return &userService{
userRepo: userRepo,
tokenService: tokenService,
notifyService: notifyService,
logger: logger,
}
}
// CreateUser 创建新用户
func (s *userService) CreateUser(req *dto.CreateUserRequest) (*dto.CreateUserResponse, error) {
user := &models.User{
Username: req.Username,
Password: req.Password, // 密码会在 BeforeSave 钩子中哈希
}
if err := s.userRepo.Create(user); err != nil {
s.logger.Errorf("创建用户: 创建用户失败: %v", err)
// 尝试查询用户,以判断是否是用户名重复导致的错误
_, findErr := s.userRepo.FindByUsername(req.Username)
if findErr == nil { // 如果能找到用户,说明是用户名重复
return nil, errors.New("用户名已存在")
}
// 其他创建失败的情况
return nil, errors.New("创建用户失败")
}
return &dto.CreateUserResponse{
Username: user.Username,
ID: user.ID,
}, nil
}
// Login 用户登录
func (s *userService) Login(req *dto.LoginRequest) (*dto.LoginResponse, error) {
// 使用新的方法,通过唯一标识符(用户名、邮箱等)查找用户
user, err := s.userRepo.FindUserForLogin(req.Identifier)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("登录凭证不正确")
}
s.logger.Errorf("登录: 查询用户失败: %v", err)
return nil, errors.New("登录失败")
}
if !user.CheckPassword(req.Password) {
return nil, errors.New("登录凭证不正确")
}
// 登录成功,生成 JWT token
tokenString, err := s.tokenService.GenerateToken(user.ID)
if err != nil {
s.logger.Errorf("登录: 生成令牌失败: %v", err)
return nil, errors.New("登录失败,无法生成认证信息")
}
return &dto.LoginResponse{
Username: user.Username,
ID: user.ID,
Token: tokenString,
}, nil
}
// SendTestNotification 发送测试通知
func (s *userService) SendTestNotification(userID uint, req *dto.SendTestNotificationRequest) error {
err := s.notifyService.SendTestMessage(userID, req.Type)
if err != nil {
s.logger.Errorf("发送测试通知: 服务层调用失败: %v", err)
return errors.New("发送测试消息失败: " + err.Error())
}
s.logger.Infof("发送测试通知: 成功为用户 %d 发送类型为 %s 的测试消息", userID, req.Type)
return nil
}

View File

@@ -0,0 +1,441 @@
package webhook
import (
"encoding/base64"
"encoding/json"
"io"
"math"
"net/http"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport/proto"
gproto "google.golang.org/protobuf/proto"
"gorm.io/datatypes"
)
// ChirpStackListener 主动发送的请求的event字段, 这个字段代表事件类型
const (
eventTypeUp = "up" // 上行数据事件:当接收到设备发送的数据时触发,这是最核心的事件。
eventTypeStatus = "status" // 设备状态事件:当设备报告其状态时触发(例如电池电量、信号强度)。
eventTypeJoin = "join" // 入网事件:当设备成功加入网络时触发。
eventTypeAck = "ack" // 下行确认事件:当设备确认收到下行消息时触发。
eventTypeTxAck = "txack" // 网关发送确认事件:当网关确认已发送下行消息时触发(不代表设备已收到)。
eventTypeLog = "log" // 日志事件:当设备或 ChirpStack 产生日志信息时触发。
eventTypeLocation = "location" // 位置事件:当设备的位置被解析或更新时触发。
eventTypeIntegration = "integration" // 集成事件:当其他集成(如第三方服务)处理数据后触发。
)
// ChirpStackListener 是一个监听器, 用于监听ChirpStack反馈的设备上行事件
type ChirpStackListener struct {
logger *logs.Logger
sensorDataRepo repository.SensorDataRepository
deviceRepo repository.DeviceRepository
areaControllerRepo repository.AreaControllerRepository
deviceCommandLogRepo repository.DeviceCommandLogRepository
pendingCollectionRepo repository.PendingCollectionRepository
}
// NewChirpStackListener 创建一个新的 ChirpStackListener 实例
func NewChirpStackListener(
logger *logs.Logger,
sensorDataRepo repository.SensorDataRepository,
deviceRepo repository.DeviceRepository,
areaControllerRepo repository.AreaControllerRepository,
deviceCommandLogRepo repository.DeviceCommandLogRepository,
pendingCollectionRepo repository.PendingCollectionRepository,
) ListenHandler { // 返回接口类型
return &ChirpStackListener{
logger: logger,
sensorDataRepo: sensorDataRepo,
deviceRepo: deviceRepo,
areaControllerRepo: areaControllerRepo,
deviceCommandLogRepo: deviceCommandLogRepo,
pendingCollectionRepo: pendingCollectionRepo,
}
}
// Handler 监听ChirpStack反馈的事件, 因为这是个Webhook, 所以直接回复掉再慢慢处理信息
func (c *ChirpStackListener) Handler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
b, err := io.ReadAll(r.Body)
if err != nil {
c.logger.Errorf("读取请求体失败: %v", err)
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}
event := r.URL.Query().Get("event")
w.WriteHeader(http.StatusOK)
// 将异步处理逻辑委托给 handler 方法
go c.handler(b, event)
}
}
// handler 用于处理 ChirpStack 发送的事件
func (c *ChirpStackListener) handler(data []byte, eventType string) {
switch eventType {
case eventTypeUp:
var msg UpEvent
if err := json.Unmarshal(data, &msg); err != nil {
c.logger.Errorf("解析 'up' 事件失败: %v, data: %s", err, string(data))
return
}
c.handleUpEvent(&msg)
case eventTypeJoin:
var msg JoinEvent
if err := json.Unmarshal(data, &msg); err != nil {
c.logger.Errorf("解析 'join' 事件失败: %v, data: %s", err, string(data))
return
}
c.handleJoinEvent(&msg)
case eventTypeAck:
var msg AckEvent
if err := json.Unmarshal(data, &msg); err != nil {
c.logger.Errorf("解析 'ack' 事件失败: %v, data: %s", err, string(data))
return
}
c.handleAckEvent(&msg)
case eventTypeTxAck:
var msg TxAckEvent
if err := json.Unmarshal(data, &msg); err != nil {
c.logger.Errorf("解析 'txack' 事件失败: %v, data: %s", err, string(data))
return
}
c.handleTxAckEvent(&msg)
case eventTypeStatus:
var msg StatusEvent
if err := json.Unmarshal(data, &msg); err != nil {
c.logger.Errorf("解析 'status' 事件失败: %v, data: %s", err, string(data))
return
}
c.handleStatusEvent(&msg)
case eventTypeLog:
var msg LogEvent
if err := json.Unmarshal(data, &msg); err != nil {
c.logger.Errorf("解析 'log' 事件失败: %v, data: %s", err, string(data))
return
}
c.handleLogEvent(&msg)
case eventTypeLocation:
var msg LocationEvent
if err := json.Unmarshal(data, &msg); err != nil {
c.logger.Errorf("解析 'location' 事件失败: %v, data: %s", err, string(data))
return
}
c.handleLocationEvent(&msg)
case eventTypeIntegration:
var msg IntegrationEvent
if err := json.Unmarshal(data, &msg); err != nil {
c.logger.Errorf("解析 'integration' 事件失败: %v, data: %s", err, string(data))
return
}
c.handleIntegrationEvent(&msg)
default:
c.logger.Errorf("未知的ChirpStack事件: %s, data: %s", eventType, string(data))
}
}
// --- 业务处理函数 ---
// handleUpEvent 处理上行数据事件
func (c *ChirpStackListener) handleUpEvent(event *UpEvent) {
c.logger.Infof("开始处理 'up' 事件, DevEui: %s", event.DeviceInfo.DevEui)
// 1. 查找区域主控设备
regionalController, err := c.areaControllerRepo.FindByNetworkID(event.DeviceInfo.DevEui)
if err != nil {
c.logger.Errorf("处理 'up' 事件失败:无法通过 DevEui '%s' 找到区域主控设备: %v", event.DeviceInfo.DevEui, err)
return
}
// 依赖 SelfCheck 确保区域主控有效
if err := regionalController.SelfCheck(); err != nil {
c.logger.Errorf("处理 'up' 事件失败:区域主控 %v(ID: %d) 未通过自检: %v", regionalController.Name, regionalController.ID, err)
return
}
c.logger.Infof("找到区域主控: %s (ID: %d)", regionalController.Name, regionalController.ID)
// 2. 记录区域主控的信号强度 (如果存在)
if len(event.RxInfo) > 0 {
// 根据业务逻辑,一个猪场只有一个网关,所以 RxInfo 中通常只有一个元素,或者 gateway_id 都是相同的。
// 因此,我们只取第一个 RxInfo 中的信号数据即可。
rx := event.RxInfo[0] // 取第一个接收到的网关信息
// 构建 SignalMetrics 结构体
signalMetrics := models.SignalMetrics{
RssiDbm: rx.Rssi,
SnrDb: rx.Snr,
}
// 记录信号强度
c.recordSensorData(regionalController.ID, regionalController.ID, event.Time, models.SensorTypeSignalMetrics, signalMetrics)
c.logger.Infof("已记录区域主控 (ID: %d) 的信号强度: RSSI=%d, SNR=%.2f", regionalController.ID, rx.Rssi, rx.Snr)
} else {
c.logger.Warnf("处理 'up' 事件时未找到 RxInfo无法记录信号数据。DevEui: %s", event.DeviceInfo.DevEui)
}
// 3. 处理上报的传感器数据
if event.Data == "" {
c.logger.Warnf("处理 'up' 事件时 Data 字段为空无需记录上行数据。DevEui: %s", event.DeviceInfo.DevEui)
return
}
// 3.1 Base64 解码
decodedData, err := base64.StdEncoding.DecodeString(event.Data)
if err != nil {
c.logger.Errorf("Base64 解码 'up' 事件的 Data 失败: %v, Data: %s", err, event.Data)
return
}
// 3.2 解析外层 "信封"
var instruction proto.Instruction
if err := gproto.Unmarshal(decodedData, &instruction); err != nil {
c.logger.Errorf("解析上行 Instruction Protobuf 失败: %v, Decoded Data: %x", err, decodedData)
return
}
// 3.3 使用 type switch 从 oneof payload 中提取 CollectResult
var collectResp *proto.CollectResult
switch p := instruction.GetPayload().(type) {
case *proto.Instruction_CollectResult:
collectResp = p.CollectResult
default:
// 如果上行的数据不是采集结果,记录日志并忽略
c.logger.Infof("收到一个非采集响应的上行指令 (Type: %T),无需处理。", p)
return
}
// 检查 collectResp 是否为 nil虽然在 type switch 成功的情况下不太可能
if collectResp == nil {
c.logger.Errorf("从 Instruction 中提取的 CollectResult 为 nil")
return
}
correlationID := collectResp.CorrelationId
c.logger.Infof("成功解析采集响应 (CorrelationID: %s),包含 %d 个值。", correlationID, len(collectResp.Values))
// 4. 根据 CorrelationID 查找待处理请求
pendingReq, err := c.pendingCollectionRepo.FindByCorrelationID(correlationID)
if err != nil {
c.logger.Errorf("处理采集响应失败:无法找到待处理请求 (CorrelationID: %s): %v", correlationID, err)
return
}
// 检查状态,防止重复处理
if pendingReq.Status != models.PendingStatusPending && pendingReq.Status != models.PendingStatusTimedOut {
c.logger.Warnf("收到一个已处理过的采集响应 (CorrelationID: %s, Status: %s),将忽略。", correlationID, pendingReq.Status)
return
}
// 5. 匹配数据并存入数据库
deviceIDs := pendingReq.CommandMetadata
values := collectResp.Values
if len(deviceIDs) != len(values) {
c.logger.Errorf("数据不匹配:下行指令要求采集 %d 个设备,但上行响应包含 %d 个值 (CorrelationID: %s)", len(deviceIDs), len(values), correlationID)
// 即使数量不匹配,也更新状态为完成,以防止请求永远 pending
err = c.pendingCollectionRepo.UpdateStatusToFulfilled(correlationID, event.Time)
if err != nil {
c.logger.Errorf("处理采集响应失败:无法更新待处理请求 (CorrelationID: %s) 的状态为完成: %v", correlationID, err)
}
return
}
for i, deviceID := range deviceIDs {
rawSensorValue := values[i] // 这是设备上报的原始值
// 检查设备上报的值是否为 NaN (Not a Number),如果是则跳过
if math.IsNaN(float64(rawSensorValue)) {
c.logger.Warnf("设备 (ID: %d) 上报了一个无效的 NaN 值,已跳过当前值的记录。", deviceID)
continue
}
// 5.1 获取设备及其模板
dev, err := c.deviceRepo.FindByID(deviceID)
if err != nil {
c.logger.Errorf("处理采集数据失败:无法找到设备 (ID: %d): %v", deviceID, err)
continue
}
// 依赖 SelfCheck 确保设备和模板有效
if err := dev.SelfCheck(); err != nil {
c.logger.Warnf("跳过设备 %d因其未通过自检: %v", dev.ID, err)
continue
}
if err := dev.DeviceTemplate.SelfCheck(); err != nil {
c.logger.Warnf("跳过设备 %d因其设备模板未通过自检: %v", dev.ID, err)
continue
}
// 5.2 从设备模板中解析 ValueDescriptor
var valueDescriptors []*models.ValueDescriptor
if err := dev.DeviceTemplate.ParseValues(&valueDescriptors); err != nil {
c.logger.Warnf("跳过设备 %d因其设备模板的 Values 属性解析失败: %v", dev.ID, err)
continue
}
// 根据 DeviceTemplate.SelfCheck这里应该只有一个 ValueDescriptor
if len(valueDescriptors) == 0 {
c.logger.Warnf("跳过设备 %d因其设备模板缺少 ValueDescriptor 定义", dev.ID)
continue
}
valueDescriptor := valueDescriptors[0]
// 5.3 应用乘数和偏移量计算最终值
parsedValue := float64(rawSensorValue)*valueDescriptor.Multiplier + valueDescriptor.Offset
// 5.4 根据传感器类型构建具体的数据结构
var dataToRecord interface{}
switch valueDescriptor.Type {
case models.SensorTypeTemperature:
dataToRecord = models.TemperatureData{TemperatureCelsius: parsedValue}
case models.SensorTypeHumidity:
dataToRecord = models.HumidityData{HumidityPercent: parsedValue}
case models.SensorTypeWeight:
dataToRecord = models.WeightData{WeightKilograms: parsedValue}
default:
// TODO 未知传感器的数据需要记录吗
c.logger.Warnf("未知的传感器类型 '%s',将使用通用格式记录", valueDescriptor.Type)
dataToRecord = map[string]float64{"value": parsedValue}
}
// 5.5 记录传感器数据
c.recordSensorData(regionalController.ID, dev.ID, event.Time, valueDescriptor.Type, dataToRecord)
c.logger.Infof("成功记录传感器数据: 设备ID=%d, 类型=%s, 原始值=%f, 解析值=%.2f", dev.ID, valueDescriptor.Type, rawSensorValue, parsedValue)
}
// 6. 更新请求状态为“已完成”
if err := c.pendingCollectionRepo.UpdateStatusToFulfilled(correlationID, event.Time); err != nil {
c.logger.Errorf("更新待采集请求状态为 'fulfilled' 失败 (CorrelationID: %s): %v", correlationID, err)
} else {
c.logger.Infof("成功完成并关闭采集请求 (CorrelationID: %s)", correlationID)
}
}
// handleStatusEvent 处理设备状态事件
func (c *ChirpStackListener) handleStatusEvent(event *StatusEvent) {
c.logger.Infof("处接收到理 'status' 事件: %+v", event)
// 查找区域主控设备
regionalController, err := c.areaControllerRepo.FindByNetworkID(event.DeviceInfo.DevEui)
if err != nil {
c.logger.Errorf("处理 'status' 事件失败:无法通过 DevEui '%s' 找到区域主控设备: %v", event.DeviceInfo.DevEui, err)
return
}
// 记录信号强度
signalMetrics := models.SignalMetrics{
MarginDb: event.Margin,
}
c.recordSensorData(regionalController.ID, regionalController.ID, event.Time, models.SensorTypeSignalMetrics, signalMetrics)
c.logger.Infof("已记录区域主控 (ID: %d) 的信号状态: %+v", regionalController.ID, signalMetrics)
// 记录电量
batteryLevel := models.BatteryLevel{
BatteryLevelRatio: event.BatteryLevel,
BatteryLevelUnavailable: event.BatteryLevelUnavailable,
ExternalPower: event.ExternalPower,
}
c.recordSensorData(regionalController.ID, regionalController.ID, event.Time, models.SensorTypeBatteryLevel, batteryLevel)
c.logger.Infof("已记录区域主控 (ID: %d) 的电池状态: %+v", regionalController.ID, batteryLevel)
}
// handleAckEvent 处理下行确认事件
func (c *ChirpStackListener) handleAckEvent(event *AckEvent) {
c.logger.Infof("接收到 'ack' 事件: %+v", event)
// 更新下行任务记录的确认时间及接收成功状态
err := c.deviceCommandLogRepo.UpdateAcknowledgedAt(event.DeduplicationID, event.Time, event.Acknowledged)
if err != nil {
c.logger.Errorf("更新下行任务记录的确认时间及接收成功状态失败 (MessageID: %s, DevEui: %s, Acknowledged: %t): %v",
event.DeduplicationID, event.DeviceInfo.DevEui, event.Acknowledged, err)
return
}
c.logger.Infof("成功更新下行任务记录确认时间及接收成功状态 (MessageID: %s, DevEui: %s, Acknowledged: %t, AcknowledgedAt: %s)",
event.DeduplicationID, event.DeviceInfo.DevEui, event.Acknowledged, event.Time.Format(time.RFC3339))
}
// handleLogEvent 处理日志事件
func (c *ChirpStackListener) handleLogEvent(event *LogEvent) {
// 首先,打印完整的事件结构体,用于详细排查
c.logger.Infof("接收到 'log' 事件的完整内容: %+v", event)
// 接着,根据 ChirpStack 日志的级别,使用我们自己的 logger 对应级别来打印核心信息
logMessage := "ChirpStack 日志: [%s] %s (DevEui: %s)"
switch event.Level {
case "INFO":
c.logger.Infof(logMessage, event.Code, event.Description, event.DeviceInfo.DevEui)
case "WARNING":
c.logger.Warnf(logMessage, event.Code, event.Description, event.DeviceInfo.DevEui)
case "ERROR":
c.logger.Errorf(logMessage, event.Code, event.Description, event.DeviceInfo.DevEui)
default:
// 对于未知级别,使用 Warn 级别打印,并明确指出级别未知
c.logger.Warnf("ChirpStack 日志: [未知级别: %s] %s %s (DevEui: %s)",
event.Level, event.Code, event.Description, event.DeviceInfo.DevEui)
}
}
// handleJoinEvent 处理入网事件
func (c *ChirpStackListener) handleJoinEvent(event *JoinEvent) {
c.logger.Infof("接收到 'join' 事件: %+v", event)
// 在这里添加您的业务逻辑
}
// handleTxAckEvent 处理网关发送确认事件
func (c *ChirpStackListener) handleTxAckEvent(event *TxAckEvent) {
c.logger.Infof("接收到 'txack' 事件: %+v", event)
// 在这里添加您的业务逻辑
}
// handleLocationEvent 处理位置事件
func (c *ChirpStackListener) handleLocationEvent(event *LocationEvent) {
c.logger.Infof("接收到 'location' 事件: %+v", event)
// 在这里添加您的业务逻辑
}
// handleIntegrationEvent 处理集成事件
func (c *ChirpStackListener) handleIntegrationEvent(event *IntegrationEvent) {
c.logger.Infof("接收到 'integration' 事件: %+v", event)
// 在这里添加您的业务逻辑
}
// recordSensorData 是一个通用方法,用于将传感器数据存入数据库。
// regionalControllerID: 区域主控设备的ID
// sensorDeviceID: 实际产生传感器数据的普通设备的ID
// sensorType: 传感器值的类型 (例如 models.SensorTypeTemperature)
// data: 具体的传感器数据结构体实例 (例如 models.TemperatureData)
func (c *ChirpStackListener) recordSensorData(regionalControllerID uint, sensorDeviceID uint, eventTime time.Time, sensorType models.SensorType, data interface{}) {
// 1. 将传入的结构体序列化为 JSON
jsonData, err := json.Marshal(data)
if err != nil {
c.logger.Errorf("记录传感器数据失败:序列化数据为 JSON 时出错: %v", err)
return
}
// 2. 构建 SensorData 模型
sensorData := &models.SensorData{
Time: eventTime,
DeviceID: sensorDeviceID,
RegionalControllerID: regionalControllerID,
SensorType: sensorType,
Data: datatypes.JSON(jsonData),
}
// 3. 调用仓库创建记录
if err := c.sensorDataRepo.Create(sensorData); err != nil {
c.logger.Errorf("记录传感器数据失败:存入数据库时出错: %v", err)
}
}

View File

@@ -0,0 +1,198 @@
package webhook
import (
"encoding/json"
"time"
)
// --- 通用结构体 ---
// DeviceInfo 包含了所有事件中通用的设备信息。
// 基于 aiserver.proto v4 (integration)
type DeviceInfo struct {
TenantID string `json:"tenant_id"` // 租户ID
TenantName string `json:"tenant_name"` // 租户名称
ApplicationID string `json:"application_id"` // 应用ID
ApplicationName string `json:"application_name"` // 应用名称
DeviceProfileID string `json:"device_profile_id"` // 设备配置文件ID
DeviceProfileName string `json:"device_profile_name"` // 设备配置文件名称
DeviceName string `json:"device_name"` // 设备名称
DevEui string `json:"dev_eui"` // 设备EUI (十六进制编码)
DeviceClassEnabled string `json:"device_class_enabled,omitempty"` // 设备启用的LoRaWAN类别 (A, B, 或 C)
Tags map[string]string `json:"tags"` // 用户定义的标签
}
// Location 包含了地理位置信息。
type Location struct {
Latitude float64 `json:"latitude"` // 纬度
Longitude float64 `json:"longitude"` // 经度
Altitude float64 `json:"altitude"` // 海拔
}
// --- 可复用的子结构体 ---
// UplinkRelayRxInfo 包含了上行中继接收信息。
type UplinkRelayRxInfo struct {
DevEui string `json:"dev_eui"` // 中继设备的DevEUI
Frequency uint32 `json:"frequency"` // 接收频率
Dr uint32 `json:"dr"` // 数据速率
Snr int32 `json:"snr"` // 信噪比
Rssi int32 `json:"rssi"` // 接收信号强度指示
WorChannel uint32 `json:"wor_channel"` // Work-on-Relay 通道
}
// KeyEnvelope 包装了一个加密的密钥。
// 基于 common.proto
type KeyEnvelope struct {
KEKLabel string `json:"kek_label,omitempty"` // 密钥加密密钥 (KEK) 标签
AESKey string `json:"aes_key,omitempty"` // Base64 编码的加密密钥
}
// JoinServerContext 包含了 Join-Server 上下文。
// 基于 common.proto
type JoinServerContext struct {
SessionKeyID string `json:"session_key_id"` // 会话密钥ID
AppSKey *KeyEnvelope `json:"app_s_key,omitempty"` // 应用会话密钥
}
// UplinkRxInfo 包含了上行接收信息。
type UplinkRxInfo struct {
GatewayID string `json:"gateway_id"` // 接收到上行数据的网关ID
UplinkID uint32 `json:"uplink_id"` // 上行ID
Time time.Time `json:"time"` // 接收时间
Rssi int `json:"rssi"` // 接收信号强度指示
Snr float64 `json:"snr"` // 信噪比
Channel int `json:"channel"` // 接收通道
Location *Location `json:"location"` // 网关位置
Context string `json:"context"` // 上下文信息
Metadata map[string]string `json:"metadata"` // 元数据
}
// LoraModulationInfo 包含了 LoRa 调制的具体参数。
type LoraModulationInfo struct {
Bandwidth int `json:"bandwidth"` // 带宽
SpreadingFactor int `json:"spreading_factor"` // 扩频因子
CodeRate string `json:"code_rate"` // 编码率
Polarization bool `json:"polarization_invert,omitempty"` // 极化反转
}
// Modulation 包含了具体的调制信息。
type Modulation struct {
Lora LoraModulationInfo `json:"lora"` // LoRa 调制信息
}
// UplinkTxInfo 包含了上行发送信息。
type UplinkTxInfo struct {
Frequency int `json:"frequency"` // 发送频率
Modulation Modulation `json:"modulation"` // 调制信息
}
// DownlinkTxInfo 包含了下行发送信息。
type DownlinkTxInfo struct {
Frequency int `json:"frequency"` // 发送频率
Power int `json:"power"` // 发送功率
Modulation Modulation `json:"modulation"` // 调制信息
}
// ResolvedLocation 包含了地理位置解析结果。
type ResolvedLocation struct {
Latitude float64 `json:"latitude"` // 纬度
Longitude float64 `json:"longitude"` // 经度
Altitude float64 `json:"altitude"` // 海拔
Source string `json:"source"` // 位置来源
Accuracy int `json:"accuracy"` // 精度
}
// --- 事件专属结构体 ---
// UpEvent 对应 ChirpStack 的 "up" 事件。
type UpEvent struct {
DeduplicationID string `json:"deduplication_id"` // 去重ID
Time time.Time `json:"time"` // 事件时间
DeviceInfo DeviceInfo `json:"device_info"` // 设备信息
DevAddr string `json:"dev_addr"` // 设备地址
ADR bool `json:"adr"` // 自适应数据速率 (ADR) 是否启用
DR int `json:"dr"` // 数据速率
FCnt uint32 `json:"f_cnt"` // 帧计数器
FPort uint8 `json:"f_port"` // 端口
Confirmed bool `json:"confirmed"` // 是否是确认帧
Data string `json:"data"` // Base64 编码的原始负载数据
Object json.RawMessage `json:"object"` // 解码后的JSON对象负载
RxInfo []UplinkRxInfo `json:"rx_info"` // 接收信息列表
TxInfo UplinkTxInfo `json:"tx_info"` // 发送信息
RelayRxInfo *UplinkRelayRxInfo `json:"relay_rx_info,omitempty"` // 中继接收信息
JoinServerContext *JoinServerContext `json:"join_server_context,omitempty"` // Join-Server 上下文
RegionConfigID string `json:"region_config_id,omitempty"` // 区域配置ID
}
// JoinEvent 对应 ChirpStack 的 "join" 事件。
type JoinEvent struct {
DeduplicationID string `json:"deduplication_id"` // 去重ID
Time time.Time `json:"time"` // 事件时间
DeviceInfo DeviceInfo `json:"device_info"` // 设备信息
DevAddr string `json:"dev_addr"` // 设备地址
RelayRxInfo *UplinkRelayRxInfo `json:"relay_rx_info,omitempty"` // 中继接收信息
JoinServerContext *JoinServerContext `json:"join_server_context,omitempty"` // Join-Server 上下文
RegionConfigID string `json:"region_config_id,omitempty"` // 区域配置ID
}
// AckEvent 对应 ChirpStack 的 "ack" 事件。
type AckEvent struct {
DeduplicationID string `json:"deduplication_id"` // 去重ID
Time time.Time `json:"time"` // 事件时间
DeviceInfo DeviceInfo `json:"device_info"` // 设备信息
Acknowledged bool `json:"acknowledged"` // 是否已确认
FCntDown uint32 `json:"f_cnt_down"` // 下行帧计数器
QueueItemID string `json:"queue_item_id"` // 队列项ID
}
// TxAckEvent 对应 ChirpStack 的 "txack" 事件。
type TxAckEvent struct {
DownlinkID uint32 `json:"downlink_id"` // 下行ID
Time time.Time `json:"time"` // 事件时间
DeviceInfo DeviceInfo `json:"device_info"` // 设备信息
FCntDown uint32 `json:"f_cnt_down"` // 下行帧计数器
GatewayID string `json:"gateway_id"` // 网关ID
QueueItemID string `json:"queue_item_id"` // 队列项ID
TxInfo DownlinkTxInfo `json:"tx_info"` // 下行发送信息
}
// StatusEvent 对应 ChirpStack 的 "status" 事件。
type StatusEvent struct {
DeduplicationID string `json:"deduplication_id"` // 去重ID
Time time.Time `json:"time"` // 事件时间
DeviceInfo DeviceInfo `json:"device_info"` // 设备信息
Margin int `json:"margin"` // 链路预算余量 (dB)
ExternalPower bool `json:"external_power_source"` // 设备是否连接外部电源
BatteryLevel float32 `json:"battery_level"` // 电池剩余电量
BatteryLevelUnavailable bool `json:"battery_level_unavailable"` // 电池电量是否不可用
}
// LogEvent 对应 ChirpStack 的 "log" 事件。
type LogEvent struct {
DeduplicationID string `json:"deduplication_id"` // 去重ID
Time time.Time `json:"time"` // 事件时间
DeviceInfo DeviceInfo `json:"device_info"` // 设备信息
Level string `json:"level"` // 日志级别 (e.g., INFO, WARNING, ERROR)
Code string `json:"code"` // 日志代码
Description string `json:"description"` // 日志描述
Context map[string]string `json:"context"` // 上下文信息
}
// LocationEvent 对应 ChirpStack 的 "location" 事件。
type LocationEvent struct {
DeduplicationID string `json:"deduplication_id"` // 去重ID
Time time.Time `json:"time"` // 事件时间
DeviceInfo DeviceInfo `json:"device_info"` // 设备信息
Location ResolvedLocation `json:"location"` // 解析后的位置信息
}
// IntegrationEvent 对应 ChirpStack 的 "integration" 事件。
type IntegrationEvent struct {
DeduplicationID string `json:"deduplication_id"` // 去重ID
Time time.Time `json:"time"` // 事件时间
DeviceInfo DeviceInfo `json:"device_info"` // 设备信息
IntegrationName string `json:"integration_name"` // 集成名称
EventType string `json:"event_type,omitempty"` // 事件类型
Object json.RawMessage `json:"object"` // 集成事件的原始JSON负载
}

View File

@@ -0,0 +1,30 @@
package webhook
import (
"net/http"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
)
// PlaceholderListener 是一个占位符, 用于在非 LoRaWAN 配置下满足 ListenHandler 接口
type PlaceholderListener struct {
logger *logs.Logger
}
// NewPlaceholderListener 创建一个新的 PlaceholderListener 实例
// 它只打印一条日志, 表明 ChirpStack webhook 未被激活
func NewPlaceholderListener(logger *logs.Logger) ListenHandler {
logger.Info("当前配置非 LoRaWAN, ChirpStack webhook 监听器未激活。")
return &PlaceholderListener{
logger: logger,
}
}
// Handler 返回一个不执行任何操作的 http.HandlerFunc
// 理论上, 在占位符生效的模式下, 这个 Handler 不应该被调用
func (p *PlaceholderListener) Handler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
p.logger.Warn("PlaceholderListener 的 Handler 被调用, 这通常是意料之外的。")
w.WriteHeader(http.StatusNotImplemented)
}
}

View File

@@ -0,0 +1,8 @@
package webhook
import "net/http"
// ListenHandler 是一个监听器, 用于监听设备上行事件
type ListenHandler interface {
Handler() http.HandlerFunc
}

View File

@@ -7,61 +7,63 @@ import (
"syscall" "syscall"
"git.huangwc.com/pig/pig-farm-controller/internal/app/api" "git.huangwc.com/pig/pig-farm-controller/internal/app/api"
"git.huangwc.com/pig/pig-farm-controller/internal/app/service/token"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/config" "git.huangwc.com/pig/pig-farm-controller/internal/infra/config"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/database"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/task"
) )
// Application 是整个应用的核心,封装了所有组件和生命周期。 // Application 是整个应用的核心,封装了所有组件和生命周期。
type Application struct { type Application struct {
Config *config.Config Config *config.Config
Logger *logs.Logger Logger *logs.Logger
Storage database.Storage API *api.API
Executor *task.Executor
API *api.API // 添加 API 对象 Infra *Infrastructure
Domain *DomainServices
App *AppServices
} }
// NewApplication 创建并初始化一个新的 Application 实例。 // NewApplication 创建并初始化一个新的 Application 实例。
// 这是应用的“组合根”,所有依赖都在这里被创建和注入。 // 这是应用的“组合根”,所有依赖都在这里被创建和注入。
func NewApplication(configPath string) (*Application, error) { func NewApplication(configPath string) (*Application, error) {
// 1. 加载配置 // 1. 初始化基本组件: 配置和日志
cfg := config.NewConfig() cfg := config.NewConfig()
if err := cfg.Load(configPath); err != nil { if err := cfg.Load(configPath); err != nil {
return nil, fmt.Errorf("无法加载配置: %w", err) return nil, fmt.Errorf("无法加载配置: %w", err)
} }
// 2. 初始化日志记录器
logger := logs.NewLogger(cfg.Log) logger := logs.NewLogger(cfg.Log)
// 3. 初始化数据库存储 // 2. 初始化所有分层服务
storage, err := initStorage(cfg.Database, logger) infra, err := initInfrastructure(cfg, logger)
if err != nil { if err != nil {
return nil, err // 错误已在 initStorage 中被包装 return nil, fmt.Errorf("初始化基础设施失败: %w", err)
} }
domain := initDomainServices(cfg, infra, logger)
appServices := initAppServices(infra, domain, logger)
// 4. 初始化任务执行器 // 3. 初始化 API 入口点
executor := task.NewExecutor(cfg.Heartbeat.Concurrency, logger) apiServer := api.NewAPI(
cfg.Server,
logger,
infra.repos.userRepo,
appServices.pigFarmService,
appServices.pigBatchService,
appServices.monitorService,
appServices.deviceService,
appServices.planService,
appServices.userService,
infra.tokenService,
appServices.auditService,
infra.lora.listenHandler,
)
// 5. 初始化用户仓库 // 4. 组装 Application 对象
userRepo := repository.NewGormUserRepository(storage.GetDB())
// 6. 初始化 Token 服务
tokenService := token.NewTokenService([]byte(cfg.App.JWTSecret))
// 7. 初始化 API 服务器
apiServer := api.NewAPI(cfg.Server, logger, userRepo, tokenService)
// 8. 组装 Application 对象
app := &Application{ app := &Application{
Config: cfg, Config: cfg,
Logger: logger, Logger: logger,
Storage: storage,
Executor: executor,
API: apiServer, API: apiServer,
Infra: infra,
Domain: domain,
App: appServices,
} }
return app, nil return app, nil
@@ -71,13 +73,23 @@ func NewApplication(configPath string) (*Application, error) {
func (app *Application) Start() error { func (app *Application) Start() error {
app.Logger.Info("应用启动中...") app.Logger.Info("应用启动中...")
// 启动任务执行 // 1. 启动底层监听
app.Executor.Start() if err := app.Infra.lora.loraListener.Listen(); err != nil {
return fmt.Errorf("启动 LoRa Mesh 监听器失败: %w", err)
}
// 启动 API 服务器 // 2. 初始化应用状态 (清理、刷新任务等)
if err := app.initializeState(); err != nil {
return fmt.Errorf("初始化应用状态失败: %w", err)
}
// 3. 启动后台工作协程
app.Domain.planService.Start()
// 4. 启动 API 服务器
app.API.Start() app.API.Start()
// 等待关闭信号 // 5. 等待关闭信号
quit := make(chan os.Signal, 1) quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit <-quit
@@ -94,35 +106,21 @@ func (app *Application) Stop() error {
app.API.Stop() app.API.Stop()
// 关闭任务执行器 // 关闭任务执行器
app.Executor.Stop() app.Domain.planService.Stop()
// 断开数据库连接 // 断开数据库连接
if err := app.Storage.Disconnect(); err != nil { if err := app.Infra.storage.Disconnect(); err != nil {
app.Logger.Errorw("数据库连接断开失败", "error", err) app.Logger.Errorw("数据库连接断开失败", "error", err)
} }
// 关闭 LoRa Mesh 监听器
if err := app.Infra.lora.loraListener.Stop(); err != nil {
app.Logger.Errorw("LoRa Mesh 监听器关闭失败", "error", err)
}
// 刷新日志缓冲区 // 刷新日志缓冲区
_ = app.Logger.Sync() _ = app.Logger.Sync()
app.Logger.Info("应用已成功关闭") app.Logger.Info("应用已成功关闭")
return nil return nil
} }
// initStorage 封装了数据库的初始化、连接和迁移逻辑。
func initStorage(cfg config.DatabaseConfig, logger *logs.Logger) (database.Storage, error) {
// 创建存储实例
storage := database.NewStorage(cfg, logger)
if err := storage.Connect(); err != nil {
// 错误已在 Connect 内部被记录,这里只需包装并返回
return nil, fmt.Errorf("数据库连接失败: %w", err)
}
// 执行数据库迁移
// 这里需要添加所有需要自动迁移的模型
var dbModels = []interface{}{&models.User{}, &models.Device{}}
if err := storage.Migrate(dbModels...); err != nil {
return nil, fmt.Errorf("数据库迁移失败: %w", err)
}
return storage, nil
}

View File

@@ -0,0 +1,385 @@
package core
import (
"fmt"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/app/service"
"git.huangwc.com/pig/pig-farm-controller/internal/app/webhook"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/audit"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/device"
domain_notify "git.huangwc.com/pig/pig-farm-controller/internal/domain/notify"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/pig"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/plan"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/task"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/token"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/config"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/database"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/notify"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport/lora"
"gorm.io/gorm"
)
// Infrastructure 聚合了所有基础设施层的组件。
type Infrastructure struct {
storage database.Storage
repos *Repositories
lora *LoraComponents
notifyService domain_notify.Service
tokenService token.Service
}
// initInfrastructure 初始化所有基础设施层组件。
func initInfrastructure(cfg *config.Config, logger *logs.Logger) (*Infrastructure, error) {
storage, err := initStorage(cfg.Database, logger)
if err != nil {
return nil, err
}
repos := initRepositories(storage.GetDB(), logger)
lora, err := initLora(cfg, logger, repos)
if err != nil {
return nil, err
}
notifyService, err := initNotifyService(cfg.Notify, logger, repos.userRepo, repos.notificationRepo)
if err != nil {
return nil, fmt.Errorf("初始化通知服务失败: %w", err)
}
tokenService := token.NewTokenService([]byte(cfg.App.JWTSecret))
return &Infrastructure{
storage: storage,
repos: repos,
lora: lora,
notifyService: notifyService,
tokenService: tokenService,
}, nil
}
// Repositories 聚合了所有的仓库实例。
type Repositories struct {
userRepo repository.UserRepository
deviceRepo repository.DeviceRepository
areaControllerRepo repository.AreaControllerRepository
deviceTemplateRepo repository.DeviceTemplateRepository
planRepo repository.PlanRepository
pendingTaskRepo repository.PendingTaskRepository
executionLogRepo repository.ExecutionLogRepository
sensorDataRepo repository.SensorDataRepository
deviceCommandLogRepo repository.DeviceCommandLogRepository
pendingCollectionRepo repository.PendingCollectionRepository
userActionLogRepo repository.UserActionLogRepository
pigBatchRepo repository.PigBatchRepository
pigBatchLogRepo repository.PigBatchLogRepository
pigFarmRepo repository.PigFarmRepository
pigPenRepo repository.PigPenRepository
pigTransferLogRepo repository.PigTransferLogRepository
pigTradeRepo repository.PigTradeRepository
pigSickPigLogRepo repository.PigSickLogRepository
medicationLogRepo repository.MedicationLogRepository
rawMaterialRepo repository.RawMaterialRepository
notificationRepo repository.NotificationRepository
unitOfWork repository.UnitOfWork
}
// initRepositories 初始化所有的仓库。
func initRepositories(db *gorm.DB, logger *logs.Logger) *Repositories {
return &Repositories{
userRepo: repository.NewGormUserRepository(db),
deviceRepo: repository.NewGormDeviceRepository(db),
areaControllerRepo: repository.NewGormAreaControllerRepository(db),
deviceTemplateRepo: repository.NewGormDeviceTemplateRepository(db),
planRepo: repository.NewGormPlanRepository(db),
pendingTaskRepo: repository.NewGormPendingTaskRepository(db),
executionLogRepo: repository.NewGormExecutionLogRepository(db),
sensorDataRepo: repository.NewGormSensorDataRepository(db),
deviceCommandLogRepo: repository.NewGormDeviceCommandLogRepository(db),
pendingCollectionRepo: repository.NewGormPendingCollectionRepository(db),
userActionLogRepo: repository.NewGormUserActionLogRepository(db),
pigBatchRepo: repository.NewGormPigBatchRepository(db),
pigBatchLogRepo: repository.NewGormPigBatchLogRepository(db),
pigFarmRepo: repository.NewGormPigFarmRepository(db),
pigPenRepo: repository.NewGormPigPenRepository(db),
pigTransferLogRepo: repository.NewGormPigTransferLogRepository(db),
pigTradeRepo: repository.NewGormPigTradeRepository(db),
pigSickPigLogRepo: repository.NewGormPigSickLogRepository(db),
medicationLogRepo: repository.NewGormMedicationLogRepository(db),
rawMaterialRepo: repository.NewGormRawMaterialRepository(db),
notificationRepo: repository.NewGormNotificationRepository(db),
unitOfWork: repository.NewGormUnitOfWork(db, logger),
}
}
// DomainServices 聚合了所有的领域服务实例。
type DomainServices struct {
pigPenTransferManager pig.PigPenTransferManager
pigTradeManager pig.PigTradeManager
pigSickManager pig.SickPigManager
pigBatchDomain pig.PigBatchService
generalDeviceService device.Service
taskFactory plan.TaskFactory
planExecutionManager plan.ExecutionManager
analysisPlanTaskManager plan.AnalysisPlanTaskManager
planService plan.Service
}
// initDomainServices 初始化所有的领域服务。
func initDomainServices(cfg *config.Config, infra *Infrastructure, logger *logs.Logger) *DomainServices {
// 猪群管理相关
pigPenTransferManager := pig.NewPigPenTransferManager(infra.repos.pigPenRepo, infra.repos.pigTransferLogRepo, infra.repos.pigBatchRepo)
pigTradeManager := pig.NewPigTradeManager(infra.repos.pigTradeRepo)
pigSickManager := pig.NewSickPigManager(infra.repos.pigSickPigLogRepo, infra.repos.medicationLogRepo)
pigBatchDomain := pig.NewPigBatchService(infra.repos.pigBatchRepo, infra.repos.pigBatchLogRepo, infra.repos.unitOfWork,
pigPenTransferManager, pigTradeManager, pigSickManager)
// 通用设备服务
generalDeviceService := device.NewGeneralDeviceService(
infra.repos.deviceRepo,
infra.repos.deviceCommandLogRepo,
infra.repos.pendingCollectionRepo,
logger,
infra.lora.comm,
)
// 任务工厂
taskFactory := task.NewTaskFactory(logger, infra.repos.sensorDataRepo, infra.repos.deviceRepo, generalDeviceService)
// 计划任务管理器
analysisPlanTaskManager := plan.NewAnalysisPlanTaskManager(infra.repos.planRepo, infra.repos.pendingTaskRepo, infra.repos.executionLogRepo, logger)
// 任务执行器
planExecutionManager := plan.NewPlanExecutionManager(
infra.repos.pendingTaskRepo,
infra.repos.executionLogRepo,
infra.repos.deviceRepo,
infra.repos.sensorDataRepo,
infra.repos.planRepo,
analysisPlanTaskManager,
taskFactory,
logger,
generalDeviceService,
time.Duration(cfg.Task.Interval)*time.Second,
cfg.Task.NumWorkers,
)
// 计划管理器
planService := plan.NewPlanService(
planExecutionManager,
analysisPlanTaskManager,
infra.repos.planRepo,
infra.repos.deviceRepo,
infra.repos.unitOfWork,
taskFactory,
logger)
return &DomainServices{
pigPenTransferManager: pigPenTransferManager,
pigTradeManager: pigTradeManager,
pigSickManager: pigSickManager,
pigBatchDomain: pigBatchDomain,
generalDeviceService: generalDeviceService,
analysisPlanTaskManager: analysisPlanTaskManager,
taskFactory: taskFactory,
planExecutionManager: planExecutionManager,
planService: planService,
}
}
// AppServices 聚合了所有的应用服务实例。
type AppServices struct {
pigFarmService service.PigFarmService
pigBatchService service.PigBatchService
monitorService service.MonitorService
deviceService service.DeviceService
planService service.PlanService
userService service.UserService
auditService audit.Service
}
// initAppServices 初始化所有的应用服务。
func initAppServices(infra *Infrastructure, domainServices *DomainServices, logger *logs.Logger) *AppServices {
pigFarmService := service.NewPigFarmService(infra.repos.pigFarmRepo, infra.repos.pigPenRepo, infra.repos.pigBatchRepo, domainServices.pigBatchDomain, infra.repos.unitOfWork, logger)
pigBatchService := service.NewPigBatchService(domainServices.pigBatchDomain, logger)
monitorService := service.NewMonitorService(
infra.repos.sensorDataRepo,
infra.repos.deviceCommandLogRepo,
infra.repos.executionLogRepo,
infra.repos.planRepo,
infra.repos.pendingCollectionRepo,
infra.repos.userActionLogRepo,
infra.repos.rawMaterialRepo,
infra.repos.medicationLogRepo,
infra.repos.pigBatchRepo,
infra.repos.pigBatchLogRepo,
infra.repos.pigTransferLogRepo,
infra.repos.pigSickPigLogRepo,
infra.repos.pigTradeRepo,
infra.repos.notificationRepo,
)
deviceService := service.NewDeviceService(
infra.repos.deviceRepo,
infra.repos.areaControllerRepo,
infra.repos.deviceTemplateRepo,
domainServices.generalDeviceService,
)
auditService := audit.NewService(infra.repos.userActionLogRepo, logger)
planService := service.NewPlanService(logger, domainServices.planService)
userService := service.NewUserService(infra.repos.userRepo, infra.tokenService, infra.notifyService, logger)
return &AppServices{
pigFarmService: pigFarmService,
pigBatchService: pigBatchService,
monitorService: monitorService,
deviceService: deviceService,
auditService: auditService,
planService: planService,
userService: userService,
}
}
// LoraComponents 聚合了所有 LoRa 相关组件。
type LoraComponents struct {
listenHandler webhook.ListenHandler
comm transport.Communicator
loraListener transport.Listener
}
// initLora 根据配置初始化 LoRa 相关组件。
func initLora(
cfg *config.Config,
logger *logs.Logger,
repos *Repositories,
) (*LoraComponents, error) {
var listenHandler webhook.ListenHandler
var comm transport.Communicator
var loraListener transport.Listener
if cfg.Lora.Mode == config.LoraMode_LoRaWAN {
logger.Info("当前运行模式: lora_wan。初始化 ChirpStack 监听器和传输层。")
listenHandler = webhook.NewChirpStackListener(logger, repos.sensorDataRepo, repos.deviceRepo, repos.areaControllerRepo, repos.deviceCommandLogRepo, repos.pendingCollectionRepo)
comm = lora.NewChirpStackTransport(cfg.ChirpStack, logger)
loraListener = lora.NewPlaceholderTransport(logger)
} else {
logger.Info("当前运行模式: lora_mesh。初始化 LoRa Mesh 传输层和占位符监听器。")
listenHandler = webhook.NewPlaceholderListener(logger)
tp, err := lora.NewLoRaMeshUartPassthroughTransport(cfg.LoraMesh, logger, repos.areaControllerRepo, repos.pendingCollectionRepo, repos.deviceRepo, repos.sensorDataRepo)
if err != nil {
return nil, fmt.Errorf("无法初始化 LoRa Mesh 模块: %w", err)
}
loraListener = tp
comm = tp
}
return &LoraComponents{
listenHandler: listenHandler,
comm: comm,
loraListener: loraListener,
}, nil
}
// initNotifyService 根据配置初始化并返回一个通知领域服务。
// 它确保至少有一个 LogNotifier 总是可用,并根据配置启用其他通知器。
func initNotifyService(
cfg config.NotifyConfig,
log *logs.Logger,
userRepo repository.UserRepository,
notificationRepo repository.NotificationRepository,
) (domain_notify.Service, error) {
var availableNotifiers []notify.Notifier
// 1. 总是创建 LogNotifier 作为所有告警的最终记录渠道
logNotifier := notify.NewLogNotifier(log)
availableNotifiers = append(availableNotifiers, logNotifier)
log.Info("Log通知器已启用 (作为所有告警的最终记录渠道)")
// 2. 根据配置,按需创建并收集所有启用的其他 Notifier 实例
if cfg.SMTP.Enabled {
smtpNotifier := notify.NewSMTPNotifier(
cfg.SMTP.Host,
cfg.SMTP.Port,
cfg.SMTP.Username,
cfg.SMTP.Password,
cfg.SMTP.Sender,
)
availableNotifiers = append(availableNotifiers, smtpNotifier)
log.Info("SMTP通知器已启用")
}
if cfg.WeChat.Enabled {
wechatNotifier := notify.NewWechatNotifier(
cfg.WeChat.CorpID,
cfg.WeChat.AgentID,
cfg.WeChat.Secret,
)
availableNotifiers = append(availableNotifiers, wechatNotifier)
log.Info("企业微信通知器已启用")
}
if cfg.Lark.Enabled {
larkNotifier := notify.NewLarkNotifier(
cfg.Lark.AppID,
cfg.Lark.AppSecret,
)
availableNotifiers = append(availableNotifiers, larkNotifier)
log.Info("飞书通知器已启用")
}
// 3. 动态确定首选通知器
var primaryNotifier notify.Notifier
primaryNotifierType := notify.NotifierType(cfg.Primary)
// 检查用户指定的主渠道是否已启用
for _, n := range availableNotifiers {
if n.Type() == primaryNotifierType {
primaryNotifier = n
break
}
}
// 如果用户指定的主渠道未启用或未指定,则自动选择第一个可用的 (这将是 LogNotifier如果其他都未启用)
if primaryNotifier == nil {
primaryNotifier = availableNotifiers[0] // 确保总能找到一个,因为 LogNotifier 总是存在的
log.Warnf("配置的首选渠道 '%s' 未启用或未指定,已自动降级使用 '%s' 作为首选渠道。", cfg.Primary, primaryNotifier.Type())
}
// 4. 使用创建的 Notifier 列表和 notificationRepo 来组装领域服务
notifyService, err := domain_notify.NewFailoverService(
log,
userRepo,
availableNotifiers,
primaryNotifier.Type(),
cfg.FailureThreshold,
notificationRepo,
)
if err != nil {
return nil, fmt.Errorf("创建故障转移通知服务失败: %w", err)
}
log.Infof("通知服务初始化成功,首选渠道: %s, 故障阈值: %d", primaryNotifier.Type(), cfg.FailureThreshold)
return notifyService, nil
}
// initStorage 封装了数据库的初始化、连接和迁移逻辑。
func initStorage(cfg config.DatabaseConfig, logger *logs.Logger) (database.Storage, error) {
// 创建存储实例
storage := database.NewStorage(cfg, logger)
if err := storage.Connect(); err != nil {
// 错误已在 Connect 内部被记录,这里只需包装并返回
return nil, fmt.Errorf("数据库连接失败: %w", err)
}
// 执行数据库迁移
if err := storage.Migrate(models.GetAllModels()...); err != nil {
return nil, fmt.Errorf("数据库迁移失败: %w", err)
}
logger.Info("数据库初始化完成。")
return storage, nil
}

View File

@@ -0,0 +1,245 @@
package core
import (
"fmt"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
)
const (
// PlanNameTimedFullDataCollection 是定时全量数据采集计划的名称
PlanNameTimedFullDataCollection = "定时全量数据采集"
)
// initializeState 在应用启动时准备其初始数据状态。
// 这包括清理任何因上次异常关闭而留下的悬空任务或请求。
func (app *Application) initializeState() error {
// 初始化预定义系统计划 (致命错误)
if err := app.initializeSystemPlans(); err != nil {
return fmt.Errorf("初始化预定义系统计划失败: %w", err)
}
// 清理待采集任务 (非致命错误)
if err := app.initializePendingCollections(); err != nil {
app.Logger.Errorw("清理待采集任务时发生非致命错误", "error", err)
}
// 初始化待执行任务列表 (致命错误)
if err := app.initializePendingTasks(); err != nil {
return fmt.Errorf("初始化待执行任务列表失败: %w", err)
}
return nil
}
// initializeSystemPlans 确保预定义的系统计划在数据库中存在并保持最新。
func (app *Application) initializeSystemPlans() error {
app.Logger.Info("开始检查并更新预定义的系统计划...")
// 动态构建预定义计划列表
predefinedSystemPlans := app.getPredefinedSystemPlans()
// 1. 获取所有已存在的系统计划
existingPlans, _, err := app.Infra.repos.planRepo.ListPlans(repository.ListPlansOptions{
PlanType: repository.PlanTypeFilterSystem,
}, 1, 99999) // 使用一个较大的 pageSize 来获取所有系统计划
if err != nil {
return fmt.Errorf("获取现有系统计划失败: %w", err)
}
// 2. 为了方便查找, 将现有计划名放入一个 map
existingPlanMap := make(map[string]*models.Plan)
for i := range existingPlans {
existingPlanMap[existingPlans[i].Name] = &existingPlans[i]
}
// 3. 遍历预定义的计划列表
for i := range predefinedSystemPlans {
predefinedPlan := &predefinedSystemPlans[i] // 获取可修改的指针
if foundExistingPlan, ok := existingPlanMap[predefinedPlan.Name]; ok {
// 如果计划存在,则进行无差别更新
app.Logger.Infof("预定义计划 '%s' 已存在,正在进行无差别更新...", predefinedPlan.Name)
// 将数据库中已存在的计划的ID和运行时状态字段赋值给预定义计划
predefinedPlan.ID = foundExistingPlan.ID
predefinedPlan.ExecuteCount = foundExistingPlan.ExecuteCount
if err := app.Infra.repos.planRepo.UpdatePlan(predefinedPlan); err != nil {
return fmt.Errorf("更新预定义计划 '%s' 失败: %w", predefinedPlan.Name, err)
} else {
app.Logger.Infof("成功更新预定义计划 '%s'。", predefinedPlan.Name)
}
} else {
// 如果计划不存在, 则创建
app.Logger.Infof("预定义计划 '%s' 不存在,正在创建...", predefinedPlan.Name)
if err := app.Infra.repos.planRepo.CreatePlan(predefinedPlan); err != nil {
return fmt.Errorf("创建预定义计划 '%s' 失败: %w", predefinedPlan.Name, err)
} else {
app.Logger.Infof("成功创建预定义计划 '%s'。", predefinedPlan.Name)
}
}
}
app.Logger.Info("预定义系统计划检查完成。")
return nil
}
// getPredefinedSystemPlans 返回一个基于当前配置的预定义系统计划列表。
func (app *Application) getPredefinedSystemPlans() []models.Plan {
// 根据配置创建定时全量采集计划
interval := app.Config.Collection.Interval
if interval <= 0 {
interval = 1 // 确保间隔至少为1分钟
}
cronExpression := fmt.Sprintf("*/%d * * * *", interval)
timedCollectionPlan := models.Plan{
Name: PlanNameTimedFullDataCollection,
Description: fmt.Sprintf("这是一个系统预定义的计划, 每 %d 分钟自动触发一次全量数据采集。", app.Config.Collection.Interval),
PlanType: models.PlanTypeSystem,
ExecutionType: models.PlanExecutionTypeAutomatic,
CronExpression: cronExpression,
Status: models.PlanStatusEnabled,
ContentType: models.PlanContentTypeTasks,
Tasks: []models.Task{
{
Name: "全量采集",
Description: "触发一次全量数据采集",
ExecutionOrder: 1,
Type: models.TaskTypeFullCollection,
},
},
}
return []models.Plan{timedCollectionPlan}
}
// initializePendingCollections 在应用启动时处理所有未完成的采集请求。
// 我们的策略是:任何在程序重启前仍处于“待处理”状态的请求,都应被视为已失败。
// 这保证了系统在每次启动时都处于一个干净、确定的状态。
func (app *Application) initializePendingCollections() error {
app.Logger.Info("开始清理所有未完成的采集请求...")
// 直接将所有 'pending' 状态的请求更新为 'timed_out'。
count, err := app.Infra.repos.pendingCollectionRepo.MarkAllPendingAsTimedOut()
if err != nil {
return fmt.Errorf("清理未完成的采集请求失败: %v", err)
} else if count > 0 {
app.Logger.Infof("成功将 %d 个未完成的采集请求标记为超时。", count)
} else {
app.Logger.Info("没有需要清理的采集请求。")
}
return nil
}
// initializePendingTasks 在应用启动时清理并刷新待执行任务列表。
func (app *Application) initializePendingTasks() error {
logger := app.Logger
planRepo := app.Infra.repos.planRepo
pendingTaskRepo := app.Infra.repos.pendingTaskRepo
executionLogRepo := app.Infra.repos.executionLogRepo
planService := app.Domain.planService
logger.Info("开始初始化待执行任务列表...")
// 阶段一:修正因崩溃导致状态不一致的固定次数计划
logger.Info("阶段一:开始修正因崩溃导致状态不一致的固定次数计划...")
plansToCorrect, err := planRepo.FindPlansWithPendingTasks()
if err != nil {
return fmt.Errorf("查找需要修正的计划失败: %w", err)
}
for _, plan := range plansToCorrect {
logger.Infof("发现需要修正的计划 #%d (名称: %s)。", plan.ID, plan.Name)
// 更新计划的执行计数
plan.ExecuteCount++
logger.Infof("计划 #%d 执行计数已从 %d 更新为 %d。", plan.ID, plan.ExecuteCount-1, plan.ExecuteCount)
if plan.ExecutionType == models.PlanExecutionTypeManual ||
(plan.ExecutionType == models.PlanExecutionTypeAutomatic && plan.ExecuteCount >= plan.ExecuteNum) {
// 更新计划状态为已停止
plan.Status = models.PlanStatusStopped
logger.Infof("计划 #%d 状态已更新为 '执行完毕'。", plan.ID)
}
// 保存更新后的计划
if err := planRepo.UpdatePlan(plan); err != nil {
logger.Errorf("修正计划 #%d 状态失败: %v", plan.ID, err)
// 这是一个非阻塞性错误,继续处理其他计划
}
}
logger.Info("阶段一:固定次数计划修正完成。")
// 阶段二:清理所有待执行任务和相关日志
logger.Info("阶段二:开始清理所有待执行任务和相关日志...")
// --- 新增逻辑:处理因崩溃导致状态不一致的计划主表状态 ---
// 1. 查找所有未完成的计划执行日志 (状态为 Started 或 Waiting)
incompletePlanLogs, err := executionLogRepo.FindIncompletePlanExecutionLogs()
if err != nil {
return fmt.Errorf("查找未完成的计划执行日志失败: %w", err)
}
// 2. 收集所有受影响的唯一 PlanID
affectedPlanIDs := make(map[uint]struct{})
for _, log := range incompletePlanLogs {
affectedPlanIDs[log.PlanID] = struct{}{}
}
// 3. 对于每个受影响的 PlanID重置其 execute_count 并将其状态设置为 Failed, 系统计划不受此影响
for planID := range affectedPlanIDs {
// 首先,获取计划的详细信息以判断其类型
plan, err := planRepo.GetBasicPlanByID(planID)
if err != nil {
logger.Errorf("在尝试修正计划状态时,获取计划 #%d 的基本信息失败: %v", planID, err)
continue // 获取失败,跳过此计划
}
// 如果是系统计划,则不应标记为失败,仅记录日志
if plan.PlanType == models.PlanTypeSystem {
logger.Warnf("检测到系统计划 #%d 在应用崩溃前处于未完成状态,但根据策略,将保持其原有状态不标记为失败。", planID)
continue // 跳过,不处理
}
// 对于非系统计划,执行原有的失败标记逻辑
logger.Warnf("检测到计划 #%d 在应用崩溃前处于未完成状态,将重置其计数并标记为失败。", planID)
// 使用 UpdatePlanStateAfterExecution 来更新主表状态,避免影响关联数据
if err := planRepo.UpdatePlanStateAfterExecution(planID, 0, models.PlanStatusFailed); err != nil {
logger.Errorf("重置计划 #%d 计数并标记为失败时出错: %v", planID, err)
// 这是一个非阻塞性错误,继续处理其他计划
}
}
logger.Info("阶段二:计划主表状态修正完成。")
// 直接调用新的方法来更新计划执行日志状态为失败
if err := executionLogRepo.FailAllIncompletePlanExecutionLogs(); err != nil {
logger.Errorf("更新所有未完成计划执行日志状态为失败失败: %v", err)
// 这是一个非阻塞性错误,继续执行
}
// 直接调用新的方法来更新任务执行日志状态为取消
if err := executionLogRepo.CancelAllIncompleteTaskExecutionLogs(); err != nil {
logger.Errorf("更新所有未完成任务执行日志状态为取消失败: %v", err)
// 这是一个非阻塞性错误,继续执行
}
// 清空待执行列表
if err := pendingTaskRepo.ClearAllPendingTasks(); err != nil {
return fmt.Errorf("清空待执行任务列表失败: %w", err)
}
logger.Info("阶段二:待执行任务和相关日志清理完成。")
// 阶段三:初始刷新
logger.Info("阶段三:开始刷新待执行列表...")
if err := planService.RefreshPlanTriggers(); err != nil {
return fmt.Errorf("刷新待执行任务列表失败: %w", err)
}
logger.Info("阶段三:待执行任务列表初始化完成。")
logger.Info("待执行任务列表初始化完成。")
return nil
}

View File

@@ -0,0 +1,69 @@
// Package audit 提供了用户操作审计相关的功能
package audit
import (
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
// 移除对 "github.com/gin-gonic/gin" 的直接依赖
)
// RequestContext 封装了审计日志所需的请求上下文信息
type RequestContext struct {
ClientIP string
HTTPPath string
HTTPMethod string
}
// Service 定义了审计服务的接口
type Service interface {
LogAction(user *models.User, reqCtx RequestContext, actionType, description string, targetResource interface{}, status models.AuditStatus, resultDetails string)
}
// service 是 Service 接口的实现
type service struct {
userActionLogRepository repository.UserActionLogRepository
logger *logs.Logger
}
// NewService 创建一个新的审计服务实例
func NewService(repo repository.UserActionLogRepository, logger *logs.Logger) Service {
return &service{userActionLogRepository: repo, logger: logger}
}
// LogAction 记录一个用户操作。它在一个新的 goroutine 中异步执行,以避免阻塞主请求。
func (s *service) LogAction(user *models.User, reqCtx RequestContext, actionType, description string, targetResource interface{}, status models.AuditStatus, resultDetails string) {
// 不再从 context 中获取用户信息,直接使用传入的 user 对象
if user == nil {
s.logger.Warnw("无法记录审计日志:传入的用户对象为 nil")
return
}
log := &models.UserActionLog{
Time: time.Now(),
UserID: user.ID,
Username: user.Username, // 用户名快照
SourceIP: reqCtx.ClientIP,
ActionType: actionType,
Description: description,
Status: status,
HTTPPath: reqCtx.HTTPPath,
HTTPMethod: reqCtx.HTTPMethod,
ResultDetails: resultDetails,
}
// 使用模型提供的方法来设置 TargetResource
if err := log.SetTargetResource(targetResource); err != nil {
s.logger.Errorw("无法记录审计日志:序列化 targetResource 失败", "error", err)
// 即使序列化失败,我们可能仍然希望记录操作本身,所以不在此处 return
}
// 异步写入数据库,不阻塞当前请求
go func() {
if err := s.userActionLogRepository.Create(log); err != nil {
s.logger.Errorw("异步保存审计日志失败", "error", err)
}
}()
}

View File

@@ -0,0 +1,42 @@
package device
import "git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
// 设备行为
type DeviceAction string
var (
DeviceActionStart DeviceAction = "start" // 启动
DeviceActionStop DeviceAction = "stop" // 停止
)
// 指令类型
type Method string
var (
MethodSwitch Method = "switch" // 启停指令
)
// Service 抽象了一组方法用于控制设备行为
type Service interface {
// Switch 用于切换指定设备的状态, 比如启动和停止
Switch(device *models.Device, action DeviceAction) error
// Collect 用于发起对指定区域主控下的多个设备的批量采集请求。
Collect(regionalControllerID uint, devicesToCollect []*models.Device) error
}
// 设备操作指令通用结构(最外层)
type DeviceRequest struct {
MessageID int // 消息ID, 用于后续匹配响应
Method string // 这是什么指令
Data interface{} // 指令参数
}
// 设备操作指令通用响应结构(最外层)
type DeviceResponse struct {
MessageID int // 消息ID, 用于匹配这是哪一个请求的响应
Message string
Data interface{} // 响应内容
}

View File

@@ -0,0 +1,248 @@
package device
import (
"errors"
"fmt"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport/proto"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/utils/command_generater"
"github.com/google/uuid"
gproto "google.golang.org/protobuf/proto"
)
type GeneralDeviceService struct {
deviceRepo repository.DeviceRepository
deviceCommandLogRepo repository.DeviceCommandLogRepository
pendingCollectionRepo repository.PendingCollectionRepository
logger *logs.Logger
comm transport.Communicator
}
// NewGeneralDeviceService 创建一个通用设备服务
func NewGeneralDeviceService(
deviceRepo repository.DeviceRepository,
deviceCommandLogRepo repository.DeviceCommandLogRepository,
pendingCollectionRepo repository.PendingCollectionRepository,
logger *logs.Logger,
comm transport.Communicator,
) Service {
return &GeneralDeviceService{
deviceRepo: deviceRepo,
deviceCommandLogRepo: deviceCommandLogRepo,
pendingCollectionRepo: pendingCollectionRepo,
logger: logger,
comm: comm,
}
}
func (g *GeneralDeviceService) Switch(device *models.Device, action DeviceAction) error {
// 1. 依赖模型自身的 SelfCheck 进行全面校验
if err := device.SelfCheck(); err != nil {
return fmt.Errorf("设备 %v(id=%v) 未通过自检: %w", device.Name, device.ID, err)
}
if err := device.DeviceTemplate.SelfCheck(); err != nil {
return fmt.Errorf("设备 %v(id=%v) 的模板未通过自检: %w", device.Name, device.ID, err)
}
// 2. 检查预加载的 AreaController 是否有效
areaController := &device.AreaController
if err := areaController.SelfCheck(); err != nil {
return fmt.Errorf("区域主控 %v(id=%v) 未通过自检: %w", areaController.Name, areaController.ID, err)
}
// 3. 使用模型层预定义的 Bus485Properties 结构体解析设备属性
var deviceProps models.Bus485Properties
if err := device.ParseProperties(&deviceProps); err != nil {
return fmt.Errorf("解析设备 %v(id=%v) 的属性失败: %w", device.Name, device.ID, err)
}
// 4. 解析设备模板中的开关指令参数
var switchCmd models.SwitchCommands
if err := device.DeviceTemplate.ParseCommands(&switchCmd); err != nil {
return fmt.Errorf("解析设备 %v(id=%v) 的开关指令失败: %w", device.Name, device.ID, err)
}
// 5. 根据 action 生成 Modbus RTU 写入指令
onOffState := true // 默认为开启
if action == DeviceActionStop { // 如果是停止动作,则设置为关闭
onOffState = false
}
modbusCommandBytes, err := command_generater.GenerateModbusRTUSwitchCommand(
deviceProps.BusAddress,
switchCmd.ModbusStartAddress,
onOffState,
)
if err != nil {
return fmt.Errorf("生成Modbus RTU写入指令失败: %w", err)
}
// 6. 构建 Protobuf Raw485Command包含总线号
raw485Cmd := &proto.Raw485Command{
BusNumber: int32(deviceProps.BusNumber), // 添加总线号
CommandBytes: modbusCommandBytes,
}
instruction := &proto.Instruction{
Payload: &proto.Instruction_Raw_485Command{
Raw_485Command: raw485Cmd,
},
}
message, err := gproto.Marshal(instruction)
if err != nil {
return fmt.Errorf("序列化指令失败: %w", err)
}
// 7. 发送指令
networkID := areaController.NetworkID
sendResult, err := g.comm.Send(networkID, message)
if err != nil {
return fmt.Errorf("发送指令到 %s 失败: %w", networkID, err)
}
// 8. 创建并保存命令日志
logRecord := &models.DeviceCommandLog{
MessageID: sendResult.MessageID,
DeviceID: areaController.ID,
SentAt: time.Now(),
}
if sendResult.AcknowledgedAt != nil {
logRecord.AcknowledgedAt = sendResult.AcknowledgedAt
}
if sendResult.ReceivedSuccess != nil {
logRecord.ReceivedSuccess = *sendResult.ReceivedSuccess
}
if err := g.deviceCommandLogRepo.Create(logRecord); err != nil {
// 记录日志失败是一个需要关注的问题,但可能不应该中断主流程。
// 我们记录一个错误日志,然后成功返回。
g.logger.Errorf("创建指令日志失败 (MessageID: %s): %v", sendResult.MessageID, err)
}
g.logger.Infof("成功发送指令到 %s 并创建日志 (MessageID: %s)", networkID, sendResult.MessageID)
return nil
}
// Collect 实现了 Service 接口,用于发起对指定区域主控下的多个设备的批量采集请求。
func (g *GeneralDeviceService) Collect(regionalControllerID uint, devicesToCollect []*models.Device) error {
if len(devicesToCollect) == 0 {
g.logger.Info("待采集设备列表为空,无需执行采集任务。")
return nil
}
// 1. 从设备列表中获取预加载的区域主控,并进行校验
regionalController := &devicesToCollect[0].AreaController
if regionalController.ID != regionalControllerID {
return fmt.Errorf("设备列表与指定的区域主控ID (%d) 不匹配", regionalControllerID)
}
if err := regionalController.SelfCheck(); err != nil {
return fmt.Errorf("区域主控 (ID: %d) 未通过自检: %w", regionalControllerID, err)
}
// 2. 准备采集任务列表
var childDeviceIDs []uint
var collectTasks []*proto.CollectTask
for _, dev := range devicesToCollect {
// 依赖模型自身的 SelfCheck 进行全面校验
if err := dev.SelfCheck(); err != nil {
g.logger.Warnf("跳过设备 %d因其未通过自检: %v", dev.ID, err)
continue
}
if err := dev.DeviceTemplate.SelfCheck(); err != nil {
g.logger.Warnf("跳过设备 %d因其设备模板未通过自检: %v", dev.ID, err)
continue
}
// 使用模板的 ParseCommands 方法获取传感器指令参数
var sensorCmd models.SensorCommands
if err := dev.DeviceTemplate.ParseCommands(&sensorCmd); err != nil {
g.logger.Warnf("跳过设备 %d因其模板指令无法解析为 SensorCommands: %v", dev.ID, err)
continue
}
// 使用模型层预定义的 Bus485Properties 结构体解析设备属性
var deviceProps models.Bus485Properties
if err := dev.ParseProperties(&deviceProps); err != nil {
g.logger.Warnf("跳过设备 %d因其属性解析失败: %v", dev.ID, err)
continue
}
// 生成 Modbus RTU 读取指令
modbusCommandBytes, err := command_generater.GenerateModbusRTUReadCommand(
deviceProps.BusAddress,
sensorCmd.ModbusFunctionCode,
sensorCmd.ModbusStartAddress,
sensorCmd.ModbusQuantity,
)
if err != nil {
g.logger.Warnf("跳过设备 %d因生成Modbus RTU读取指令失败: %v", dev.ID, err)
continue
}
g.logger.Debugf("生成485指令: %v", modbusCommandBytes)
// 构建 Raw485Command包含总线号
raw485Cmd := &proto.Raw485Command{
BusNumber: int32(deviceProps.BusNumber), // 添加总线号
CommandBytes: modbusCommandBytes,
}
collectTasks = append(collectTasks, &proto.CollectTask{
Command: raw485Cmd,
})
childDeviceIDs = append(childDeviceIDs, dev.ID)
}
if len(childDeviceIDs) == 0 {
return errors.New("经过滤后,没有可通过自检的有效设备")
}
// 3. 构建并发送指令
networkID := regionalController.NetworkID
// 4. 创建待处理请求记录
correlationID := uuid.New().String()
pendingReq := &models.PendingCollection{
CorrelationID: correlationID,
DeviceID: regionalController.ID,
CommandMetadata: childDeviceIDs,
Status: models.PendingStatusPending,
CreatedAt: time.Now(),
}
if err := g.pendingCollectionRepo.Create(pendingReq); err != nil {
g.logger.Errorf("创建待采集请求失败 (CorrelationID: %s): %v", correlationID, err)
return err
}
g.logger.Infof("成功创建待采集请求 (CorrelationID: %s, DeviceID: %d)", correlationID, regionalController.ID)
// 5. 构建最终的空中载荷
batchCmd := &proto.BatchCollectCommand{
CorrelationId: correlationID,
Tasks: collectTasks,
}
instruction := &proto.Instruction{
Payload: &proto.Instruction_BatchCollectCommand{
BatchCollectCommand: batchCmd,
},
}
payload, err := gproto.Marshal(instruction)
if err != nil {
g.logger.Errorf("序列化采集指令失败 (CorrelationID: %s): %v", correlationID, err)
return err
}
g.logger.Infof("构造空中载荷成功: networkID: %v, payload: %v", networkID, instruction)
if _, err := g.comm.Send(networkID, payload); err != nil {
g.logger.DPanicf("待采集请求 (CorrelationID: %s) 已创建,但发送到设备失败: %v。数据可能不一致", correlationID, err)
return err
}
g.logger.Infof("成功将采集请求 (CorrelationID: %s) 发送到设备 %s", correlationID, networkID)
return nil
}

View File

@@ -0,0 +1,292 @@
package notify
import (
"fmt"
"strings"
"sync"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/notify"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
"go.uber.org/zap"
)
// Service 定义了通知领域的核心业务逻辑接口
type Service interface {
// SendBatchAlarm 向一批用户发送告警通知。它会并发地为每个用户执行带故障转移的发送逻辑。
SendBatchAlarm(userIDs []uint, content notify.AlarmContent) error
// BroadcastAlarm 向所有用户发送告警通知。它会并发地为每个用户执行带故障转移的发送逻辑。
BroadcastAlarm(content notify.AlarmContent) error
// SendTestMessage 向指定用户发送一条测试消息,用于手动验证特定通知渠道的配置。
SendTestMessage(userID uint, notifierType notify.NotifierType) error
}
// failoverService 是 Service 接口的实现,提供了故障转移功能
type failoverService struct {
log *logs.Logger
userRepo repository.UserRepository
notifiers map[notify.NotifierType]notify.Notifier
primaryNotifier notify.Notifier
failureThreshold int
failureCounters *sync.Map // 使用 sync.Map 来安全地并发读写失败计数, key: userID (uint), value: counter (int)
notificationRepo repository.NotificationRepository
}
// NewFailoverService 创建一个新的故障转移通知服务
func NewFailoverService(
log *logs.Logger,
userRepo repository.UserRepository,
notifiers []notify.Notifier,
primaryNotifierType notify.NotifierType,
failureThreshold int,
notificationRepo repository.NotificationRepository,
) (Service, error) {
notifierMap := make(map[notify.NotifierType]notify.Notifier)
for _, n := range notifiers {
notifierMap[n.Type()] = n
}
primaryNotifier, ok := notifierMap[primaryNotifierType]
if !ok {
return nil, fmt.Errorf("首选通知器类型 '%s' 在提供的通知器列表中不存在", primaryNotifierType)
}
return &failoverService{
log: log,
userRepo: userRepo,
notifiers: notifierMap,
primaryNotifier: primaryNotifier,
failureThreshold: failureThreshold,
failureCounters: &sync.Map{},
notificationRepo: notificationRepo,
}, nil
}
// SendBatchAlarm 实现了向多个用户并发发送告警的功能
func (s *failoverService) SendBatchAlarm(userIDs []uint, content notify.AlarmContent) error {
var wg sync.WaitGroup
var mu sync.Mutex
var allErrors []string
s.log.Infow("开始批量发送告警...", "userCount", len(userIDs))
for _, userID := range userIDs {
wg.Add(1)
go func(id uint) {
defer wg.Done()
if err := s.sendAlarmToUser(id, content); err != nil {
mu.Lock()
allErrors = append(allErrors, fmt.Sprintf("发送失败 (用户ID: %d): %v", id, err))
mu.Unlock()
}
}(userID)
}
wg.Wait()
if len(allErrors) > 0 {
finalError := fmt.Errorf("批量告警发送完成,但有 %d 个用户发送失败:\n%s", len(allErrors), strings.Join(allErrors, "\n"))
s.log.Error(finalError.Error())
return finalError
}
s.log.Info("批量发送告警成功完成,所有用户均已通知。")
return nil
}
// BroadcastAlarm 实现了向所有用户发送告警的功能
func (s *failoverService) BroadcastAlarm(content notify.AlarmContent) error {
users, err := s.userRepo.FindAll()
if err != nil {
s.log.Errorw("广播告警失败:查找所有用户时出错", "error", err)
return fmt.Errorf("广播告警失败:查找所有用户时出错: %w", err)
}
var userIDs []uint
for _, user := range users {
userIDs = append(userIDs, user.ID)
}
s.log.Infow("开始广播告警给所有用户", "totalUsers", len(userIDs))
// 复用 SendBatchAlarm 的逻辑进行并发发送和错误处理
return s.SendBatchAlarm(userIDs, content)
}
// sendAlarmToUser 是为单个用户发送告警的内部方法,包含了完整的故障转移逻辑
func (s *failoverService) sendAlarmToUser(userID uint, content notify.AlarmContent) error {
user, err := s.userRepo.FindByID(userID)
if err != nil {
s.log.Errorw("发送告警失败:查找用户时出错", "userID", userID, "error", err)
return fmt.Errorf("查找用户失败: %w", err)
}
counter, _ := s.failureCounters.LoadOrStore(userID, 0)
failureCount := counter.(int)
if failureCount < s.failureThreshold {
primaryType := s.primaryNotifier.Type()
addr := getAddressForNotifier(primaryType, user.Contact)
if addr == "" {
// 记录跳过通知
s.recordNotificationAttempt(userID, primaryType, content, "", models.NotificationStatusSkipped, fmt.Errorf("用户未配置首选通知方式 '%s' 的地址", primaryType))
return fmt.Errorf("用户未配置首选通知方式 '%s' 的地址", primaryType)
}
err = s.primaryNotifier.Send(content, addr)
if err == nil {
// 记录成功通知
s.recordNotificationAttempt(userID, primaryType, content, addr, models.NotificationStatusSuccess, nil)
if failureCount > 0 {
s.log.Infow("首选渠道发送恢复正常", "userID", userID, "notifierType", primaryType)
s.failureCounters.Store(userID, 0)
}
return nil
}
// 记录失败通知
s.recordNotificationAttempt(userID, primaryType, content, addr, models.NotificationStatusFailed, err)
newFailureCount := failureCount + 1
s.failureCounters.Store(userID, newFailureCount)
s.log.Warnw("首选渠道发送失败", "userID", userID, "notifierType", primaryType, "error", err, "failureCount", newFailureCount)
failureCount = newFailureCount
}
if failureCount >= s.failureThreshold {
s.log.Warnw("故障转移阈值已达到,开始广播通知", "userID", userID, "threshold", s.failureThreshold)
var lastErr error
for _, notifier := range s.notifiers {
addr := getAddressForNotifier(notifier.Type(), user.Contact)
if addr == "" {
// 记录跳过通知
s.recordNotificationAttempt(userID, notifier.Type(), content, "", models.NotificationStatusSkipped, fmt.Errorf("用户未配置通知方式 '%s' 的地址", notifier.Type()))
continue
}
if err := notifier.Send(content, addr); err == nil {
// 记录成功通知
s.recordNotificationAttempt(userID, notifier.Type(), content, addr, models.NotificationStatusSuccess, nil)
s.log.Infow("广播通知成功", "userID", userID, "notifierType", notifier.Type())
s.failureCounters.Store(userID, 0)
return nil
}
// 记录失败通知
s.recordNotificationAttempt(userID, notifier.Type(), content, addr, models.NotificationStatusFailed, err)
lastErr = err
s.log.Warnw("广播通知:渠道发送失败", "userID", userID, "notifierType", notifier.Type(), "error", err)
}
return fmt.Errorf("所有渠道均发送失败,最后一个错误: %w", lastErr)
}
return nil
}
// SendTestMessage 实现了手动发送测试消息的功能
func (s *failoverService) SendTestMessage(userID uint, notifierType notify.NotifierType) error {
user, err := s.userRepo.FindByID(userID)
if err != nil {
s.log.Errorw("发送测试消息失败:查找用户时出错", "userID", userID, "error", err)
return fmt.Errorf("查找用户失败: %w", err)
}
notifier, ok := s.notifiers[notifierType]
if !ok {
s.log.Errorw("发送测试消息失败:通知器类型不存在", "userID", userID, "notifierType", notifierType)
return fmt.Errorf("指定的通知器类型 '%s' 不存在", notifierType)
}
addr := getAddressForNotifier(notifierType, user.Contact)
if addr == "" {
s.log.Warnw("发送测试消息失败:缺少地址", "userID", userID, "notifierType", notifierType)
// 记录跳过通知
s.recordNotificationAttempt(userID, notifierType, notify.AlarmContent{
Title: "通知服务测试",
Message: fmt.Sprintf("这是一条来自【%s】渠道的测试消息。如果您收到此消息说明您的配置正确。", notifierType),
Level: zap.InfoLevel,
Timestamp: time.Now(),
}, "", models.NotificationStatusFailed, fmt.Errorf("用户未配置通知方式 '%s' 的地址", notifierType))
return fmt.Errorf("用户未配置通知方式 '%s' 的地址", notifierType)
}
testContent := notify.AlarmContent{
Title: "通知服务测试",
Message: fmt.Sprintf("这是一条来自【%s】渠道的测试消息。如果您收到此消息说明您的配置正确。", notifierType),
Level: zap.InfoLevel,
Timestamp: time.Now(),
}
s.log.Infow("正在发送测试消息...", "userID", userID, "notifierType", notifierType, "address", addr)
err = notifier.Send(testContent, addr)
if err != nil {
s.log.Errorw("发送测试消息失败", "userID", userID, "notifierType", notifierType, "error", err)
// 记录失败通知
s.recordNotificationAttempt(userID, notifierType, testContent, addr, models.NotificationStatusFailed, err)
return err
}
s.log.Infow("发送测试消息成功", "userID", userID, "notifierType", notifierType)
// 记录成功通知
s.recordNotificationAttempt(userID, notifierType, testContent, addr, models.NotificationStatusSuccess, nil)
return nil
}
// getAddressForNotifier 是一个辅助函数,根据通知器类型从 ContactInfo 中获取对应的地址
func getAddressForNotifier(notifierType notify.NotifierType, contact models.ContactInfo) string {
switch notifierType {
case notify.NotifierTypeSMTP:
return contact.Email
case notify.NotifierTypeWeChat:
return contact.WeChat
case notify.NotifierTypeLark:
return contact.Feishu
case notify.NotifierTypeLog:
return "log" // LogNotifier不需要具体的地址但为了函数签名一致性返回一个无意义的非空字符串以绕过配置存在检查
default:
return ""
}
}
// recordNotificationAttempt 记录一次通知发送尝试的结果
// userID: 接收通知的用户ID
// notifierType: 使用的通知器类型
// content: 通知内容
// toAddress: 实际发送到的地址
// status: 发送尝试的状态 (成功、失败、跳过)
// err: 如果发送失败,记录的错误信息
func (s *failoverService) recordNotificationAttempt(
userID uint,
notifierType notify.NotifierType,
content notify.AlarmContent,
toAddress string,
status models.NotificationStatus,
err error,
) {
errorMessage := ""
if err != nil {
errorMessage = err.Error()
}
notification := &models.Notification{
NotifierType: notifierType,
UserID: userID,
Title: content.Title,
Message: content.Message,
Level: models.LogLevel(content.Level),
AlarmTimestamp: content.Timestamp,
ToAddress: toAddress,
Status: status,
ErrorMessage: errorMessage,
}
if saveErr := s.notificationRepo.Create(notification); saveErr != nil {
s.log.Errorw("无法保存通知发送记录到数据库",
"userID", userID,
"notifierType", notifierType,
"status", status,
"originalError", errorMessage,
"saveError", saveErr,
)
}
}

View File

@@ -0,0 +1,175 @@
package pig
import (
"errors"
"fmt"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
"gorm.io/gorm"
)
// PigPenTransferManager 定义了与猪只位置转移相关的底层数据库操作。
// 它是一个内部服务,被主服务 PigBatchService 调用。
type PigPenTransferManager interface {
// LogTransfer 在数据库中创建一条猪只迁移日志。
LogTransfer(tx *gorm.DB, log *models.PigTransferLog) error
// GetPenByID 用于获取猪栏的详细信息,供上层服务进行业务校验。
GetPenByID(tx *gorm.DB, penID uint) (*models.Pen, error)
// GetPensByBatchID 获取一个猪群当前关联的所有猪栏。
GetPensByBatchID(tx *gorm.DB, batchID uint) ([]*models.Pen, error)
// UpdatePenFields 更新一个猪栏的指定字段。
UpdatePenFields(tx *gorm.DB, penID uint, updates map[string]interface{}) error
// GetCurrentPigsInPen 通过汇总猪只迁移日志,计算给定猪栏中的当前猪只数量。
GetCurrentPigsInPen(tx *gorm.DB, penID uint) (int, error)
// GetTotalPigsInPensForBatchTx 计算指定猪群下所有猪栏的当前总存栏数
GetTotalPigsInPensForBatchTx(tx *gorm.DB, batchID uint) (int, error)
// ReleasePen 将猪栏的猪群归属移除,并将其状态标记为空闲。
ReleasePen(tx *gorm.DB, penID uint) error
}
// pigPenTransferManager 是 PigPenTransferManager 接口的具体实现。
// 它作为调栏管理器,处理底层的数据库交互。
type pigPenTransferManager struct {
penRepo repository.PigPenRepository
logRepo repository.PigTransferLogRepository
pigBatchRepo repository.PigBatchRepository
}
// NewPigPenTransferManager 是 pigPenTransferManager 的构造函数。
func NewPigPenTransferManager(penRepo repository.PigPenRepository, logRepo repository.PigTransferLogRepository, pigBatchRepo repository.PigBatchRepository) PigPenTransferManager {
return &pigPenTransferManager{
penRepo: penRepo,
logRepo: logRepo,
pigBatchRepo: pigBatchRepo,
}
}
// LogTransfer 实现了在数据库中创建迁移日志的逻辑。
func (s *pigPenTransferManager) LogTransfer(tx *gorm.DB, log *models.PigTransferLog) error {
return s.logRepo.CreatePigTransferLog(tx, log)
}
// GetPenByID 实现了获取猪栏信息的逻辑。
func (s *pigPenTransferManager) GetPenByID(tx *gorm.DB, penID uint) (*models.Pen, error) {
return s.penRepo.GetPenByIDTx(tx, penID)
}
// GetPensByBatchID 实现了获取猪群关联猪栏列表的逻辑。
func (s *pigPenTransferManager) GetPensByBatchID(tx *gorm.DB, batchID uint) ([]*models.Pen, error) {
return s.penRepo.GetPensByBatchIDTx(tx, batchID)
}
// UpdatePenFields 实现了更新猪栏字段的逻辑。
func (s *pigPenTransferManager) UpdatePenFields(tx *gorm.DB, penID uint, updates map[string]interface{}) error {
return s.penRepo.UpdatePenFieldsTx(tx, penID, updates)
}
// GetCurrentPigsInPen 实现了计算猪栏当前猪只数量的逻辑。
func (s *pigPenTransferManager) GetCurrentPigsInPen(tx *gorm.DB, penID uint) (int, error) {
// 1. 通过猪栏ID查出所属猪群信息
pen, err := s.penRepo.GetPenByIDTx(tx, penID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, ErrPenNotFound
}
return 0, err
}
// 如果猪栏没有关联任何猪群那么猪只数必为0
if pen.PigBatchID == nil || *pen.PigBatchID == 0 {
return 0, nil
}
currentBatchID := *pen.PigBatchID
// 2. 根据猪群ID获取猪群的起始日期
batch, err := s.pigBatchRepo.GetPigBatchByIDTx(tx, currentBatchID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, ErrPigBatchNotFound
}
return 0, err
}
batchStartDate := batch.StartDate
// 3. 调用仓库方法,获取从猪群开始至今,该猪栏的所有倒序日志
logs, err := s.logRepo.GetLogsForPenSince(tx, penID, batchStartDate)
if err != nil {
return 0, err
}
// 如果没有日志猪只数为0
if len(logs) == 0 {
return 0, nil
}
// 4. 在内存中筛选出最后一段连续日志,并进行计算
var totalPigs int
// 再次确认当前猪群ID以最新的日志为准防止在极小时间窗口内猪栏被快速切换
latestBatchID := *pen.PigBatchID
for _, log := range logs {
// 一旦发现日志不属于最新的猪群,立即停止计算
if log.PigBatchID != latestBatchID {
break
}
totalPigs += log.Quantity
}
return totalPigs, nil
}
// GetTotalPigsInPensForBatchTx 计算指定猪群下所有猪栏的当前总存栏数
// 该方法通过遍历猪群下的每个猪栏,并调用 GetCurrentPigsInPen 来累加存栏数。
func (s *pigPenTransferManager) GetTotalPigsInPensForBatchTx(tx *gorm.DB, batchID uint) (int, error) {
// 1. 获取该批次下所有猪栏的列表
pensInBatch, err := s.GetPensByBatchID(tx, batchID)
if err != nil {
return 0, fmt.Errorf("获取猪群 %d 下属猪栏失败: %w", batchID, err)
}
totalPigs := 0
// 2. 遍历每个猪栏,累加其存栏数
for _, pen := range pensInBatch {
pigsInPen, err := s.GetCurrentPigsInPen(tx, pen.ID)
if err != nil {
return 0, fmt.Errorf("获取猪栏 %d 存栏数失败: %w", pen.ID, err)
}
totalPigs += pigsInPen
}
return totalPigs, nil
}
// ReleasePen 将猪栏的猪群归属移除,并将其状态标记为空闲。
// 此操作通常在猪栏被清空后调用。
func (s *pigPenTransferManager) ReleasePen(tx *gorm.DB, penID uint) error {
// 1. 获取猪栏信息
pen, err := s.penRepo.GetPenByIDTx(tx, penID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("猪栏 %d 不存在: %w", penID, ErrPenNotFound)
}
return fmt.Errorf("获取猪栏 %d 信息失败: %w", penID, err)
}
// 2. 更新猪栏字段
// 将 pig_batch_id 设置为 nil (SQL NULL)
// 将 status 设置为 PenStatusEmpty
updates := map[string]interface{}{
"pig_batch_id": nil, // 使用 nil 来表示 SQL NULL
"status": models.PenStatusEmpty,
}
if err := s.penRepo.UpdatePenFieldsTx(tx, penID, updates); err != nil {
return fmt.Errorf("释放猪栏 %v 失败: %w", pen.PenNumber, err)
}
return nil
}

View File

@@ -0,0 +1,123 @@
package pig
import (
"errors"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
)
// --- 业务错误定义 ---
var (
// ErrPigBatchNotFound 表示当尝试访问一个不存在的猪批次时发生的错误。
ErrPigBatchNotFound = errors.New("指定的猪批次不存在")
// ErrPigBatchActive 表示当尝试对一个活跃的猪批次执行不允许的操作(如删除)时发生的错误。
ErrPigBatchActive = errors.New("活跃的猪批次不能被删除")
// ErrPigBatchNotActive 表示当猪批次不处于活跃状态,但执行了需要其活跃的操作时发生的错误。
ErrPigBatchNotActive = errors.New("猪批次不处于活跃状态,无法修改关联猪栏")
// ErrPenOccupiedByOtherBatch 表示当尝试将一个已经被其他批次占用的猪栏分配给新批次时发生的错误。
ErrPenOccupiedByOtherBatch = errors.New("猪栏已被其他批次使用")
// ErrPenStatusInvalidForAllocation 表示猪栏的当前状态(例如,'维修中')不允许被分配。
ErrPenStatusInvalidForAllocation = errors.New("猪栏状态不允许分配")
// ErrPenNotFound 表示猪栏不存在
ErrPenNotFound = errors.New("指定的猪栏不存在")
// ErrPenNotAssociatedWithBatch 表示猪栏未与该批次关联
ErrPenNotAssociatedWithBatch = errors.New("猪栏未与该批次关联")
// ErrPenNotEmpty 表示猪栏内仍有猪只,不允许执行当前操作。
ErrPenNotEmpty = errors.New("猪栏内仍有猪只,无法执行此操作")
// ErrInvalidOperation 非法操作
ErrInvalidOperation = errors.New("非法操作")
)
// --- 领域服务接口 ---
// PigBatchService 定义了猪批次管理的核心业务逻辑接口。
// 它抽象了所有与猪批次相关的操作,使得应用层可以依赖于此接口,而不是具体的实现。
type PigBatchService interface {
// CreatePigBatch 创建猪批次,并记录初始日志。
CreatePigBatch(operatorID uint, batch *models.PigBatch) (*models.PigBatch, error)
// GetPigBatch 获取单个猪批次。
GetPigBatch(id uint) (*models.PigBatch, error)
// UpdatePigBatch 更新猪批次信息。
UpdatePigBatch(batch *models.PigBatch) (*models.PigBatch, error)
// DeletePigBatch 删除猪批次,包含业务规则校验。
DeletePigBatch(id uint) error
// ListPigBatches 批量查询猪批次。
ListPigBatches(isActive *bool) ([]*models.PigBatch, error)
// AssignEmptyPensToBatch 为猪群分配空栏
AssignEmptyPensToBatch(batchID uint, penIDs []uint, operatorID uint) error
// MovePigsIntoPen 将猪只从“虚拟库存”移入指定猪栏
MovePigsIntoPen(batchID uint, toPenID uint, quantity int, operatorID uint, remarks string) error
// ReclassifyPenToNewBatch 连猪带栏,整体划拨到另一个猪群
ReclassifyPenToNewBatch(fromBatchID uint, toBatchID uint, penID uint, operatorID uint, remarks string) error
// RemoveEmptyPenFromBatch 将一个猪栏移除出猪群,此方法需要在猪栏为空的情况下执行。
RemoveEmptyPenFromBatch(batchID uint, penID uint) error
// GetCurrentPigQuantity 获取指定猪批次的当前猪只数量。
GetCurrentPigQuantity(batchID uint) (int, error)
// GetCurrentPigsInPen 获取指定猪栏的当前存栏量。
GetCurrentPigsInPen(penID uint) (int, error)
// GetTotalPigsInPensForBatch 获取指定猪群下所有猪栏的当前总存栏数
GetTotalPigsInPensForBatch(batchID uint) (int, error)
UpdatePigBatchQuantity(operatorID uint, batchID uint, changeType models.LogChangeType, changeAmount int, changeReason string, happenedAt time.Time) error
// ---交易子服务---
// SellPigs 处理卖猪的业务逻辑。
SellPigs(batchID uint, penID uint, quantity int, unitPrice float64, tatalPrice float64, traderName string, tradeDate time.Time, remarks string, operatorID uint) error
// BuyPigs 处理买猪的业务逻辑。
BuyPigs(batchID uint, penID uint, quantity int, unitPrice float64, tatalPrice float64, traderName string, tradeDate time.Time, remarks string, operatorID uint) error
// ---调栏子服务 ---
TransferPigsAcrossBatches(sourceBatchID uint, destBatchID uint, fromPenID uint, toPenID uint, quantity uint, operatorID uint, remarks string) error
TransferPigsWithinBatch(batchID uint, fromPenID uint, toPenID uint, quantity uint, operatorID uint, remarks string) error
// --- 病猪管理相关方法 ---
// RecordSickPigs 记录新增病猪事件。
RecordSickPigs(operatorID uint, batchID uint, penID uint, quantity int, treatmentLocation models.PigBatchSickPigTreatmentLocation, happenedAt time.Time, remarks string) error
// RecordSickPigRecovery 记录病猪康复事件。
RecordSickPigRecovery(operatorID uint, batchID uint, penID uint, quantity int, treatmentLocation models.PigBatchSickPigTreatmentLocation, happenedAt time.Time, remarks string) error
// RecordSickPigDeath 记录病猪死亡事件。
RecordSickPigDeath(operatorID uint, batchID uint, penID uint, quantity int, treatmentLocation models.PigBatchSickPigTreatmentLocation, happenedAt time.Time, remarks string) error
// RecordSickPigCull 记录病猪淘汰事件。
RecordSickPigCull(operatorID uint, batchID uint, penID uint, quantity int, treatmentLocation models.PigBatchSickPigTreatmentLocation, happenedAt time.Time, remarks string) error
// --- 正常猪只管理相关方法 ---
// RecordDeath 记录正常猪只死亡事件。
RecordDeath(operatorID uint, batchID uint, penID uint, quantity int, happenedAt time.Time, remarks string) error
// RecordCull 记录正常猪只淘汰事件。
RecordCull(operatorID uint, batchID uint, penID uint, quantity int, happenedAt time.Time, remarks string) error
}
// pigBatchService 是 PigBatchService 接口的具体实现。
// 它作为猪群领域的主服务,封装了所有业务逻辑。
type pigBatchService struct {
pigBatchRepo repository.PigBatchRepository // 猪批次仓库
pigBatchLogRepo repository.PigBatchLogRepository // 猪批次日志仓库
uow repository.UnitOfWork // 工作单元,用于管理事务
transferSvc PigPenTransferManager // 调栏子服务
tradeSvc PigTradeManager // 交易子服务
sickSvc SickPigManager // 病猪子服务
}
// NewPigBatchService 是 pigBatchService 的构造函数。
// 它通过依赖注入的方式,创建并返回一个 PigBatchService 接口的实例。
func NewPigBatchService(
pigBatchRepo repository.PigBatchRepository,
pigBatchLogRepo repository.PigBatchLogRepository,
uow repository.UnitOfWork,
transferSvc PigPenTransferManager,
tradeSvc PigTradeManager,
sickSvc SickPigManager,
) PigBatchService {
return &pigBatchService{
pigBatchRepo: pigBatchRepo,
pigBatchLogRepo: pigBatchLogRepo,
uow: uow,
transferSvc: transferSvc,
tradeSvc: tradeSvc,
sickSvc: sickSvc,
}
}

View File

@@ -0,0 +1,196 @@
package pig
import (
"errors"
"fmt"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"gorm.io/gorm"
)
// --- 领域服务实现 ---
// CreatePigBatch 实现了创建猪批次的逻辑,并同时创建初始批次日志。
func (s *pigBatchService) CreatePigBatch(operatorID uint, batch *models.PigBatch) (*models.PigBatch, error) {
// 业务规则可以在这里添加,例如检查批次号是否唯一等
var createdBatch *models.PigBatch
err := s.uow.ExecuteInTransaction(func(tx *gorm.DB) error {
// 1. 创建猪批次
// 注意: 此处依赖一个假设存在的 pigBatchRepo.CreatePigBatchTx 方法
var err error
createdBatch, err = s.pigBatchRepo.CreatePigBatchTx(tx, batch)
if err != nil {
return fmt.Errorf("创建猪批次失败: %w", err)
}
// 2. 创建初始批次日志
initialLog := &models.PigBatchLog{
PigBatchID: createdBatch.ID,
HappenedAt: time.Now(),
ChangeType: models.ChangeTypeCorrection, // 初始创建可视为一种校正
ChangeCount: createdBatch.InitialCount,
Reason: fmt.Sprintf("创建了新的猪批次 %s初始数量 %d", createdBatch.BatchNumber, createdBatch.InitialCount),
BeforeCount: 0, // 初始创建前数量为0
AfterCount: createdBatch.InitialCount,
OperatorID: operatorID,
}
// 3. 记录批次日志
if err := s.pigBatchLogRepo.CreateTx(tx, initialLog); err != nil {
return fmt.Errorf("记录初始批次日志失败: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return createdBatch, nil
}
// GetPigBatch 实现了获取单个猪批次的逻辑。
func (s *pigBatchService) GetPigBatch(id uint) (*models.PigBatch, error) {
batch, err := s.pigBatchRepo.GetPigBatchByID(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrPigBatchNotFound
}
return nil, err
}
return batch, nil
}
// UpdatePigBatch 实现了更新猪批次的逻辑。
func (s *pigBatchService) UpdatePigBatch(batch *models.PigBatch) (*models.PigBatch, error) {
// 可以在这里添加更新前的业务校验
updatedBatch, rowsAffected, err := s.pigBatchRepo.UpdatePigBatch(batch)
if err != nil {
return nil, err
}
if rowsAffected == 0 {
return nil, ErrPigBatchNotFound // 如果没有行被更新,可能意味着记录不存在
}
return updatedBatch, nil
}
// DeletePigBatch 实现了删除猪批次的逻辑,并包含业务规则校验。
func (s *pigBatchService) DeletePigBatch(id uint) error {
return s.uow.ExecuteInTransaction(func(tx *gorm.DB) error {
// 1. 获取猪批次信息
batch, err := s.pigBatchRepo.GetPigBatchByIDTx(tx, id) // 使用事务内方法
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPigBatchNotFound
}
return err
}
// 2. 核心业务规则:检查猪批次是否为活跃状态
if batch.IsActive() {
return ErrPigBatchActive // 如果活跃,则不允许删除
}
// 3. 释放所有关联的猪栏
// 获取该批次下所有猪栏
pensInBatch, err := s.transferSvc.GetPensByBatchID(tx, id)
if err != nil {
return fmt.Errorf("获取猪批次 %d 关联猪栏失败: %w", id, err)
}
// 逐一释放猪栏
for _, pen := range pensInBatch {
if err := s.transferSvc.ReleasePen(tx, pen.ID); err != nil {
return fmt.Errorf("释放猪栏 %d 失败: %w", pen.ID, err)
}
}
// 4. 执行删除猪批次
rowsAffected, err := s.pigBatchRepo.DeletePigBatchTx(tx, id)
if err != nil {
return err
}
if rowsAffected == 0 {
return ErrPigBatchNotFound
}
return nil
})
}
// ListPigBatches 实现了批量查询猪批次的逻辑。
func (s *pigBatchService) ListPigBatches(isActive *bool) ([]*models.PigBatch, error) {
return s.pigBatchRepo.ListPigBatches(isActive)
}
// GetCurrentPigQuantity 实现了获取指定猪批次的当前猪只数量的逻辑。
func (s *pigBatchService) GetCurrentPigQuantity(batchID uint) (int, error) {
var getErr error
var quantity int
err := s.uow.ExecuteInTransaction(func(tx *gorm.DB) error {
quantity, getErr = s.getCurrentPigQuantityTx(tx, batchID)
return getErr
})
if err != nil {
return 0, err
}
return quantity, nil
}
// getCurrentPigQuantityTx 实现了获取指定猪批次的当前猪只数量的逻辑。
func (s *pigBatchService) getCurrentPigQuantityTx(tx *gorm.DB, batchID uint) (int, error) {
// 1. 获取猪批次初始信息
batch, err := s.pigBatchRepo.GetPigBatchByIDTx(tx, batchID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, ErrPigBatchNotFound
}
return 0, fmt.Errorf("获取猪批次 %d 初始信息失败: %w", batchID, err)
}
// 2. 尝试获取该批次的最后一条日志记录
lastLog, err := s.pigBatchLogRepo.GetLastLogByBatchIDTx(tx, batchID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// 如果没有找到任何日志记录(除了初始创建),则当前数量就是初始数量
return batch.InitialCount, nil
}
return 0, fmt.Errorf("获取猪批次 %d 最后一条日志失败: %w", batchID, err)
}
// 3. 如果找到最后一条日志,则当前数量为该日志的 AfterCount
return lastLog.AfterCount, nil
}
func (s *pigBatchService) UpdatePigBatchQuantity(operatorID uint, batchID uint, changeType models.LogChangeType, changeAmount int, changeReason string, happenedAt time.Time) error {
return s.uow.ExecuteInTransaction(func(tx *gorm.DB) error {
return s.updatePigBatchQuantityTx(tx, operatorID, batchID, changeType, changeAmount, changeReason, happenedAt)
})
}
func (s *pigBatchService) updatePigBatchQuantityTx(tx *gorm.DB, operatorID uint, batchID uint, changeType models.LogChangeType, changeAmount int, changeReason string, happenedAt time.Time) error {
lastLog, err := s.pigBatchLogRepo.GetLastLogByBatchIDTx(tx, batchID)
if err != nil {
return err
}
// 检查数量不应该减到小于零
if changeAmount < 0 {
if lastLog.AfterCount+changeAmount < 0 {
return ErrInvalidOperation
}
}
pigBatchLog := &models.PigBatchLog{
PigBatchID: batchID,
ChangeType: changeType,
ChangeCount: changeAmount,
Reason: changeReason,
BeforeCount: lastLog.AfterCount,
AfterCount: lastLog.AfterCount + changeAmount,
OperatorID: operatorID,
HappenedAt: happenedAt,
}
return s.pigBatchLogRepo.CreateTx(tx, pigBatchLog)
}

View File

@@ -0,0 +1,455 @@
package pig
import (
"errors"
"fmt"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
// executeTransferAndLog 是一个私有辅助方法,用于封装创建和记录迁移日志的通用逻辑。
func (s *pigBatchService) executeTransferAndLog(tx *gorm.DB, fromBatchID, toBatchID, fromPenID, toPenID uint, quantity int, transferType models.PigTransferType, operatorID uint, remarks string) error {
// 通用校验:任何调出操作都不能超过源猪栏的当前存栏数
if quantity < 0 { // 当调出时才需要检查
currentPigsInFromPen, err := s.transferSvc.GetCurrentPigsInPen(tx, fromPenID)
if err != nil {
return fmt.Errorf("获取源猪栏 %d 当前猪只数失败: %w", fromPenID, err)
}
if currentPigsInFromPen+quantity < 0 {
return fmt.Errorf("调出数量 %d 超过源猪栏 %d 当前存栏数 %d", -quantity, fromPenID, currentPigsInFromPen)
}
}
// 1. 生成关联ID
correlationID := uuid.New().String()
// 2. 创建调出日志
logOut := &models.PigTransferLog{
TransferTime: time.Now(),
PigBatchID: fromBatchID,
PenID: fromPenID,
Quantity: -quantity, // 调出为负数
Type: transferType,
CorrelationID: correlationID,
OperatorID: operatorID,
Remarks: remarks,
}
// 3. 创建调入日志
logIn := &models.PigTransferLog{
TransferTime: time.Now(),
PigBatchID: toBatchID,
PenID: toPenID,
Quantity: quantity, // 调入为正数
Type: transferType,
CorrelationID: correlationID,
OperatorID: operatorID,
Remarks: remarks,
}
// 4. 调用子服务记录日志
if err := s.transferSvc.LogTransfer(tx, logOut); err != nil {
return fmt.Errorf("记录调出日志失败: %w", err)
}
if err := s.transferSvc.LogTransfer(tx, logIn); err != nil {
return fmt.Errorf("记录调入日志失败: %w", err)
}
return nil
}
// TransferPigsWithinBatch 实现了同一个猪群内部的调栏业务。
func (s *pigBatchService) TransferPigsWithinBatch(batchID uint, fromPenID uint, toPenID uint, quantity uint, operatorID uint, remarks string) error {
if fromPenID == toPenID {
return errors.New("源猪栏和目标猪栏不能相同")
}
if quantity == 0 {
return errors.New("迁移数量不能为零")
}
return s.uow.ExecuteInTransaction(func(tx *gorm.DB) error {
// 1. 核心业务规则校验
fromPen, err := s.transferSvc.GetPenByID(tx, fromPenID)
if err != nil {
return fmt.Errorf("获取源猪栏信息失败: %w", err)
}
toPen, err := s.transferSvc.GetPenByID(tx, toPenID)
if err != nil {
return fmt.Errorf("获取目标猪栏信息失败: %w", err)
}
if fromPen.PigBatchID == nil || *fromPen.PigBatchID != batchID {
return fmt.Errorf("源猪栏 %d 不属于指定的猪群 %d", fromPenID, batchID)
}
if toPen.PigBatchID != nil && *toPen.PigBatchID != batchID {
return fmt.Errorf("目标猪栏 %d 已被其他猪群占用", toPenID)
}
// 2. 调用通用辅助方法执行日志记录
err = s.executeTransferAndLog(tx, batchID, batchID, fromPenID, toPenID, int(quantity), "群内调栏", operatorID, remarks)
if err != nil {
return err
}
// 3. 群内调栏,猪群总数不变
return nil
})
}
// TransferPigsAcrossBatches 实现了跨猪群的调栏业务。
func (s *pigBatchService) TransferPigsAcrossBatches(sourceBatchID uint, destBatchID uint, fromPenID uint, toPenID uint, quantity uint, operatorID uint, remarks string) error {
if sourceBatchID == destBatchID {
return errors.New("源猪群和目标猪群不能相同")
}
if quantity == 0 {
return errors.New("迁移数量不能为零")
}
return s.uow.ExecuteInTransaction(func(tx *gorm.DB) error {
// 1. 核心业务规则校验
// 1.1 校验猪群存在
if _, err := s.pigBatchRepo.GetPigBatchByIDTx(tx, sourceBatchID); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("源猪群 %d 不存在", sourceBatchID)
}
return fmt.Errorf("获取源猪群信息失败: %w", err)
}
if _, err := s.pigBatchRepo.GetPigBatchByIDTx(tx, destBatchID); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("目标猪群 %d 不存在", destBatchID)
}
return fmt.Errorf("获取目标猪群信息失败: %w", err)
}
// 1.2 校验猪栏归属
fromPen, err := s.transferSvc.GetPenByID(tx, fromPenID)
if err != nil {
return fmt.Errorf("获取源猪栏信息失败: %w", err)
}
if fromPen.PigBatchID == nil || *fromPen.PigBatchID != sourceBatchID {
return fmt.Errorf("源猪栏 %d 不属于源猪群 %d", fromPenID, sourceBatchID)
}
// 2. 调用通用辅助方法执行猪只物理转移的日志记录
err = s.executeTransferAndLog(tx, sourceBatchID, destBatchID, fromPenID, toPenID, int(quantity), "跨群调栏", operatorID, remarks)
if err != nil {
return err
}
// 3. 通过创建批次日志来修改猪群总数,确保数据可追溯
now := time.Now()
// 3.1 记录源猪群数量减少
reasonOut := fmt.Sprintf("跨群调栏: %d头猪从批次 %d 调出至批次 %d。备注: %s", quantity, sourceBatchID, destBatchID, remarks)
err = s.updatePigBatchQuantityTx(tx, operatorID, sourceBatchID, models.ChangeTypeTransferOut, -int(quantity), reasonOut, now)
if err != nil {
return fmt.Errorf("更新源猪群 %d 数量失败: %w", sourceBatchID, err)
}
// 3.2 记录目标猪群数量增加
reasonIn := fmt.Sprintf("跨群调栏: %d头猪从批次 %d 调入。备注: %s", quantity, sourceBatchID, remarks)
err = s.updatePigBatchQuantityTx(tx, operatorID, destBatchID, models.ChangeTypeTransferIn, int(quantity), reasonIn, now)
if err != nil {
return fmt.Errorf("更新目标猪群 %d 数量失败: %w", destBatchID, err)
}
return nil
})
}
// AssignEmptyPensToBatch 为猪群分配空栏
func (s *pigBatchService) AssignEmptyPensToBatch(batchID uint, penIDs []uint, operatorID uint) error {
return s.uow.ExecuteInTransaction(func(tx *gorm.DB) error {
// 1. 验证猪批次是否存在且活跃
pigBatch, err := s.pigBatchRepo.GetPigBatchByIDTx(tx, batchID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPigBatchNotFound
}
return fmt.Errorf("获取猪批次信息失败: %w", err)
}
if !pigBatch.IsActive() {
return ErrPigBatchNotActive
}
// 2. 遍历并校验每一个待分配的猪栏
for _, penID := range penIDs {
pen, err := s.transferSvc.GetPenByID(tx, penID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("猪栏 %d 不存在: %w", penID, ErrPenNotFound)
}
return fmt.Errorf("获取猪栏 %d 信息失败: %w", penID, err)
}
// 核心业务规则:校验猪栏是否完全空闲
if pen.Status != models.PenStatusEmpty {
return fmt.Errorf("猪栏 %s 状态不为空 (%s),无法分配", pen.PenNumber, pen.Status)
}
if pen.PigBatchID != nil {
return fmt.Errorf("猪栏 %s 已被其他批次 %d 占用,无法分配", pen.PenNumber, *pen.PigBatchID)
}
// 3. 更新猪栏的归属
updates := map[string]interface{}{
"pig_batch_id": &batchID,
"status": models.PenStatusOccupied,
}
if err := s.transferSvc.UpdatePenFields(tx, penID, updates); err != nil {
return fmt.Errorf("分配猪栏 %d 失败: %w", penID, err)
}
}
return nil
})
}
// MovePigsIntoPen 将猪只从“虚拟库存”移入指定猪栏
func (s *pigBatchService) MovePigsIntoPen(batchID uint, toPenID uint, quantity int, operatorID uint, remarks string) error {
if quantity <= 0 {
return errors.New("迁移数量必须大于零")
}
return s.uow.ExecuteInTransaction(func(tx *gorm.DB) error {
// 1. 验证猪批次是否存在且活跃
pigBatch, err := s.pigBatchRepo.GetPigBatchByIDTx(tx, batchID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPigBatchNotFound
}
return fmt.Errorf("获取猪批次信息失败: %w", err)
}
if !pigBatch.IsActive() {
return ErrPigBatchNotActive
}
// 2. 校验目标猪栏
toPen, err := s.transferSvc.GetPenByID(tx, toPenID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("目标猪栏 %d 不存在: %w", toPenID, ErrPenNotFound)
}
return fmt.Errorf("获取目标猪栏 %d 信息失败: %w", toPenID, err)
}
// 校验目标猪栏的归属和状态
if toPen.PigBatchID == nil {
return fmt.Errorf("目标猪栏 %s 不属于当前批次 %s", toPen.PenNumber, batchID)
}
if toPen.PigBatchID != nil && *toPen.PigBatchID != batchID {
return fmt.Errorf("目标猪栏 %s 已被其他批次 %d 占用,无法移入", toPen.PenNumber, *toPen.PigBatchID)
}
// 3. 校验猪群中有足够的“未分配”猪只
currentBatchTotal, err := s.getCurrentPigQuantityTx(tx, batchID)
if err != nil {
return fmt.Errorf("获取猪群 %d 当前总数量失败: %w", batchID, err)
}
// 获取该批次下所有猪栏的当前总存栏数
totalPigsInPens, err := s.transferSvc.GetTotalPigsInPensForBatchTx(tx, batchID)
if err != nil {
return fmt.Errorf("计算猪群 %d 下属猪栏总存栏失败: %w", batchID, err)
}
unassignedPigs := currentBatchTotal - totalPigsInPens
if unassignedPigs < quantity {
return fmt.Errorf("猪群 %d 未分配猪只不足,当前未分配 %d 头,需要移入 %d 头", batchID, unassignedPigs, quantity)
}
// 4. 记录转移日志
logIn := &models.PigTransferLog{
TransferTime: time.Now(),
PigBatchID: batchID,
PenID: toPenID,
Quantity: quantity, // 调入为正数
Type: models.PigTransferTypeInternal, // 首次入栏
OperatorID: operatorID,
Remarks: remarks,
}
if err := s.transferSvc.LogTransfer(tx, logIn); err != nil {
return fmt.Errorf("记录入栏日志失败: %w", err)
}
return nil
})
}
// ReclassifyPenToNewBatch 连猪带栏,整体划拨到另一个猪群
func (s *pigBatchService) ReclassifyPenToNewBatch(fromBatchID uint, toBatchID uint, penID uint, operatorID uint, remarks string) error {
if fromBatchID == toBatchID {
return errors.New("源猪群和目标猪群不能相同")
}
return s.uow.ExecuteInTransaction(func(tx *gorm.DB) error {
// 1. 核心业务规则校验
// 1.1 校验猪群存在
fromBatch, err := s.pigBatchRepo.GetPigBatchByIDTx(tx, fromBatchID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("源猪群 %d 不存在", fromBatchID)
}
return fmt.Errorf("获取源猪群信息失败: %w", err)
}
toBatch, err := s.pigBatchRepo.GetPigBatchByIDTx(tx, toBatchID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("目标猪群 %d 不存在", toBatchID)
}
return fmt.Errorf("获取目标猪群信息失败: %w", err)
}
// 1.2 校验猪栏归属
pen, err := s.transferSvc.GetPenByID(tx, penID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("猪栏 %d 不存在: %w", penID, ErrPenNotFound)
}
return fmt.Errorf("获取猪栏 %d 信息失败: %w", penID, err)
}
if pen.PigBatchID == nil || *pen.PigBatchID != fromBatchID {
return fmt.Errorf("猪栏 %v 不属于源猪群 %v无法划拨", pen.PenNumber, fromBatch.BatchNumber)
}
// 2. 获取猪栏当前存栏数
quantity, err := s.transferSvc.GetCurrentPigsInPen(tx, penID)
if err != nil {
return fmt.Errorf("获取猪栏 %v 存栏数失败: %w", pen.PenNumber, err)
}
// 3. 更新猪栏的归属
updates := map[string]interface{}{
"pig_batch_id": &toBatchID,
}
if err := s.transferSvc.UpdatePenFields(tx, penID, updates); err != nil {
return fmt.Errorf("更新猪栏 %v 归属失败: %w", pen.PenNumber, err)
}
// 如果猪栏是空的,则只进行归属变更,不影响猪群数量
if quantity == 0 {
return nil // 空栏划拨,不涉及猪只数量变更
}
// 4. 记录猪只从旧批次“迁出”的猪栏日志
correlationID := uuid.New().String()
logOut := &models.PigTransferLog{
TransferTime: time.Now(),
PigBatchID: fromBatchID,
PenID: penID,
Quantity: -quantity, // 迁出为负数
Type: models.PigTransferTypeCrossBatch,
CorrelationID: correlationID,
OperatorID: operatorID,
Remarks: fmt.Sprintf("整栏划拨迁出: %d头猪从批次 %v 随猪栏 %v 划拨至批次 %v。备注: %s", quantity, fromBatch.BatchNumber, pen.PenNumber, toBatch.BatchNumber, remarks),
}
if err := s.transferSvc.LogTransfer(tx, logOut); err != nil {
return fmt.Errorf("记录猪栏 %d 迁出日志失败: %w", penID, err)
}
// 5. 记录猪只到新批次“迁入”的猪栏日志
logIn := &models.PigTransferLog{
TransferTime: time.Now(),
PigBatchID: toBatchID,
PenID: penID,
Quantity: quantity, // 迁入为正数
Type: models.PigTransferTypeCrossBatch,
CorrelationID: correlationID,
OperatorID: operatorID,
Remarks: fmt.Sprintf("整栏划拨迁入: %v头猪随猪栏 %v 从批次 %v 划拨入。备注: %s", quantity, fromBatch.BatchNumber, pen.PenNumber, remarks),
}
if err := s.transferSvc.LogTransfer(tx, logIn); err != nil {
return fmt.Errorf("记录猪栏 %d 迁入日志失败: %w", penID, err)
}
// 7. 通过创建批次日志来修改猪群总数,确保数据可追溯
now := time.Now()
// 7.1 记录源猪群数量减少
reasonOutBatch := fmt.Sprintf("整栏划拨: %d头猪随猪栏 %v 从批次 %v 划拨至批次 %v。备注: %s", quantity, pen.PenNumber, fromBatch.BatchNumber, toBatchID, remarks)
err = s.updatePigBatchQuantityTx(tx, operatorID, fromBatchID, models.ChangeTypeTransferOut, -quantity, reasonOutBatch, now)
if err != nil {
return fmt.Errorf("更新源猪群 %v 数量失败: %w", fromBatch.BatchNumber, err)
}
// 7.2 记录目标猪群数量增加
reasonInBatch := fmt.Sprintf("整栏划拨: %v头猪随猪栏 %v 从批次 %v 划拨入。备注: %s", quantity, pen.PenNumber, fromBatch.BatchNumber, remarks)
err = s.updatePigBatchQuantityTx(tx, operatorID, toBatchID, models.ChangeTypeTransferIn, quantity, reasonInBatch, now)
if err != nil {
return fmt.Errorf("更新目标猪群 %v 数量失败: %w", toBatch.BatchNumber, err)
}
return nil
})
}
func (s *pigBatchService) RemoveEmptyPenFromBatch(batchID uint, penID uint) error {
return s.uow.ExecuteInTransaction(func(tx *gorm.DB) error {
// 1. 检查猪批次是否存在且活跃
batch, err := s.pigBatchRepo.GetPigBatchByIDTx(tx, batchID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPigBatchNotFound
}
return err
}
if !batch.IsActive() {
return ErrPigBatchNotActive
}
// 2. 检查猪栏是否存在
pen, err := s.transferSvc.GetPenByID(tx, penID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPenNotFound
}
return err
}
// 3. 检查猪栏是否与当前批次关联
if pen.PigBatchID == nil || *pen.PigBatchID != batchID {
return ErrPenNotAssociatedWithBatch
}
// 4. 检查猪栏是否为空
pigsInPen, err := s.transferSvc.GetCurrentPigsInPen(tx, penID)
if err != nil {
return err
}
if pigsInPen > 0 {
return ErrPenNotEmpty
}
// 5. 释放猪栏 (将 pig_batch_id 设置为 nil状态设置为空闲)
if err := s.transferSvc.ReleasePen(tx, penID); err != nil {
return err
}
return nil
})
}
func (s *pigBatchService) GetCurrentPigsInPen(penID uint) (int, error) {
var currentPigs int
err := s.uow.ExecuteInTransaction(func(tx *gorm.DB) error {
pigs, err := s.transferSvc.GetCurrentPigsInPen(tx, penID)
if err != nil {
return err
}
currentPigs = pigs
return nil
})
return currentPigs, err
}
// GetTotalPigsInPensForBatch 实现了获取指定猪群下所有猪栏的当前总存栏数的逻辑。
func (s *pigBatchService) GetTotalPigsInPensForBatch(batchID uint) (int, error) {
var totalPigs int
err := s.uow.ExecuteInTransaction(func(tx *gorm.DB) error {
pigs, err := s.transferSvc.GetTotalPigsInPensForBatchTx(tx, batchID)
if err != nil {
return err
}
totalPigs = pigs
return nil
})
return totalPigs, err
}

View File

@@ -0,0 +1,483 @@
package pig
import (
"errors"
"fmt"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"gorm.io/gorm"
)
// RecordSickPigs 记录新增病猪事件。
func (s *pigBatchService) RecordSickPigs(operatorID uint, batchID uint, penID uint, quantity int, treatmentLocation models.PigBatchSickPigTreatmentLocation, happenedAt time.Time, remarks string) error {
if quantity <= 0 {
return errors.New("新增病猪数量必须大于0")
}
var err error
// 1. 开启事务
err = s.uow.ExecuteInTransaction(func(tx *gorm.DB) error {
// 1.1 检查批次是否活跃
batch, err := s.pigBatchRepo.GetPigBatchByIDTx(tx, batchID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPigBatchNotFound
}
return fmt.Errorf("获取批次 %d 失败: %w", batchID, err)
}
if !batch.IsActive() {
return fmt.Errorf("批次 %d 不活跃,无法记录病猪事件", batchID)
}
// 1.2 检查猪栏是否关联
pen, err := s.transferSvc.GetPenByID(tx, penID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPenNotFound
}
return fmt.Errorf("获取猪栏 %d 失败: %w", penID, err)
}
if pen.PigBatchID == nil || *pen.PigBatchID != batchID {
return fmt.Errorf("猪栏 %d 未与批次 %d 关联", penID, batchID)
}
// 1.3 检查剩余健康猪不能少于即将转化的病猪数量
totalPigsInBatch, err := s.getCurrentPigQuantityTx(tx, batchID)
if err != nil {
return fmt.Errorf("获取批次 %d 总猪只数量失败: %w", batchID, err)
}
currentSickPigs, err := s.sickSvc.GetCurrentSickPigCount(tx, batchID)
if err != nil {
return fmt.Errorf("获取批次 %d 当前病猪数量失败: %w", batchID, err)
}
healthyPigs := totalPigsInBatch - currentSickPigs
if healthyPigs < quantity {
return fmt.Errorf("健康猪数量不足,当前健康猪 %d 头,尝试记录病猪 %d 头", healthyPigs, quantity)
}
// 1.4 创建病猪日志
sickLog := &models.PigSickLog{
PigBatchID: batchID,
PenID: penID,
ChangeCount: quantity, // 新增病猪ChangeCount 为正数
Reason: models.SickPigReasonTypeIllness,
TreatmentLocation: treatmentLocation,
Remarks: remarks,
OperatorID: operatorID,
HappenedAt: happenedAt,
}
if err := s.sickSvc.ProcessSickPigLog(tx, sickLog); err != nil {
return fmt.Errorf("处理病猪日志失败: %w", err)
}
return nil
})
if err != nil {
return fmt.Errorf("记录新增病猪事件失败: %w", err)
}
return nil
}
// RecordSickPigRecovery 记录病猪康复事件。
func (s *pigBatchService) RecordSickPigRecovery(operatorID uint, batchID uint, penID uint, quantity int, treatmentLocation models.PigBatchSickPigTreatmentLocation, happenedAt time.Time, remarks string) error {
if quantity <= 0 {
return errors.New("康复猪只数量必须大于0")
}
var err error
err = s.uow.ExecuteInTransaction(func(tx *gorm.DB) error {
// 1. 检查批次是否活跃
batch, err := s.pigBatchRepo.GetPigBatchByIDTx(tx, batchID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPigBatchNotFound
}
return fmt.Errorf("获取批次 %d 失败: %w", batchID, err)
}
if !batch.IsActive() {
return fmt.Errorf("批次 %d 不活跃,无法记录病猪康复事件", batchID)
}
// 2. 检查猪栏是否关联
pen, err := s.transferSvc.GetPenByID(tx, penID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPenNotFound
}
return fmt.Errorf("获取猪栏 %d 失败: %w", penID, err)
}
if pen.PigBatchID == nil || *pen.PigBatchID != batchID {
return fmt.Errorf("猪栏 %d 未与批次 %d 关联", penID, batchID)
}
// 3. 检查当前病猪数量是否足够康复
currentSickPigs, err := s.sickSvc.GetCurrentSickPigCount(tx, batchID)
if err != nil {
return fmt.Errorf("获取批次 %d 当前病猪数量失败: %w", batchID, err)
}
if currentSickPigs < quantity {
return fmt.Errorf("当前病猪数量不足,当前病猪 %d 头,尝试康复 %d 头", currentSickPigs, quantity)
}
// 4. 创建病猪日志
sickLog := &models.PigSickLog{
PigBatchID: batchID,
PenID: penID,
ChangeCount: -quantity, // 康复病猪ChangeCount 为负数
Reason: models.SickPigReasonTypeRecovery,
TreatmentLocation: treatmentLocation,
Remarks: remarks,
OperatorID: operatorID,
HappenedAt: happenedAt,
}
if err := s.sickSvc.ProcessSickPigLog(tx, sickLog); err != nil {
return fmt.Errorf("处理病猪康复日志失败: %w", err)
}
return nil
})
if err != nil {
return fmt.Errorf("记录病猪康复事件失败: %w", err)
}
return nil
}
// RecordSickPigDeath 记录病猪死亡事件。
func (s *pigBatchService) RecordSickPigDeath(operatorID uint, batchID uint, penID uint, quantity int, treatmentLocation models.PigBatchSickPigTreatmentLocation, happenedAt time.Time, remarks string) error {
if quantity <= 0 {
return errors.New("死亡猪只数量必须大于0")
}
var err error
err = s.uow.ExecuteInTransaction(func(tx *gorm.DB) error {
// 1. 检查批次是否活跃
batch, err := s.pigBatchRepo.GetPigBatchByIDTx(tx, batchID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPigBatchNotFound
}
return fmt.Errorf("获取批次 %d 失败: %w", batchID, err)
}
if !batch.IsActive() {
return fmt.Errorf("批次 %d 不活跃,无法记录病猪死亡事件", batchID)
}
// 2. 检查猪栏是否关联
pen, err := s.transferSvc.GetPenByID(tx, penID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPenNotFound
}
return fmt.Errorf("获取猪栏 %d 失败: %w", penID, err)
}
if pen.PigBatchID == nil || *pen.PigBatchID != batchID {
return fmt.Errorf("猪栏 %d 未与批次 %d 关联", penID, batchID)
}
// 3. 检查当前病猪数量是否足够死亡
currentSickPigs, err := s.sickSvc.GetCurrentSickPigCount(tx, batchID)
if err != nil {
return fmt.Errorf("获取批次 %d 当前病猪数量失败: %w", batchID, err)
}
if currentSickPigs < quantity {
return fmt.Errorf("当前病猪数量不足,当前病猪 %d 头,尝试记录死亡 %d 头", currentSickPigs, quantity)
}
// 4. 检查猪栏内猪只数量是否足够死亡
currentPigsInPen, err := s.transferSvc.GetCurrentPigsInPen(tx, penID)
if err != nil {
return fmt.Errorf("获取猪栏 %d 当前猪只数量失败: %w", penID, err)
}
if currentPigsInPen < quantity {
return fmt.Errorf("猪栏 %d 内猪只数量不足,当前 %d 头,尝试记录死亡 %d 头", penID, currentPigsInPen, quantity)
}
// 5. 创建病猪日志 (减少病猪数量)
sickLog := &models.PigSickLog{
PigBatchID: batchID,
PenID: penID,
ChangeCount: -quantity, // 死亡病猪ChangeCount 为负数
Reason: models.SickPigReasonTypeDeath,
TreatmentLocation: treatmentLocation,
Remarks: remarks,
OperatorID: operatorID,
HappenedAt: happenedAt,
}
if err := s.sickSvc.ProcessSickPigLog(tx, sickLog); err != nil {
return fmt.Errorf("处理病猪死亡日志失败: %w", err)
}
// 6. 更新批次总猪只数量 (减少批次总数)
if err := s.UpdatePigBatchQuantity(operatorID, batchID, models.ChangeTypeDeath, -quantity, remarks, happenedAt); err != nil {
return fmt.Errorf("更新批次 %d 总猪只数量失败: %w", batchID, err)
}
// 7. 记录猪只转移日志 (减少猪栏内猪只数量)
transferLog := &models.PigTransferLog{
TransferTime: happenedAt,
PigBatchID: batchID,
PenID: penID,
Quantity: -quantity, // 减少猪只数量
Type: models.PigTransferTypeDeath,
OperatorID: operatorID,
Remarks: remarks,
}
if err := s.transferSvc.LogTransfer(tx, transferLog); err != nil {
return fmt.Errorf("记录猪只死亡转移日志失败: %w", err)
}
return nil
})
if err != nil {
return fmt.Errorf("记录病猪死亡事件失败: %w", err)
}
return nil
}
// RecordSickPigCull 记录病猪淘汰事件。
func (s *pigBatchService) RecordSickPigCull(operatorID uint, batchID uint, penID uint, quantity int, treatmentLocation models.PigBatchSickPigTreatmentLocation, happenedAt time.Time, remarks string) error {
if quantity <= 0 {
return errors.New("淘汰猪只数量必须大于0")
}
var err error
err = s.uow.ExecuteInTransaction(func(tx *gorm.DB) error {
// 1. 检查批次是否活跃
batch, err := s.pigBatchRepo.GetPigBatchByIDTx(tx, batchID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPigBatchNotFound
}
return fmt.Errorf("获取批次 %d 失败: %w", batchID, err)
}
if !batch.IsActive() {
return fmt.Errorf("批次 %d 不活跃,无法记录病猪淘汰事件", batchID)
}
// 2. 检查猪栏是否关联
pen, err := s.transferSvc.GetPenByID(tx, penID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPenNotFound
}
return fmt.Errorf("获取猪栏 %d 失败: %w", penID, err)
}
if pen.PigBatchID == nil || *pen.PigBatchID != batchID {
return fmt.Errorf("猪栏 %d 未与批次 %d 关联", penID, batchID)
}
// 3. 检查当前病猪数量是否足够淘汰
currentSickPigs, err := s.sickSvc.GetCurrentSickPigCount(tx, batchID)
if err != nil {
return fmt.Errorf("获取批次 %d 当前病猪数量失败: %w", batchID, err)
}
if currentSickPigs < quantity {
return fmt.Errorf("当前病猪数量不足,当前病猪 %d 头,尝试淘汰 %d 头", currentSickPigs, quantity)
}
// 4. 检查猪栏内猪只数量是否足够淘汰
currentPigsInPen, err := s.transferSvc.GetCurrentPigsInPen(tx, penID)
if err != nil {
return fmt.Errorf("获取猪栏 %d 当前猪只数量失败: %w", penID, err)
}
if currentPigsInPen < quantity {
return fmt.Errorf("猪栏 %d 内猪只数量不足,当前 %d 头,尝试记录淘汰 %d 头", penID, currentPigsInPen, quantity)
}
// 5. 创建病猪日志 (减少病猪数量)
sickLog := &models.PigSickLog{
PigBatchID: batchID,
PenID: penID,
ChangeCount: -quantity, // 淘汰病猪ChangeCount 为负数
Reason: models.SickPigReasonTypeEliminate,
TreatmentLocation: treatmentLocation,
Remarks: remarks,
OperatorID: operatorID,
HappenedAt: happenedAt,
}
if err := s.sickSvc.ProcessSickPigLog(tx, sickLog); err != nil {
return fmt.Errorf("处理病猪淘汰日志失败: %w", err)
}
// 6. 更新批次总猪只数量 (减少批次总数)
if err := s.UpdatePigBatchQuantity(operatorID, batchID, models.ChangeTypeCull, -quantity, remarks, happenedAt); err != nil {
return fmt.Errorf("更新批次 %d 总猪只数量失败: %w", batchID, err)
}
// 7. 记录猪只转移日志 (减少猪栏内猪只数量)
transferLog := &models.PigTransferLog{
TransferTime: happenedAt,
PigBatchID: batchID,
PenID: penID,
Quantity: -quantity, // 减少猪只数量
Type: models.PigTransferTypeCull, // 淘汰类型
OperatorID: operatorID,
Remarks: remarks,
}
if err := s.transferSvc.LogTransfer(tx, transferLog); err != nil {
return fmt.Errorf("记录猪只淘汰转移日志失败: %w", err)
}
return nil
})
if err != nil {
return fmt.Errorf("记录病猪淘汰事件失败: %w", err)
}
return nil
}
// RecordDeath 记录正常猪只死亡事件。
func (s *pigBatchService) RecordDeath(operatorID uint, batchID uint, penID uint, quantity int, happenedAt time.Time, remarks string) error {
if quantity <= 0 {
return errors.New("死亡猪只数量必须大于0")
}
var err error
err = s.uow.ExecuteInTransaction(func(tx *gorm.DB) error {
// 1. 检查批次是否活跃
batch, err := s.pigBatchRepo.GetPigBatchByIDTx(tx, batchID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPigBatchNotFound
}
return fmt.Errorf("获取批次 %d 失败: %w", batchID, err)
}
if !batch.IsActive() {
return fmt.Errorf("批次 %d 不活跃,无法记录死亡事件", batchID)
}
// 2. 检查猪栏是否关联
pen, err := s.transferSvc.GetPenByID(tx, penID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPenNotFound
}
return fmt.Errorf("获取猪栏 %d 失败: %w", penID, err)
}
if pen.PigBatchID == nil || *pen.PigBatchID != batchID {
return fmt.Errorf("猪栏 %d 未与批次 %d 关联", penID, batchID)
}
// 3. 检查猪栏内猪只数量是否足够死亡
currentPigsInPen, err := s.transferSvc.GetCurrentPigsInPen(tx, penID)
if err != nil {
return fmt.Errorf("获取猪栏 %d 当前猪只数量失败: %w", penID, err)
}
if currentPigsInPen < quantity {
return fmt.Errorf("猪栏 %d 内猪只数量不足,当前 %d 头,尝试记录死亡 %d 头", penID, currentPigsInPen, quantity)
}
// 4. 更新批次总猪只数量 (减少批次总数)
if err := s.UpdatePigBatchQuantity(operatorID, batchID, models.ChangeTypeDeath, -quantity, remarks, happenedAt); err != nil {
return fmt.Errorf("更新批次 %d 总猪只数量失败: %w", batchID, err)
}
// 5. 记录猪只转移日志 (减少猪栏内猪只数量)
transferLog := &models.PigTransferLog{
TransferTime: happenedAt,
PigBatchID: batchID,
PenID: penID,
Quantity: -quantity, // 减少猪只数量
Type: models.PigTransferTypeDeath,
OperatorID: operatorID,
Remarks: remarks,
}
if err := s.transferSvc.LogTransfer(tx, transferLog); err != nil {
return fmt.Errorf("记录猪只死亡转移日志失败: %w", err)
}
return nil
})
if err != nil {
return fmt.Errorf("记录正常猪只死亡事件失败: %w", err)
}
return nil
}
// RecordCull 记录正常猪只淘汰事件。
func (s *pigBatchService) RecordCull(operatorID uint, batchID uint, penID uint, quantity int, happenedAt time.Time, remarks string) error {
if quantity <= 0 {
return errors.New("淘汰猪只数量必须大于0")
}
var err error
err = s.uow.ExecuteInTransaction(func(tx *gorm.DB) error {
// 1. 检查批次是否活跃
batch, err := s.pigBatchRepo.GetPigBatchByIDTx(tx, batchID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPigBatchNotFound
}
return fmt.Errorf("获取批次 %d 失败: %w", batchID, err)
}
if !batch.IsActive() {
return fmt.Errorf("批次 %d 不活跃,无法记录淘汰事件", batchID)
}
// 2. 检查猪栏是否关联
pen, err := s.transferSvc.GetPenByID(tx, penID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPenNotFound
}
return fmt.Errorf("获取猪栏 %d 失败: %w", penID, err)
}
if pen.PigBatchID == nil || *pen.PigBatchID != batchID {
return fmt.Errorf("猪栏 %d 未与批次 %d 关联", penID, batchID)
}
// 3. 检查猪栏内猪只数量是否足够淘汰
currentPigsInPen, err := s.transferSvc.GetCurrentPigsInPen(tx, penID)
if err != nil {
return fmt.Errorf("获取猪栏 %d 当前猪只数量失败: %w", penID, err)
}
if currentPigsInPen < quantity {
return fmt.Errorf("猪栏 %d 内猪只数量不足,当前 %d 头,尝试记录淘汰 %d 头", penID, currentPigsInPen, quantity)
}
// 4. 更新批次总猪只数量 (减少批次总数)
if err := s.UpdatePigBatchQuantity(operatorID, batchID, models.ChangeTypeCull, -quantity, remarks, happenedAt); err != nil {
return fmt.Errorf("更新批次 %d 总猪只数量失败: %w", batchID, err)
}
// 5. 记录猪只转移日志 (减少猪栏内猪只数量)
transferLog := &models.PigTransferLog{
TransferTime: happenedAt,
PigBatchID: batchID,
PenID: penID,
Quantity: -quantity, // 减少猪只数量
Type: models.PigTransferTypeCull,
OperatorID: operatorID,
Remarks: remarks,
}
if err := s.transferSvc.LogTransfer(tx, transferLog); err != nil {
return fmt.Errorf("记录猪只淘汰转移日志失败: %w", err)
}
return nil
})
if err != nil {
return fmt.Errorf("记录正常猪只淘汰事件失败: %w", err)
}
return nil
}

View File

@@ -0,0 +1,154 @@
package pig
import (
"errors"
"fmt"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"gorm.io/gorm"
)
// SellPigs 处理批量销售猪的业务逻辑。
func (s *pigBatchService) SellPigs(batchID uint, penID uint, quantity int, unitPrice float64, tatalPrice float64, traderName string, tradeDate time.Time, remarks string, operatorID uint) error {
return s.uow.ExecuteInTransaction(func(tx *gorm.DB) error {
if quantity <= 0 {
return errors.New("销售数量必须大于0")
}
// 1. 校验猪栏信息
pen, err := s.transferSvc.GetPenByID(tx, penID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPenNotFound
}
return fmt.Errorf("获取猪栏 %d 信息失败: %w", penID, err)
}
// 校验猪栏是否属于该批次
if pen.PigBatchID == nil || *pen.PigBatchID != batchID {
return ErrPenNotAssociatedWithBatch
}
// 2. 业务校验:检查销售数量是否超过猪栏当前猪只数
currentPigsInPen, err := s.transferSvc.GetCurrentPigsInPen(tx, penID)
if err != nil {
return fmt.Errorf("获取猪栏 %d 当前猪只数失败: %w", penID, err)
}
if quantity > currentPigsInPen {
return fmt.Errorf("销售数量 %d 超过猪栏 %d 当前猪只数 %d", quantity, penID, currentPigsInPen)
}
// 3. 记录销售交易 (财务)
sale := &models.PigSale{
PigBatchID: batchID,
SaleDate: tradeDate,
Buyer: traderName,
Quantity: quantity,
UnitPrice: unitPrice,
TotalPrice: tatalPrice, // 总价不一定是单价x数量, 所以要传进来
Remarks: remarks,
OperatorID: operatorID,
}
if err := s.tradeSvc.SellPig(tx, sale); err != nil {
return fmt.Errorf("记录销售交易失败: %w", err)
}
// 4. 创建猪只转移日志 (物理)
transferLog := &models.PigTransferLog{
TransferTime: tradeDate,
PigBatchID: batchID,
PenID: penID,
Quantity: -quantity, // 销售导致数量减少
Type: models.PigTransferTypeSale,
OperatorID: operatorID,
Remarks: fmt.Sprintf("销售给 %s", traderName),
}
if err := s.transferSvc.LogTransfer(tx, transferLog); err != nil {
return fmt.Errorf("创建猪只转移日志失败: %w", err)
}
// 5. 记录批次数量变更日志 (逻辑)
if err := s.updatePigBatchQuantityTx(tx, operatorID, batchID, models.ChangeTypeSale, -quantity,
fmt.Sprintf("猪批次 %d 从猪栏 %d 销售 %d 头猪给 %s", batchID, penID, quantity, traderName),
tradeDate); err != nil {
return fmt.Errorf("更新猪批次数量失败: %w", err)
}
return nil
})
}
// BuyPigs 处理批量购买猪的业务逻辑。
func (s *pigBatchService) BuyPigs(batchID uint, penID uint, quantity int, unitPrice float64, totalPrice float64, traderName string, tradeDate time.Time, remarks string, operatorID uint) error {
return s.uow.ExecuteInTransaction(func(tx *gorm.DB) error {
if quantity <= 0 {
return errors.New("采购数量必须大于0")
}
// 1. 校验猪栏信息
pen, err := s.transferSvc.GetPenByID(tx, penID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPenNotFound
}
return fmt.Errorf("获取猪栏 %d 信息失败: %w", penID, err)
}
// 校验猪栏是否属于该批次
if pen.PigBatchID == nil || *pen.PigBatchID != batchID {
return ErrPenNotAssociatedWithBatch
}
// 2. 业务校验:检查猪栏容量,如果超出,在备注中记录警告
currentPigsInPen, err := s.transferSvc.GetCurrentPigsInPen(tx, penID)
if err != nil {
return fmt.Errorf("获取猪栏 %d 当前猪只数失败: %w", penID, err)
}
transferRemarks := fmt.Sprintf("从 %s 采购", traderName)
if currentPigsInPen+quantity > pen.Capacity {
warning := fmt.Sprintf("[警告]猪栏容量超出: 当前 %d, 采购 %d, 容量 %d.", currentPigsInPen, quantity, pen.Capacity)
transferRemarks = fmt.Sprintf("%s %s", transferRemarks, warning)
}
// 3. 记录采购交易 (财务)
purchase := &models.PigPurchase{
PigBatchID: batchID,
PurchaseDate: tradeDate,
Supplier: traderName,
Quantity: quantity,
UnitPrice: unitPrice,
TotalPrice: totalPrice, // 总价不一定是单价x数量, 所以要传进来
Remarks: remarks, // 用户传入的备注
OperatorID: operatorID,
}
if err := s.tradeSvc.BuyPig(tx, purchase); err != nil {
return fmt.Errorf("记录采购交易失败: %w", err)
}
// 4. 创建猪只转移日志 (物理)
transferLog := &models.PigTransferLog{
TransferTime: tradeDate,
PigBatchID: batchID,
PenID: penID,
Quantity: quantity, // 采购导致数量增加
Type: models.PigTransferTypePurchase,
OperatorID: operatorID,
Remarks: transferRemarks, // 包含系统生成的备注和潜在的警告
}
if err := s.transferSvc.LogTransfer(tx, transferLog); err != nil {
return fmt.Errorf("创建猪只转移日志失败: %w", err)
}
// 5. 记录批次数量变更日志 (逻辑)
if err := s.updatePigBatchQuantityTx(tx, operatorID, batchID, models.ChangeTypeBuy, quantity,
fmt.Sprintf("猪批次 %d 在猪栏 %d 采购 %d 头猪从 %s", batchID, penID, quantity, traderName),
tradeDate); err != nil {
return fmt.Errorf("更新猪批次数量失败: %w", err)
}
return nil
})
}

View File

@@ -0,0 +1,127 @@
package pig
import (
"errors"
"fmt"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
"gorm.io/gorm"
)
// SickPigManager 定义了与病猪管理相关的操作接口。
// 这是一个领域服务,负责协调病猪记录、用药等业务逻辑。
type SickPigManager interface {
// ProcessSickPigLog 处理病猪相关的日志事件。
// log 包含事件的基本信息,如 PigBatchID, PenID, PigIDs, ChangeCount, Reason, TreatmentLocation, Remarks, OperatorID, HappenedAt。
// Manager 内部会计算并填充 BeforeCount 和 AfterCount并进行必要的业务校验和副作用处理。
ProcessSickPigLog(tx *gorm.DB, log *models.PigSickLog) error
// GetCurrentSickPigCount 获取指定批次当前患病猪只的总数
GetCurrentSickPigCount(tx *gorm.DB, batchID uint) (int, error)
}
// sickPigManager 是 SickPigManager 接口的具体实现。
// 它依赖于仓库接口来执行数据持久化操作。
type sickPigManager struct {
sickLogRepo repository.PigSickLogRepository
medicationLogRepo repository.MedicationLogRepository
}
// NewSickPigManager 是 sickPigManager 的构造函数。
func NewSickPigManager(
sickLogRepo repository.PigSickLogRepository,
medicationLogRepo repository.MedicationLogRepository,
) SickPigManager {
return &sickPigManager{
sickLogRepo: sickLogRepo,
medicationLogRepo: medicationLogRepo,
}
}
func (s *sickPigManager) ProcessSickPigLog(tx *gorm.DB, log *models.PigSickLog) error {
// 1. 输入校验
if log == nil {
return errors.New("病猪日志不能为空")
}
// 关键字段校验
var missingFields []string
if log.PigBatchID == 0 {
missingFields = append(missingFields, "PigBatchID")
}
if log.ChangeCount == 0 {
missingFields = append(missingFields, "ChangeCount")
}
if log.Reason == "" {
missingFields = append(missingFields, "Reason")
}
if log.TreatmentLocation == "" {
missingFields = append(missingFields, "TreatmentLocation")
}
if log.HappenedAt.IsZero() {
missingFields = append(missingFields, "HappenedAt")
}
if log.OperatorID == 0 {
missingFields = append(missingFields, "OperatorID")
}
if log.PenID == 0 {
missingFields = append(missingFields, "PenID")
}
if len(missingFields) > 0 {
return fmt.Errorf("以下关键字段不能为空或零值: %v", missingFields)
}
// 业务规则校验 - ChangeCount 与 Reason 的一致性
switch log.Reason {
case models.SickPigReasonTypeIllness, models.SickPigReasonTypeTransferIn:
if log.ChangeCount < 0 {
return fmt.Errorf("原因 '%s' 的 ChangeCount 必须为正数", log.Reason)
}
case models.SickPigReasonTypeRecovery, models.SickPigReasonTypeDeath, models.SickPigReasonTypeEliminate, models.SickPigReasonTypeTransferOut:
if log.ChangeCount > 0 {
return fmt.Errorf("原因 '%s' 的 ChangeCount 必须为负数", log.Reason)
}
case models.SickPigReasonTypeOther:
// 其他原因ChangeCount 可以是任意值但不能为0
if log.ChangeCount == 0 {
return errors.New("原因 '其他' 的 ChangeCount 不能为零")
}
default:
return fmt.Errorf("未知的病猪日志原因类型: %s", log.Reason)
}
// 2. 获取当前病猪数量 (BeforeCount)
beforeCount, err := s.GetCurrentSickPigCount(tx, log.PigBatchID)
if err != nil {
return fmt.Errorf("获取批次 %d 当前病猪数量失败: %w", log.PigBatchID, err)
}
log.BeforeCount = beforeCount
// 3. 计算变化后的数量 (AfterCount)
log.AfterCount = log.BeforeCount + log.ChangeCount
// 4. 业务规则校验 - 数量合法性
if log.AfterCount < 0 {
return fmt.Errorf("操作后病猪数量不能为负数,当前 %d变化 %d", log.BeforeCount, log.ChangeCount)
}
// 5. 持久化 PigSickLog
if err := s.sickLogRepo.CreatePigSickLogTx(tx, log); err != nil {
return fmt.Errorf("创建 PigSickLog 失败: %w", err)
}
return nil
}
func (s *sickPigManager) GetCurrentSickPigCount(tx *gorm.DB, batchID uint) (int, error) {
lastLog, err := s.sickLogRepo.GetLastLogByBatchTx(tx, batchID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, nil // 如果没有找到任何日志表示当前病猪数量为0
}
return 0, fmt.Errorf("获取批次 %d 的最新病猪日志失败: %w", batchID, err)
}
return lastLog.AfterCount, nil
}

View File

@@ -0,0 +1,46 @@
package pig
import (
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" // 引入基础设施层的仓库接口
"gorm.io/gorm"
)
// PigTradeManager 定义了与猪只交易相关的操作接口。
// 这是一个领域服务,负责协调业务逻辑。
type PigTradeManager interface {
// SellPig 处理卖猪的业务逻辑,通过仓库接口创建 PigSale 记录。
SellPig(tx *gorm.DB, sale *models.PigSale) error
// BuyPig 处理买猪的业务逻辑,通过仓库接口创建 PigPurchase 记录。
BuyPig(tx *gorm.DB, purchase *models.PigPurchase) error
}
// pigTradeManager 是 PigTradeManager 接口的具体实现。
// 它依赖于 repository.PigTradeRepository 接口来执行数据持久化操作。
type pigTradeManager struct {
tradeRepo repository.PigTradeRepository // 依赖于基础设施层定义的仓库接口
}
// NewPigTradeManager 是 pigTradeManager 的构造函数。
func NewPigTradeManager(tradeRepo repository.PigTradeRepository) PigTradeManager {
return &pigTradeManager{
tradeRepo: tradeRepo,
}
}
// SellPig 实现了卖猪的逻辑。
// 它通过调用 tradeRepo 来持久化销售记录。
func (s *pigTradeManager) SellPig(tx *gorm.DB, sale *models.PigSale) error {
// 在此处可以添加更复杂的卖猪前置校验或业务逻辑
// 例如:检查猪只库存、更新猪只状态等。
return s.tradeRepo.CreatePigSaleTx(tx, sale)
}
// BuyPig 实现了买猪的逻辑。
// 它通过调用 tradeRepo 来持久化采购记录。
func (s *pigTradeManager) BuyPig(tx *gorm.DB, purchase *models.PigPurchase) error {
// 在此处可以添加更复杂的买猪前置校验或业务逻辑
// 例如:检查资金、更新猪只状态等。
return s.tradeRepo.CreatePigPurchaseTx(tx, purchase)
}

View File

@@ -0,0 +1,328 @@
package plan
import (
"fmt"
"sync"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/utils"
)
// AnalysisPlanTaskManager 定义了分析计划任务管理器的接口。
type AnalysisPlanTaskManager interface {
// Refresh 同步数据库中的计划状态和待执行队列中的触发器任务。
Refresh() error
// CreateOrUpdateTrigger 为给定的 planID 创建其关联的触发任务。
// 如果触发器已存在,会根据计划类型更新其执行时间。
CreateOrUpdateTrigger(planID uint) error
// EnsureAnalysisTaskDefinition 确保计划的分析任务定义存在于 tasks 表中。
// 如果不存在,则会自动创建。此方法不涉及待执行队列。
EnsureAnalysisTaskDefinition(planID uint) error
}
// analysisPlanTaskManagerImpl 负责管理分析计划的触发器任务。
// 它确保数据库中可执行的计划在待执行队列中有对应的触发器,并移除无效的触发器。
// 这是一个有状态的组件,包含一个互斥锁以确保并发安全。
type analysisPlanTaskManagerImpl struct {
planRepo repository.PlanRepository
pendingTaskRepo repository.PendingTaskRepository
executionLogRepo repository.ExecutionLogRepository
logger *logs.Logger
mu sync.Mutex
}
// NewAnalysisPlanTaskManager 是 analysisPlanTaskManagerImpl 的构造函数。
func NewAnalysisPlanTaskManager(
planRepo repository.PlanRepository,
pendingTaskRepo repository.PendingTaskRepository,
executionLogRepo repository.ExecutionLogRepository,
logger *logs.Logger,
) AnalysisPlanTaskManager {
return &analysisPlanTaskManagerImpl{
planRepo: planRepo,
pendingTaskRepo: pendingTaskRepo,
executionLogRepo: executionLogRepo,
logger: logger,
}
}
// Refresh 同步数据库中的计划状态和待执行队列中的触发器任务。
// 这是一个编排方法,将复杂的逻辑分解到多个内部方法中。
func (m *analysisPlanTaskManagerImpl) Refresh() error {
m.mu.Lock()
defer m.mu.Unlock()
m.logger.Info("开始同步计划任务管理器...")
// 1. 一次性获取所有需要的数据
runnablePlans, invalidPlanIDs, pendingTasks, err := m.getRefreshData()
if err != nil {
return fmt.Errorf("获取刷新数据失败: %w", err)
}
// 2. 清理所有与失效计划相关的待执行任务
if err := m.cleanupInvalidTasks(invalidPlanIDs, pendingTasks); err != nil {
// 仅记录错误,清理失败不应阻止新任务的添加
m.logger.Errorf("清理无效任务时出错: %v", err)
}
// 3. 添加或更新触发器
if err := m.addOrUpdateTriggers(runnablePlans, pendingTasks); err != nil {
return fmt.Errorf("添加或更新触发器时出错: %w", err)
}
m.logger.Info("计划任务管理器同步完成.")
return nil
}
// CreateOrUpdateTrigger 为给定的 planID 创建其关联的触发任务。
// 如果触发器已存在,会根据计划类型更新其执行时间。
func (m *analysisPlanTaskManagerImpl) CreateOrUpdateTrigger(planID uint) error {
m.mu.Lock()
defer m.mu.Unlock()
// 检查计划是否可执行
plan, err := m.planRepo.GetBasicPlanByID(planID)
if err != nil {
return fmt.Errorf("获取计划基本信息失败: %w", err)
}
if plan.Status != models.PlanStatusEnabled {
return fmt.Errorf("计划 #%d 当前状态为 '%d',无法创建或更新触发器", planID, plan.Status)
}
// 查找现有触发器
existingTrigger, err := m.pendingTaskRepo.FindPendingTriggerByPlanID(planID)
if err != nil {
return fmt.Errorf("查找现有触发器失败: %w", err)
}
// 如果触发器已存在,则根据计划类型更新其执行时间
if existingTrigger != nil {
var expectedExecuteAt time.Time
if plan.ExecutionType == models.PlanExecutionTypeManual {
// 手动计划,如果再次触发,则立即执行
expectedExecuteAt = time.Now()
} else { // 自动计划
// 自动计划,根据 Cron 表达式计算下一次执行时间
next, err := utils.GetNextCronTime(plan.CronExpression)
if err != nil {
m.logger.Errorf("为计划 #%d 解析Cron表达式失败无法更新触发器: %v", plan.ID, err)
return fmt.Errorf("解析 Cron 表达式失败: %w", err)
}
expectedExecuteAt = next
}
// 如果计算出的执行时间与当前待执行任务的时间不一致,则更新
if !existingTrigger.ExecuteAt.Equal(expectedExecuteAt) {
m.logger.Infof("计划 #%d 的执行时间已变更,正在更新触发器 #%d 的执行时间从 %v 到 %v...", plan.ID, existingTrigger.ID, existingTrigger.ExecuteAt, expectedExecuteAt)
if err := m.pendingTaskRepo.UpdatePendingTaskExecuteAt(existingTrigger.ID, expectedExecuteAt); err != nil {
m.logger.Errorf("更新触发器 #%d 的执行时间失败: %v", existingTrigger.ID, err)
return fmt.Errorf("更新触发器执行时间失败: %w", err)
}
} else {
m.logger.Infof("计划 #%d 的触发器已存在且执行时间无需更新。", plan.ID)
}
return nil // 触发器已存在且已处理更新,直接返回
}
// 如果触发器不存在,则创建新的触发器
m.logger.Infof("为计划 #%d 创建新的触发器...", planID)
return m.createTriggerTask(plan)
}
// EnsureAnalysisTaskDefinition 确保计划的分析任务定义存在于 tasks 表中。
// 如果不存在,则会自动创建。此方法不涉及待执行队列。
func (m *analysisPlanTaskManagerImpl) EnsureAnalysisTaskDefinition(planID uint) error {
m.mu.Lock()
defer m.mu.Unlock()
plan, err := m.planRepo.GetBasicPlanByID(planID)
if err != nil {
return fmt.Errorf("确保分析任务定义失败:获取计划 #%d 基本信息时出错: %w", planID, err)
}
analysisTask, err := m.planRepo.FindPlanAnalysisTaskByPlanID(plan.ID)
if err != nil {
return fmt.Errorf("确保分析任务定义失败:查找计划 #%d 的分析任务时出错: %w", plan.ID, err)
}
if analysisTask == nil {
m.logger.Infof("未找到计划 #%d 关联的 'plan_analysis' 任务定义,将自动创建...", plan.ID)
_, err := m.planRepo.CreatePlanAnalysisTask(plan) // CreatePlanAnalysisTask returns *models.Task, error
if err != nil {
return fmt.Errorf("自动创建 'plan_analysis' 任务定义失败: %w", err)
}
m.logger.Infof("已成功为计划 #%d 创建 'plan_analysis' 任务定义。", plan.ID)
} else {
m.logger.Infof("计划 #%d 的 'plan_analysis' 任务定义已存在。", plan.ID)
}
return nil
}
// --- 内部私有方法 ---
// getRefreshData 从数据库获取刷新所需的所有数据。
func (m *analysisPlanTaskManagerImpl) getRefreshData() (runnablePlans []*models.Plan, invalidPlanIDs []uint, pendingTasks []models.PendingTask, err error) {
runnablePlans, err = m.planRepo.FindRunnablePlans()
if err != nil {
m.logger.Errorf("获取可执行计划列表失败: %v", err)
return
}
invalidPlans, err := m.planRepo.FindInactivePlans()
if err != nil {
m.logger.Errorf("获取失效计划列表失败: %v", err)
return
}
invalidPlanIDs = make([]uint, len(invalidPlans))
for i, p := range invalidPlans {
invalidPlanIDs[i] = p.ID
}
pendingTasks, err = m.pendingTaskRepo.FindAllPendingTasks()
if err != nil {
m.logger.Errorf("获取所有待执行任务失败: %v", err)
return
}
return
}
// cleanupInvalidTasks 清理所有与失效计划相关的待执行任务。
func (m *analysisPlanTaskManagerImpl) cleanupInvalidTasks(invalidPlanIDs []uint, allPendingTasks []models.PendingTask) error {
if len(invalidPlanIDs) == 0 {
return nil // 没有需要清理的计划
}
invalidPlanIDSet := make(map[uint]struct{}, len(invalidPlanIDs))
for _, id := range invalidPlanIDs {
invalidPlanIDSet[id] = struct{}{}
}
var tasksToDeleteIDs []uint
var logsToCancelIDs []uint
for _, pt := range allPendingTasks {
if pt.Task == nil { // 防御性编程,确保 Task 被预加载
continue
}
if _, isInvalid := invalidPlanIDSet[pt.Task.PlanID]; isInvalid {
tasksToDeleteIDs = append(tasksToDeleteIDs, pt.ID)
logsToCancelIDs = append(logsToCancelIDs, pt.TaskExecutionLogID)
}
}
if len(tasksToDeleteIDs) == 0 {
return nil // 没有找到需要清理的任务
}
m.logger.Infof("准备从待执行队列中清理 %d 个与失效计划相关的任务...", len(tasksToDeleteIDs))
// 批量删除待执行任务
if err := m.pendingTaskRepo.DeletePendingTasksByIDs(tasksToDeleteIDs); err != nil {
return fmt.Errorf("批量删除待执行任务失败: %w", err)
}
// 批量更新相关执行日志状态为“已取消”
if err := m.executionLogRepo.UpdateTaskExecutionLogStatusByIDs(logsToCancelIDs, models.ExecutionStatusCancelled); err != nil {
// 这是一个非关键性错误,只记录日志
m.logger.Warnf("批量更新日志状态为 'Cancelled' 失败: %v", err)
}
return nil
}
// addOrUpdateTriggers 检查、更新或创建触发器。
func (m *analysisPlanTaskManagerImpl) addOrUpdateTriggers(runnablePlans []*models.Plan, allPendingTasks []models.PendingTask) error {
// 创建一个映射,存放所有已在队列中的计划触发器
pendingTriggersMap := make(map[uint]models.PendingTask)
for _, pt := range allPendingTasks {
if pt.Task != nil && pt.Task.Type == models.TaskPlanAnalysis {
pendingTriggersMap[pt.Task.PlanID] = pt
}
}
for _, plan := range runnablePlans {
existingTrigger, exists := pendingTriggersMap[plan.ID]
if exists {
// --- 新增逻辑:检查并更新现有触发器 ---
// 只对自动计划检查时间更新
if plan.ExecutionType == models.PlanExecutionTypeAutomatic {
next, err := utils.GetNextCronTime(plan.CronExpression)
if err != nil {
m.logger.Errorf("为计划 #%d 解析Cron表达式失败跳过更新: %v", plan.ID, err)
continue
}
// 如果数据库中记录的执行时间与根据当前Cron表达式计算出的下一次时间不一致则更新
if !existingTrigger.ExecuteAt.Equal(next) {
m.logger.Infof("计划 #%d 的执行时间已变更,正在更新触发器 #%d 的执行时间从 %v 到 %v...", plan.ID, existingTrigger.ID, existingTrigger.ExecuteAt, next)
if err := m.pendingTaskRepo.UpdatePendingTaskExecuteAt(existingTrigger.ID, next); err != nil {
m.logger.Errorf("更新触发器 #%d 的执行时间失败: %v", existingTrigger.ID, err)
}
}
}
} else {
// --- 原有逻辑:为缺失的计划创建新触发器 ---
m.logger.Infof("发现应执行但队列中缺失的计划 #%d正在为其创建触发器...", plan.ID)
if err := m.createTriggerTask(plan); err != nil {
m.logger.Errorf("为计划 #%d 创建触发器失败: %v", plan.ID, err)
// 继续处理下一个,不因单点失败而中断
}
}
}
return nil
}
// createTriggerTask 是创建触发器任务的内部核心逻辑。
func (m *analysisPlanTaskManagerImpl) createTriggerTask(plan *models.Plan) error {
analysisTask, err := m.planRepo.FindPlanAnalysisTaskByPlanID(plan.ID)
if err != nil {
return fmt.Errorf("查找计划分析任务失败: %w", err)
}
// --- 如果触发器任务定义不存在,则自动创建 ---
if analysisTask == nil {
m.logger.Warnf("未找到计划 #%d 关联的 'plan_analysis' 任务定义,将自动创建...", plan.ID)
newAnalysisTask, err := m.planRepo.CreatePlanAnalysisTask(plan)
if err != nil {
return fmt.Errorf("自动创建 'plan_analysis' 任务定义失败: %w", err)
}
analysisTask = newAnalysisTask
m.logger.Infof("已成功为计划 #%d 创建 'plan_analysis' 任务定义 (ID: %d)", plan.ID, analysisTask.ID)
}
var executeAt time.Time
if plan.ExecutionType == models.PlanExecutionTypeManual {
executeAt = time.Now()
} else {
next, err := utils.GetNextCronTime(plan.CronExpression)
if err != nil {
return fmt.Errorf("解析 Cron 表达式 '%s' 失败: %w", plan.CronExpression, err)
}
executeAt = next
}
taskLog := &models.TaskExecutionLog{
TaskID: analysisTask.ID,
Status: models.ExecutionStatusWaiting,
}
if err := m.executionLogRepo.CreateTaskExecutionLog(taskLog); err != nil {
return fmt.Errorf("创建任务执行日志失败: %w", err)
}
pendingTask := &models.PendingTask{
TaskID: analysisTask.ID,
ExecuteAt: executeAt,
TaskExecutionLogID: taskLog.ID,
}
if err := m.pendingTaskRepo.CreatePendingTask(pendingTask); err != nil {
return fmt.Errorf("创建待执行任务失败: %w", err)
}
m.logger.Infof("成功为计划 #%d 创建触发器 (任务ID: %d),执行时间: %v", plan.ID, analysisTask.ID, executeAt)
return nil
}

View File

@@ -0,0 +1 @@
package plan

View File

@@ -0,0 +1,474 @@
package plan
import (
"errors"
"sync"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/device"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
"github.com/panjf2000/ants/v2"
"gorm.io/gorm"
)
// ExecutionManager 定义了计划执行管理器的接口。
type ExecutionManager interface {
// Start 启动计划执行管理器。
Start()
// Stop 优雅地停止计划执行管理器。
Stop()
}
// ProgressTracker 仅用于在内存中提供计划执行的并发锁
type ProgressTracker struct {
mu sync.Mutex
cond *sync.Cond // 用于实现阻塞锁
runningPlans map[uint]bool // key: planExecutionLogID, value: true (用作内存锁)
}
// NewProgressTracker 创建一个新的进度跟踪器
func NewProgressTracker() *ProgressTracker {
t := &ProgressTracker{
runningPlans: make(map[uint]bool),
}
t.cond = sync.NewCond(&t.mu)
return t
}
// TryLock (非阻塞) 尝试锁定一个计划。如果计划未被锁定,则锁定并返回 true。
func (t *ProgressTracker) TryLock(planLogID uint) bool {
t.mu.Lock()
defer t.mu.Unlock()
if t.runningPlans[planLogID] {
return false // 已被锁定
}
t.runningPlans[planLogID] = true
return true
}
// Lock (阻塞) 获取一个计划的执行锁。如果锁已被占用,则会一直等待直到锁被释放。
func (t *ProgressTracker) Lock(planLogID uint) {
t.mu.Lock()
// 当计划正在运行时,调用 t.cond.Wait() 会原子地解锁 mu 并挂起当前协程。
// 当被唤醒时,它会重新锁定 mu 并再次检查循环条件。
for t.runningPlans[planLogID] {
t.cond.Wait()
}
// 获取到锁
t.runningPlans[planLogID] = true
t.mu.Unlock()
}
// Unlock 解锁一个计划,并唤醒所有正在等待此锁的协程。
func (t *ProgressTracker) Unlock(planLogID uint) {
t.mu.Lock()
defer t.mu.Unlock()
delete(t.runningPlans, planLogID)
// 唤醒所有在此条件上等待的协程
t.cond.Broadcast()
}
// GetRunningPlanIDs 获取当前所有正在执行的计划ID列表
func (t *ProgressTracker) GetRunningPlanIDs() []uint {
t.mu.Lock()
defer t.mu.Unlock()
ids := make([]uint, 0, len(t.runningPlans))
for id := range t.runningPlans {
ids = append(ids, id)
}
return ids
}
// planExecutionManagerImpl 是核心的、持久化的任务调度器
type planExecutionManagerImpl struct {
logger *logs.Logger
pollingInterval time.Duration
workers int
pendingTaskRepo repository.PendingTaskRepository
executionLogRepo repository.ExecutionLogRepository
deviceRepo repository.DeviceRepository
sensorDataRepo repository.SensorDataRepository
planRepo repository.PlanRepository
taskFactory TaskFactory
analysisPlanTaskManager AnalysisPlanTaskManager
progressTracker *ProgressTracker
deviceService device.Service
pool *ants.Pool // 使用 ants 协程池来管理并发
wg sync.WaitGroup
stopChan chan struct{} // 用于停止主循环的信号通道
}
// NewPlanExecutionManager 创建一个新的调度器实例
func NewPlanExecutionManager(
pendingTaskRepo repository.PendingTaskRepository,
executionLogRepo repository.ExecutionLogRepository,
deviceRepo repository.DeviceRepository,
sensorDataRepo repository.SensorDataRepository,
planRepo repository.PlanRepository,
analysisPlanTaskManager AnalysisPlanTaskManager,
taskFactory TaskFactory,
logger *logs.Logger,
deviceService device.Service,
interval time.Duration,
numWorkers int,
) ExecutionManager {
return &planExecutionManagerImpl{
pendingTaskRepo: pendingTaskRepo,
executionLogRepo: executionLogRepo,
deviceRepo: deviceRepo,
sensorDataRepo: sensorDataRepo,
planRepo: planRepo,
analysisPlanTaskManager: analysisPlanTaskManager,
taskFactory: taskFactory,
logger: logger,
deviceService: deviceService,
pollingInterval: interval,
workers: numWorkers,
progressTracker: NewProgressTracker(),
stopChan: make(chan struct{}), // 初始化停止信号通道
}
}
// Start 启动调度器,包括初始化协程池和启动主轮询循环
func (s *planExecutionManagerImpl) Start() {
s.logger.Warnf("任务调度器正在启动,工作协程数: %d...", s.workers)
pool, err := ants.NewPool(s.workers, ants.WithPanicHandler(func(err interface{}) {
s.logger.Errorf("[严重] 任务执行时发生 panic: %v", err)
}))
if err != nil {
panic("初始化协程池失败: " + err.Error())
}
s.pool = pool
s.wg.Add(1)
go s.run()
s.logger.Warnf("任务调度器已成功启动")
}
// Stop 优雅地停止调度器
func (s *planExecutionManagerImpl) Stop() {
s.logger.Warnf("正在停止任务调度器...")
close(s.stopChan) // 1. 发出停止信号,停止主循环
s.wg.Wait() // 2. 等待主循环完成
s.pool.Release() // 3. 释放 ants 池 (等待所有已提交的任务执行完毕)
s.logger.Warnf("任务调度器已安全停止")
}
// run 是主轮询循环,负责从数据库认领任务并提交到协程池
func (s *planExecutionManagerImpl) run() {
defer s.wg.Done()
ticker := time.NewTicker(s.pollingInterval)
defer ticker.Stop()
for {
select {
case <-s.stopChan:
// 收到停止信号,退出循环
return
case <-ticker.C:
// 定时触发任务认领和提交
go s.claimAndSubmit()
}
}
}
// claimAndSubmit 实现了最终的“认领-锁定-执行 或 等待-放回”的健壮逻辑
func (s *planExecutionManagerImpl) claimAndSubmit() {
runningPlanIDs := s.progressTracker.GetRunningPlanIDs()
claimedLog, pendingTask, err := s.pendingTaskRepo.ClaimNextAvailableTask(runningPlanIDs)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
s.logger.Errorf("认领任务时发生错误: %v", err)
}
// gorm.ErrRecordNotFound 说明没任务要执行
return
}
// 尝试获取内存执行锁
if s.progressTracker.TryLock(claimedLog.PlanExecutionLogID) {
// 成功获取锁,正常派发任务
err = s.pool.Submit(func() {
defer s.progressTracker.Unlock(claimedLog.PlanExecutionLogID)
s.processTask(claimedLog)
})
if err != nil {
s.logger.Errorf("向协程池提交任务失败: %v", err)
// 提交失败,必须释放刚刚获取的锁
s.progressTracker.Unlock(claimedLog.PlanExecutionLogID)
// 同样需要将任务安全放回
s.handleRequeue(claimedLog.PlanExecutionLogID, pendingTask)
}
} else {
// 获取锁失败,说明有“兄弟”任务正在执行。执行“锁定并安全放回”逻辑。
s.handleRequeue(claimedLog.PlanExecutionLogID, pendingTask)
}
}
// handleRequeue 同步地、安全地将一个无法立即执行的任务放回队列。
func (s *planExecutionManagerImpl) handleRequeue(planExecutionLogID uint, taskToRequeue *models.PendingTask) {
s.logger.Warnf("计划 %d 正在执行,任务 %d (TaskID: %d) 将等待并重新入队...", planExecutionLogID, taskToRequeue.ID, taskToRequeue.TaskID)
// 1. 阻塞式地等待,直到可以获取到该计划的锁。
s.progressTracker.Lock(planExecutionLogID)
defer s.progressTracker.Unlock(planExecutionLogID)
// 2. 在持有锁的情况下,将任务安全地放回队列。
if err := s.pendingTaskRepo.RequeueTask(taskToRequeue); err != nil {
s.logger.Errorf("[严重] 任务重新入队失败, 原始PendingTaskID: %d, 错误: %v", taskToRequeue.ID, err)
return
}
s.logger.Warnf("任务 (原始ID: %d) 已成功重新入队,并已释放计划 %d 的锁。", taskToRequeue.ID, planExecutionLogID)
}
// processTask 处理单个任务的逻辑
func (s *planExecutionManagerImpl) processTask(claimedLog *models.TaskExecutionLog) {
s.logger.Warnf("开始处理任务, 日志ID: %d, 任务ID: %d, 任务名称: %s, 描述: %s",
claimedLog.ID, claimedLog.TaskID, claimedLog.Task.Name, claimedLog.Task.Description)
claimedLog.StartedAt = time.Now()
claimedLog.Status = models.ExecutionStatusCompleted // 先乐观假定任务成功, 后续失败了再改
defer s.updateTaskExecutionLogStatus(claimedLog)
// 执行任务
err := s.runTask(claimedLog)
if err != nil {
claimedLog.Status = models.ExecutionStatusFailed
claimedLog.Output = err.Error()
// 任务失败时,调用统一的终止服务
s.handlePlanTermination(claimedLog.PlanExecutionLogID, "子任务执行失败: "+err.Error())
return
}
// 如果是计划分析任务,它的职责是解析和分发任务,到此即完成,不参与后续的计划完成度检查。
if claimedLog.Task.Type == models.TaskPlanAnalysis {
s.logger.Warnf("完成计划分析任务, 日志ID: %d", claimedLog.ID)
return
}
// --- 以下是常规任务的完成逻辑 ---
s.logger.Warnf("完成任务, 日志ID: %d", claimedLog.ID)
// 检查是否是最后一个任务
incompleteCount, err := s.executionLogRepo.CountIncompleteTasksByPlanLogID(claimedLog.PlanExecutionLogID)
if err != nil {
s.logger.Errorf("检查计划 %d 的未完成任务数时出错: %v", claimedLog.PlanExecutionLogID, err)
return
}
// 如果此计划执行中,未完成的任务只剩下当前这一个(因为当前任务的状态此时在数据库中仍为 'started'
// 则认为整个计划已完成。
if incompleteCount == 1 {
s.handlePlanCompletion(claimedLog.PlanExecutionLogID)
}
}
// runTask 用于执行具体任务
func (s *planExecutionManagerImpl) runTask(claimedLog *models.TaskExecutionLog) error {
// 这是个特殊任务, 用于解析Plan并将解析出的任务队列添加到待执行队列中
if claimedLog.Task.Type == models.TaskPlanAnalysis {
// 解析plan
err := s.analysisPlan(claimedLog)
if err != nil {
// TODO 这里要处理一下, 比如再插一个新的触发器回去
s.logger.Errorf("[严重] 计划解析失败, 日志ID: %d, 错误: %v", claimedLog.ID, err)
return err
}
} else {
// 执行普通任务
task := s.taskFactory.Production(claimedLog)
if err := task.Execute(); err != nil {
s.logger.Errorf("[严重] 任务执行失败, 日志ID: %d, 错误: %v", claimedLog.ID, err)
task.OnFailure(err)
return err
}
}
return nil
}
// analysisPlan 解析Plan并将解析出的Task列表插入待执行队列中
func (s *planExecutionManagerImpl) analysisPlan(claimedLog *models.TaskExecutionLog) error {
// 创建Plan执行记录
// 从任务的 Parameters 中解析出真实的 PlanID
var params struct {
PlanID uint `json:"plan_id"`
}
if err := claimedLog.Task.ParseParameters(&params); err != nil {
s.logger.Errorf("解析任务参数中的计划ID失败日志ID: %d, 错误: %v", claimedLog.ID, err)
return err
}
realPlanID := params.PlanID
planLog := &models.PlanExecutionLog{
PlanID: realPlanID, // 使用从参数中解析出的真实 PlanID
Status: models.ExecutionStatusStarted,
StartedAt: time.Now(),
}
if err := s.executionLogRepo.CreatePlanExecutionLog(planLog); err != nil {
s.logger.Errorf("[严重] 创建计划执行日志失败, 日志ID: %d, 错误: %v", claimedLog.ID, err)
return err
}
// 解析出Task列表
tasks, err := s.planRepo.FlattenPlanTasks(realPlanID)
if err != nil {
s.logger.Errorf("[严重] 解析计划失败, 日志ID: %d, 错误: %v", claimedLog.ID, err)
return err
}
// 写入执行历史
taskLogs := make([]*models.TaskExecutionLog, len(tasks))
for i, task := range tasks {
taskLogs[i] = &models.TaskExecutionLog{
PlanExecutionLogID: planLog.ID,
TaskID: task.ID,
Status: models.ExecutionStatusWaiting,
}
}
err = s.executionLogRepo.CreateTaskExecutionLogsInBatch(taskLogs)
if err != nil {
s.logger.Errorf("[严重] 写入执行历史, 日志ID: %d, 错误: %v", claimedLog.ID, err)
return err
}
// 写入待执行队列
pendingTasks := make([]*models.PendingTask, len(tasks))
for i, task := range tasks {
pendingTasks[i] = &models.PendingTask{
TaskID: task.ID,
TaskExecutionLogID: taskLogs[i].ID, // 使用正确的 TaskExecutionLogID
// 待执行队列是通过任务触发时间排序的, 且只要在调度器获取的时间点之前的都可以被触发
ExecuteAt: time.Now().Add(time.Duration(i) * time.Second),
}
}
err = s.pendingTaskRepo.CreatePendingTasksInBatch(pendingTasks)
if err != nil {
s.logger.Errorf("[严重] 写入待执行队列, 日志ID: %d, 错误: %v", claimedLog.ID, err)
return err
}
// --- 处理空计划的边缘情况 ---
// 如果一个计划被解析后,发现其任务列表为空,
// 那么它实际上已经“执行”完毕了,我们需要在这里手动为它创建下一次的触发器。
if len(tasks) == 0 {
s.handlePlanCompletion(planLog.ID)
}
return nil
}
// updateTaskExecutionLogStatus 修改任务历史中的执行状态
func (s *planExecutionManagerImpl) updateTaskExecutionLogStatus(claimedLog *models.TaskExecutionLog) error {
claimedLog.EndedAt = time.Now()
if err := s.executionLogRepo.UpdateTaskExecutionLog(claimedLog); err != nil {
s.logger.Errorf("[严重] 更新任务执行日志失败, 日志ID: %d, 错误: %v", claimedLog.ID, err)
return err
}
return nil
}
// handlePlanTermination 集中处理计划的终止逻辑(失败或取消)
func (s *planExecutionManagerImpl) handlePlanTermination(planLogID uint, reason string) {
// 1. 从待执行队列中删除所有相关的子任务
if err := s.pendingTaskRepo.DeletePendingTasksByPlanLogID(planLogID); err != nil {
s.logger.Errorf("从待执行队列中删除计划 %d 的后续任务时出错: %v", planLogID, err)
}
// 2. 将父计划的执行日志标记为失败
if err := s.executionLogRepo.FailPlanExecution(planLogID, reason); err != nil {
s.logger.Errorf("标记计划执行日志 %d 为失败时出错: %v", planLogID, err)
}
// 3. 将所有未完成的子任务日志标记为已取消
if err := s.executionLogRepo.CancelIncompleteTasksByPlanLogID(planLogID, "父计划失败或被取消"); err != nil {
s.logger.Errorf("取消计划 %d 的后续任务日志时出错: %v", planLogID, err)
}
// 4. 获取计划执行日志以获取顶层 PlanID
planLog, err := s.executionLogRepo.FindPlanExecutionLogByID(planLogID)
if err != nil {
s.logger.Errorf("无法找到计划执行日志 %d 以更新父计划状态: %v", planLogID, err)
return
}
// 5. 获取顶层计划的详细信息,以检查其类型
topLevelPlan, err := s.planRepo.GetBasicPlanByID(planLog.PlanID)
if err != nil {
s.logger.Errorf("获取顶层计划 %d 的基本信息失败: %v", planLog.PlanID, err)
return
}
// 6. 如果是系统任务,则不修改计划状态
if topLevelPlan.PlanType == models.PlanTypeSystem {
s.logger.Warnf("系统任务 %d (日志ID: %d) 执行失败,但根据策略不修改其计划状态。", topLevelPlan.ID, planLogID)
return
}
// 7. 将计划本身的状态更新为失败 (仅对非系统任务执行)
if err := s.planRepo.UpdatePlanStatus(planLog.PlanID, models.PlanStatusFailed); err != nil {
s.logger.Errorf("更新计划 %d 状态为 '失败' 时出错: %v", planLog.PlanID, err)
}
}
// handlePlanCompletion 集中处理计划成功完成后的所有逻辑
func (s *planExecutionManagerImpl) handlePlanCompletion(planLogID uint) {
s.logger.Infof("计划执行 %d 的所有任务已完成,开始处理计划完成逻辑...", planLogID)
// 1. 通过 PlanExecutionLog 反查正确的顶层 PlanID
planExecutionLog, err := s.executionLogRepo.FindPlanExecutionLogByID(planLogID)
if err != nil {
s.logger.Errorf("获取计划执行日志 %d 失败: %v", planLogID, err)
return
}
topLevelPlanID := planExecutionLog.PlanID // 这才是正确的顶层计划ID
// 2. 获取计划的最新数据,这里我们只需要基本信息来判断执行类型和次数
plan, err := s.planRepo.GetBasicPlanByID(topLevelPlanID)
if err != nil {
s.logger.Errorf("获取计划 %d 的基本信息失败: %v", topLevelPlanID, err)
return
}
// 3. 在内存中计算新的计数值和状态
newExecuteCount := plan.ExecuteCount + 1
newStatus := plan.Status // 默认为当前状态
// 如果是自动计划且达到执行次数上限,或计划是手动类型,则更新计划状态为已停止
if (plan.ExecutionType == models.PlanExecutionTypeAutomatic && plan.ExecuteNum > 0 && newExecuteCount >= plan.ExecuteNum) || plan.ExecutionType == models.PlanExecutionTypeManual {
newStatus = models.PlanStatusStopped
s.logger.Infof("计划 %d 已完成执行,状态更新为 '执行完毕'。", topLevelPlanID)
}
// 4. 使用专门的方法来原子性地更新计数值和状态
if err := s.planRepo.UpdatePlanStateAfterExecution(topLevelPlanID, newExecuteCount, newStatus); err != nil {
s.logger.Errorf("更新计划 %d 的执行后状态失败: %v", topLevelPlanID, err)
return
}
// 5. 更新计划执行日志状态为完成
if err := s.executionLogRepo.UpdatePlanExecutionLogStatus(planLogID, models.ExecutionStatusCompleted); err != nil {
s.logger.Errorf("更新计划执行日志 %d 状态为 '完成' 失败: %v", planLogID, err)
}
// 6. 调用共享的 Manager 来处理触发器更新逻辑
// 只有当计划在本次执行后仍然是 Enabled 状态时,才需要创建下一次的触发器。
if newStatus == models.PlanStatusEnabled {
if err := s.analysisPlanTaskManager.CreateOrUpdateTrigger(topLevelPlanID); err != nil {
s.logger.Errorf("为计划 %d 创建/更新触发器失败: %v", topLevelPlanID, err)
}
} else {
s.logger.Infof("计划 %d 状态为 '%d',无需创建下一次触发器。", topLevelPlanID, newStatus)
}
}

View File

@@ -0,0 +1,409 @@
package plan
import (
"errors"
"fmt"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
"gorm.io/gorm"
)
var (
// ErrPlanNotFound 表示未找到计划
ErrPlanNotFound = errors.New("计划不存在")
// ErrPlanCannotBeModified 表示计划不允许修改
ErrPlanCannotBeModified = errors.New("系统计划不允许修改")
// ErrPlanCannotBeDeleted 表示计划不允许删除
ErrPlanCannotBeDeleted = errors.New("系统计划不允许删除")
// ErrPlanCannotBeStarted 表示计划不允许手动启动
ErrPlanCannotBeStarted = errors.New("系统计划不允许手动启动")
// ErrPlanAlreadyEnabled 表示计划已处于启动状态
ErrPlanAlreadyEnabled = errors.New("计划已处于启动状态,无需重复操作")
// ErrPlanNotEnabled 表示计划未处于启动状态
ErrPlanNotEnabled = errors.New("计划当前不是启用状态")
// ErrPlanCannotBeStopped 表示计划不允许停止
ErrPlanCannotBeStopped = errors.New("系统计划不允许停止")
)
// Service 定义了计划领域服务的接口。
type Service interface {
// Start 启动计划相关的后台服务,例如计划执行管理器。
Start()
// Stop 停止计划相关的后台服务,例如计划执行管理器。
Stop()
// RefreshPlanTriggers 刷新计划触发器,同步数据库中的计划状态和待执行队列中的触发器任务。
RefreshPlanTriggers() error
// CreatePlan 创建一个新的计划
CreatePlan(plan *models.Plan) (*models.Plan, error)
// GetPlanByID 根据ID获取计划详情
GetPlanByID(id uint) (*models.Plan, error)
// ListPlans 获取计划列表,支持过滤和分页
ListPlans(opts repository.ListPlansOptions, page, pageSize int) ([]models.Plan, int64, error)
// UpdatePlan 更新计划
UpdatePlan(plan *models.Plan) (*models.Plan, error)
// DeletePlan 删除计划(软删除)
DeletePlan(id uint) error
// StartPlan 启动计划
StartPlan(id uint) error
// StopPlan 停止计划
StopPlan(id uint) error
}
// planServiceImpl 是 Service 接口的具体实现。
type planServiceImpl struct {
executionManager ExecutionManager
taskManager AnalysisPlanTaskManager
planRepo repository.PlanRepository
deviceRepo repository.DeviceRepository
unitOfWork repository.UnitOfWork
taskFactory TaskFactory
logger *logs.Logger
}
// NewPlanService 创建一个新的 Service 实例。
func NewPlanService(
executionManager ExecutionManager,
taskManager AnalysisPlanTaskManager,
planRepo repository.PlanRepository,
deviceRepo repository.DeviceRepository,
unitOfWork repository.UnitOfWork,
taskFactory TaskFactory,
logger *logs.Logger,
) Service {
return &planServiceImpl{
executionManager: executionManager,
taskManager: taskManager,
planRepo: planRepo,
deviceRepo: deviceRepo,
unitOfWork: unitOfWork,
taskFactory: taskFactory,
logger: logger,
}
}
// Start 启动计划相关的后台服务。
func (s *planServiceImpl) Start() {
s.logger.Infof("PlanService 正在启动...")
s.executionManager.Start()
}
// Stop 停止计划相关的后台服务。
func (s *planServiceImpl) Stop() {
s.logger.Infof("PlanService 正在停止...")
s.executionManager.Stop()
}
// RefreshPlanTriggers 刷新计划触发器。
func (s *planServiceImpl) RefreshPlanTriggers() error {
s.logger.Infof("PlanService 正在刷新计划触发器...")
return s.taskManager.Refresh()
}
// CreatePlan 创建一个新的计划
func (s *planServiceImpl) CreatePlan(planToCreate *models.Plan) (*models.Plan, error) {
const actionType = "领域层:创建计划"
// 1. 业务规则处理
// 用户创建的计划永远是自定义计划
planToCreate.PlanType = models.PlanTypeCustom
// 自动判断 ContentType
if len(planToCreate.SubPlans) > 0 {
planToCreate.ContentType = models.PlanContentTypeSubPlans
} else {
planToCreate.ContentType = models.PlanContentTypeTasks
}
// 2. 验证和重排顺序 (领域逻辑)
if err := planToCreate.ValidateExecutionOrder(); err != nil {
s.logger.Errorf("%s: 计划 (ID: %d) 的执行顺序无效: %v", actionType, planToCreate.ID, err)
return nil, err
}
planToCreate.ReorderSteps()
// 3. 在调用仓库前,准备好所有数据,包括设备关联
for i := range planToCreate.Tasks {
taskModel := &planToCreate.Tasks[i]
// 使用工厂创建临时领域对象
taskResolver, err := s.taskFactory.CreateTaskFromModel(taskModel)
if err != nil {
// 如果一个任务类型不支持,我们可以选择跳过或报错
s.logger.Warnf("跳过为任务类型 '%s' 解析设备ID: %v", taskModel.Type, err)
continue
}
deviceIDs, err := taskResolver.ResolveDeviceIDs()
if err != nil {
// 在事务外解析失败,直接返回错误
return nil, fmt.Errorf("为任务 '%s' 提取设备ID失败: %w", taskModel.Name, err)
}
if len(deviceIDs) > 0 {
// 优化无需查询完整的设备对象只需构建包含ID的结构体即可建立关联
devices := make([]models.Device, len(deviceIDs))
for i, id := range deviceIDs {
devices[i] = models.Device{Model: gorm.Model{ID: id}}
}
taskModel.Devices = devices
}
}
// 4. 调用仓库方法创建计划,该方法内部会处理事务
err := s.planRepo.CreatePlan(planToCreate)
if err != nil {
s.logger.Errorf("%s: 数据库创建计划失败: %v", actionType, err)
return nil, err
}
// 5. 创建成功后,调用 manager 确保触发器任务定义存在,但不立即加入待执行队列
if err := s.taskManager.EnsureAnalysisTaskDefinition(planToCreate.ID); err != nil {
// 这是一个非阻塞性错误,我们只记录日志,因为主流程(创建计划)已经成功
s.logger.Errorf("为新创建的计划 %d 确保触发器任务定义失败: %v", planToCreate.ID, err)
}
s.logger.Infof("%s: 计划创建成功, ID: %d", actionType, planToCreate.ID)
return planToCreate, nil
}
// GetPlanByID 根据ID获取计划详情
func (s *planServiceImpl) GetPlanByID(id uint) (*models.Plan, error) {
const actionType = "领域层:获取计划详情"
plan, err := s.planRepo.GetPlanByID(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
s.logger.Warnf("%s: 计划不存在, ID: %d", actionType, id)
return nil, ErrPlanNotFound
}
s.logger.Errorf("%s: 数据库查询失败: %v, ID: %d", actionType, err, id)
return nil, err
}
s.logger.Infof("%s: 获取计划详情成功, ID: %d", actionType, id)
return plan, nil
}
// ListPlans 获取计划列表,支持过滤和分页
func (s *planServiceImpl) ListPlans(opts repository.ListPlansOptions, page, pageSize int) ([]models.Plan, int64, error) {
const actionType = "领域层:获取计划列表"
plans, total, err := s.planRepo.ListPlans(opts, page, pageSize)
if err != nil {
s.logger.Errorf("%s: 数据库查询失败: %v", actionType, err)
return nil, 0, err
}
s.logger.Infof("%s: 获取计划列表成功, 数量: %d", actionType, len(plans))
return plans, total, nil
}
// UpdatePlan 更新计划
func (s *planServiceImpl) UpdatePlan(planToUpdate *models.Plan) (*models.Plan, error) {
const actionType = "领域层:更新计划"
existingPlan, err := s.planRepo.GetBasicPlanByID(planToUpdate.ID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
s.logger.Warnf("%s: 计划不存在, ID: %d", actionType, planToUpdate.ID)
return nil, ErrPlanNotFound
}
s.logger.Errorf("%s: 获取计划信息失败: %v, ID: %d", actionType, err, planToUpdate.ID)
return nil, err
}
// 系统计划不允许修改
if existingPlan.PlanType == models.PlanTypeSystem {
s.logger.Warnf("%s: 尝试修改系统计划, ID: %d", actionType, planToUpdate.ID)
return nil, ErrPlanCannotBeModified
}
// 自动判断 ContentType
if len(planToUpdate.SubPlans) > 0 {
planToUpdate.ContentType = models.PlanContentTypeSubPlans
} else {
planToUpdate.ContentType = models.PlanContentTypeTasks
}
// 验证和重排顺序 (领域逻辑)
if err := planToUpdate.ValidateExecutionOrder(); err != nil {
s.logger.Errorf("%s: 计划 (ID: %d) 的执行顺序无效: %v", actionType, planToUpdate.ID, err)
return nil, err
}
planToUpdate.ReorderSteps()
// 只要是更新任务,就重置执行计数器
planToUpdate.ExecuteCount = 0
s.logger.Infof("计划 #%d 被更新,执行计数器已重置为 0。", planToUpdate.ID)
// 在调用仓库前,准备好所有数据,包括设备关联
for i := range planToUpdate.Tasks {
taskModel := &planToUpdate.Tasks[i]
taskResolver, err := s.taskFactory.CreateTaskFromModel(taskModel)
if err != nil {
s.logger.Warnf("跳过为任务类型 '%s' 解析设备ID: %v", taskModel.Type, err)
continue
}
deviceIDs, err := taskResolver.ResolveDeviceIDs()
if err != nil {
return nil, fmt.Errorf("为任务 '%s' 提取设备ID失败: %w", taskModel.Name, err)
}
if len(deviceIDs) > 0 {
// 优化无需查询完整的设备对象只需构建包含ID的结构体即可建立关联
devices := make([]models.Device, len(deviceIDs))
for i, id := range deviceIDs {
devices[i] = models.Device{Model: gorm.Model{ID: id}}
}
taskModel.Devices = devices
}
}
// 调用仓库方法更新计划,该方法内部会处理事务
err = s.planRepo.UpdatePlanMetadataAndStructure(planToUpdate)
if err != nil {
s.logger.Errorf("%s: 数据库更新计划失败: %v, Plan: %+v", actionType, err, planToUpdate)
return nil, err
}
if err := s.taskManager.EnsureAnalysisTaskDefinition(planToUpdate.ID); err != nil {
s.logger.Errorf("为更新后的计划 %d 确保触发器任务定义失败: %v", planToUpdate.ID, err)
}
updatedPlan, err := s.planRepo.GetPlanByID(planToUpdate.ID)
if err != nil {
s.logger.Errorf("%s: 获取更新后计划详情失败: %v, ID: %d", actionType, err, planToUpdate.ID)
return nil, errors.New("获取更新后计划详情时发生内部错误")
}
s.logger.Infof("%s: 计划更新成功, ID: %d", actionType, updatedPlan.ID)
return updatedPlan, nil
}
// DeletePlan 删除计划(软删除)
func (s *planServiceImpl) DeletePlan(id uint) error {
const actionType = "领域层:删除计划"
plan, err := s.planRepo.GetBasicPlanByID(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
s.logger.Warnf("%s: 计划不存在, ID: %d", actionType, id)
return ErrPlanNotFound
}
s.logger.Errorf("%s: 获取计划信息失败: %v, ID: %d", actionType, err, id)
return err
}
// 系统计划不允许删除
if plan.PlanType == models.PlanTypeSystem {
s.logger.Warnf("%s: 尝试删除系统计划, ID: %d", actionType, id)
return ErrPlanCannotBeDeleted
}
// 如果计划处于启用状态,先停止它
if plan.Status == models.PlanStatusEnabled {
if err := s.planRepo.StopPlanTransactionally(id); err != nil {
s.logger.Errorf("%s: 停止计划失败: %v, ID: %d", actionType, err, id)
return err
}
}
if err := s.planRepo.DeletePlan(id); err != nil {
s.logger.Errorf("%s: 数据库删除失败: %v, ID: %d", actionType, err, id)
return err
}
s.logger.Infof("%s: 计划删除成功, ID: %d", actionType, id)
return nil
}
// StartPlan 启动计划
func (s *planServiceImpl) StartPlan(id uint) error {
const actionType = "领域层:启动计划"
plan, err := s.planRepo.GetBasicPlanByID(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
s.logger.Warnf("%s: 计划不存在, ID: %d", actionType, id)
return ErrPlanNotFound
}
s.logger.Errorf("%s: 获取计划信息失败: %v, ID: %d", actionType, err, id)
return err
}
// 系统计划不允许手动启动
if plan.PlanType == models.PlanTypeSystem {
s.logger.Warnf("%s: 尝试手动启动系统计划, ID: %d", actionType, id)
return ErrPlanCannotBeStarted
}
// 计划已处于启动状态,无需重复操作
if plan.Status == models.PlanStatusEnabled {
s.logger.Warnf("%s: 计划已处于启动状态,无需重复操作, ID: %d", actionType, id)
return ErrPlanAlreadyEnabled
}
// 如果计划未处于启用状态
if plan.Status != models.PlanStatusEnabled {
// 如果执行计数器大于0重置为0
if plan.ExecuteCount > 0 {
if err := s.planRepo.UpdateExecuteCount(plan.ID, 0); err != nil {
s.logger.Errorf("%s: 重置计划执行计数失败: %v, ID: %d", actionType, err, plan.ID)
return err
}
s.logger.Infof("计划 #%d 的执行计数器已重置为 0。", plan.ID)
}
// 更新计划状态为启用
if err := s.planRepo.UpdatePlanStatus(plan.ID, models.PlanStatusEnabled); err != nil {
s.logger.Errorf("%s: 更新计划状态失败: %v, ID: %d", actionType, err, plan.ID)
return err
}
s.logger.Infof("已成功更新计划 #%d 的状态为 '已启动'。", plan.ID)
}
// 创建或更新触发器
if err := s.taskManager.CreateOrUpdateTrigger(plan.ID); err != nil {
s.logger.Errorf("%s: 创建或更新触发器失败: %v, ID: %d", actionType, err, plan.ID)
return err
}
s.logger.Infof("%s: 计划已成功启动, ID: %d", actionType, id)
return nil
}
// StopPlan 停止计划
func (s *planServiceImpl) StopPlan(id uint) error {
const actionType = "领域层:停止计划"
plan, err := s.planRepo.GetBasicPlanByID(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
s.logger.Warnf("%s: 计划不存在, ID: %d", actionType, id)
return ErrPlanNotFound
}
s.logger.Errorf("%s: 获取计划信息失败: %v, ID: %d", actionType, err, id)
return err
}
// 系统计划不允许停止
if plan.PlanType == models.PlanTypeSystem {
s.logger.Warnf("%s: 尝试停止系统计划, ID: %d", actionType, id)
return ErrPlanCannotBeStopped
}
// 计划当前不是启用状态
if plan.Status != models.PlanStatusEnabled {
s.logger.Warnf("%s: 计划当前不是启用状态, ID: %d, Status: %s", actionType, id, plan.Status)
return ErrPlanNotEnabled
}
// 停止计划事务性操作
if err := s.planRepo.StopPlanTransactionally(id); err != nil {
s.logger.Errorf("%s: 停止计划失败: %v, ID: %d", actionType, err, id)
return err
}
s.logger.Infof("%s: 计划已成功停止, ID: %d", actionType, id)
return nil
}

View File

@@ -0,0 +1,34 @@
package plan
import "git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
// Task 定义了所有可被调度器执行的任务必须实现的接口。
type Task interface {
// Execute 是任务的核心执行逻辑。
// ctx: 用于控制任务的超时或取消。
// log: 包含了当前任务执行的完整上下文信息,包括从数据库中加载的任务参数等。
// 返回的 error 表示任务是否执行成功。调度器会根据返回的 error 是否为 nil 来决定任务状态。
Execute() error
// OnFailure 定义了当 Execute 方法返回错误时,需要执行的回滚或清理逻辑。
// log: 任务执行的上下文。
// executeErr: 从 Execute 方法返回的原始错误。
OnFailure(executeErr error)
TaskDeviceIDResolver
}
// TaskDeviceIDResolver 定义了从任务配置中解析设备ID的方法
type TaskDeviceIDResolver interface {
// ResolveDeviceIDs 从任务配置中解析并返回所有关联的设备ID列表
// 返回值: uint数组每个字符串代表一个设备ID
ResolveDeviceIDs() ([]uint, error)
}
// TaskFactory 是一个工厂接口,用于根据任务执行日志创建任务实例。
type TaskFactory interface {
// Production 根据指定的任务执行日志创建一个任务实例。
Production(claimedLog *models.TaskExecutionLog) Task
// CreateTaskFromModel 仅根据任务模型创建一个任务实例,用于非执行场景(如参数解析)。
CreateTaskFromModel(taskModel *models.Task) (TaskDeviceIDResolver, error)
}

View File

@@ -0,0 +1,71 @@
package task
import (
"fmt"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/plan"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
)
type DelayTaskParams struct {
DelayDuration float64 `json:"delay_duration"`
}
// DelayTask 是一个用于模拟延迟的 Task 实现
type DelayTask struct {
executionTask *models.TaskExecutionLog
duration time.Duration
logger *logs.Logger
}
func NewDelayTask(logger *logs.Logger, executionTask *models.TaskExecutionLog) plan.Task {
return &DelayTask{
executionTask: executionTask,
logger: logger,
}
}
// Execute 执行延迟任务,等待指定的时间
func (d *DelayTask) Execute() error {
if err := d.parseParameters(); err != nil {
return err
}
d.logger.Infof("任务 %v: 开始延迟 %v...", d.executionTask.TaskID, d.duration)
time.Sleep(d.duration)
d.logger.Infof("任务 %v: 延迟结束。", d.executionTask.TaskID)
return nil
}
func (d *DelayTask) parseParameters() error {
if d.executionTask.Task.Parameters == nil {
d.logger.Errorf("任务 %v: 缺少参数", d.executionTask.TaskID)
return fmt.Errorf("任务 %v: 参数不全", d.executionTask.TaskID)
}
var params DelayTaskParams
err := d.executionTask.Task.ParseParameters(&params)
if err != nil {
d.logger.Errorf("任务 %v: 解析参数失败: %v", d.executionTask.TaskID, err)
return fmt.Errorf("任务 %v: 解析参数失败: %v", d.executionTask.TaskID, err)
}
if params.DelayDuration <= 0 {
d.logger.Errorf("任务 %v: 参数 delay_duration 缺失或无效 (必须大于0)", d.executionTask.TaskID)
return fmt.Errorf("任务 %v: 参数 delay_duration 缺失或无效 (必须大于0)", d.executionTask.TaskID)
}
d.duration = time.Duration(params.DelayDuration) * time.Second
return nil
}
func (d *DelayTask) OnFailure(executeErr error) {
d.logger.Errorf("任务 %v: 执行失败: %v", d.executionTask.TaskID, executeErr)
}
func (d *DelayTask) ResolveDeviceIDs() ([]uint, error) {
return []uint{}, nil
}

View File

@@ -0,0 +1,100 @@
package task
import (
"fmt"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/device"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/plan"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
)
// FullCollectionTask 实现了 plan.Task 接口,用于执行一次全量的设备数据采集
type FullCollectionTask struct {
log *models.TaskExecutionLog
deviceRepo repository.DeviceRepository
deviceService device.Service
logger *logs.Logger
}
// NewFullCollectionTask 创建一个全量采集任务实例
func NewFullCollectionTask(
log *models.TaskExecutionLog,
deviceRepo repository.DeviceRepository,
deviceService device.Service,
logger *logs.Logger,
) plan.Task {
return &FullCollectionTask{
log: log,
deviceRepo: deviceRepo,
deviceService: deviceService,
logger: logger,
}
}
// Execute 是任务的核心执行逻辑
func (t *FullCollectionTask) Execute() error {
t.logger.Infow("开始执行全量采集任务", "task_id", t.log.TaskID, "task_type", t.log.Task.Type, "log_id", t.log.ID)
sensors, err := t.deviceRepo.ListAllSensors()
if err != nil {
return fmt.Errorf("全量采集任务: 从数据库获取所有传感器失败: %w", err)
}
if len(sensors) == 0 {
t.logger.Infow("全量采集任务: 未发现任何传感器设备,跳过本次采集", "task_id", t.log.TaskID, "task_type", t.log.Task.Type, "log_id", t.log.ID)
return nil
}
sensorsByController := make(map[uint][]*models.Device)
for _, sensor := range sensors {
sensorsByController[sensor.AreaControllerID] = append(sensorsByController[sensor.AreaControllerID], sensor)
}
var firstError error
for controllerID, controllerSensors := range sensorsByController {
t.logger.Infow("全量采集任务: 准备为区域主控下的传感器下发采集指令",
"task_id", t.log.TaskID,
"task_type", t.log.Task.Type,
"log_id", t.log.ID,
"controller_id", controllerID,
"sensor_count", len(controllerSensors),
)
if err := t.deviceService.Collect(controllerID, controllerSensors); err != nil {
t.logger.Errorw("全量采集任务: 为区域主控下发采集指令失败",
"task_id", t.log.TaskID,
"task_type", t.log.Task.Type,
"log_id", t.log.ID,
"controller_id", controllerID,
"error", err,
)
if firstError == nil {
firstError = err // 保存第一个错误
}
}
}
if firstError != nil {
return fmt.Errorf("全量采集任务执行期间发生错误: %w", firstError)
}
t.logger.Infow("全量采集任务执行完成", "task_id", t.log.TaskID, "task_type", t.log.Task.Type, "log_id", t.log.ID)
return nil
}
// OnFailure 定义了当 Execute 方法返回错误时,需要执行的回滚或清理逻辑
func (t *FullCollectionTask) OnFailure(executeErr error) {
t.logger.Errorw("全量采集任务执行失败",
"task_id", t.log.TaskID,
"task_type", t.log.Task.Type,
"log_id", t.log.ID,
"error", executeErr,
)
}
// ResolveDeviceIDs 获取当前任务需要使用的设备ID列表
func (t *FullCollectionTask) ResolveDeviceIDs() ([]uint, error) {
// 全量采集任务不和任何设备绑定, 每轮采集都会重新获取全量传感器
return []uint{}, nil
}

View File

@@ -0,0 +1,182 @@
package task
import (
"encoding/json"
"fmt"
"sync"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/device"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/plan"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
)
// ReleaseFeedWeightTaskParams 定义了 ReleaseFeedWeightTask 的参数结构
type ReleaseFeedWeightTaskParams struct {
ReleaseWeight float64 `json:"release_weight"` // 需要释放的重量
FeedPortDeviceID uint `json:"feed_port_device_id"` // 下料口ID
MixingTankDeviceID uint `json:"mixing_tank_device_id"` // 称重传感器ID
}
// ReleaseFeedWeightTask 是一个控制下料口释放指定重量的任务
type ReleaseFeedWeightTask struct {
deviceRepo repository.DeviceRepository
sensorDataRepo repository.SensorDataRepository
claimedLog *models.TaskExecutionLog
feedPortDevice *models.Device
releaseWeight float64
mixingTankDeviceID uint
feedPort device.Service
// onceParse 保证解析参数只执行一次
onceParse sync.Once
logger *logs.Logger
}
// NewReleaseFeedWeightTask 创建一个新的 ReleaseFeedWeightTask 实例
func NewReleaseFeedWeightTask(
claimedLog *models.TaskExecutionLog,
sensorDataRepo repository.SensorDataRepository,
deviceRepo repository.DeviceRepository,
deviceService device.Service,
logger *logs.Logger,
) plan.Task {
return &ReleaseFeedWeightTask{
claimedLog: claimedLog,
deviceRepo: deviceRepo,
sensorDataRepo: sensorDataRepo,
feedPort: deviceService,
logger: logger,
}
}
func (r *ReleaseFeedWeightTask) Execute() error {
r.logger.Infof("任务 %v: 开始执行, 日志ID: %v", r.claimedLog.TaskID, r.claimedLog.ID)
if err := r.parseParameters(); err != nil {
return err
}
weight, err := r.getNowWeight()
if err != nil {
return err
}
if err = r.feedPort.Switch(r.feedPortDevice, device.DeviceActionStart); err != nil {
r.logger.Errorf("启动下料口(id=%v)失败: %v , 日志ID: %v", r.feedPortDevice.ID, err, r.claimedLog.ID)
return err
}
targetWeight := weight - r.releaseWeight
errCount := 1
// TODO 这个判断有延迟, 尤其是LoRa通信本身延迟较高, 可以考虑根据信号质量或其他指标提前发送停止命令
for targetWeight <= weight {
weight, err = r.getNowWeight()
if err != nil {
errCount++
if errCount > 3 { // 如果连续三次没成功采集到重量数据,则认为计划执行失败
r.logger.Errorf("获取当前计划执行日志(id=%v)的当前搅拌罐重量失败: %v, 任务结束", r.claimedLog.ID, err)
return err
}
r.logger.Warnf("第%v次尝试获取当前计划执行日志(id=%v)的当前搅拌罐重量失败: %v", errCount, r.claimedLog.ID, err)
continue
}
time.Sleep(100 * time.Millisecond)
}
if err = r.feedPort.Switch(r.feedPortDevice, device.DeviceActionStop); err != nil {
r.logger.Errorf("关闭下料口(id=%v)失败: %v , 日志ID: %v", r.feedPortDevice.ID, err, r.claimedLog.ID)
return err
}
r.logger.Infof("完成计划执行日志(id=%v)的当前计划, 完成下料 %vkg, 搅拌罐剩余重量 %vkg", r.claimedLog.ID, r.releaseWeight, weight)
return nil
}
// 获取当前搅拌罐重量
func (r *ReleaseFeedWeightTask) getNowWeight() (float64, error) {
sensorData, err := r.sensorDataRepo.GetLatestSensorDataByDeviceIDAndSensorType(r.mixingTankDeviceID, models.SensorTypeWeight)
if err != nil {
r.logger.Errorf("获取设备 %v 最新传感器数据失败: %v , 日志ID: %v", r.mixingTankDeviceID, err, r.claimedLog.ID)
return 0, err
}
if sensorData == nil {
return 0, fmt.Errorf("未找到设备 %v 的最新重量传感器数据", r.mixingTankDeviceID)
}
wg := &models.WeightData{}
err = json.Unmarshal(sensorData.Data, wg)
if err != nil {
r.logger.Errorf("反序列化设备 %v 最新传感器数据失败: %v , 日志ID: %v", r.mixingTankDeviceID, err, r.claimedLog.ID)
return 0, err
}
return wg.WeightKilograms, nil
}
func (r *ReleaseFeedWeightTask) parseParameters() error {
var err error
r.onceParse.Do(func() {
if r.claimedLog.Task.Parameters == nil {
r.logger.Errorf("任务 %v: 缺少参数", r.claimedLog.TaskID)
err = fmt.Errorf("任务 %v: 参数不全", r.claimedLog.TaskID)
}
var params ReleaseFeedWeightTaskParams
err := r.claimedLog.Task.ParseParameters(&params)
if err != nil {
r.logger.Errorf("任务 %v: 解析参数失败: %v", r.claimedLog.TaskID, err)
err = fmt.Errorf("任务 %v: 解析参数失败: %v", r.claimedLog.TaskID, err)
}
// 校验参数是否存在
if params.ReleaseWeight == 0 {
r.logger.Errorf("任务 %v: 参数 release_weight 缺失或无效", r.claimedLog.TaskID)
err = fmt.Errorf("任务 %v: 参数 release_weight 缺失或无效", r.claimedLog.TaskID)
}
if params.FeedPortDeviceID == 0 {
r.logger.Errorf("任务 %v: 参数 feed_port_device_id 缺失或无效", r.claimedLog.TaskID)
err = fmt.Errorf("任务 %v: 参数 feed_port_device_id 缺失或无效", r.claimedLog.TaskID)
}
if params.MixingTankDeviceID == 0 {
r.logger.Errorf("任务 %v: 参数 mixing_tank_device_id 缺失或无效", r.claimedLog.TaskID)
err = fmt.Errorf("任务 %v: 参数 mixing_tank_device_id 缺失或无效", r.claimedLog.TaskID)
}
r.releaseWeight = params.ReleaseWeight
r.mixingTankDeviceID = params.MixingTankDeviceID
r.feedPortDevice, err = r.deviceRepo.FindByID(params.FeedPortDeviceID)
if err != nil {
r.logger.Errorf("任务 %v: 获取设备信息失败: %v", r.claimedLog.TaskID, err)
err = fmt.Errorf("任务 %v: 获取设备信息失败: %v", r.claimedLog.TaskID, err)
}
})
return err
}
func (r *ReleaseFeedWeightTask) OnFailure(executeErr error) {
r.logger.Errorf("开始善后处理, 日志ID:%v; 错误信息: %v", r.claimedLog.ID, executeErr)
if r.feedPort != nil {
err := r.feedPort.Switch(r.feedPortDevice, device.DeviceActionStop)
if err != nil {
r.logger.Errorf("[严重] 下料口停止失败, 日志ID: %v, 错误: %v", r.claimedLog.ID, err)
}
} else {
r.logger.Warnf("[警告] 下料口通信器尚未初始化, 不进行任何操作, 日志ID: %v", r.claimedLog.ID)
}
r.logger.Errorf("善后处理完成, 日志ID:%v", r.claimedLog.ID)
}
func (r *ReleaseFeedWeightTask) ResolveDeviceIDs() ([]uint, error) {
if err := r.parseParameters(); err != nil {
return nil, err
}
return []uint{r.feedPortDevice.ID, r.mixingTankDeviceID}, nil
}

View File

@@ -0,0 +1,71 @@
package task
import (
"fmt"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/device"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/plan"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
)
type taskFactory struct {
logger *logs.Logger
sensorDataRepo repository.SensorDataRepository
deviceRepo repository.DeviceRepository
deviceService device.Service
}
func NewTaskFactory(
logger *logs.Logger,
sensorDataRepo repository.SensorDataRepository,
deviceRepo repository.DeviceRepository,
deviceService device.Service,
) plan.TaskFactory {
return &taskFactory{
logger: logger,
sensorDataRepo: sensorDataRepo,
deviceRepo: deviceRepo,
deviceService: deviceService,
}
}
func (t *taskFactory) Production(claimedLog *models.TaskExecutionLog) plan.Task {
switch claimedLog.Task.Type {
case models.TaskTypeWaiting:
return NewDelayTask(t.logger, claimedLog)
case models.TaskTypeReleaseFeedWeight:
return NewReleaseFeedWeightTask(claimedLog, t.sensorDataRepo, t.deviceRepo, t.deviceService, t.logger)
case models.TaskTypeFullCollection:
return NewFullCollectionTask(claimedLog, t.deviceRepo, t.deviceService, t.logger)
default:
// TODO 这里直接panic合适吗? 不过这个场景确实不该出现任何异常的任务类型
t.logger.Panicf("不支持的任务类型: %s", claimedLog.Task.Type)
panic("不支持的任务类型") // 显式panic防编译器报错
}
}
// CreateTaskFromModel 实现了 TaskFactory 接口,用于从模型创建任务实例。
func (t *taskFactory) CreateTaskFromModel(taskModel *models.Task) (plan.TaskDeviceIDResolver, error) {
// 这个方法不关心 claimedLog 的其他字段,所以可以构造一个临时的
// 它只用于访问那些不依赖于执行日志的方法,比如 ResolveDeviceIDs
tempLog := &models.TaskExecutionLog{Task: *taskModel}
switch taskModel.Type {
case models.TaskTypeWaiting:
return NewDelayTask(t.logger, tempLog), nil
case models.TaskTypeReleaseFeedWeight:
return NewReleaseFeedWeightTask(
tempLog,
t.sensorDataRepo,
t.deviceRepo,
t.deviceService,
t.logger,
), nil
case models.TaskTypeFullCollection:
return NewFullCollectionTask(tempLog, t.deviceRepo, t.deviceService, t.logger), nil
default:
return nil, fmt.Errorf("不支持为类型 '%s' 的任务创建模型实例", taskModel.Type)
}
}

View File

@@ -1,6 +1,7 @@
package token package token
import ( import (
"fmt"
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
@@ -12,19 +13,19 @@ type Claims struct {
jwt.RegisteredClaims jwt.RegisteredClaims
} }
// TokenService 定义了 token 操作的接口 // Service 定义了 token 操作的接口
type TokenService interface { type Service interface {
GenerateToken(userID uint) (string, error) GenerateToken(userID uint) (string, error)
ParseToken(tokenString string) (*Claims, error) ParseToken(tokenString string) (*Claims, error)
} }
// tokenService 是 TokenService 接口的实现 // tokenService 是 Service 接口的实现
type tokenService struct { type tokenService struct {
secret []byte secret []byte
} }
// NewTokenService 创建并返回一个新的 TokenService 实例 // NewTokenService 创建并返回一个新的 Service 实例
func NewTokenService(secret []byte) TokenService { func NewTokenService(secret []byte) Service {
return &tokenService{secret: secret} return &tokenService{secret: secret}
} }
@@ -52,9 +53,16 @@ func (s *tokenService) ParseToken(tokenString string) (*Claims, error) {
return s.secret, nil return s.secret, nil
}) })
// 优先检查解析过程中是否发生错误
if err != nil {
return nil, err
}
// 只有当 token 对象有效时,才尝试获取 Claims 并验证
if claims, ok := token.Claims.(*Claims); ok && token.Valid { if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil return claims, nil
} }
return nil, err // 如果 token 无效(例如,过期但没有返回错误,或者 Claims 类型不匹配),则返回一个通用错误
return nil, fmt.Errorf("token is invalid")
} }

View File

@@ -29,6 +29,24 @@ type Config struct {
// Heartbeat 心跳配置 // Heartbeat 心跳配置
Heartbeat HeartbeatConfig `yaml:"heartbeat"` Heartbeat HeartbeatConfig `yaml:"heartbeat"`
// ChirpStack ChirpStack API 配置
ChirpStack ChirpStackConfig `yaml:"chirp_stack"`
// TaskConfig 任务调度配置
Task TaskConfig `yaml:"task"`
// Lora Lora配置
Lora LoraConfig `yaml:"lora"`
// LoraMesh LoraMesh配置
LoraMesh LoraMeshConfig `yaml:"lora_mesh"`
// Notify 通知服务配置
Notify NotifyConfig `yaml:"notify"`
// Collection 定时采集配置
Collection CollectionConfig `yaml:"collection"`
} }
// AppConfig 代表应用基础配置 // AppConfig 代表应用基础配置
@@ -78,6 +96,9 @@ type DatabaseConfig struct {
// SSLMode SSL模式 // SSLMode SSL模式
SSLMode string `yaml:"sslmode"` SSLMode string `yaml:"sslmode"`
// IsTimescaleDB is timescaledb
IsTimescaleDB bool `yaml:"is_timescaledb"`
// MaxOpenConns 最大开放连接数 // MaxOpenConns 最大开放连接数
MaxOpenConns int `yaml:"max_open_conns"` MaxOpenConns int `yaml:"max_open_conns"`
@@ -106,10 +127,91 @@ type HeartbeatConfig struct {
Concurrency int `yaml:"concurrency"` Concurrency int `yaml:"concurrency"`
} }
// ChirpStackConfig 代表 ChirpStack API 配置
type ChirpStackConfig struct {
APIHost string `yaml:"api_host"`
APIToken string `yaml:"api_token"`
FPort int `yaml:"fport"`
APITimeout int `yaml:"api_timeout"`
CollectionRequestTimeout int `yaml:"collection_request_timeout"`
}
// TaskConfig 代表任务调度配置
type TaskConfig struct {
Interval int `yaml:"interval"`
NumWorkers int `yaml:"num_workers"`
}
type LoraMode string
const (
LoraMode_LoRaWAN LoraMode = "lora_wan"
LoraMode_LoRaMesh LoraMode = "lora_mesh"
)
// LoraConfig 代表Lora配置
type LoraConfig struct {
Mode LoraMode `yaml:"mode"`
}
// LoraMeshConfig 代表Lora Mesh配置
type LoraMeshConfig struct {
UARTPort string `yaml:"uart_port"`
BaudRate int `yaml:"baud_rate"`
Timeout int `yaml:"timeout"`
LoraMeshMode string `yaml:"lora_mesh_mode"`
MaxChunkSize int `yaml:"max_chunk_size"`
ReassemblyTimeout int `yaml:"reassembly_timeout"`
}
// NotifyConfig 包含了所有与通知服务相关的配置
type NotifyConfig struct {
Primary string `yaml:"primary"` // 首选通知渠道 (e.g., "邮件", "企业微信", "飞书", "日志")
FailureThreshold int `yaml:"failureThreshold"` // 连续失败多少次后触发广播模式
SMTP SMTPConfig `yaml:"smtp"`
WeChat WeChatConfig `yaml:"wechat"`
Lark LarkConfig `yaml:"lark"`
}
// SMTPConfig SMTP邮件配置
type SMTPConfig struct {
Enabled bool `yaml:"enabled"`
Host string `yaml:"host"`
Port int `yaml:"port"`
Username string `yaml:"username"`
Password string `yaml:"password"`
Sender string `yaml:"sender"`
}
// WeChatConfig 企业微信应用配置
type WeChatConfig struct {
Enabled bool `yaml:"enabled"`
CorpID string `yaml:"corpID"`
AgentID string `yaml:"agentID"`
Secret string `yaml:"secret"`
}
// LarkConfig 飞书应用配置
type LarkConfig struct {
Enabled bool `yaml:"enabled"`
AppID string `yaml:"appID"`
AppSecret string `yaml:"appSecret"`
}
// CollectionConfig 代表定时采集配置
type CollectionConfig struct {
// Interval 采集间隔(分钟), 默认 1
Interval int `yaml:"interval"`
}
// NewConfig 创建并返回一个新的配置实例 // NewConfig 创建并返回一个新的配置实例
func NewConfig() *Config { func NewConfig() *Config {
// 默认值可以在这里设置,但我们优先使用配置文件中的值 // 默认值可以在这里设置,但我们优先使用配置文件中的值
return &Config{} return &Config{
Collection: CollectionConfig{
Interval: 1, // 默认为1分钟
},
}
} }
// Load 从指定路径加载配置文件 // Load 从指定路径加载配置文件
@@ -128,16 +230,7 @@ func (c *Config) Load(path string) error {
return nil return nil
} }
// GetDatabaseConnectionString 获取数据库连接字符串 // GenerateAPIKey 用于补齐API Key作为请求头时缺失的部分
func (c *Config) GetDatabaseConnectionString() string { func (c ChirpStackConfig) GenerateAPIKey() string {
// 构建PostgreSQL连接字符串 return "Bearer " + c.APIToken
return fmt.Sprintf(
"user=%s password=%s dbname=%s host=%s port=%d sslmode=%s",
c.Database.Username,
c.Database.Password,
c.Database.DBName,
c.Database.Host,
c.Database.Port,
c.Database.SSLMode,
)
} }

View File

@@ -7,6 +7,8 @@ import (
"fmt" "fmt"
"time" "time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/gorm" "gorm.io/gorm"
@@ -16,6 +18,7 @@ import (
// 使用GORM作为ORM库 // 使用GORM作为ORM库
type PostgresStorage struct { type PostgresStorage struct {
db *gorm.DB db *gorm.DB
isTimescaleDB bool
connectionString string connectionString string
maxOpenConns int maxOpenConns int
maxIdleConns int maxIdleConns int
@@ -25,9 +28,10 @@ type PostgresStorage struct {
// NewPostgresStorage 创建并返回一个新的PostgreSQL存储实例 // NewPostgresStorage 创建并返回一个新的PostgreSQL存储实例
// 它接收一个 logger 实例,而不是自己创建 // 它接收一个 logger 实例,而不是自己创建
func NewPostgresStorage(connectionString string, maxOpenConns, maxIdleConns, connMaxLifetime int, logger *logs.Logger) *PostgresStorage { func NewPostgresStorage(connectionString string, isTimescaleDB bool, maxOpenConns, maxIdleConns, connMaxLifetime int, logger *logs.Logger) *PostgresStorage {
return &PostgresStorage{ return &PostgresStorage{
connectionString: connectionString, connectionString: connectionString,
isTimescaleDB: isTimescaleDB,
maxOpenConns: maxOpenConns, maxOpenConns: maxOpenConns,
maxIdleConns: maxIdleConns, maxIdleConns: maxIdleConns,
connMaxLifetime: connMaxLifetime, connMaxLifetime: connMaxLifetime,
@@ -70,6 +74,9 @@ func (ps *PostgresStorage) Connect() error {
sqlDB.SetMaxIdleConns(ps.maxIdleConns) sqlDB.SetMaxIdleConns(ps.maxIdleConns)
sqlDB.SetConnMaxLifetime(time.Duration(ps.connMaxLifetime) * time.Second) sqlDB.SetConnMaxLifetime(time.Duration(ps.connMaxLifetime) * time.Second)
// gorm会根据字段名自动创建外键约束, 但触发器Task的PlanID是不存在的, 所以需要关闭, 这个关闭对
ps.db.DisableForeignKeyConstraintWhenMigrating = true
ps.logger.Info("PostgreSQL数据库连接成功") ps.logger.Info("PostgreSQL数据库连接成功")
return nil return nil
} }
@@ -113,5 +120,150 @@ func (ps *PostgresStorage) Migrate(models ...interface{}) error {
return fmt.Errorf("数据库表结构迁移失败: %w", err) return fmt.Errorf("数据库表结构迁移失败: %w", err)
} }
ps.logger.Info("数据库表结构迁移完成") ps.logger.Info("数据库表结构迁移完成")
// -- 处理gorm做不到的初始化逻辑 --
if err := ps.creatingIndex(); err != nil {
return err
}
// 如果是 TimescaleDB, 则将部分表转换为 hypertable
if ps.isTimescaleDB {
ps.logger.Info("检测到 TimescaleDB, 准备进行超表转换和压缩策略配置")
if err := ps.setupTimescaleDB(); err != nil {
return err
}
}
return nil
}
// setupTimescaleDB 统一处理所有 TimescaleDB 相关的设置
func (ps *PostgresStorage) setupTimescaleDB() error {
if err := ps.creatingHyperTable(); err != nil {
return err
}
if err := ps.applyCompressionPolicies(); err != nil {
return err
}
return nil
}
// creatingHyperTable 用于在数据库是 TimescaleDB 时创建超表
func (ps *PostgresStorage) creatingHyperTable() error {
// 定义一个辅助结构体来管理超表转换
tablesToConvert := []struct {
model interface{ TableName() string }
timeColumn string
}{
{models.SensorData{}, "time"},
{models.DeviceCommandLog{}, "sent_at"},
{models.PlanExecutionLog{}, "created_at"},
{models.TaskExecutionLog{}, "created_at"},
{models.PendingCollection{}, "created_at"},
{models.UserActionLog{}, "time"},
{models.RawMaterialPurchase{}, "purchase_date"},
{models.RawMaterialStockLog{}, "happened_at"},
{models.FeedUsageRecord{}, "recorded_at"},
{models.MedicationLog{}, "happened_at"},
{models.PigBatchLog{}, "happened_at"},
{models.WeighingBatch{}, "weighing_time"},
{models.WeighingRecord{}, "weighing_time"},
{models.PigTransferLog{}, "transfer_time"},
{models.PigSickLog{}, "happened_at"},
{models.PigPurchase{}, "purchase_date"},
{models.PigSale{}, "sale_date"},
{models.Notification{}, "alarm_timestamp"},
}
for _, table := range tablesToConvert {
tableName := table.model.TableName()
chunkInterval := "1 days" // 统一设置为1天
ps.logger.Debugw("准备将表转换为超表", "table", tableName, "chunk_interval", chunkInterval)
sql := fmt.Sprintf("SELECT create_hypertable('%s', '%s', chunk_time_interval => INTERVAL '%s', if_not_exists => TRUE);", tableName, table.timeColumn, chunkInterval)
if err := ps.db.Exec(sql).Error; err != nil {
ps.logger.Errorw("转换为超表失败", "table", tableName, "error", err)
return fmt.Errorf("将 %s 转换为超表失败: %w", tableName, err)
}
ps.logger.Debugw("成功将表转换为超表 (或已转换)", "table", tableName)
}
return nil
}
// applyCompressionPolicies 为超表配置自动压缩策略
func (ps *PostgresStorage) applyCompressionPolicies() error {
policies := []struct {
model interface{ TableName() string }
segmentColumn string
}{
{models.SensorData{}, "device_id"},
{models.DeviceCommandLog{}, "device_id"},
{models.PlanExecutionLog{}, "plan_id"},
{models.TaskExecutionLog{}, "task_id"},
{models.PendingCollection{}, "device_id"},
{models.UserActionLog{}, "user_id"},
{models.RawMaterialPurchase{}, "raw_material_id"},
{models.RawMaterialStockLog{}, "raw_material_id"},
{models.FeedUsageRecord{}, "pen_id"},
{models.MedicationLog{}, "pig_batch_id"},
{models.PigBatchLog{}, "pig_batch_id"},
{models.WeighingBatch{}, "pig_batch_id"},
{models.WeighingRecord{}, "weighing_batch_id"},
{models.PigTransferLog{}, "pig_batch_id"},
{models.PigSickLog{}, "pig_batch_id"},
{models.PigPurchase{}, "pig_batch_id"},
{models.PigSale{}, "pig_batch_id"},
{models.Notification{}, "user_id"},
}
for _, policy := range policies {
tableName := policy.model.TableName()
compressAfter := "3 days" // 统一设置为2天后即进入第3天开始压缩
// 1. 开启表的压缩设置,并指定分段列
ps.logger.Debugw("为表启用压缩设置", "table", tableName, "segment_by", policy.segmentColumn)
// 使用 + 而非Sprintf以规避goland静态检查报错
alterSQL := "ALTER TABLE" + " " + tableName + " SET (timescaledb.compress, timescaledb.compress_segmentby = '" + policy.segmentColumn + "');"
if err := ps.db.Exec(alterSQL).Error; err != nil {
// 忽略错误,因为这个设置可能是不可变的,重复执行会报错
ps.logger.Warnw("启用压缩设置时遇到问题 (可能已设置,可忽略)", "table", tableName, "error", err)
}
ps.logger.Debugw("成功为表启用压缩设置 (或已启用)", "table", tableName)
// 2. 添加压缩策略
ps.logger.Debugw("为表添加压缩策略", "table", tableName, "compress_after", compressAfter)
policySQL := fmt.Sprintf("SELECT add_compression_policy('%s', INTERVAL '%s', if_not_exists => TRUE);", tableName, compressAfter)
if err := ps.db.Exec(policySQL).Error; err != nil {
ps.logger.Errorw("添加压缩策略失败", "table", tableName, "error", err)
return fmt.Errorf("为 %s 添加压缩策略失败: %w", tableName, err)
}
ps.logger.Debugw("成功为表添加压缩策略 (或已存在)", "table", tableName)
}
return nil
}
// creatingIndex 用于创建gorm无法处理的索引, 如gin索引
func (ps *PostgresStorage) creatingIndex() error {
// 使用 IF NOT EXISTS 保证幂等性
// 如果索引已存在,此命令不会报错
// 为 sensor_data 表的 data 字段创建 GIN 索引
ps.logger.Debug("正在为 sensor_data 表的 data 字段创建 GIN 索引")
ginSensorDataIndexSQL := "CREATE INDEX IF NOT EXISTS idx_sensor_data_data_gin ON sensor_data USING GIN (data);"
if err := ps.db.Exec(ginSensorDataIndexSQL).Error; err != nil {
ps.logger.Errorw("为 sensor_data 的 data 字段创建 GIN 索引失败", "error", err)
return fmt.Errorf("为 sensor_data 的 data 字段创建 GIN 索引失败: %w", err)
}
ps.logger.Debug("成功为 sensor_data 的 data 字段创建 GIN 索引 (或已存在)")
// 为 tasks.parameters 创建 GIN 索引
ps.logger.Debug("正在为 tasks 表的 parameters 字段创建 GIN 索引")
taskGinIndexSQL := "CREATE INDEX IF NOT EXISTS idx_tasks_parameters_gin ON tasks USING GIN (parameters);"
if err := ps.db.Exec(taskGinIndexSQL).Error; err != nil {
ps.logger.Errorw("为 tasks 的 parameters 字段创建 GIN 索引失败", "error", err)
return fmt.Errorf("为 tasks 的 parameters 字段创建 GIN 索引失败: %w", err)
}
ps.logger.Debug("成功为 tasks 的 parameters 字段创建 GIN 索引 (或已存在)")
return nil return nil
} }

View File

@@ -42,9 +42,11 @@ func NewStorage(cfg config.DatabaseConfig, logger *logs.Logger) Storage {
cfg.SSLMode, cfg.SSLMode,
) )
// 当前默认返回PostgreSQL存储实现并将 logger 注入
// 当前默认返回PostgreSQL存储实现并将 logger 注入 // 当前默认返回PostgreSQL存储实现并将 logger 注入
return NewPostgresStorage( return NewPostgresStorage(
connectionString, connectionString,
cfg.IsTimescaleDB, // <--- 添加 IsTimescaleDB
cfg.MaxOpenConns, cfg.MaxOpenConns,
cfg.MaxIdleConns, cfg.MaxIdleConns,
cfg.ConnMaxLifetime, cfg.ConnMaxLifetime,

View File

@@ -6,17 +6,32 @@ package logs
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"os" "os"
"strings" "strings"
"time" "time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/config" "git.huangwc.com/pig/pig-farm-controller/internal/infra/config"
"go.uber.org/zap" "go.uber.org/zap"
"go.uber.org/zap/buffer"
"go.uber.org/zap/zapcore" "go.uber.org/zap/zapcore"
"gopkg.in/natefinch/lumberjack.v2" "gopkg.in/natefinch/lumberjack.v2"
gormlogger "gorm.io/gorm/logger" gormlogger "gorm.io/gorm/logger"
) )
// ANSI 颜色代码常量
const (
reset = "\033[0m"
red = "\033[31m"
green = "\033[32m"
yellow = "\033[33m"
blue = "\033[34m"
magenta = "\033[35m"
cyan = "\033[36m"
white = "\033[37m"
bold = "\033[1m"
)
// Logger 是一个封装了 zap.SugaredLogger 的日志记录器。 // Logger 是一个封装了 zap.SugaredLogger 的日志记录器。
// 它提供了结构化日志记录的各种方法,并实现了 io.Writer 接口以兼容 Gin。 // 它提供了结构化日志记录的各种方法,并实现了 io.Writer 接口以兼容 Gin。
type Logger struct { type Logger struct {
@@ -27,7 +42,7 @@ type Logger struct {
// 这是实现依赖注入的关键,在应用启动时调用一次。 // 这是实现依赖注入的关键,在应用启动时调用一次。
func NewLogger(cfg config.LogConfig) *Logger { func NewLogger(cfg config.LogConfig) *Logger {
// 1. 设置日志编码器 // 1. 设置日志编码器
encoder := getEncoder(cfg.Format) encoder := GetEncoder(cfg.Format)
// 2. 设置日志写入器 (支持文件和控制台) // 2. 设置日志写入器 (支持文件和控制台)
writeSyncer := getWriteSyncer(cfg) writeSyncer := getWriteSyncer(cfg)
@@ -49,17 +64,74 @@ func NewLogger(cfg config.LogConfig) *Logger {
return &Logger{zapLogger.Sugar()} return &Logger{zapLogger.Sugar()}
} }
// getEncoder 根据指定的格式返回一个 zapcore.Encoder。 // GetEncoder 根据指定的格式返回一个 zapcore.Encoder。
func getEncoder(format string) zapcore.Encoder { func GetEncoder(format string) zapcore.Encoder {
encoderConfig := zap.NewProductionEncoderConfig() encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder // 时间格式: 2006-01-02T15:04:05.000Z0700 encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder // 时间格式: 2006-01-02T15:04:05.000Z0700
encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder // 日志级别大写: INFO encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder // 日志级别大写: INFO (由 coloredConsoleEncoder 处理颜色)
if format == "json" { if format == "json" {
return zapcore.NewJSONEncoder(encoderConfig) return zapcore.NewJSONEncoder(encoderConfig)
} }
// 默认或 "console" // 默认或 "console" 格式,使用自定义的带颜色编码器
return zapcore.NewConsoleEncoder(encoderConfig) return NewColoredConsoleEncoder(encoderConfig)
}
// coloredConsoleEncoder 是一个自定义的 zapcore.Encoder用于为整个日志行添加颜色。
type coloredConsoleEncoder struct {
zapcore.Encoder // 嵌入默认的 ConsoleEncoder以便委托其大部分功能
cfg zapcore.EncoderConfig
}
// NewColoredConsoleEncoder 创建一个新的 coloredConsoleEncoder 实例。
func NewColoredConsoleEncoder(cfg zapcore.EncoderConfig) zapcore.Encoder {
// 创建一个标准的 ConsoleEncoder 作为基础
baseEncoder := zapcore.NewConsoleEncoder(cfg)
return &coloredConsoleEncoder{
Encoder: baseEncoder,
cfg: cfg,
}
}
// EncodeEntry 重写 EncodeEntry 方法,在原始日志输出前后添加颜色代码。
func (c *coloredConsoleEncoder) EncodeEntry(entry zapcore.Entry, fields []zapcore.Field) (*buffer.Buffer, error) {
// 首先,让嵌入的默认编码器生成原始的日志行
buf, err := c.Encoder.EncodeEntry(entry, fields)
if err != nil {
return nil, err
}
// 根据日志级别确定颜色
var color string
switch entry.Level {
case zapcore.DebugLevel:
color = blue
case zapcore.InfoLevel:
color = green
case zapcore.WarnLevel:
color = yellow
case zapcore.ErrorLevel:
color = red
case zapcore.DPanicLevel, zapcore.PanicLevel, zapcore.FatalLevel:
color = bold + red
default:
color = reset
}
// 创建一个新的 buffer 来存储带颜色的日志行
coloredBuf := buffer.NewPool().Get()
coloredBuf.AppendString(color)
coloredBuf.Write(buf.Bytes())
coloredBuf.AppendString(reset)
buf.Free() // 释放原始 buffer 回池中
return coloredBuf, nil
}
// Clone 方法也需要重写,以确保返回一个新的 coloredConsoleEncoder 实例。
func (c *coloredConsoleEncoder) Clone() zapcore.Encoder {
return NewColoredConsoleEncoder(c.cfg)
} }
// getWriteSyncer 根据配置创建日志写入目标。 // getWriteSyncer 根据配置创建日志写入目标。
@@ -163,3 +235,15 @@ func (g *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql
g.ZapLogger.With(fields...).Debugf("[GORM] trace") g.ZapLogger.With(fields...).Debugf("[GORM] trace")
// --- 逻辑修复结束 --- // --- 逻辑修复结束 ---
} }
// NewSilentLogger 创建一个不输出任何日志的 Logger 实例, 用于测试中屏蔽日志
func NewSilentLogger() *Logger {
// 创建一个不输出日志的真实 logs.Logger 实例
discardSyncer := zapcore.AddSync(io.Discard)
encoderConfig := zap.NewProductionEncoderConfig()
encoder := zapcore.NewConsoleEncoder(encoderConfig)
core := zapcore.NewCore(encoder, discardSyncer, zap.DebugLevel) // 设置为 DebugLevel 以确保所有日志都被处理(并丢弃)
zapLogger := zap.New(core)
sugaredLogger := zapLogger.Sugar()
return &Logger{SugaredLogger: sugaredLogger}
}

View File

@@ -1,127 +0,0 @@
package logs_test
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/config"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"github.com/stretchr/testify/assert"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
// captureOutput 是一个辅助函数,用于捕获 logger 的输出到内存缓冲区
func captureOutput(cfg config.LogConfig) (*logs.Logger, *bytes.Buffer) {
var buf bytes.Buffer
// 使用一个简单的 Console Encoder 进行测试,方便断言字符串
encoderConfig := zap.NewDevelopmentEncoderConfig()
encoderConfig.EncodeTime = nil // 忽略时间,避免测试结果不一致
encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
encoderConfig.EncodeCaller = nil // 忽略调用者信息
encoder := zapcore.NewConsoleEncoder(encoderConfig)
writer := zapcore.AddSync(&buf)
level := zap.NewAtomicLevel()
_ = level.UnmarshalText([]byte(cfg.Level))
core := zapcore.NewCore(encoder, writer, level)
// 在测试中我们直接操作 zap Logger而不是通过封装的 NewLogger以注入内存 writer
zapLogger := zap.New(core)
logger := &logs.Logger{SugaredLogger: zapLogger.Sugar()}
return logger, &buf
}
func TestNewLogger(t *testing.T) {
t.Run("构造函数不会 panic", func(t *testing.T) {
// 测试 Console 格式
cfgConsole := config.LogConfig{Level: "info", Format: "console"}
assert.NotPanics(t, func() { logs.NewLogger(cfgConsole) })
// 测试 JSON 格式
cfgJSON := config.LogConfig{Level: "info", Format: "json"}
assert.NotPanics(t, func() { logs.NewLogger(cfgJSON) })
// 测试文件日志启用
// 不实际写入文件,只确保构造函数能正常运行
cfgFile := config.LogConfig{
Level: "info",
EnableFile: true,
FilePath: "test.log",
}
assert.NotPanics(t, func() { logs.NewLogger(cfgFile) })
})
}
func TestLogger_Write_ForGin(t *testing.T) {
logger, buf := captureOutput(config.LogConfig{Level: "info"})
ginLog := "[GIN-debug] Listening and serving HTTP on :8080\n"
_, err := logger.Write([]byte(ginLog))
assert.NoError(t, err)
output := buf.String()
// logger.Write 会将 gin 的日志转为 info 级别
assert.Contains(t, output, "INFO")
assert.Contains(t, output, strings.TrimSpace(ginLog))
}
func TestGormLogger(t *testing.T) {
logger, buf := captureOutput(config.LogConfig{Level: "debug"}) // 设置为 debug 以捕获所有级别
gormLogger := logs.NewGormLogger(logger)
// 模拟 GORM 的 Trace 调用参数
ctx := context.Background()
sql := "SELECT * FROM users WHERE id = 1"
rows := int64(1)
fc := func() (string, int64) {
return sql, rows
}
t.Run("Slow Query", func(t *testing.T) {
buf.Reset()
// 模拟一个耗时超过 200ms 的查询
begin := time.Now().Add(-300 * time.Millisecond)
gormLogger.Trace(ctx, begin, fc, nil)
output := buf.String()
assert.Contains(t, output, "WARN", "应包含 WARN 级别")
assert.Contains(t, output, "[GORM] slow query", "应包含慢查询信息")
assert.Contains(t, output, "SELECT * FROM users WHERE id = 1", "应包含 SQL 语句")
})
t.Run("Error Query", func(t *testing.T) {
buf.Reset()
queryError := errors.New("syntax error")
gormLogger.Trace(ctx, time.Now(), fc, queryError)
output := buf.String()
assert.Contains(t, output, "ERROR")
assert.Contains(t, output, "[GORM] error: syntax error")
})
t.Run("Record Not Found Error is Skipped", func(t *testing.T) {
buf.Reset()
// 错误必须包含 "record not found" 字符串以匹配 logs.go 中的判断逻辑
queryError := errors.New("record not found")
gormLogger.Trace(ctx, time.Now(), fc, queryError)
assert.Empty(t, buf.String(), "开启 SkipErrRecordNotFound 后record not found 错误不应产生任何日志")
})
t.Run("Normal Query", func(t *testing.T) {
buf.Reset()
// 模拟一个快速查询
gormLogger.Trace(ctx, time.Now(), fc, nil)
output := buf.String()
assert.Contains(t, output, "DEBUG") // 正常查询是 Debug 级别
assert.Contains(t, output, "[GORM] trace")
})
}

View File

@@ -3,79 +3,109 @@ package models
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"strings"
"gorm.io/datatypes" "gorm.io/datatypes"
"gorm.io/gorm" "gorm.io/gorm"
) )
// DeviceType 定义了设备的高级类别
type DeviceType string
const (
// DeviceTypeAreaController 区域主控,负责管理一个片区的设备
DeviceTypeAreaController DeviceType = "area_controller"
// DeviceTypeDevice 普通设备,如传感器、阀门等
DeviceTypeDevice DeviceType = "device"
)
// DeviceSubType 定义了普通设备的具体子类别
type DeviceSubType string
const (
// SubTypeNone 未指定或不适用的子类型
SubTypeNone DeviceSubType = ""
// SubTypeSensorTemp 温度传感器
SubTypeSensorTemp DeviceSubType = "temperature"
// SubTypeSensorHumidity 湿度传感器
SubTypeSensorHumidity DeviceSubType = "humidity"
// SubTypeSensorAmmonia 氨气传感器
SubTypeSensorAmmonia DeviceSubType = "ammonia"
// SubTypeValveFeed 下料阀门
SubTypeValveFeed DeviceSubType = "feed_valve"
// SubTypeFan 风机
SubTypeFan DeviceSubType = "fan"
// SubTypeWaterCurtain 水帘
SubTypeWaterCurtain DeviceSubType = "water_curtain"
)
// --- Properties 结构体定义 --- // --- Properties 结构体定义 ---
// LoraProperties 定义了区域主控的特有属性 // Bus485Properties 定义了总线设备的特有属性
type LoraProperties struct { type Bus485Properties struct {
LoraAddress string `json:"lora_address"` // LoRa 地址 BusNumber uint8 `json:"bus_number"` // 485 总线号
BusAddress uint8 `json:"bus_address"` // 485 总线地址
} }
// BusProperties 定义了总线设备的特有属性 // AreaController 是一个LoRa转总线(如485)的通信网关
type BusProperties struct { type AreaController struct {
BusID int `json:"bus_id"` // 485 总线号 gorm.Model
BusAddress int `json:"bus_address"` // 485 总线地址
// Name 是主控的业务名称,例如 "1号猪舍主控"
Name string `gorm:"not null;unique" json:"name"`
// NetworkID 是主控在通信网络中的唯一标识,例如 LoRaWAN 的 DevEUI。
// 这是 transport 层用来寻址的关键。
NetworkID string `gorm:"not null;unique;index" json:"network_id"`
// Location 描述了主控的物理安装位置。
Location string `gorm:"index" json:"location"`
// Status 表示主控的在线状态等,可以后续扩展。
Status string `gorm:"default:'unknown'" json:"status"`
// Properties 用于存储其他与主控相关的属性,例如硬件版本、固件版本等。
Properties datatypes.JSON `json:"properties"`
} }
// Device 代表系统中的所有设备 // SelfCheck 对 AreaController 的关键字段进行业务逻辑验证。
func (ac *AreaController) SelfCheck() error {
if strings.TrimSpace(ac.NetworkID) == "" {
return errors.New("区域主控的 NetworkID 不能为空")
}
return nil
}
// TableName 自定义 GORM 使用的数据库表名
func (AreaController) TableName() string {
return "area_controllers"
}
// Device 代表系统中的所有普通设备
type Device struct { type Device struct {
// gorm.Model 内嵌了标准模型字段 (ID, CreatedAt, UpdatedAt, DeletedAt) // gorm.Model 内嵌了标准模型字段 (ID, CreatedAt, UpdatedAt, DeletedAt)
gorm.Model gorm.Model
// Name 是设备的业务名称,应清晰可读,例如 "1号猪舍温度传感器" 或 "做料车间主控" // Name 是设备的业务名称,应清晰可读,例如 "1号猪舍温度传感器"
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
// Type 是设备的高级类别,用于区分区域主控和普通设备。建立索引以优化按类型查询。 // DeviceTemplateID 是设备模板的外键
Type DeviceType `gorm:"not null;index" json:"type"` DeviceTemplateID uint `gorm:"not null;index" json:"device_template_id"`
// SubType 是设备的子类别,用于描述普通设备的具体功能,例如 "temperature", "fan" 等。建立索引以优化按子类型查询。 // DeviceTemplate 是设备的模板,包含了设备的通用信息
SubType DeviceSubType `gorm:"index" json:"sub_type"` DeviceTemplate DeviceTemplate `json:"device_template"`
// ParentID 指向其父级设备的ID。对于顶层设备如区域主控此值为 NULL。 // AreaControllerID 是区域主控的外键
// 使用指针类型 *uint 来允许 NULL 值,从而清晰地表示“无父级”,避免了使用 0 作为魔术数字的歧义。建立索引以优化层级查询。 AreaControllerID uint `gorm:"not null;index" json:"area_controller_id"`
ParentID *uint `gorm:"index" json:"parent_id"`
// AreaController 是设备所属的区域主控
AreaController AreaController `json:"area_controller"`
// Location 描述了设备的物理安装位置,例如 "1号猪舍东侧",方便运维。建立索引以优化按位置查询。 // Location 描述了设备的物理安装位置,例如 "1号猪舍东侧",方便运维。建立索引以优化按位置查询。
Location string `gorm:"index" json:"location"` Location string `gorm:"index" json:"location"`
// Properties 用于存储特定类型设备的独有属性采用JSON格式。 // Properties 用于存储特定类型设备的独有属性采用JSON格式。
// 建议在应用层为不同子类型的设备定义专用的属性结构体(如 LoraProperties, BusProperties,以保证数据一致性。 // 建议在应用层为不同子类型的设备定义专用的属性结构体,以保证数据一致性。
Properties datatypes.JSON `json:"properties"` Properties datatypes.JSON `json:"properties"`
// Tasks 是与此设备关联的任务列表,通过 DeviceTask 关联表实现多对多关系
Tasks []Task `gorm:"many2many:device_tasks;" json:"tasks"`
}
// SelfCheck 对 Device 的关键字段和属性进行业务逻辑验证。
func (d *Device) SelfCheck() error {
if d.AreaControllerID == 0 {
return errors.New("设备必须关联一个区域主控 (AreaControllerID不能为0)")
}
if d.DeviceTemplateID == 0 {
return errors.New("设备必须关联一个设备模板 (DeviceTemplateID不能为0)")
}
// 验证 Properties 是否包含必要的总线地址信息
if d.Properties == nil {
return errors.New("设备属性 (Properties) 不能为空")
}
var props Bus485Properties
if err := json.Unmarshal(d.Properties, &props); err != nil {
return errors.New("无法解析设备属性 (Properties)")
}
if props.BusAddress == 0 {
return errors.New("设备属性 (Properties) 中缺少总线地址 (bus_address)")
}
return nil
} }
// TableName 自定义 GORM 使用的数据库表名 // TableName 自定义 GORM 使用的数据库表名

View File

@@ -0,0 +1,167 @@
package models
import (
"encoding/json"
"errors"
"fmt"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/utils/command_generater"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// DeviceCategory 定义了设备模板的宽泛类别
type DeviceCategory string
const (
// CategoryActuator 代表一个执行器,可以被控制(例如:风机、阀门)
CategoryActuator DeviceCategory = "执行器"
// CategorySensor 代表一个传感器,用于报告测量值(例如:温度计)
CategorySensor DeviceCategory = "传感器"
)
// ValueDescriptor 描述了传感器可以报告的单个数值。
// 它提供了必要的元数据,以便应用程序能够正确解释从设备读取的原始数据。
type ValueDescriptor struct {
Type SensorType `json:"type"`
Multiplier float64 `json:"multiplier"` // 乘数,用于原始数据转换
Offset float64 `json:"offset"` // 偏移量,用于原始数据转换
}
// --- 指令结构体 (Command Structs) ---
// SwitchCommands 定义了开关类指令所需的Modbus参数
type SwitchCommands struct {
// ModbusStartAddress 记录Modbus寄存器的起始地址用于生成指令。(一般是第三到四字节)
ModbusStartAddress uint16 `json:"modbus_start_address"`
// ModbusQuantity 记录Modbus寄存器的数量对于开关通常为1。(一般是五到六字节)
ModbusQuantity uint16 `json:"modbus_quantity"`
}
// SelfCheck 校验开关指令参数的有效性
func (sc *SwitchCommands) SelfCheck() error {
// 对于开关数量通常为1
if sc.ModbusQuantity != 1 {
return errors.New("'switch' 指令集 ModbusQuantity 必须为1")
}
return nil
}
// SensorCommands 定义了传感器读取指令所需的Modbus参数
type SensorCommands struct {
// ModbusFunctionCode 记录Modbus功能码例如 ReadHoldingRegisters。(一般是第二字节)
ModbusFunctionCode command_generater.ModbusFunctionCode `json:"modbus_function_code"`
// ModbusStartAddress 记录Modbus寄存器的起始地址用于生成指令。(一般是第三到四字节)
ModbusStartAddress uint16 `json:"modbus_start_address"`
// ModbusQuantity 记录Modbus寄存器的数量用于生成指令。(一般是五到六字节)
ModbusQuantity uint16 `json:"modbus_quantity"`
}
// SelfCheck 校验读取指令参数的有效性
func (sc *SensorCommands) SelfCheck() error {
// 校验ModbusFunctionCode是否为读取类型
switch sc.ModbusFunctionCode {
case command_generater.ReadCoils, command_generater.ReadDiscreteInputs, command_generater.ReadHoldingRegisters, command_generater.ReadInputRegisters:
// 支持的读取功能码
default:
return fmt.Errorf("'sensor' 指令集 ModbusFunctionCode %X 无效或不是读取类型", sc.ModbusFunctionCode)
}
// 校验ModbusQuantity的合理性例如不能为0且在常见Modbus读取数量限制内
if sc.ModbusQuantity == 0 || sc.ModbusQuantity > 125 {
return fmt.Errorf("'sensor' 指令集 ModbusQuantity 无效: %d, 必须在1-125之间", sc.ModbusQuantity)
}
return nil
}
// DeviceTemplate 代表一种物理设备的类型。
type DeviceTemplate struct {
gorm.Model
// Name 是此模板的唯一名称, 例如 "FanModel-XYZ-2000" 或 "TempSensor-T1"
Name string `gorm:"not null;unique" json:"name"`
// Manufacturer 是设备的制造商。
Manufacturer string `json:"manufacturer"`
// Description 提供了关于此设备类型的更多详细信息。
Description string `json:"description"`
// Category 将模板分类为传感器、执行器
Category DeviceCategory `gorm:"not null;index" json:"category"`
// Commands 存储了生成Modbus指令所需的参数而不是原始指令字符串。
// 使用 JSON 格式,具有良好的可扩展性。
// 例如,对于执行器 (开关): {"modbus_start_address": 0, "modbus_quantity": 1}
// 例如,对于传感器: {"modbus_function_code": 3, "modbus_start_address": 0, "modbus_quantity": 1}
Commands datatypes.JSON `json:"commands"`
// Values 描述了传感器模板所能提供的数据点。
// 当 Category 是 "sensor" 时,此字段尤为重要。
// 它是一个 ValueDescriptor 对象的 JSON 数组。
Values datatypes.JSON `json:"values"`
}
// TableName 自定义 GORM 使用的数据库表名
func (DeviceTemplate) TableName() string {
return "device_templates"
}
// ParseCommands ...
func (dt *DeviceTemplate) ParseCommands(v interface{}) error {
if dt.Commands == nil {
return errors.New("设备模板的 Commands 属性为空,无法解析")
}
return json.Unmarshal(dt.Commands, v)
}
// ParseValues ...
func (dt *DeviceTemplate) ParseValues(v interface{}) error {
if dt.Values == nil {
return errors.New("设备模板的 Values 属性为空,无法解析")
}
return json.Unmarshal(dt.Values, v)
}
// SelfCheck 对 DeviceTemplate 进行彻底的、基于角色的校验
func (dt *DeviceTemplate) SelfCheck() error {
if dt.Commands == nil {
return errors.New("所有设备模板都必须有 Commands 定义")
}
switch dt.Category {
case CategoryActuator:
var cmd SwitchCommands
if err := dt.ParseCommands(&cmd); err != nil {
return errors.New("执行器模板的 Commands 无法被解析为 'switch' 指令集: " + err.Error())
}
if err := cmd.SelfCheck(); err != nil {
return err
}
case CategorySensor:
var cmd SensorCommands
if err := dt.ParseCommands(&cmd); err != nil {
return errors.New("传感器模板的 Commands 无法被解析为 'sensor' 指令集: " + err.Error())
}
if err := cmd.SelfCheck(); err != nil {
return err
}
if dt.Values == nil {
return errors.New("传感器类型的设备模板缺少 Values 定义")
}
// 这里应该解析为 ValueDescriptor 的切片,因为传感器可能提供多个数据点
var values []ValueDescriptor
if err := dt.ParseValues(&values); err != nil {
return errors.New("无法解析传感器模板的 Values 属性: " + err.Error())
}
if len(values) == 0 {
return errors.New("传感器类型的设备模板 Values 属性不能为空")
}
default:
return errors.New("未知的设备模板类别")
}
return nil
}

View File

@@ -0,0 +1,235 @@
package models
import (
"encoding/json"
"errors"
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// 定义系统任务的特殊ID
const (
SystemTaskIDResolvePlan int = -1 // 代表“解析计划”的系统任务
)
type ExecutionStatus string
const (
ExecutionStatusStarted ExecutionStatus = "已开始" // 开始执行
ExecutionStatusCompleted ExecutionStatus = "已完成" // 执行完成
ExecutionStatusFailed ExecutionStatus = "失败" // 执行失败
ExecutionStatusCancelled ExecutionStatus = "已取消" // 执行取消
ExecutionStatusWaiting ExecutionStatus = "等待中" // 等待执行 (用于预写日志)
)
// PlanExecutionLog 记录整个计划的一次执行历史
type PlanExecutionLog struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time `gorm:"primaryKey"` // 作为联合主键方便只查询热点数据
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
PlanID uint `gorm:"index"`
Status ExecutionStatus
StartedAt time.Time
EndedAt time.Time
Error string
}
// TableName 自定义 GORM 使用的数据库表名
func (PlanExecutionLog) TableName() string {
return "plan_execution_logs"
}
// TaskExecutionLog 记录单个任务的一次执行历史
type TaskExecutionLog struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time `gorm:"primaryKey"` // 作为联合主键方便只查询热点数据
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
PlanExecutionLogID uint `gorm:"index"` // 关联到某次计划执行
// TaskID 使用 int 类型以容纳特殊的负数ID代表系统任务
TaskID int `gorm:"index"`
// 关键改动:移除了 OnDelete 约束。
Task Task `gorm:"foreignKey:TaskID;constraint:OnUpdate:CASCADE;"`
Status ExecutionStatus
Output string // 任务执行的输出或错误信息
StartedAt time.Time
EndedAt time.Time
}
// TableName 自定义 GORM 使用的数据库表名
func (TaskExecutionLog) TableName() string {
return "task_execution_logs"
}
// AfterFind 是 GORM 的一个钩子,在查询数据后自动执行
// 我们用它来优雅地处理系统任务的“虚拟”Task定义
func (log *TaskExecutionLog) AfterFind(tx *gorm.DB) (err error) {
// 检查是否是我们的“解析计划”系统任务
if log.TaskID == SystemTaskIDResolvePlan {
// 如果是,手动创建一个写死的 Task 定义并绑定上去
// 这使得上层服务在处理日志时无需关心TaskID是否为负数
log.Task = Task{
// 注意:这里不能设置 ID否则 GORM 可能会混淆
Name: "系统:解析并启动计划",
Description: "这是一个由系统自动触发的内部任务,用于准备计划的执行。",
}
}
return
}
// --- 指令与采集 ---
// PendingCollectionStatus 定义了待采集请求的状态
type PendingCollectionStatus string
const (
PendingStatusPending PendingCollectionStatus = "等待中" // 请求已发送,等待设备响应
PendingStatusFulfilled PendingCollectionStatus = "已完成" // 已收到设备响应并成功处理
PendingStatusTimedOut PendingCollectionStatus = "已超时" // 请求超时,未收到设备响应
)
// DeviceCommandLog 记录所有“发后即忘”的下行指令日志。
// 这张表主要用于追踪指令是否被网关成功发送 (ack)。
type DeviceCommandLog struct {
// MessageID 是下行消息的唯一标识符。
// 可以是 ChirpStack 的 DeduplicationID 或其他系统生成的ID。
MessageID string `gorm:"primaryKey" json:"message_id"`
// DeviceID 是接收此下行任务的设备的ID。
// 对于 LoRaWAN这通常是区域主控设备的ID。
DeviceID uint `gorm:"not null;index" json:"device_id"`
// SentAt 记录下行任务最初发送的时间。
SentAt time.Time `gorm:"primaryKey" json:"sent_at"`
// AcknowledgedAt 记录设备确认收到下行消息的时间。
// 如果设备未确认,则为零值或 NULL。使用指针类型 *time.Time 允许 NULL 值。
AcknowledgedAt *time.Time `json:"acknowledged_at"`
// ReceivedSuccess 表示设备是否成功接收到下行消息。
// true 表示设备已确认收到false 表示设备未确认收到或下发失败。
ReceivedSuccess bool `gorm:"not null" json:"received_success"`
}
// TableName 自定义 GORM 使用的数据库表名
func (DeviceCommandLog) TableName() string {
return "device_command_log"
}
// PendingCollection 记录所有需要设备响应的“待采集请求”。
// 这是一张状态机表,追踪从请求发送到收到响应的整个生命周期。
type PendingCollection struct {
// CorrelationID 是由平台生成的、在请求和响应之间全局唯一的关联ID作为主键。
CorrelationID string `gorm:"primaryKey"`
// DeviceID 是接收此任务的设备ID
// 对于 LoRaWAN这通常是区域主控设备的ID。
DeviceID uint `gorm:"index"`
// CommandMetadata 存储了此次采集任务对应的设备ID列表顺序与设备响应值的顺序一致。
CommandMetadata UintArray `gorm:"type:bigint[]"`
// Status 是该请求的当前状态,用于状态机管理和超时处理。
Status PendingCollectionStatus `gorm:"index"`
// FulfilledAt 是收到设备响应并成功处理的时间。使用指针以允许 NULL 值。
FulfilledAt *time.Time
// CreatedAt 是 GORM 的标准字段,记录请求创建时间。
CreatedAt time.Time `gorm:"primaryKey"`
}
// TableName 自定义 GORM 使用的数据库表名
func (PendingCollection) TableName() string {
return "pending_collections"
}
// --- 用户审计日志 ---
// TODO 这些变量放这个包合适吗?
// --- 审计日志状态常量 ---
type AuditStatus string
const (
AuditStatusSuccess AuditStatus = "成功"
AuditStatusFailed AuditStatus = "失败"
)
// --- 审计日志相关上下文键 ---
type AuditContextKey string
const (
ContextAuditActionType AuditContextKey = "auditActionType"
ContextAuditTargetResource AuditContextKey = "auditTargetResource"
ContextAuditDescription AuditContextKey = "auditDescription"
ContextAuditStatus AuditContextKey = "auditStatus"
ContextAuditResultDetails AuditContextKey = "auditResultDetails"
ContextUserKey AuditContextKey = "user"
)
func (a AuditContextKey) String() string {
return string(a)
}
// UserActionLog 记录用户的操作历史,用于审计
type UserActionLog struct {
// 用 ID 和 Time 组成复合主键, 防止高并发时时间重复
ID uint `gorm:"primaryKey"`
// Time 是操作发生的时间,作为主键和超表的时间分区键
Time time.Time `gorm:"primaryKey" json:"time"`
// --- Who (谁) ---
UserID uint `gorm:"not null" json:"user_id,omitempty"`
Username string `json:"username,omitempty"` // 操作发生时用户名的快照
// --- Where (何地) ---
SourceIP string `json:"source_ip,omitempty"`
// --- What (什么) & How (如何) ---
ActionType string `gorm:"index" json:"action_type,omitempty"` // 标准化的操作类型,如 "CREATE_DEVICE"
TargetResource datatypes.JSON `gorm:"type:jsonb" json:"target_resource,omitempty"` // 被操作的资源, e.g., {"type": "device", "id": 123}
Description string `json:"description,omitempty"` // 人类可读的操作描述
Status AuditStatus `json:"status,omitempty"` // success 或 failed
HTTPPath string `json:"http_path,omitempty"` // 请求的API路径
HTTPMethod string `json:"http_method,omitempty"` // 请求的HTTP方法
ResultDetails string `json:"result_details,omitempty"` // 结果详情,如失败时的错误信息
}
// TableName 自定义 GORM 使用的数据库表名
func (UserActionLog) TableName() string {
return "user_action_logs"
}
// ParseTargetResource 解析 JSON 属性到一个具体的结构体中。
// 调用方需要传入一个指向目标结构体实例的指针。
func (l *UserActionLog) ParseTargetResource(v interface{}) error {
if l.TargetResource == nil {
return errors.New("目标资源为空,无法解析")
}
return json.Unmarshal(l.TargetResource, v)
}
// SetTargetResource 将任意结构体序列化为 JSON 并设置到 TargetResource 字段
func (l *UserActionLog) SetTargetResource(data interface{}) error {
if data == nil {
l.TargetResource = nil
return nil
}
bytes, err := json.Marshal(data)
if err != nil {
return err
}
l.TargetResource = bytes
return nil
}

View File

@@ -0,0 +1,39 @@
package models
import (
"gorm.io/gorm"
)
/*
猪场固定资产相关模型
*/
// PigHouse 定义了猪舍,是猪栏的集合
type PigHouse struct {
gorm.Model
Name string `gorm:"size:100;not null;unique;comment:猪舍名称, 如 '育肥舍A栋'"`
Description string `gorm:"size:255;comment:描述信息"`
Pens []Pen `gorm:"foreignKey:HouseID"` // 一个猪舍包含多个猪栏
}
// PenStatus 定义了猪栏的当前状态
type PenStatus string
const (
PenStatusEmpty PenStatus = "空闲"
PenStatusOccupied PenStatus = "使用中"
PenStatusSickPen PenStatus = "病猪栏"
PenStatusRecovering PenStatus = "康复栏"
PenStatusCleaning PenStatus = "清洗消毒"
PenStatusUnderMaint PenStatus = "维修中"
)
// Pen 是猪栏的物理实体模型, 是所有空间相关数据的“锚点”
type Pen struct {
gorm.Model
PenNumber string `gorm:"not null;comment:猪栏的唯一编号, 如 A-01"`
HouseID uint `gorm:"index;comment:所属猪舍ID"`
PigBatchID *uint `gorm:"index;comment:关联的猪批次ID"`
Capacity int `gorm:"not null;comment:设计容量 (头)"`
Status PenStatus `gorm:"not null;index;comment:猪栏当前状态"`
}

View File

@@ -0,0 +1,113 @@
package models
import (
"time"
"gorm.io/gorm"
)
/*
饲料和饲喂相关的模型
*/
// RawMaterial 代表饲料的原料。
// 建议:所有重量单位统一存储 (例如, 全部使用 'g'),便于计算和避免转换错误。
type RawMaterial struct {
gorm.Model
Name string `gorm:"size:100;unique;not null;comment:原料名称"`
Description string `gorm:"size:255;comment:描述"`
Quantity float64 `gorm:"not null;comment:库存总量, 单位: g"`
}
func (RawMaterial) TableName() string {
return "raw_materials"
}
// RawMaterialPurchase 记录了原料的每一次采购。
type RawMaterialPurchase struct {
gorm.Model
RawMaterialID uint `gorm:"not null;index;comment:关联的原料ID"`
RawMaterial RawMaterial `gorm:"foreignKey:RawMaterialID"`
Supplier string `gorm:"size:100;comment:供应商"`
Amount float64 `gorm:"not null;comment:采购数量, 单位: g"`
UnitPrice float64 `gorm:"comment:单价"`
TotalPrice float64 `gorm:"comment:总价"`
PurchaseDate time.Time `gorm:"primaryKey;comment:采购日期"`
CreatedAt time.Time
}
func (RawMaterialPurchase) TableName() string {
return "raw_material_purchases"
}
// StockLogSourceType 定义了库存日志来源的类型
type StockLogSourceType string
const (
StockLogSourcePurchase StockLogSourceType = "采购入库"
StockLogSourceFeeding StockLogSourceType = "饲喂出库"
StockLogSourceDeteriorate StockLogSourceType = "变质出库"
StockLogSourceSale StockLogSourceType = "售卖出库"
StockLogSourceMiscellaneous StockLogSourceType = "杂用领取"
StockLogSourceManual StockLogSourceType = "手动盘点"
)
// RawMaterialStockLog 记录了原料库存的所有变动,提供了完整的追溯链。
type RawMaterialStockLog struct {
gorm.Model
RawMaterialID uint `gorm:"not null;index;comment:关联的原料ID"`
ChangeAmount float64 `gorm:"not null;comment:变动数量, 正数为入库, 负数为出库"`
SourceType StockLogSourceType `gorm:"size:50;not null;index;comment:库存变动来源类型"`
SourceID uint `gorm:"not null;index;comment:来源记录的ID (如 RawMaterialPurchase.ID 或 FeedUsageRecord.ID)"`
HappenedAt time.Time `gorm:"primaryKey;comment:业务发生时间"`
Remarks string `gorm:"comment:备注, 如主动领取的理由等"`
}
func (RawMaterialStockLog) TableName() string {
return "raw_material_stock_logs"
}
// FeedFormula 代表饲料配方。
// 对于没有配方的外购饲料,可以将其视为一种特殊的 RawMaterial, 并为其创建一个仅包含它自己的 FeedFormula。
type FeedFormula struct {
gorm.Model
Name string `gorm:"size:100;unique;not null;comment:配方名称"`
Description string `gorm:"size:255;comment:描述"`
Components []FeedFormulaComponent `gorm:"foreignKey:FeedFormulaID"`
}
func (FeedFormula) TableName() string {
return "feed_formulas"
}
// FeedFormulaComponent 代表配方中的一种原料及其占比。
type FeedFormulaComponent struct {
gorm.Model
FeedFormulaID uint `gorm:"not null;index;comment:外键到 FeedFormula"`
RawMaterialID uint `gorm:"not null;index;comment:外键到 RawMaterial"`
RawMaterial RawMaterial `gorm:"foreignKey:RawMaterialID"`
Percentage float64 `gorm:"not null;comment:该原料在配方中的百分比 (0-1.0)"`
}
func (FeedFormulaComponent) TableName() string {
return "feed_formula_components"
}
// FeedUsageRecord 代表饲料使用记录。
// 应用层逻辑:当一条使用记录被创建时,应根据其使用的 FeedFormula,
// 计算出每种 RawMaterial 的消耗量,并在 RawMaterialStockLog 中创建对应的出库记录。
type FeedUsageRecord struct {
gorm.Model
PenID uint `gorm:"not null;index;comment:关联的猪栏ID"`
Pen Pen `gorm:"foreignKey:PenID"`
FeedFormulaID uint `gorm:"not null;index;comment:使用的饲料配方ID"`
FeedFormula FeedFormula `gorm:"foreignKey:FeedFormulaID"`
Amount float64 `gorm:"not null;comment:使用数量, 单位: g"`
RecordedAt time.Time `gorm:"primaryKey;comment:记录时间"`
OperatorID uint `gorm:"not null;comment:操作员"`
Remarks string `gorm:"comment:备注, 如 '例行喂料, 弱猪补料' 等"`
}
func (FeedUsageRecord) TableName() string {
return "feed_usage_records"
}

Some files were not shown because too many files have changed in this diff Show More