Compare commits

..

101 Commits

Author SHA1 Message Date
8f3a07a138 重构seeder 2025-12-08 21:43:13 +08:00
a2a55732eb 优化代码 2025-12-08 19:19:11 +08:00
ae9b796680 重构lora初始化部分 2025-12-08 19:10:07 +08:00
9a1b148a7a handleRequestFile 2025-12-08 19:00:09 +08:00
aac9ecf371 handleUpdateStatusReport 2025-12-08 15:17:57 +08:00
5c6290432a 优化 2025-12-07 18:36:35 +08:00
c8b32d542d 平台侧发送和准备工作实现完成 2025-12-07 18:05:21 +08:00
35c19d0495 实现 StartUpgrade 2025-12-07 17:43:18 +08:00
a7022c4c3f signManifest 和 generateManifest 2025-12-07 16:25:37 +08:00
bb17b2e476 fmt 2025-12-05 16:55:03 +08:00
349a31518d 实现 StopUpgrade 2025-12-05 16:53:52 +08:00
b4ecee6626 ota服务层 2025-12-05 16:31:12 +08:00
2bb187071f 定义控制器和注册路由(服务层和领域层没实现) 2025-12-05 16:08:06 +08:00
7017ffa128 重构设备控制器 2025-12-05 14:44:36 +08:00
d7ba7747ec 提供文件操作类utils内容 2025-12-04 18:45:25 +08:00
3707f519fe OtaService 接口定义 2025-12-04 18:44:56 +08:00
3cfe532517 创建检查系统计划 2025-12-03 17:51:29 +08:00
b26b7ee0f3 更新任务工厂 2025-12-03 17:39:46 +08:00
9d9b5f801f ota升级超时检查任务 2025-12-03 17:34:38 +08:00
a1deb0011b 定义otatask模型 2025-12-03 16:23:33 +08:00
4a3c82fc25 拆分device.Service接口 2025-12-03 15:12:43 +08:00
7974955335 更新proto 2025-12-03 14:06:56 +08:00
72d70e90f1 更新ota方案 2025-12-02 17:16:48 +08:00
3bede99cc6 ota升级方案修改 2025-12-02 16:40:11 +08:00
95aaf80f3a ota升级方案修改 2025-12-02 16:40:10 +08:00
77ed812901 支持ota升级方案初稿 2025-12-02 16:40:10 +08:00
2e6a0abac3 增加ping指令并获取带版本号的响应 2025-12-02 16:40:10 +08:00
d5056af676 支持ota升级结果相应处理 2025-12-02 16:40:10 +08:00
1e685340f8 增加AreaControllerProperties 2025-12-02 16:40:10 +08:00
7ec9fb3f0b index.md 2025-12-02 16:40:10 +08:00
d430307b48 提供lora公共逻辑 2025-12-02 16:40:10 +08:00
5113e5953a lora处理逻辑统一方案 2025-12-02 16:40:10 +08:00
766fda7292 重构webhook包 2025-12-02 16:40:10 +08:00
1bc49ea249 proto 2025-12-02 16:40:10 +08:00
70fad51f40 Merge pull request 'issue_72' (#73) from issue_72 into main
Reviewed-on: #73
2025-12-02 16:34:47 +08:00
6764684fe7 优化ai初始化逻辑 2025-12-02 16:34:14 +08:00
da2c296c05 去掉无效日志 2025-12-02 16:19:56 +08:00
bdf74652b3 实现ai 2025-12-02 15:51:37 +08:00
70e8627a96 增加ai配置 2025-12-02 13:38:49 +08:00
c2c6577064 格式化 2025-11-29 16:04:12 +08:00
260c7d054c 更新makefile 2025-11-29 15:54:00 +08:00
d25933cf26 Merge pull request 'issue_66' (#70) from issue_66 into main
Reviewed-on: #70
2025-11-29 15:39:49 +08:00
4aa56441ce 归档任务 2025-11-29 15:38:52 +08:00
de68151539 优化日算法 2025-11-28 22:22:39 +08:00
04b46d8025 优化日志 2025-11-28 14:37:53 +08:00
bc4355cad5 修bug 2025-11-27 22:01:37 +08:00
968d996a9b 修bug 2025-11-27 21:47:07 +08:00
d6e5d89768 优化展示 2025-11-27 21:39:09 +08:00
1b5f715dec 实现优先使用库存的配方一键生成 2025-11-27 21:06:15 +08:00
da8e1d0191 优化算法 2025-11-27 20:03:14 +08:00
33cdf7278e 增加最后一次操作类型 2025-11-27 18:32:22 +08:00
3b12802900 实现按原料是否有库存筛选 2025-11-27 17:33:28 +08:00
e6b307b0dc 修bug 2025-11-27 16:42:32 +08:00
b8e0301175 修正数据错误 2025-11-27 16:27:49 +08:00
e2da441a6d 修正数据错误 2025-11-27 15:52:38 +08:00
dca6cc5dd4 原料增加最大添加量限制 2025-11-27 00:39:01 +08:00
5c99ff7475 原料增加最大添加量限制 2025-11-26 22:56:24 +08:00
0283c250e4 重构seeder 2025-11-26 22:51:58 +08:00
5bd52df240 优化算法 2025-11-26 22:41:38 +08:00
5ad403bf86 增加原料添加量限制 2025-11-26 22:35:52 +08:00
ce3844957f 修复逻辑错误 2025-11-26 22:13:51 +08:00
6c0f655d0a 增加删除原料校验 2025-11-26 21:14:32 +08:00
29b820b846 优化展示 2025-11-26 21:08:34 +08:00
34311889e8 实现使用系统中所有可用的原料一键生成配方 2025-11-26 20:44:41 +08:00
ba60ed541c 实现配方生成器 2025-11-26 20:23:29 +08:00
35eae7b3ec 只在第一次启动平台导入预设数据 2025-11-26 15:05:34 +08:00
ca85671a4c 支持预设价格 2025-11-26 14:35:58 +08:00
d7e2777c13 使用枚举 2025-11-25 20:22:38 +08:00
566f2d9a15 注入对象 2025-11-25 20:05:52 +08:00
c01ce6d1e6 原料模型增加参考价 2025-11-25 20:03:36 +08:00
c66671bf5f 原料删除校验 2025-11-25 18:54:11 +08:00
44ff3b19d6 实现库存管理相关逻辑 2025-11-25 18:10:28 +08:00
ae27eb142d agents.md 2025-11-25 15:25:52 +08:00
d7deaa346b 配方增删改查服务层和控制器 2025-11-24 13:25:15 +08:00
1200f36d14 重构配方类服务层 2025-11-23 15:16:45 +08:00
1b2e211bfa 重构配方类控制器 2025-11-23 14:49:55 +08:00
851682d579 配方领域层方法+重构配方领域 2025-11-22 21:29:23 +08:00
b40eb35016 实现修改猪营养需求 2025-11-22 20:52:15 +08:00
3ef2910058 实现修改猪营养需求 2025-11-22 17:55:56 +08:00
0637f5fb6c 实现修改猪营养需求 2025-11-22 17:55:52 +08:00
4405d1f3f1 修bug 2025-11-22 17:29:25 +08:00
9aea487537 实现修改原料营养信息 2025-11-22 16:44:22 +08:00
f81635f997 seeder支持按顺序读取 2025-11-21 18:36:02 +08:00
7829ac9931 拓展接口响应 2025-11-21 17:23:57 +08:00
4224be8567 拓展接口响应 2025-11-21 16:37:09 +08:00
534891309c 实现配方领域的web接口 2025-11-21 16:02:06 +08:00
9996fcfd74 实现配方领域关于猪模型和营养需求的增删改查 2025-11-21 15:03:42 +08:00
a669bfda6c 修复数据bug 2025-11-20 23:01:08 +08:00
aa13239e89 实现从json读取猪营养需求并写入数据库 2025-11-20 22:55:18 +08:00
c4ab53db12 优化PigBatchStatus 2025-11-20 21:00:58 +08:00
d185f334ac 增加Taber实现校验 2025-11-20 18:58:49 +08:00
1313140e45 重构creatingUniqueIndex和createGinIndexes 2025-11-20 17:46:01 +08:00
da934a9bbb 修复软删除和唯一索引同时存在的bug 2025-11-20 17:37:02 +08:00
1f3d3d8a7c 配置continue 2025-11-20 16:47:47 +08:00
6ca101727a 定义猪的模型和营养需求模型 2025-11-20 14:38:36 +08:00
c697e668e3 实现原材料的增删改查和仓库层的原料库存记录表增查 2025-11-20 13:43:09 +08:00
fd0939fe0a 定义配方领域, 实现营养元素的增删改查 2025-11-19 23:23:48 +08:00
365d69e0c6 支持预设原料和营养简介 2025-11-19 19:58:09 +08:00
a74ab4e5e7 迁移配置文件, 实现从json文件中读取原材料营养预设值, 并自动写入数据库 2025-11-19 19:31:51 +08:00
a1be06854f 调整文件位置 2025-11-19 15:06:18 +08:00
e1399be538 删除原有食物逻辑和模型
新增原料和营养价值表和原料库存日志和营养表定义
2025-11-18 22:22:31 +08:00
122 changed files with 24257 additions and 3421 deletions

View File

@@ -0,0 +1,9 @@
name: New MCP server
version: 0.0.1
schema: v1
mcpServers:
- name: chrome-devtools
command: node
args:
- "C:\\nvm4w\\nodejs\\node_modules\\chrome-devtools-mcp\\build\\src\\index.js"
env: {}

View File

@@ -0,0 +1,26 @@
1. **语言与沟通**
* 优先用 Go 语言提供所有代码示例。
* 使用中文进行回复, 且项目中的注释、报错、枚举、日志等所有文本内容全部使用中文。
* 遇到任何不确定或模糊不清的情况,必须直接询问,绝不进行猜测。
2. **工作流程与方案制定**
* 对于所有需求都必须优先输出详尽的文字方案,只有在获得您的明确许可后,才能进行任何代码或文件的修改。
* 制定方案时严格遵循两步流程:
1. **初步方案**: 基于需求,快速形成一个概要方案。
2. **详尽方案**: 通过搜索和阅读所有涉及的代码文件,将初步方案细化为一个不包含任何模糊信息(如“可能需要”、“我需要先查找”等)的、可直接执行的最终方案。
* 如果项目根目录存在 `project_structure.txt` 文件,必须查阅该文件以全面了解项目结构,确保在制定方案和修改文件时使用准确的文件路径。
3. **文件操作与代码修改**
* 我将不再使用任何 MCP 服务提供的能力(包括但不限于 `write_file`, `create_new_file`, `replace_text_in_file` 等)来直接修改、新增或删除文件。
* 每次提出修改前,我仍会先读取文件的最新内容,并基于最新内容提供修改建议。
* 我可以在不征得同意的情况下读取任何我需要分析的文件。
* 在需要新建文件时,我将提供文件内容和建议的文件路径,由用户手动创建。
4. **注释规范**
* 积极编写有价值的功能注释、参数注释和逻辑注释。
* 绝对禁止添加任何解释性、总结性或礼貌性的“废话”注释(例如:“这段代码修复了问题”,“优化后的代码”,“新增:xxx”“注入:xxx”等
* 不得删除或修改用户已有的任何注释,包括但不限于 TODO、FIXME 或文档注释。
5. **多工具链协同应用策略**
* 我知晓并能主动运用下列独立的 MCP 服务,以最高效、最安全的方式完成任务。
* **Chrome DevTools MCP 服务 (浏览器自动化)**: 用于所有与前端浏览器相关的任务,包括页面导航、模拟用户交互、检查 DOM 和网络状态等。

1
.gitignore vendored
View File

@@ -16,6 +16,7 @@ vendor/
# IDE-specific files
.idea/
.vscode/
*.swp
*.swo

View File

@@ -1,18 +1,11 @@
<!-- OPENSPEC:START -->
# OpenSpec Instructions
# 资源地址
These instructions are for AI assistants working in this project.
1. 你可以访问 http://localhost:8080/ 进入我的前端界面, 前端项目是另一个项目, 但接入的是当前项目对应的后端平台, 如果需要登录账号密码都是huang
2. 你可以阅读 config/config.yml 了解我的配置信息, 包括数据库的连接地址和账号密码, 本平台监听的端口等, 后端的swagger界面在 http://localhost:8086/swagger/index.html
3. 项目根目录有project_structure.txt, 你需要先阅读此文件了解项目目录结构
4. 项目中有config/presets-data目录, 里面有一些预设数据, 可以间接看作数据库内的数据, 在测试环境中他们一般会和数据库的数据保持一致
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 -->
1. 我授权你执行数据库的所有查询类sql
2. 我授权你操作浏览器访问我的项目swagger文档地址和前端项目, 并允许你进行任何操作

View File

@@ -10,10 +10,15 @@ help:
@echo " build Build the application"
@echo " clean Clean generated files"
@echo " test Run all tests"
@echo " swag Generate swagger docs"
@echo " help Show this help message"
@echo " swag Generate Swagger docs"
@echo " proto Generate protobuf files"
@echo " lint Lint the code"
@echo " lint Lint the code"
@echo " dev Run in development mode with hot-reload"
@echo " mcp-chrome Start the Google Chrome MCP server"
@echo " mcp-pgsql Start the PostgreSQL MCP server"
@echo " tree Generate the project file structure list"
@echo " gemini Start the gemini-cli"
@echo " help Show this help message"
# 运行应用
.PHONY: run
@@ -50,18 +55,23 @@ proto:
# 运行代码检查
.PHONY: lint
lint:
golangci-lint run ./...
golangci-lint run ./... -c ./config/.golangci.yml
# 测试模式(改动文件自动重编译重启)
.PHONY: dev
dev:
air
air -c ./config/.air.toml
# 启用谷歌浏览器MCP服务器
.PHONY: mcp-chrome
mcp-chrome:
node "C:\nvm4w\nodejs\node_modules\chrome-devtools-mcp\build\src\index.js"
# 启用PostgreSQL MCP服务器
.PHONY: mcp-pgsql
mcp-pgsql:
npx mcp-postgres-server "postgresql://pig-farm-controller:pig-farm-controller@192.168.5.16:5431/pig-farm-controller"
# 生成文件目录树
.PHONY: tree
@@ -79,4 +89,5 @@ tree:
# 启用gemini-cli
.PHONY: gemini
gemini:
gemini -m "gemini-2.5-flash"
gemini -m "gemini-2.5-flash"

View File

@@ -3,6 +3,9 @@ app:
name: "PigFarmController" # 应用名称
version: "1.0.0" # 应用版本
jwt_secret: "your_jwt_secret_key_here" # JWT 签名密钥,请务必修改为强密码
# 全局临时文件目录用于存放上传、OTA等临时文件
# 在容器化部署时,建议将此路径映射到持久化卷
temp_path: "./tmp"
# 服务器配置
server:
@@ -125,3 +128,21 @@ alarm_notification:
dpanic: 1
panic: 1
fatal: 1
# AI 服务配置
ai:
model: "gemini" # 不指定就是不用AI
gemini:
api_key: "YOUR_GEMINI_API_KEY" # 替换为你的 Gemini API Key
model_name: "gemini-2.5-flash" # Gemini 模型名称,例如 "gemini-pro"
timeout: 30 # AI 请求超时时间 (秒)
# OTA 升级配置
ota:
# 升级任务的全局超时时间(秒)。如果一个升级任务在此时间内没有完成,将被标记为超时。
default_timeout_seconds: 300
# 等待设备响应的单次请求超时时间(秒)。例如,下发 PrepareUpdateReq 后等待设备请求文件的超时。
default_request_timeout_seconds: 60
# 默认的固件块请求重试次数。
default_retry_count: 3

View File

@@ -4,6 +4,9 @@ app:
version: "1.0.0"
# JWT 密钥,用于签发和验证 token。请在生产环境中替换为更复杂的密钥
jwt_secret: "pig-farm-controller"
# 全局临时文件目录用于存放上传、OTA等临时文件
# 在容器化部署时,建议将此路径映射到持久化卷
temp_path: "./tmp"
# HTTP 服务配置
server:
@@ -12,7 +15,7 @@ server:
# 日志配置
log:
level: "debug" # 日志级别: "debug", "info", "warn", "error", "dpanic", "panic", "fatal"
level: "info" # 日志级别: "debug", "info", "warn", "error", "dpanic", "panic", "fatal"
format: "console" # 日志格式: "console" 或 "json"
enable_file: true # 是否启用文件日志
file_path: "./app_logs/app.log" # 日志文件路径
@@ -103,3 +106,20 @@ alarm_notification:
dpanic: 1
panic: 1
fatal: 1
# AI 服务配置
ai:
model: Gemini
gemini:
api_key: "AIzaSyAJdXUmoN07LIswDac6YxPeRnvXlR73OO8" # 替换为你的 Gemini API Key
model_name: "gemini-2.0-flash" # Gemini 模型名称,例如 "gemini-pro"
timeout: 30 # AI 请求超时时间 (秒)
# OTA 升级配置
ota:
# 升级任务的全局超时时间(秒)。如果一个升级任务在此时间内没有完成,将被标记为超时。
default_timeout_seconds: 300
# 等待设备响应的单次请求超时时间(秒)。例如,下发 PrepareUpdateReq 后等待设备请求文件的超时。
default_request_timeout_seconds: 60
# 默认的固件块请求重试次数。
default_retry_count: 3

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,914 @@
{
"type": "pig_nutrient_requirements",
"data": {
"杜长大 (DLY)": {
"保育期": {
"可消化赖氨酸 (SID %)": {
"min_requirement": 1.2,
"max_requirement": 1.5
},
"蛋+胱氨酸 (%)": {
"min_requirement": 0.72,
"max_requirement": 1.05
},
"可消化苏氨酸 (SID %)": {
"min_requirement": 0.78,
"max_requirement": 1.08
},
"可消化色氨酸 (SID %)": {
"min_requirement": 0.22,
"max_requirement": 0.30
},
"粗蛋白 (%)": {
"min_requirement": 18.0,
"max_requirement": 22.0
},
"粗脂肪 (%)": {
"min_requirement": 3.0,
"max_requirement": 6.0
},
"粗纤维 (%)": {
"min_requirement": 2.0,
"max_requirement": 6.0
},
"钙 (%)": {
"min_requirement": 0.9,
"max_requirement": 1.2
},
"总磷 (%)": {
"min_requirement": 0.6,
"max_requirement": 0.8
},
"有效磷 (%)": {
"min_requirement": 0.2,
"max_requirement": 0.45
},
"代谢能 (kcal/kg)": {
"min_requirement": 3226.5,
"max_requirement": 3585.0
},
"钠 (%)": {
"min_requirement": 0.15,
"max_requirement": 0.25
},
"氯 (%)": {
"min_requirement": 0.25,
"max_requirement": 0.45
},
"黄曲霉毒素B1 (μg/kg)": {
"max_requirement": 10
},
"呕吐毒素DON (μg/kg)": {
"max_requirement": 1
},
"玉米赤霉烯酮ZEN (μg/kg)": {
"max_requirement": 0.15
}
},
"育肥前期": {
"可消化赖氨酸 (SID %)": {
"min_requirement": 0.94,
"max_requirement": 1.10
},
"蛋+胱氨酸 (%)": {
"min_requirement": 0.55,
"max_requirement": 0.73
},
"可消化苏氨酸 (SID %)": {
"min_requirement": 0.58,
"max_requirement": 0.77
},
"可消化色氨酸 (SID %)": {
"min_requirement": 0.16,
"max_requirement": 0.22
},
"粗蛋白 (%)": {
"min_requirement": 16.0,
"max_requirement": 18.0
},
"粗脂肪 (%)": {
"min_requirement": 3.0,
"max_requirement": 6.0
},
"粗纤维 (%)": {
"min_requirement": 2.0,
"max_requirement": 6.0
},
"钙 (%)": {
"min_requirement": 0.7,
"max_requirement": 0.9
},
"总磷 (%)": {
"min_requirement": 0.5,
"max_requirement": 0.7
},
"有效磷 (%)": {
"min_requirement": 0.2,
"max_requirement": 0.40
},
"代谢能 (kcal/kg)": {
"min_requirement": 3107.0,
"max_requirement": 3346.0
},
"钠 (%)": {
"min_requirement": 0.15,
"max_requirement": 0.25
},
"氯 (%)": {
"min_requirement": 0.25,
"max_requirement": 0.45
},
"黄曲霉毒素B1 (μg/kg)": {
"max_requirement": 10
},
"呕吐毒素DON (μg/kg)": {
"max_requirement": 1
},
"玉米赤霉烯酮ZEN (μg/kg)": {
"max_requirement": 0.15
}
},
"育肥后期": {
"可消化赖氨酸 (SID %)": {
"min_requirement": 0.81,
"max_requirement": 0.90
},
"蛋+胱氨酸 (%)": {
"min_requirement": 0.45,
"max_requirement": 0.58
},
"可消化苏氨酸 (SID %)": {
"min_requirement": 0.48,
"max_requirement": 0.61
},
"可消化色氨酸 (SID %)": {
"min_requirement": 0.13,
"max_requirement": 0.18
},
"粗蛋白 (%)": {
"min_requirement": 14.0,
"max_requirement": 16.0
},
"粗脂肪 (%)": {
"min_requirement": 3.0,
"max_requirement": 6.0
},
"粗纤维 (%)": {
"min_requirement": 2.0,
"max_requirement": 6.0
},
"钙 (%)": {
"min_requirement": 0.6,
"max_requirement": 0.8
},
"总磷 (%)": {
"min_requirement": 0.45,
"max_requirement": 0.6
},
"有效磷 (%)": {
"min_requirement": 0.18,
"max_requirement": 0.35
},
"代谢能 (kcal/kg)": {
"min_requirement": 2987.5,
"max_requirement": 3226.5
},
"钠 (%)": {
"min_requirement": 0.15,
"max_requirement": 0.25
},
"氯 (%)": {
"min_requirement": 0.25,
"max_requirement": 0.45
},
"黄曲霉毒素B1 (μg/kg)": {
"max_requirement": 10
},
"呕吐毒素DON (μg/kg)": {
"max_requirement": 1
},
"玉米赤霉烯酮ZEN (μg/kg)": {
"max_requirement": 0.15
}
},
"二次育肥期": {
"可消化赖氨酸 (SID %)": {
"min_requirement": 0.53,
"max_requirement": 0.65
},
"蛋+胱氨酸 (%)": {
"min_requirement": 0.30,
"max_requirement": 0.41
},
"可消化苏氨酸 (SID %)": {
"min_requirement": 0.31,
"max_requirement": 0.43
},
"可消化色氨酸 (SID %)": {
"min_requirement": 0.10,
"max_requirement": 0.13
},
"粗蛋白 (%)": {
"min_requirement": 12.0,
"max_requirement": 14.0
},
"粗脂肪 (%)": {
"min_requirement": 3.0,
"max_requirement": 6.0
},
"粗纤维 (%)": {
"min_requirement": 2.0,
"max_requirement": 6.0
},
"钙 (%)": {
"min_requirement": 0.5,
"max_requirement": 0.7
},
"总磷 (%)": {
"min_requirement": 0.4,
"max_requirement": 0.55
},
"有效磷 (%)": {
"min_requirement": 0.15,
"max_requirement": 0.30
},
"代谢能 (kcal/kg)": {
"min_requirement": 2868.0,
"max_requirement": 3107.0
},
"钠 (%)": {
"min_requirement": 0.15,
"max_requirement": 0.25
},
"氯 (%)": {
"min_requirement": 0.25,
"max_requirement": 0.45
},
"黄曲霉毒素B1 (μg/kg)": {
"max_requirement": 10
},
"呕吐毒素DON (μg/kg)": {
"max_requirement": 1
},
"玉米赤霉烯酮ZEN (μg/kg)": {
"max_requirement": 0.15
}
}
},
"杜大长 (DYL)": {
"保育期": {
"可消化赖氨酸 (SID %)": {
"min_requirement": 1.2,
"max_requirement": 1.5
},
"蛋+胱氨酸 (%)": {
"min_requirement": 0.72,
"max_requirement": 1.05
},
"可消化苏氨酸 (SID %)": {
"min_requirement": 0.78,
"max_requirement": 1.08
},
"可消化色氨酸 (SID %)": {
"min_requirement": 0.22,
"max_requirement": 0.30
},
"粗蛋白 (%)": {
"min_requirement": 18.0,
"max_requirement": 22.0
},
"粗脂肪 (%)": {
"min_requirement": 3.0,
"max_requirement": 6.0
},
"粗纤维 (%)": {
"min_requirement": 2.0,
"max_requirement": 6.0
},
"钙 (%)": {
"min_requirement": 0.9,
"max_requirement": 1.2
},
"总磷 (%)": {
"min_requirement": 0.6,
"max_requirement": 0.8
},
"有效磷 (%)": {
"min_requirement": 0.2,
"max_requirement": 0.45
},
"代谢能 (kcal/kg)": {
"min_requirement": 3226.5,
"max_requirement": 3585.0
},
"钠 (%)": {
"min_requirement": 0.15,
"max_requirement": 0.25
},
"氯 (%)": {
"min_requirement": 0.25,
"max_requirement": 0.45
},
"黄曲霉毒素B1 (μg/kg)": {
"max_requirement": 10
},
"呕吐毒素DON (μg/kg)": {
"max_requirement": 1
},
"玉米赤霉烯酮ZEN (μg/kg)": {
"max_requirement": 0.15
}
},
"育肥前期": {
"可消化赖氨酸 (SID %)": {
"min_requirement": 0.94,
"max_requirement": 1.10
},
"蛋+胱氨酸 (%)": {
"min_requirement": 0.55,
"max_requirement": 0.73
},
"可消化苏氨酸 (SID %)": {
"min_requirement": 0.58,
"max_requirement": 0.77
},
"可消化色氨酸 (SID %)": {
"min_requirement": 0.16,
"max_requirement": 0.22
},
"粗蛋白 (%)": {
"min_requirement": 16.0,
"max_requirement": 18.0
},
"粗脂肪 (%)": {
"min_requirement": 3.0,
"max_requirement": 6.0
},
"粗纤维 (%)": {
"min_requirement": 2.0,
"max_requirement": 6.0
},
"钙 (%)": {
"min_requirement": 0.7,
"max_requirement": 0.9
},
"总磷 (%)": {
"min_requirement": 0.5,
"max_requirement": 0.7
},
"有效磷 (%)": {
"min_requirement": 0.2,
"max_requirement": 0.40
},
"代谢能 (kcal/kg)": {
"min_requirement": 3107.0,
"max_requirement": 3346.0
},
"钠 (%)": {
"min_requirement": 0.15,
"max_requirement": 0.25
},
"氯 (%)": {
"min_requirement": 0.25,
"max_requirement": 0.45
},
"黄曲霉毒素B1 (μg/kg)": {
"max_requirement": 10
},
"呕吐毒素DON (μg/kg)": {
"max_requirement": 1
},
"玉米赤霉烯酮ZEN (μg/kg)": {
"max_requirement": 0.15
}
},
"育肥后期": {
"可消化赖氨酸 (SID %)": {
"min_requirement": 0.81,
"max_requirement": 0.90
},
"蛋+胱氨酸 (%)": {
"min_requirement": 0.45,
"max_requirement": 0.58
},
"可消化苏氨酸 (SID %)": {
"min_requirement": 0.48,
"max_requirement": 0.61
},
"可消化色氨酸 (SID %)": {
"min_requirement": 0.13,
"max_requirement": 0.18
},
"粗蛋白 (%)": {
"min_requirement": 14.0,
"max_requirement": 16.0
},
"粗脂肪 (%)": {
"min_requirement": 3.0,
"max_requirement": 6.0
},
"粗纤维 (%)": {
"min_requirement": 2.0,
"max_requirement": 6.0
},
"钙 (%)": {
"min_requirement": 0.6,
"max_requirement": 0.8
},
"总磷 (%)": {
"min_requirement": 0.45,
"max_requirement": 0.6
},
"有效磷 (%)": {
"min_requirement": 0.18,
"max_requirement": 0.35
},
"代谢能 (kcal/kg)": {
"min_requirement": 2987.5,
"max_requirement": 3226.5
},
"钠 (%)": {
"min_requirement": 0.15,
"max_requirement": 0.25
},
"氯 (%)": {
"min_requirement": 0.25,
"max_requirement": 0.45
},
"黄曲霉毒素B1 (μg/kg)": {
"max_requirement": 10
},
"呕吐毒素DON (μg/kg)": {
"max_requirement": 1
},
"玉米赤霉烯酮ZEN (μg/kg)": {
"max_requirement": 0.15
}
},
"二次育肥期": {
"可消化赖氨酸 (SID %)": {
"min_requirement": 0.53,
"max_requirement": 0.65
},
"蛋+胱氨酸 (%)": {
"min_requirement": 0.30,
"max_requirement": 0.41
},
"可消化苏氨酸 (SID %)": {
"min_requirement": 0.31,
"max_requirement": 0.43
},
"可消化色氨酸 (SID %)": {
"min_requirement": 0.10,
"max_requirement": 0.13
},
"粗蛋白 (%)": {
"min_requirement": 12.0,
"max_requirement": 14.0
},
"粗脂肪 (%)": {
"min_requirement": 3.0,
"max_requirement": 6.0
},
"粗纤维 (%)": {
"min_requirement": 2.0,
"max_requirement": 6.0
},
"钙 (%)": {
"min_requirement": 0.5,
"max_requirement": 0.7
},
"总磷 (%)": {
"min_requirement": 0.4,
"max_requirement": 0.55
},
"有效磷 (%)": {
"min_requirement": 0.15,
"max_requirement": 0.30
},
"代谢能 (kcal/kg)": {
"min_requirement": 2868.0,
"max_requirement": 3107.0
},
"钠 (%)": {
"min_requirement": 0.15,
"max_requirement": 0.25
},
"氯 (%)": {
"min_requirement": 0.25,
"max_requirement": 0.45
},
"黄曲霉毒素B1 (μg/kg)": {
"max_requirement": 10
},
"呕吐毒素DON (μg/kg)": {
"max_requirement": 1
},
"玉米赤霉烯酮ZEN (μg/kg)": {
"max_requirement": 0.15
}
}
},
"皮长大 (PLY)": {
"保育期": {
"可消化赖氨酸 (SID %)": {
"min_requirement": 1.2,
"max_requirement": 1.5
},
"蛋+胱氨酸 (%)": {
"min_requirement": 0.72,
"max_requirement": 1.05
},
"可消化苏氨酸 (SID %)": {
"min_requirement": 0.78,
"max_requirement": 1.08
},
"可消化色氨酸 (SID %)": {
"min_requirement": 0.22,
"max_requirement": 0.30
},
"粗蛋白 (%)": {
"min_requirement": 18.0,
"max_requirement": 22.0
},
"粗脂肪 (%)": {
"min_requirement": 3.0,
"max_requirement": 6.0
},
"粗纤维 (%)": {
"min_requirement": 2.0,
"max_requirement": 6.0
},
"钙 (%)": {
"min_requirement": 0.9,
"max_requirement": 1.2
},
"总磷 (%)": {
"min_requirement": 0.6,
"max_requirement": 0.8
},
"有效磷 (%)": {
"min_requirement": 0.2,
"max_requirement": 0.45
},
"代谢能 (kcal/kg)": {
"min_requirement": 3226.5,
"max_requirement": 3585.0
},
"钠 (%)": {
"min_requirement": 0.15,
"max_requirement": 0.25
},
"氯 (%)": {
"min_requirement": 0.25,
"max_requirement": 0.45
},
"黄曲霉毒素B1 (μg/kg)": {
"max_requirement": 10
},
"呕吐毒素DON (μg/kg)": {
"max_requirement": 1
},
"玉米赤霉烯酮ZEN (μg/kg)": {
"max_requirement": 0.15
}
},
"育肥前期": {
"可消化赖氨酸 (SID %)": {
"min_requirement": 0.94,
"max_requirement": 1.10
},
"蛋+胱氨酸 (%)": {
"min_requirement": 0.55,
"max_requirement": 0.73
},
"可消化苏氨酸 (SID %)": {
"min_requirement": 0.58,
"max_requirement": 0.77
},
"可消化色氨酸 (SID %)": {
"min_requirement": 0.16,
"max_requirement": 0.22
},
"粗蛋白 (%)": {
"min_requirement": 16.0,
"max_requirement": 18.0
},
"粗脂肪 (%)": {
"min_requirement": 3.0,
"max_requirement": 6.0
},
"粗纤维 (%)": {
"min_requirement": 2.0,
"max_requirement": 6.0
},
"钙 (%)": {
"min_requirement": 0.7,
"max_requirement": 0.9
},
"总磷 (%)": {
"min_requirement": 0.5,
"max_requirement": 0.7
},
"有效磷 (%)": {
"min_requirement": 0.2,
"max_requirement": 0.40
},
"代谢能 (kcal/kg)": {
"min_requirement": 3107.0,
"max_requirement": 3346.0
},
"钠 (%)": {
"min_requirement": 0.15,
"max_requirement": 0.25
},
"氯 (%)": {
"min_requirement": 0.25,
"max_requirement": 0.45
},
"黄曲霉毒素B1 (μg/kg)": {
"max_requirement": 10
},
"呕吐毒素DON (μg/kg)": {
"max_requirement": 1
},
"玉米赤霉烯酮ZEN (μg/kg)": {
"max_requirement": 0.15
}
},
"育肥后期": {
"可消化赖氨酸 (SID %)": {
"min_requirement": 0.81,
"max_requirement": 0.90
},
"蛋+胱氨酸 (%)": {
"min_requirement": 0.45,
"max_requirement": 0.58
},
"可消化苏氨酸 (SID %)": {
"min_requirement": 0.48,
"max_requirement": 0.61
},
"可消化色氨酸 (SID %)": {
"min_requirement": 0.13,
"max_requirement": 0.18
},
"粗蛋白 (%)": {
"min_requirement": 14.0,
"max_requirement": 16.0
},
"粗脂肪 (%)": {
"min_requirement": 3.0,
"max_requirement": 6.0
},
"粗纤维 (%)": {
"min_requirement": 2.0,
"max_requirement": 6.0
},
"钙 (%)": {
"min_requirement": 0.6,
"max_requirement": 0.8
},
"总磷 (%)": {
"min_requirement": 0.45,
"max_requirement": 0.6
},
"有效磷 (%)": {
"min_requirement": 0.18,
"max_requirement": 0.35
},
"代谢能 (kcal/kg)": {
"min_requirement": 2987.5,
"max_requirement": 3226.5
},
"钠 (%)": {
"min_requirement": 0.15,
"max_requirement": 0.25
},
"氯 (%)": {
"min_requirement": 0.25,
"max_requirement": 0.45
},
"黄曲霉毒素B1 (μg/kg)": {
"max_requirement": 10
},
"呕吐毒素DON (μg/kg)": {
"max_requirement": 1
},
"玉米赤霉烯酮ZEN (μg/kg)": {
"max_requirement": 0.15
}
},
"二次育肥期": {
"可消化赖氨酸 (SID %)": {
"min_requirement": 0.53,
"max_requirement": 0.65
},
"蛋+胱氨酸 (%)": {
"min_requirement": 0.30,
"max_requirement": 0.41
},
"可消化苏氨酸 (SID %)": {
"min_requirement": 0.31,
"max_requirement": 0.43
},
"可消化色氨酸 (SID %)": {
"min_requirement": 0.10,
"max_requirement": 0.13
},
"粗蛋白 (%)": {
"min_requirement": 12.0,
"max_requirement": 14.0
},
"粗脂肪 (%)": {
"min_requirement": 3.0,
"max_requirement": 6.0
},
"粗纤维 (%)": {
"min_requirement": 2.0,
"max_requirement": 6.0
},
"钙 (%)": {
"min_requirement": 0.5,
"max_requirement": 0.7
},
"总磷 (%)": {
"min_requirement": 0.4,
"max_requirement": 0.55
},
"有效磷 (%)": {
"min_requirement": 0.15,
"max_requirement": 0.30
},
"代谢能 (kcal/kg)": {
"min_requirement": 2868.0,
"max_requirement": 3107.0
},
"钠 (%)": {
"min_requirement": 0.15,
"max_requirement": 0.25
},
"氯 (%)": {
"min_requirement": 0.25,
"max_requirement": 0.45
},
"黄曲霉毒素B1 (μg/kg)": {
"max_requirement": 10
},
"呕吐毒素DON (μg/kg)": {
"max_requirement": 1
},
"玉米赤霉烯酮ZEN (μg/kg)": {
"max_requirement": 0.15
}
}
}
},
"descriptions": {
"pig_breeds": {
"杜长大 (DLY)": {
"description": "杜长大是中国市场占有率最高的商品肉猪。通过利用杜洛克、长白、大约克三品种的杂种优势,实现高效生长和高瘦肉率。",
"parent_info": "终端父本:杜洛克 (D);二元母本:长白 (L) × 大约克 (Y)。",
"appearance_features": "全身白色,体型健壮、体躯较长,肌肉发达,背腰平直。",
"breed_advantages": "生长速度快、日增重极高、饲料转化率最优、瘦肉率稳定在60%以上、适应性良好、出栏时间最短。",
"breed_disadvantages": "抗应激能力中等,对疫病和环境变化相对敏感;无法作为种猪进行自繁。"
},
"杜大长 (DYL)": {
"description": "杜大长是另一种重要的外三元猪,与杜长大体系相似,但在母本的搭配上有所区别,同样追求高生长速度和高瘦肉率。",
"parent_info": "终端父本:杜洛克 (D);二元母本:大约克 (Y) × 长白 (L)。",
"appearance_features": "全身白色,体型比杜长大略微魁梧,肌肉丰满度高。",
"breed_advantages": "生长性能和瘦肉率与杜长大相当,同时遗传了大约克母本的良好体型和生长潜力,综合性能优秀。",
"breed_disadvantages": "与杜长大相似,无法留作种用,需要依赖稳定的种源体系;对饲养管理要求高。"
},
"皮长大 (PLY)": {
"description": "皮长大是以皮特兰作为终端父本的杂交体系,专注于生产超高瘦肉率的商品肉猪。",
"parent_info": "终端父本:皮特兰 (P);二元母本:长白 (L) × 大约克 (Y)。",
"appearance_features": "多数为白色或带有黑色斑点,肌肉极其发达,后臀饱满,体型呈方形。",
"breed_advantages": "瘦肉率极高能达到65%以上),胴体丰满,背膘薄。",
"breed_disadvantages": "生长速度和日增重逊于杜长大应激敏感性极高易发生P.S.S.管理难度大肉质易出现PSE苍白、软、渗水现象影响口感和加工性能。"
}
},
"pig_age_stages": {
"保育期": "从断奶到转入生长舍。主要目标是适应固体饲料建立肠道菌群确保健康稳定过渡体重约5kg~30kg。",
"育肥前期": "小猪转入育肥舍后到体重达到约60kg的阶段。以骨骼和肌肉生长为主是高效增重期。",
"育肥后期": "体重从约60kg到达到出栏体重约110-120kg的阶段。脂肪沉积速度开始加快是出栏前的冲刺期。",
"二次育肥期": "指收购达到常规出栏体重约100-120kg的商品猪继续饲养至更大体重140-180kg+)的阶段。其存在主要受市场价格波动驱动,目的是提高单体出肉量。"
},
"pig_breed_age_stages": {
"杜长大 (DLY)": {
"保育期": {
"description": "遗传自杜洛克的生长优势在断奶后开始显现,需精细化管理以避免断奶应激和腹泻。",
"daily_feed_intake": 400.0,
"daily_gain_weight": 350.0,
"min_days": 21,
"max_days": 70,
"min_weight": 5000.0,
"max_weight": 30000.0
},
"育肥前期": {
"description": "生长速度和饲料转化率表现良好,是瘦肉沉积效率高的阶段。",
"daily_feed_intake": 1800.0,
"daily_gain_weight": 700.0,
"min_days": 71,
"max_days": 120,
"min_weight": 30000.0,
"max_weight": 60000.0
},
"育肥后期": {
"description": "继续实现较高日增重,脂肪沉积开始加快,管理目标为达到目标出栏体重并保持料肉比。",
"daily_feed_intake": 2800.0,
"daily_gain_weight": 800.0,
"min_days": 121,
"max_days": 180,
"min_weight": 60000.0,
"max_weight": 100000.0
},
"二次育肥期": {
"description": "增重效率下降,料肉比恶化,增重多为脂肪沉积,注意热应激与蹄部问题。",
"daily_feed_intake": 3500.0,
"daily_gain_weight": 600.0,
"min_days": 181,
"max_days": 240,
"min_weight": 100000.0,
"max_weight": 140000.0
}
},
"杜大长 (DYL)": {
"保育期": {
"description": "与杜长大相近,生长潜力强,管理重点为断奶适应与稳定采食。",
"daily_feed_intake": 400.0,
"daily_gain_weight": 330.0,
"min_days": 21,
"max_days": 70,
"min_weight": 5000.0,
"max_weight": 30000.0
},
"育肥前期": {
"description": "生长期增重与料肉比接近杜长大,肌肉发展迅速。",
"daily_feed_intake": 1750.0,
"daily_gain_weight": 680.0,
"min_days": 71,
"max_days": 120,
"min_weight": 30000.0,
"max_weight": 60000.0
},
"育肥后期": {
"description": "保持较高增重速度,脂肪沉积稍快于部分 DLY 群体,需配方微调以控制背膘。",
"daily_feed_intake": 2700.0,
"daily_gain_weight": 770.0,
"min_days": 121,
"max_days": 180,
"min_weight": 60000.0,
"max_weight": 100000.0
},
"二次育肥期": {
"description": "与杜长大相似,采食量高但增重多为脂肪,注意健康与福利管理。",
"daily_feed_intake": 3500.0,
"daily_gain_weight": 580.0,
"min_days": 181,
"max_days": 240,
"min_weight": 100000.0,
"max_weight": 140000.0
}
},
"皮长大 (PLY)": {
"保育期": {
"description": "个体应激敏感性可能更高,需要稳定环境与逐步换料以减少应激性下降重。",
"daily_feed_intake": 350.0,
"daily_gain_weight": 300.0,
"min_days": 21,
"max_days": 70,
"min_weight": 5000.0,
"max_weight": 30000.0
},
"育肥前期": {
"description": "增重速度一般,但瘦肉率高;需注意高应激个体的管理以避免肉质问题。",
"daily_feed_intake": 1600.0,
"daily_gain_weight": 600.0,
"min_days": 71,
"max_days": 120,
"min_weight": 30000.0,
"max_weight": 60000.0
},
"育肥后期": {
"description": "瘦肉率高且脂肪沉积较慢,但应激易导致肉质问题,育肥管理需谨慎。",
"daily_feed_intake": 2400.0,
"daily_gain_weight": 650.0,
"min_days": 121,
"max_days": 180,
"min_weight": 60000.0,
"max_weight": 100000.0
},
"二次育肥期": {
"description": "超重育肥带来的应激和死亡风险增加,通常不推荐长期二次育肥。",
"daily_feed_intake": 3200.0,
"daily_gain_weight": 450.0,
"min_days": 181,
"max_days": 240,
"min_weight": 100000.0,
"max_weight": 140000.0
}
}
}
}
}

View File

@@ -0,0 +1,82 @@
# 需求
饲料配方管理及自动生成配方
## issue
http://git.huangwc.com/pig/pig-farm-controller/issues/66
# 开发计划
1. 原料营养价值管理
- 增删改查
- 内置60+条常用原料玉米、豆粕43、豆粕46、发酵豆粕、麸皮、次粉、DDGS、乳清粉、鱼粉、膨化大豆、各种氨基酸、预混料、油脂等
- 每种原料固定营养值消化能、粗蛋白、赖氨酸、钙、磷等15项左右
2. 饲料库存管理(代替批次)
- 字段:饲料名、当前原料种类、当前剩余量(吨)、上次入料日期、保质期剩余天数(手动填)、是否发酵料(勾选)
- 发酵料塔额外字段:
- 发酵状态(未发酵 / 正在发酵 / 已发酵可用)
- 发酵开始日期
- 发酵几天默认37天
- 水分增加比例(默认+1020%
- 营养折损系数(可调,粗蛋白-5%、能量-3%之类)
3. 猪只阶段营养需求管理
- 预设10个常用阶段教槽、仔猪、小猪、中猪、大猪、后备、怀孕前中后、哺乳
- 每个阶段维护营养需求上下限消化能、粗蛋白、赖氨酸、钙、有效磷等12项
4. 配方管理
- 按阶段建配方
- 支持增删改查 + 复制上个配方快速新建
- 配方明细:原料 + 配比(%
5. 自动生成配方(核心功能)
- 选择阶段 → 点击“自动计算最低成本配方”
- 自动读取当前所有料塔的:
- 剩余量(不够的原料自动降配比)
- 保质期剩余天数越快过期的优先用权重×1.5
- 发酵料塔如果状态是“已发酵可用”则按发酵后营养值参与计算
- 输出:总成本、营养达标情况、发酵料占比、即将过期原料使用提示
6. 配方下发与记录
- 一键下发到喂料站/料线(生成下料曲线)
- 自动记录今天用了哪个配方
7. 简单查看功能
- 两个配方对比页面(营养+成本对比)
# 实现总结
## 实现内容
实现库存和原料和营养和猪营养需求的管理, 支持根据库存和已录入原料和猪营养需求生成配方
## TODO
1. 发酵料管理考虑到发酵目前没有自动化流程, 不好追踪, 遂暂时不做
2. 目前的价格是根据原料的参考价设置的, 后续应当实现一个在服务供平台采集参考价, 以及使用原料采购价计算
3. 原料应该加上膨润土等, 比如膨润土的黄曲霉素含量应该是负数以表示减少饲料里的含量
4. 饲料保质期考虑到批次间管理暂时不方便, 等可以实现同一原料先进先出后再实现
5. 暂时不支持指定原料列表然后自动生成, 也不支持告诉用户当前生成不出是为什么, 等以后再做
# 完成事项
1. 定义原料表, 营养表, 原料营养表, 原料库存变更表
2. 迁移配置文件, 实现从json文件中读取原材料营养预设值, 并自动写入数据库
3. 定义配方领域, 实现营养元素的增删改查
4. 实现原材料的增删改查和仓库层的原料库存记录表增查
5. 定义猪的模型和营养需求模型
6. 实现从json读取猪营养需求并写入数据库
7. 实现配方领域关于猪模型和营养需求的增删改查
8. 实现配方领域的web接口
9. 实现修改原料营养信息
10. 实现修改猪营养需求
11. 配方模型定义和仓库层增删改查方法
12. 配方领域层方法
13. 重构配方领域
14. 配方增删改查服务层和控制器
15. 实现库存管理相关逻辑
16. 实现配方生成器
17. 实现使用系统中所有可用的原料一键生成配方
18. 实现优先使用库存的配方一键生成

View File

@@ -0,0 +1,20 @@
# 需求
支持主控设备ota升级和远程查看日志
## issue
http://git.huangwc.com/pig/pig-farm-controller/issues/71
# 开发计划
## Lora 监听逻辑重构
- [x] [Lora逻辑重构](design/ota-upgrade-and-log-monitoring/lora_refactoring_plan.md)
## OTA 升级
- [x] 增加一个proto对象, 用于封装ota升级包
- [x] 区域主控增加版本号
- [x] 增加ping指令并获取带版本号的响应
- [ ] [实现ota升级逻辑](design/ota-upgrade-and-log-monitoring/ota_upgrade_solution.md)

View File

@@ -0,0 +1,199 @@
# LoRa 通信层统一重构方案
## 1. 目标
统一项目当前并存的两种 LoRa 通信模式(基于 ChirpStack API 和基于串口透传),使其在架构层面遵循相同的接口和设计模式。最终实现:
- **业务逻辑统一**:所有上行业务处理逻辑集中在一个地方,与具体的通信方式无关。
- **发送接口统一**:上层服务使用同一个接口发送下行指令,无需关心底层实现。
- **架构清晰**:明确划分基础设施层(负责传输)和应用层(负责业务)的职责,并确保正确的依赖方向 (`app` -> `infra`)。
- **高扩展性**:未来支持新的通信方式时,只需添加新的“适配器”,而无需改动核心业务代码。
## 2. 背景与问题分析
### 2.1. 当前存在两种 LoRa 通信模式
1. **ChirpStack 模式**: 通过 `internal/infra/transport/lora/chirp_stack.go` 实现发送,通过 `internal/app/listener/chirp_stack/chirp_stack.go` 监听并处理 ChirpStack Webhook 推送的数据。
2. **串口透传模式**: 通过 `internal/infra/transport/lora/lora_mesh_uart_passthrough_transport.go` 实现发送和接收处理。
### 2.2. 核心差异
| 特性 | ChirpStack 模式 | 串口透传模式 |
| :--- | :--- | :--- |
| **通信模型** | 双向、有状态、异步API调用 | 单向、无状态、直接串口读写 |
| **接收机制** | Webhook (HTTP POST) 推送 | 主动从串口读取字节流 |
| **数据格式** | JSON 包装 + Base64 编码 | 自定义二进制物理帧 |
| **寻址方式**| `DevEui` | 自定义 16 位网络地址 |
| **核心职责** | LNS管理会话、ACK、队列 | 纯粹的“无线串口” |
### 2.3. 问题
- **业务逻辑分散**:处理 `CollectResult` 的业务逻辑在 `chirp_stack.go``lora_mesh_uart_passthrough_transport.go` 中都存在,造成代码重复和维护困难。
- **职责不清**`lora_mesh_uart_passthrough_transport.go` 同时承担了基础设施(串口读写)和应用(处理业务)两种职责。
- **依赖关系混乱**:为了让 `infra` 层的串口模块能调用业务逻辑,可能会导致 `infra` 层反向依赖 `app` 层,破坏了项目的核心架构原则。
## 3. 统一架构设计方案
### 3.1. 核心思想
采用 **端口与适配器模式 (Ports and Adapters Pattern)**,严格遵守 **依赖倒置原则**
- **端口 (Port)**:在 `infra` 层定义一个 `UpstreamHandler` 接口。这个接口是 `infra` 层向上层暴露的“端口”,它规定了上行业务处理器必须满足的协约。
- **适配器 (Adapter)**:在 `app` 层创建一个 `LoRaListener` 作为“适配器”,它实现 `infra` 层定义的 `UpstreamHandler` 接口,并封装所有核心业务处理逻辑。
- **依赖注入**:在系统启动时,将 `app` 层的 `LoRaListener` 实例注入到需要它的 `infra` 层组件中。
### 3.2. 统一接口定义
#### 3.2.1. 发送接口 (已存在,无需修改)
```go
// file: internal/infra/transport/transport.go
package transport
type Communicator interface {
Send(ctx context.Context, address string, payload []byte) (*SendResult, error)
}
```
#### 3.2.2. 接收处理接口 (端口定义)
此接口定义了 `infra` 层对上行业务处理器的期望,是 `infra` 层向上层暴露的“端口”。
```go
// file: internal/infra/transport/transport.go
package transport
import (
"context"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport/proto"
)
// UpstreamHandler 定义了处理所有来源的上行数据的统一协约。
// 任何实现了上行消息监听的基础设施如串口、MQTT客户端都应该在收到消息后调用此接口的实现者。
// 这样,基础设施层只负责“接收和解析”,而将“业务处理”的控制权交给了上层。
type UpstreamHandler interface {
// HandleInstruction 处理来自设备的、已解析为Instruction的业务指令。
HandleInstruction(ctx context.Context, sourceAddr string, instruction *proto.Instruction) error
// HandleStatus 处理非业务指令的设备状态更新,例如信号强度、电量等。
HandleStatus(ctx context.Context, sourceAddr string, status map[string]interface{}) error
}
```
### 3.3. 组件职责划分 (重构后)
#### 3.3.1. 统一业务处理器 (应用层适配器)
- **文件**: `internal/app/listener/lora_listener.go` (新)
- **职责**:
- 实现 `transport.UpstreamHandler` 接口。
- 包含所有处理业务所需的依赖(如领域服务、仓储等)。
- 实现 `HandleInstruction` 方法,通过 `switch-case` 编排所有核心业务。
- 实现 `HandleStatus` 方法,处理设备状态更新。
- **这是项目中唯一处理 LoRa 上行业务的地方。**
#### 3.3.2. 基础设施层 (Infra Layer)
- **文件 1**: `internal/app/listener/chirp_stack/chirp_stack.go` (重构)
- **职责**: 纯粹的 Webhook 适配器。
- 移除所有业务逻辑和数据库依赖。
- 依赖 `transport.UpstreamHandler` 接口。
- 功能:接收 Webhook -> 解析 JSON -> 调用 `handler.HandleInstruction``handler.HandleStatus`
- **文件 2**: `internal/infra/transport/lora/lora_mesh_uart_passthrough_transport.go` (重构)
- **职责**: 纯粹的串口传输工具。
- 移除所有业务逻辑和数据库依赖。
- 依赖 `transport.UpstreamHandler` 接口。
- 功能:管理串口 -> 读字节流 -> 重组分片 -> 解析 `proto.Instruction` -> 调用 `handler.HandleInstruction`
### 3.4. 架构图 (重构后)
```
+--------------------------------+
| Upper-Level Services |
| (e.g., DeviceService) |
+--------------------------------+
|
v (uses)
+--------------------------------+
| transport.Communicator (I) | <-- Infra Layer (Send Port)
+--------------------------------+
^ ^
| | (implements)
+------------------+------------------+
| ChirpStackSender | UartSender | <-- Infra Layer (Senders)
+------------------+------------------+
+--------------------------------+
| listener.LoRaListener | <-- App Layer (Adapter)
| (Implements UpstreamHandler) |
+--------------------------------+
^
| (dependency, via interface)
+--------------------------------+
| transport.UpstreamHandler (I) | <-- Infra Layer (Receive Port)
+--------------------------------+
^ ^
| | (calls)
+------------------+------------------+
| ChirpStackWebhook| UartPassthrough | <-- Infra Layer (Receivers)
+------------------+------------------+
^ ^
| | (receives from)
+------------------+------------------+
| HTTP Webhook | Serial Port |
+------------------+------------------+
```
### 3.5. 依赖注入与组装示例
```go
// file: internal/core/component_initializers.go
// 1. 创建统一的业务处理器 (App层适配器)
// 它实现了 infra 层的 transport.UpstreamHandler 接口
loraListener := listener.NewLoRaListener(logger, dbRepo1, dbRepo2)
// 2. 初始化 ChirpStack 模式
// 2a. 创建 ChirpStack 的发送器 (infra)
chirpStackCommunicator := chirp_stack.NewChirpStackTransport(...)
// 2b. 创建 ChirpStack 的监听器 (infra),并注入 App 层的业务处理器
chirpStackListener := chirp_stack.NewChirpStackListener(loraListener)
// 2c. 注册 Webhook 路由
api.RegisterWebhook("/chirpstack", chirpStackListener.Handler())
// 3. 初始化串口透传模式
// 3a. 创建串口的传输工具 (infra),并注入 App 层的业务处理器
uartTransport := lora.NewLoRaMeshUartPassthroughTransport(port, loraListener)
// 3b. 启动串口监听
uartTransport.Listen()
// 4. 向上层业务提供统一的发送器
var finalCommunicator transport.Communicator
if config.UseChirpStack {
finalCommunicator = chirpStackCommunicator
} else {
finalCommunicator = uartTransport
}
// 将 finalCommunicator 注入到需要发送指令的服务中...
```
## 4. 实施步骤
1. **定义端口**: 在 `internal/infra/transport/transport.go` 中定义 `UpstreamHandler` 接口。
2. **创建适配器**: 创建 `internal/app/listener/lora_listener.go`,定义 `LoRaListener` 结构体,并实现 `transport.UpstreamHandler` 接口。
3. **迁移业务逻辑**: 将 `chirp_stack.go``lora_mesh_uart_passthrough_transport.go` 中的业务逻辑(查库、存数据等)逐步迁移到 `lora_listener.go` 的对应方法中。
4. **重构基础设施**:
- 清理 `chirp_stack.go`,移除 Repo 依赖,改为依赖 `transport.UpstreamHandler` 接口,并调用其方法。
- 清理 `lora_mesh_uart_passthrough_transport.go`,做同样的操作。
5. **更新依赖注入**: 修改 `component_initializers.go`,按照 `3.5` 中的示例完成组件的创建和注入。
6. **测试与验证**: 对两种模式分别进行完整的上下行通信测试。
## 5. 收益
- **消除代码重复**:业务逻辑仅存在于一处。
- **职责清晰**:基础设施层只管传输,应用层只管业务。
- **正确的依赖关系**:确保了 `app` -> `infra` 的单向依赖,核心架构更加稳固。
- **可维护性**:修改业务逻辑只需改一个文件,修改传输细节不影响业务。
- **可测试性**:可以轻松地对 `LoRaListener` 进行单元测试,无需真实的硬件或网络。

View File

@@ -0,0 +1,304 @@
# 区域主控 MicroPython OTA 升级方案
## 1. 概述
### 1.1. 目标
实现区域主控 (ESP32-S3-N16R8, MicroPython 固件) 的安全、可靠的远程固件升级 (OTA)。
### 1.2. 核心思想
* **AB 分区模式**: 区域主控采用 AB 分区模式,允许在设备运行时更新非活动分区,升级失败时可回滚到上一个已知的工作版本。
* **平台主导**: 升级过程由平台完全控制,包括固件准备、文件分发和升级指令下发。
* **LoRa 传输层自动分片**: 充分利用 LoRa 传输层自动分片和重组的能力,简化应用层协议设计。
* **逐文件校验**: 设备在接收每个文件后立即进行 MD5 校验,确保文件完整性,并处理重试。
* **清单文件**: 使用清单文件管理所有待更新文件的元数据和校验信息。
* **设备自驱动**: 设备主动请求清单文件和固件文件,并在所有文件校验成功后自行激活新固件并重启。
* **平台记录升级任务**: 平台将记录 OTA 升级任务的创建、进度和最终状态。
* **配置文件独立管理**: OTA 升级过程将不涉及配置文件的更新,配置文件由平台提供独立的远程修改功能。
### 1.3. 涉及组件
* **平台**: 负责固件包管理、清单文件生成、数字签名(未来)、文件分发、指令下发、状态接收和**升级任务记录**。
* **LoRa 传输层**: 负责应用层数据的分片、传输和重组。
* **区域主控 (ESP32-S3-N16R8)**: 负责接收文件、存储到非活动分区、文件校验、分区切换、新固件启动验证和状态上报。
## 2. 固件包结构与准备
### 2.1. 原始固件包 (由开发者提供给平台)
* 一个标准的压缩包(例如 `.zip`),其中包含所有 MicroPython `.py` 文件、资源文件等。
* 压缩包内的文件结构应与期望在设备上部署的路径结构一致。
### 2.2. 平台处理流程
1. **接收**: 平台接收开发者上传的 MicroPython 项目压缩包。
2. **解压**: 平台将该压缩包解压到内部的一个临时目录。
3. **分析与生成清单**: 平台遍历解压后的所有文件,为每个文件计算:
* 在设备上的目标路径 (`path`)
* MD5 校验和 (`md5`)
* 文件大小 (`size`)
* **排除配置文件**: 平台会识别配置文件(例如通过文件名约定,如 `/config/` 目录下的所有文件),并**排除**
这些文件,不将其包含在清单文件中,也不通过 OTA 传输。
4. **生成清单文件**: 平台根据上述信息,生成一个 JSON 格式的清单文件。
5. **数字签名 (未来扩展)**: 平台使用其私钥对**清单文件**的内容进行数字签名,并将签名添加到清单文件中。
### 2.3. 清单文件 (Manifest File) 结构
清单文件是一个 JSON 对象,包含新固件的元数据和所有文件的详细信息。
```json
{
"version": "1.0.1",
// 新固件版本号
"signature": "...",
// 清单文件内容的数字签名 (未来扩展)
"files": [
{
"path": "/manifest.json",
// 清单文件本身也作为文件列表的一部分
"md5": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"size": 1024
},
{
"path": "/main.py",
"md5": "b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a1",
"size": 10240
},
{
"path": "/lib/sensor.py",
"md5": "c3d4e5f6a7b8c9d0e1f2a3b4c5d6a1b2",
"size": 5120
}
// ... 更多文件 (不包含配置文件)
]
}
```
## 3. 通信协议定义 (Protobuf Messages)
以下是 OTA 过程中平台与区域主控之间通信所需的 Protobuf 消息定义。
```protobuf
// OTA 升级指令和状态消息
// PrepareUpdateReq: 平台发送给设备,通知设备准备开始 OTA 升级
message PrepareUpdateReq {
string version = 1; // 新固件版本号
uint32 task_id = 2; // 升级任务唯一ID
string manifest_md5 = 3; // 清单文件的 MD5 校验和,用于设备初步校验清单文件完整性
}
// RequestFile: 设备向平台请求特定文件 (包括清单文件和固件文件)
message RequestFile {
uint32 task_id = 1; // 升级任务ID
string filepath = 2; // 请求的文件路径 (例如 "/manifest.json" 或 "/main.py")
}
// FileResponse: 平台响应设备请求,发送单个文件的完整内容
// LoRa 传输层会自动处理分片和重组,因此应用层可以直接发送完整的单个文件内容
message FileResponse {
uint32 task_id = 1; // 升级任务ID
string filepath = 2; // 设备上的目标路径 (例如 "/manifest.json" 或 "/main.py")
bytes content = 3; // 文件的完整内容
}
// UpdateStatusReport: 设备向平台报告升级状态
message UpdateStatusReport {
uint32 task_id = 1; // 升级任务ID
string current_version = 2; // 操作完成后的当前版本
enum Status {
STATUS_UNKNOWN = 0;
// --- 设备主动上报的状态 ---
SUCCESS = 1; // 升级成功,新固件已运行 (由设备在自检成功后主动上报)
SUCCESS_ALREADY_UP_TO_DATE = 2; // 版本已是最新,未执行升级 (由设备在版本检查后主动上报)
FAILED_PRE_CHECK = 3; // 升级前检查失败 (例如拒绝降级、准备分区失败等,由设备主动上报)
FAILED_DOWNLOAD = 4; // 文件下载或校验失败 (由设备在下载过程中主动上报)
// --- 平台推断的状态 (数据库记录用) ---
FAILED_TIMEOUT = 5; // 平台在超时后仍未收到SUCCESS报告将任务标记为此状态
}
Status status = 3; // 升级的最终状态
string error_message = 4; // 人类可读的详细错误信息
string failed_file = 5; // 失败时关联的文件路径 (可选)
}
```
## 4. 平台侧操作流程
### 4.1. 准备升级任务
1. 接收开发者提供的 MicroPython 项目压缩包。
2. 解压压缩包。
3. 遍历解压后的文件,计算每个文件的 MD5、大小并确定目标路径。
4. **排除配置文件**: 平台会识别配置文件(例如通过文件名约定,如 `/config/` 目录下的所有文件),并**排除**这些文件。
5. 生成清单文件 (Manifest File)。**注意:清单文件本身也应作为 OTA 的一部分,其元数据应包含在清单文件自身的 `files`
列表中。Manifest文件生成后将被放在解压后的文件夹的根目录下, 方便后续主控设备获取**
6. (未来扩展)对清单文件进行数字签名。
7. 将清单文件和所有固件文件存储在平台内部,等待分发。
8. **记录 OTA 升级任务**: 在数据库中创建一条新的 OTA 升级任务记录(模型名为 `OTATask`,位于 `internal/infra/models/ota.go`
),包含任务 ID、目标设备、新固件版本、状态例如“待开始”
### 4.2. 发送“准备更新”指令
1. 平台向目标区域主控发送 `PrepareUpdateReq` 消息。
2. **更新任务记录**: 平台发送指令后,更新 OTA 任务记录的状态为“进行中”。
### 4.3. 响应设备文件请求
1. 平台接收区域主控发送的 `RequestFile` 消息。
2. 平台根据 `task_id``filepath` 在内部存储中找到对应的文件内容。
3. 平台构建 `FileResponse` 消息,将文件的完整内容和路径放入其中。
4. 平台通过 LoRa 传输层发送 `FileResponse` 消息。
### 4.4. 处理设备状态上报
1. 平台接收区域主控发送的 `UpdateStatusReport` 消息。
2. 根据报告的 `status` (`SUCCESS``FAILED`),更新 OTA 任务记录的最终状态,并记录 `error_code``error_message`
3. 如果状态为 `SUCCESS`,平台应更新该设备在系统中的固件版本记录。
4. **总超时管理**: 平台为每个 OTA 任务设置一个总的超时时间(例如 2 小时)。如果在总超时时间内未能收到设备的最终状态报告,平台应自动将该任务标记为
`FAILED``error_code` 设为 `ERR_TIMEOUT`
5. **处理重复报告**: 平台在收到最终状态报告后,即使后续再次收到相同的报告,也只需更新一次任务记录,无需重复处理。
## 5. 区域主控侧操作流程 (MicroPython)
### 5.1. 接收“准备更新”指令与版本检查
1. 区域主控接收 `PrepareUpdateReq` 消息。
2. **版本检查**: 设备将 `PrepareUpdateReq` 中的 `version` 与自身当前运行的固件版本进行比较。
* **降级场景**: 如果 `新版本 < 当前版本`,设备立即中止升级,并向平台发送 `UpdateStatusReport` (status: `FAILED`,
error_code: `ERR_VERSION_ROLLBACK`, error_message: "拒绝版本回滚,目标版本低于当前版本")。
* **同版本场景**: 如果 `新版本 == 当前版本`,设备立即中止升级,并向平台发送 `UpdateStatusReport` (status: `SUCCESS`,
error_code: `SUCCESS_ALREADY_UP_TO_DATE`, error_message: "版本已是最新,无需升级")。
* **正常升级场景**: 如果 `新版本 > 当前版本`,继续执行下一步。
3. **清空非活动分区**: 使用 MicroPython 的文件系统操作(例如 `os.remove()``os.rmdir()`),递归删除非活动 OTA 分区(例如
`/ota_b`)下的所有文件和目录。
* **错误处理**: 如果清空分区失败,设备应立即中止,并向平台发送 `UpdateStatusReport` (status: `FAILED`, error_code:
`ERR_PREPARE`, error_message: "清空非活动分区失败: [具体错误]").
4. 设备准备就绪后,将直接开始请求清单文件。
### 5.2. 请求并验证清单文件
1. 设备完成准备后,向平台发送 `RequestFile` 消息,请求清单文件 (`filepath: "/manifest.json"`)。
2. 设备接收平台响应的 `FileResponse` 消息,并将其写入非活动分区(例如 `/ota_b/manifest.json`)。
3. **MD5 校验**: 计算写入的清单文件的 MD5并与 `PrepareUpdateReq` 消息中提供的 `manifest_md5` 进行比对。
4. **解析 JSON**: 解析清单文件内容。
5. **数字签名验证 (未来扩展)**: 使用预置的平台公钥,验证清单文件的数字签名。
6. 如果上述任何步骤失败,设备应向平台发送 `UpdateStatusReport` (status: `FAILED`, error_code: `ERR_MANIFEST_VERIFY`,
error_message: "[具体失败原因]"), 然后中止升级。
### 5.3. 请求与存储固件文件 (逐文件校验)
1. 设备成功接收并验证清单文件后,根据清单文件中的文件列表,**逐个文件**地向平台发送 `RequestFile` 消息。
2. 对于每个请求的文件:
* **请求、接收与写入**: 设备请求文件,接收响应,并根据 `filepath` 将内容写入到非活动 OTA 分区。需要确保目标目录存在,如果不存在则创建。
* **MD5 校验**: 在文件写入完成后,计算该文件的 MD5 校验和,并与清单文件中记录的 MD5 进行比对。
* **错误处理与重试**:
* 如果文件下载超时、写入失败或 MD5 校验失败,设备将进行重试(例如最多 3 次)。
* 如果达到最大重试次数仍失败,设备应立即中止整个 OTA 任务,并向平台发送 `UpdateStatusReport` (status: `FAILED`,
error_code: `ERR_DOWNLOAD``ERR_VERIFY`, error_message: "[具体失败原因]", failed_file: "[失败的文件路径]")。
### 5.4. 自激活与重启
1. **所有文件接收并校验成功后**,设备将自行执行以下操作:
* **配置 OTA 分区**: 使用 MicroPython 提供的 ESP-IDF OTA API设置下一个启动分区为刚刚写入新固件的非活动分区。
* **自触发重启**: 在成功配置 OTA 分区后,区域主控自行触发重启。
### 5.5. 新版本启动与验证
1. 设备重启后,启动加载器会从新的 OTA 分区加载 MicroPython 固件。
2. **自检**: 新固件启动后,应执行必要的自检(如 LoRa 初始化、网络连接等)。
3. **标记有效**: 只有当所有自检项都成功通过后,新固件才必须调用相应的 API例如 `esp.ota_mark_app_valid_cancel_rollback()`
)来标记自身为有效。
4. **看门狗与回滚**:
* 如果新固件在一定次数的尝试后仍未标记自身为有效,启动加载器会自动回滚到上一个有效固件。
* 在 MicroPython 应用层,如果自检失败,**绝不能**标记自身为有效,并应等待底层机制自动触发回滚。
### 5.6. 报告最终状态
1. **成功场景**: 新固件自检成功并标记有效后,向平台发送 `UpdateStatusReport` (status: `SUCCESS`, current_version:
新版本号)。
2. **回滚场景**: 设备回滚到旧版本后,向平台发送 `UpdateStatusReport` (status: `FAILED`, error_code: `ERR_ROLLED_BACK`,
error_message: "新固件启动失败,已自动回滚", current_version: 旧版本号)。
3. **重复发送**: 为了提高在单向 LoRa 通信中的可靠性,设备在发送最终状态报告时,应在短时间内重复发送多次(例如 3-5 次)。
## 6. 关键技术点与注意事项
### 6.1. LoRa 传输层
* 确保 `internal/infra/transport/lora/lora_mesh_uart_passthrough_transport.go` 能稳定处理大尺寸 Protobuf 消息的分片和重组。
### 6.2. 平台侧的请求处理
* `internal/app/listener/lora_listener.go` 在接收 `RequestFile` 消息时,需要高效处理,避免阻塞监听器。
### 6.3. 文件系统操作 (MicroPython)
* 确保文件系统操作(创建目录、写入文件、删除文件)的正确性和鲁棒性,并对错误进行捕获和报告。
### 6.4. MD5 校验 (MicroPython)
* MicroPython 的 `hashlib` 模块提供 MD5 算法。确保计算的效率和准确性。
### 6.5. OTA 分区管理 (MicroPython)
* 熟悉 ESP-IDF 的 OTA 机制在 MicroPython 中的绑定和使用方法。正确调用 API 来设置启动分区和标记应用有效。
### 6.6. 回滚机制
* 完全依赖 ESP-IDF 提供的 OTA 回滚机制。新固件必须在启动后标记自身为有效,否则会自动回滚。
### 6.7. 错误处理与重试
* **设备侧**: 实现文件级别的下载和校验重试。对于无法恢复的错误,立即上报 `FAILED` 状态并中止任务。
* **平台侧**: 实现任务级别的总超时管理。这是处理设备意外断电、失联等情况的关键机制。设备重启后无需保留升级状态,简化了设备端逻辑。
### 6.8. 安全性
* **数字签名**: 强烈建议尽快实现清单文件的数字签名。**没有数字签名OTA 过程将面临严重的安全风险(如中间人攻击)**
,攻击者可能下发恶意固件。平台的公钥需要被硬编码到设备固件中,作为信任的根基。
* **LoRaWAN 安全**: 确保 LoRaWAN 的网络层和应用层密钥管理得当, 防止未经授权的设备加入网络或窃听数据。
---
## 7. 固件 OTA 升级流程描述
### 阶段一:任务准备与下发
1. **上传与准备 (Developer -> Platform)**: 开发者上传固件包平台解压、计算MD5、生成清单文件、创建升级任务。
2. **下发更新通知 (Platform -> Device)**: 平台向设备发送 `PrepareUpdateReq`
### 阶段二:设备版本检查与准备
1. **版本检查 (Device)**:
* **失败分支 (降级/同版本)**: 设备拒绝升级,上报 `FAILED` (ERR_VERSION_ROLLBACK) 或 `SUCCESS` (
SUCCESS_ALREADY_UP_TO_DATE),流程结束。
* **成功分支**: 版本检查通过,设备继续。
2. **设备准备 (Device)**:
* 设备清空非活动分区。
* **失败分支**: 上报 `FAILED` (ERR_PREPARE),流程结束。
* **成功分支**: 设备发送 `RequestFile` 请求清单文件。
### 阶段三:文件循环下载和校验
1. **清单文件传输与校验 (Platform <-> Device)**:
* 平台发送清单文件,设备接收并校验。
* **失败分支**: 上报 `FAILED` (ERR_MANIFEST_VERIFY),流程结束。
2. **固件文件循环 (Device <-> Platform)**:
* 设备逐个请求、下载、校验清单中的所有文件。
* **失败分支 (重试耗尽)**: 上报 `FAILED` (ERR_DOWNLOAD / ERR_VERIFY),流程结束。
### 阶段四:激活与最终状态
1. **激活重启 (Device)**: 所有文件成功下载后,设备配置启动分区并重启。
2. **新固件自检 (Device)**:
* **成功分支**:
* 设备标记自身为有效。
* 设备上报 `SUCCESS`
* 平台更新任务状态为 `SUCCESS`
* **失败分支 (自检失败/未标记)**:
* 设备等待底层机制自动回滚。
* 设备回滚后,上报 `FAILED` (ERR_ROLLED_BACK)。
* 平台更新任务状态为 `FAILED`
3. **总超时检查 (Platform)**: 如果在规定时间内未收到任何最终报告,平台将任务标记为 `FAILED` (ERR_TIMEOUT)。

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

79
go.mod
View File

@@ -3,43 +3,52 @@ module git.huangwc.com/pig/pig-farm-controller
go 1.25
require (
github.com/bodgit/sevenzip v1.6.1
github.com/dsnet/compress v0.0.1
github.com/gibson042/canonicaljson-go v1.0.3
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/google/generative-ai-go v0.20.1
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/swaggo/echo-swagger v1.4.1
github.com/swaggo/swag v1.16.6
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
github.com/tidwall/gjson v1.18.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.43.0
google.golang.org/protobuf v1.36.9
gonum.org/v1/gonum v0.16.0
google.golang.org/api v0.256.0
google.golang.org/protobuf v1.36.10
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v2 v2.4.0
gorm.io/datatypes v1.2.6
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.30.5
)
require (
cloud.google.com/go v0.115.0 // indirect
cloud.google.com/go/ai v0.8.0 // indirect
cloud.google.com/go/auth v0.17.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/longrunning v0.5.7 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/bodgit/plumbing v1.3.0 // indirect
github.com/bodgit/windows v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/analysis v0.23.0 // indirect
github.com/go-openapi/jsonpointer v0.22.1 // indirect
@@ -57,55 +66,55 @@ require (
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/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // 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-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/reflect2 v1.0.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/pierrec/lz4/v4 v4.1.22 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/swaggo/echo-swagger v1.4.1 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/swaggo/files/v2 v2.0.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/ulikunitz/xz v0.5.12 // 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.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.21.0 // indirect
go4.org v0.0.0-20200411211856-f5505b9728dd // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/oauth2 v0.33.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/time v0.11.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.38.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect
google.golang.org/grpc v1.76.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/mysql v1.5.6 // indirect
gorm.io/driver/sqlite v1.6.0 // indirect
)

448
go.sum
View File

@@ -1,131 +1,178 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
cloud.google.com/go/ai v0.8.0 h1:rXUEz8Wp2OlrM8r1bfmpF2+VKqc1VJpafE3HgzRnD/w=
cloud.google.com/go/ai v0.8.0/go.mod h1:t3Dfk4cM61sytiggo2UyGsDVW3RF1qGZaUKDrZFyqkE=
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/longrunning v0.5.7 h1:WLbHekDbjK1fVFD3ibpFFVoyizlLRl73I7YKuAKilhU=
cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4=
github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8=
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q=
github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
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/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
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/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/gibson042/canonicaljson-go v1.0.3 h1:EAyF8L74AWabkyUmrvEFHEt/AGFQeD6RfwbAuf0j1bI=
github.com/gibson042/canonicaljson-go v1.0.3/go.mod h1:DsLpJTThXyGNO+KZlI85C1/KDcImpP67k/RKVjcaEqo=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU=
github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo=
github.com/go-openapi/errors v0.22.2 h1:rdxhzcBUazEcGccKqbY1Y7NS8FDcMyIRr0934jrYnZg=
github.com/go-openapi/errors v0.22.2/go.mod h1:+n/5UdIqdVnLIJ6Q9Se8HNGUXYaY6CN8ImWzfi/Gzp0=
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/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
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/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/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/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
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.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
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/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/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
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/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/generative-ai-go v0.20.1 h1:6dEIujpgN2V0PgLhr6c/M1ynRdc7ARtiIDPFzj45uNQ=
github.com/google/generative-ai-go v0.20.1/go.mod h1:TjOnZJmZKzarWbjUJgy+r3Ee7HGBRVLhOIgupnwR4Bg=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
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/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
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/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -138,24 +185,24 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
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/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
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/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/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
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=
@@ -166,31 +213,34 @@ github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP
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-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
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/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
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/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
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.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
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/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
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/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
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.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@@ -199,37 +249,49 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
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/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/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/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/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
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/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
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.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
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/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
@@ -238,70 +300,182 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.21.0 h1:iTC9o7+wP6cPWpDWkivCvQFGAHDQ59SrSxsLPcnkArw=
golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
go4.org v0.0.0-20200411211856-f5505b9728dd h1:BNJlw5kRTzdmyfh5U8F93HA2OwkP7ZGwA51eJ/0wKOU=
go4.org v0.0.0-20200411211856-f5505b9728dd/go.mod h1:CIiUVy99QCPfoE13bO4EZaz5GZMZXMSBGhxRdsvzbkg=
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.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
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.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
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-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
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-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.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/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-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-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/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.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.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
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/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/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.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
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=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI=
google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b h1:ULiyYQ0FdsJhwwZUwbaXpZF5yUE3h+RA+gxvBu37ucc=
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 h1:tRPGkdGHuewF4UisLzzHHr1spKw92qLM98nIzxbC0wY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
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/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/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -320,3 +494,11 @@ 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.30.5 h1:dvEfYwxL+i+xgCNSGGBT1lDjCzfELK8fHZxL3Ee9X0s=
gorm.io/gorm v1.30.5/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

View File

@@ -21,13 +21,15 @@ import (
_ "git.huangwc.com/pig/pig-farm-controller/docs" // 引入 swag 生成的 docs
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller/alarm"
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller/device"
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller/feed"
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller/health"
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller/inventory"
"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/listener"
"git.huangwc.com/pig/pig-farm-controller/internal/app/service"
"git.huangwc.com/pig/pig-farm-controller/internal/app/webhook"
domain_plan "git.huangwc.com/pig/pig-farm-controller/internal/domain/plan"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/config"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
@@ -40,23 +42,32 @@ import (
// API 结构体定义了 HTTP 服务器及其依赖
type API struct {
echo *echo.Echo // Echo 引擎实例,用于处理 HTTP 请求
Ctx context.Context // API 组件的上下文,包含日志记录器
userRepo repository.UserRepository // 用户数据仓库接口,用于用户数据操作
tokenGenerator token.Generator // Token 服务接口,用于 JWT token 的生成和解析
auditService service.AuditService // 审计服务,用于记录用户操作
httpServer *http.Server // 标准库的 HTTP 服务器实例,用于启动和停止服务
config config.ServerConfig // API 服务器的配置,使用 infra/config 包中的 ServerConfig
userController *user.Controller // 用户控制器实例
deviceController *device.Controller // 设备控制器实例
planController *plan.Controller // 计划控制器实例
pigFarmController *management.PigFarmController // 猪场管理控制器实例
pigBatchController *management.PigBatchController // 猪群控制器实例
monitorController *monitor.Controller // 数据监控控制器实例
healthController *health.Controller // 健康检查控制器实例
alarmController *alarm.ThresholdAlarmController // 阈值告警控制器
listenHandler webhook.ListenHandler // 设备上行事件监听器
analysisTaskManager *domain_plan.AnalysisPlanTaskManager // 计划触发器管理器实例
echo *echo.Echo // Echo 引擎实例,用于处理 HTTP 请求
Ctx context.Context // API 组件的上下文,包含日志记录器
userRepo repository.UserRepository // 用户数据仓库接口,用于用户数据操作
tokenGenerator token.Generator // Token 服务接口,用于 JWT token 的生成和解析
auditService service.AuditService // 审计服务,用于记录用户操作
httpServer *http.Server // 标准库的 HTTP 服务器实例,用于启动和停止服务
config config.ServerConfig // API 服务器的配置,使用 infra/config 包中的 ServerConfig
userController *user.Controller // 用户控制器实例
deviceController *device.DeviceController // 设备控制器实例
deviceTemplateController *device.DeviceTemplateController // 设备模板控制器实例
areaControllerController *device.AreaControllerController // 区域主控控制器实例
planController *plan.Controller // 计划控制器实例
pigFarmController *management.PigFarmController // 猪场管理控制器实例
pigBatchController *management.PigBatchController // 猪群控制器实例
monitorController *monitor.Controller // 数据监控控制器实例
healthController *health.Controller // 健康检查控制器实例
alarmController *alarm.ThresholdAlarmController // 阈值告警控制器
nutrientController *feed.NutrientController // 营养控制器实例
pigAgeStageController *feed.PigAgeStageController // 猪龄阶段控制器实例
pigBreedController *feed.PigBreedController // 猪品种控制器实例
pigTypeController *feed.PigTypeController // 猪种类控制器实例
rawMaterialController *feed.RawMaterialController // 原料控制器实例
recipeController *feed.RecipeController // 配方控制器实例
inventoryController *inventory.InventoryController // 库存控制器实例
listenHandler listener.ListenHandler // 设备上行事件监听器
analysisTaskManager *domain_plan.AnalysisPlanTaskManager // 计划触发器管理器实例
}
// NewAPI 创建并返回一个新的 API 实例
@@ -68,12 +79,21 @@ func NewAPI(cfg config.ServerConfig,
pigBatchService service.PigBatchService,
monitorService service.MonitorService,
deviceService service.DeviceService,
deviceTemplateService service.DeviceTemplateService,
areaControllerService service.AreaControllerService,
planService service.PlanService,
userService service.UserService,
auditService service.AuditService,
alarmService service.ThresholdAlarmService,
nutrientService service.NutrientService,
rawMaterialService service.RawMaterialService,
pigBreedService service.PigBreedService,
pigAgeStageService service.PigAgeStageService,
pigTypeService service.PigTypeService,
recipeService service.RecipeService,
inventoryService service.InventoryService,
tokenGenerator token.Generator,
listenHandler webhook.ListenHandler,
listenHandler listener.ListenHandler,
) *API {
// 使用 echo.New() 创建一个 Echo 引擎实例
e := echo.New()
@@ -88,29 +108,30 @@ func NewAPI(cfg config.ServerConfig,
// 初始化 API 结构体
baseCtx := context.Background()
api := &API{
echo: e,
Ctx: ctx,
userRepo: userRepo,
tokenGenerator: tokenGenerator,
auditService: auditService,
config: cfg,
listenHandler: listenHandler,
// 在 NewAPI 中初始化用户控制器,并将其作为 API 结构体的成员
userController: user.NewController(logs.AddCompName(baseCtx, "UserController"), userService),
// 在 NewAPI 中初始化设备控制器,并将其作为 API 结构体的成员
deviceController: device.NewController(logs.AddCompName(baseCtx, "DeviceController"), deviceService),
// 在 NewAPI 中初始化计划控制器,并将其作为 API 结构体的成员
planController: plan.NewController(logs.AddCompName(baseCtx, "PlanController"), planService),
// 在 NewAPI 中初始化猪场管理控制器
pigFarmController: management.NewPigFarmController(logs.AddCompName(baseCtx, "PigFarmController"), pigFarmService),
// 在 NewAPI 中初始化猪群控制器
pigBatchController: management.NewPigBatchController(logs.AddCompName(baseCtx, "PigBatchController"), pigBatchService),
// 在 NewAPI 中初始化数据监控控制器
monitorController: monitor.NewController(logs.AddCompName(baseCtx, "MonitorController"), monitorService),
// 在 NewAPI 中初始化健康检查控制器
healthController: health.NewController(logs.AddCompName(baseCtx, "HealthController")),
// 在 NewAPI 中初始化阈
alarmController: alarm.NewThresholdAlarmController(logs.AddCompName(baseCtx, "ThresholdAlarmController"), alarmService),
echo: e,
Ctx: ctx,
userRepo: userRepo,
tokenGenerator: tokenGenerator,
auditService: auditService,
config: cfg,
listenHandler: listenHandler,
userController: user.NewController(logs.AddCompName(baseCtx, "UserController"), userService),
deviceController: device.NewDeviceController(logs.AddCompName(baseCtx, "DeviceController"), deviceService),
deviceTemplateController: device.NewDeviceTemplateController(logs.AddCompName(baseCtx, "DeviceTemplateController"), deviceTemplateService),
areaControllerController: device.NewAreaControllerController(logs.AddCompName(baseCtx, "AreaControllerController"), areaControllerService),
planController: plan.NewController(logs.AddCompName(baseCtx, "PlanController"), planService),
pigFarmController: management.NewPigFarmController(logs.AddCompName(baseCtx, "PigFarmController"), pigFarmService),
pigBatchController: management.NewPigBatchController(logs.AddCompName(baseCtx, "PigBatchController"), pigBatchService),
monitorController: monitor.NewController(logs.AddCompName(baseCtx, "MonitorController"), monitorService),
healthController: health.NewController(logs.AddCompName(baseCtx, "HealthController")),
alarmController: alarm.NewThresholdAlarmController(logs.AddCompName(baseCtx, "ThresholdAlarmController"), alarmService),
nutrientController: feed.NewNutrientController(logs.AddCompName(baseCtx, "NutrientController"), nutrientService),
pigAgeStageController: feed.NewPigAgeStageController(logs.AddCompName(baseCtx, "PigAgeStageController"), pigAgeStageService),
pigBreedController: feed.NewPigBreedController(logs.AddCompName(baseCtx, "PigBreedController"), pigBreedService),
pigTypeController: feed.NewPigTypeController(logs.AddCompName(baseCtx, "PigTypeController"), pigTypeService),
rawMaterialController: feed.NewRawMaterialController(logs.AddCompName(baseCtx, "RawMaterialController"), rawMaterialService),
recipeController: feed.NewRecipeController(logs.AddCompName(baseCtx, "RecipeController"), recipeService),
inventoryController: inventory.NewInventoryController(logs.AddCompName(baseCtx, "InventoryController"), inventoryService),
}
api.setupRoutes() // 设置所有路由

View File

@@ -84,22 +84,25 @@ func (a *API) setupRoutes() {
// 区域主控相关路由组
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) // 删除区域主控
areaControllerGroup.POST("", a.areaControllerController.CreateAreaController) // 创建区域主控
areaControllerGroup.GET("", a.areaControllerController.ListAreaControllers) // 获取区域主控列表
areaControllerGroup.GET("/:id", a.areaControllerController.GetAreaController) // 获取单个区域主控
areaControllerGroup.PUT("/:id", a.areaControllerController.UpdateAreaController) // 更新区域主控
areaControllerGroup.DELETE("/:id", a.areaControllerController.DeleteAreaController) // 删除区域主控
areaControllerGroup.POST("/:id/ota/start", a.areaControllerController.StartUpgrade) // 开始升级
areaControllerGroup.GET("/ota/progress/:taskId", a.areaControllerController.GetUpgradeProgress) // 获取升级进度
areaControllerGroup.POST("/ota/tasks/:taskId/stop", a.areaControllerController.StopUpgrade) // 停止升级
}
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) // 删除设备模板
deviceTemplateGroup.POST("", a.deviceTemplateController.CreateDeviceTemplate) // 创建设备模板
deviceTemplateGroup.GET("", a.deviceTemplateController.ListDeviceTemplates) // 获取设备模板列表
deviceTemplateGroup.GET("/:id", a.deviceTemplateController.GetDeviceTemplate) // 获取单个设备模板
deviceTemplateGroup.PUT("/:id", a.deviceTemplateController.UpdateDeviceTemplate) // 更新设备模板
deviceTemplateGroup.DELETE("/:id", a.deviceTemplateController.DeleteDeviceTemplate) // 删除设备模板
}
logger.Debug("设备模板相关接口注册成功 (需要认证和审计)")
@@ -173,9 +176,6 @@ func (a *API) setupRoutes() {
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)
@@ -215,6 +215,67 @@ func (a *API) setupRoutes() {
}
}
logger.Debug("告警相关接口注册成功 (需要认证和审计)")
// 饲料管理相关路由组
feedGroup := authGroup.Group("/feed")
{
// 营养种类 (Nutrient) 路由
feedGroup.POST("/nutrients", a.nutrientController.CreateNutrient)
feedGroup.PUT("/nutrients/:id", a.nutrientController.UpdateNutrient)
feedGroup.DELETE("/nutrients/:id", a.nutrientController.DeleteNutrient)
feedGroup.GET("/nutrients/:id", a.nutrientController.GetNutrient)
feedGroup.GET("/nutrients", a.nutrientController.ListNutrients)
// 原料 (RawMaterial) 路由
feedGroup.POST("/raw-materials", a.rawMaterialController.CreateRawMaterial)
feedGroup.PUT("/raw-materials/:id", a.rawMaterialController.UpdateRawMaterial)
feedGroup.PUT("/raw-materials/:id/nutrients", a.rawMaterialController.UpdateRawMaterialNutrients)
feedGroup.DELETE("/raw-materials/:id", a.rawMaterialController.DeleteRawMaterial)
feedGroup.GET("/raw-materials/:id", a.rawMaterialController.GetRawMaterial)
feedGroup.GET("/raw-materials", a.rawMaterialController.ListRawMaterials)
// 猪品种 (PigBreed) 路由
feedGroup.POST("/pig-breeds", a.pigBreedController.CreatePigBreed)
feedGroup.PUT("/pig-breeds/:id", a.pigBreedController.UpdatePigBreed)
feedGroup.DELETE("/pig-breeds/:id", a.pigBreedController.DeletePigBreed)
feedGroup.GET("/pig-breeds/:id", a.pigBreedController.GetPigBreed)
feedGroup.GET("/pig-breeds", a.pigBreedController.ListPigBreeds)
// 猪年龄阶段 (PigAgeStage) 路由
feedGroup.POST("/pig-age-stages", a.pigAgeStageController.CreatePigAgeStage)
feedGroup.PUT("/pig-age-stages/:id", a.pigAgeStageController.UpdatePigAgeStage)
feedGroup.DELETE("/pig-age-stages/:id", a.pigAgeStageController.DeletePigAgeStage)
feedGroup.GET("/pig-age-stages/:id", a.pigAgeStageController.GetPigAgeStage)
feedGroup.GET("/pig-age-stages", a.pigAgeStageController.ListPigAgeStages)
// 猪类型 (PigType) 路由
feedGroup.POST("/pig-types", a.pigTypeController.CreatePigType)
feedGroup.PUT("/pig-types/:id", a.pigTypeController.UpdatePigType)
feedGroup.DELETE("/pig-types/:id", a.pigTypeController.DeletePigType)
feedGroup.GET("/pig-types/:id", a.pigTypeController.GetPigType)
feedGroup.GET("/pig-types", a.pigTypeController.ListPigTypes)
feedGroup.PUT("/pig-types/:id/nutrient-requirements", a.pigTypeController.UpdatePigTypeNutrientRequirements)
// 配方 (Recipe) 路由
feedGroup.POST("/recipes", a.recipeController.CreateRecipe)
feedGroup.PUT("/recipes/:id", a.recipeController.UpdateRecipe)
feedGroup.DELETE("/recipes/:id", a.recipeController.DeleteRecipe)
feedGroup.GET("/recipes/:id", a.recipeController.GetRecipe)
feedGroup.GET("/recipes", a.recipeController.ListRecipes)
feedGroup.POST("/recipes/generate-from-all-materials/:pig_type_id", a.recipeController.GenerateFromAllMaterials)
feedGroup.POST("/recipes/generate-prioritized-stock/:pig_type_id", a.recipeController.GenerateRecipeWithPrioritizedStockRawMaterials)
feedGroup.GET("/recipes/:id/ai-diagnose", a.recipeController.AIDiagnoseRecipe)
}
logger.Debug("饲料管理相关接口注册成功 (需要认证和审计)")
// 库存管理相关路由组
inventoryGroup := authGroup.Group("/inventory")
{
inventoryGroup.POST("/stock/adjust", a.inventoryController.AdjustStock)
inventoryGroup.GET("/stock/current", a.inventoryController.ListCurrentStock)
inventoryGroup.GET("/stock/logs", a.inventoryController.ListStockLogs)
}
logger.Debug("库存管理相关接口注册成功 (需要认证和审计)")
}
logger.Debug("所有接口注册成功")

View File

@@ -0,0 +1,310 @@
package device
import (
"context"
"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"
)
// AreaControllerController 区域主控控制器
type AreaControllerController struct {
ctx context.Context
areaControllerService service.AreaControllerService
}
// NewAreaControllerController 创建一个新的区域主控控制器实例
func NewAreaControllerController(
ctx context.Context,
areaControllerService service.AreaControllerService,
) *AreaControllerController {
return &AreaControllerController{
ctx: ctx,
areaControllerService: areaControllerService,
}
}
// 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 *AreaControllerController) CreateAreaController(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "CreateAreaController")
const actionType = "创建区域主控"
var req dto.CreateAreaControllerRequest
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
resp, err := c.areaControllerService.CreateAreaController(reqCtx, &req)
if err != nil {
logger.Errorf("%s: 服务层创建失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建区域主控失败: "+err.Error(), actionType, "服务层创建失败", req)
}
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 *AreaControllerController) GetAreaController(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "GetAreaController")
const actionType = "获取区域主控"
acID := ctx.Param("id")
id, err := strconv.ParseUint(acID, 10, 64)
if err != nil {
logger.Errorf("%s: 无效的ID: %s", actionType, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID: "+acID, actionType, "无效的ID", acID)
}
resp, err := c.areaControllerService.GetAreaController(reqCtx, uint32(id))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logger.Warnf("%s: 区域主控不存在, ID: %s", actionType, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "区域主控未找到", actionType, "区域主控不存在", acID)
}
logger.Errorf("%s: 服务层获取失败: %v, ID: %s", actionType, err, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取区域主控信息失败: "+err.Error(), actionType, "服务层获取失败", acID)
}
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 *AreaControllerController) ListAreaControllers(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "ListAreaControllers")
const actionType = "获取区域主控列表"
resp, err := c.areaControllerService.ListAreaControllers(reqCtx)
if err != nil {
logger.Errorf("%s: 服务层获取列表失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取区域主控列表失败: "+err.Error(), actionType, "服务层获取列表失败", nil)
}
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 *AreaControllerController) UpdateAreaController(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "UpdateAreaController")
const actionType = "更新区域主控"
acID := ctx.Param("id")
var req dto.UpdateAreaControllerRequest
if err := ctx.Bind(&req); err != nil {
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 {
logger.Errorf("%s: 无效的ID: %s", actionType, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID: "+acID, actionType, "无效的ID", acID)
}
resp, err := c.areaControllerService.UpdateAreaController(reqCtx, uint32(id), &req)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logger.Warnf("%s: 区域主控不存在, ID: %s", actionType, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "区域主控未找到", actionType, "区域主控不存在", acID)
}
logger.Errorf("%s: 服务层更新失败: %v, ID: %s", actionType, err, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新区域主控失败: "+err.Error(), actionType, "服务层更新失败", acID)
}
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 *AreaControllerController) DeleteAreaController(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "DeleteAreaController")
const actionType = "删除区域主控"
acID := ctx.Param("id")
id, err := strconv.ParseUint(acID, 10, 64)
if err != nil {
logger.Errorf("%s: 无效的ID: %s", actionType, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID: "+acID, actionType, "无效的ID", acID)
}
if err := c.areaControllerService.DeleteAreaController(reqCtx, uint32(id)); err != nil {
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
logger.Warnf("%s: 区域主控不存在, ID: %s", actionType, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "区域主控未找到", actionType, "区域主控不存在", acID)
case errors.Is(err, service.ErrAreaControllerInUse):
logger.Warnf("%s: 尝试删除正在被使用的主控, ID: %s", actionType, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), actionType, "主控正在被使用", acID)
default:
logger.Errorf("%s: 服务层删除失败: %v, ID: %s", actionType, err, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除区域主控失败: "+err.Error(), actionType, "服务层删除失败", acID)
}
}
logger.Infof("%s: 区域主控删除成功, ID: %s", actionType, acID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "区域主控删除成功", nil, actionType, "区域主控删除成功", acID)
}
// StartUpgrade godoc
// @Summary 启动区域主控OTA升级
// @Description 为指定的区域主控上传固件并启动一个OTA升级任务
// @Tags 区域主控管理
// @Security BearerAuth
// @Accept mpfd
// @Produce json
// @Param id path string true "区域主控ID"
// @Param firmware_file formData file true "固件压缩包文件"
// @Success 200 {object} controller.Response{data=dto.OtaUpgradeResponse}
// @Router /api/v1/area-controllers/{id}/ota/start [post]
func (c *AreaControllerController) StartUpgrade(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "StartUpgrade")
const actionType = "启动区域主控OTA升级"
acID := ctx.Param("id")
id, err := strconv.ParseUint(acID, 10, 64)
if err != nil {
logger.Errorf("%s: 无效的区域主控ID: %s", actionType, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的区域主控ID: "+acID, actionType, "无效的ID", acID)
}
var req dto.OtaUpgradeRequest
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求: "+err.Error(), actionType, "请求绑定失败", req)
}
resp, err := c.areaControllerService.StartUpgrade(reqCtx, uint32(id), &req)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logger.Warnf("%s: 区域主控不存在, ID: %s", actionType, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "区域主控未找到", actionType, "区域主控不存在", acID)
}
logger.Errorf("%s: 服务层启动升级失败: %v, ID: %s", actionType, err, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "启动升级失败: "+err.Error(), actionType, "服务层启动升级失败", acID)
}
logger.Infof("%s: 升级任务启动成功, 任务ID: %d", actionType, resp.TaskID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "升级任务启动成功", resp, actionType, "升级任务启动成功", resp)
}
// GetUpgradeProgress godoc
// @Summary 查询OTA升级进度
// @Description 根据任务ID查询指定OTA升级任务的当前进度
// @Tags 区域主控管理
// @Security BearerAuth
// @Produce json
// @Param taskId path string true "OTA任务ID"
// @Success 200 {object} controller.Response{data=dto.OtaUpgradeProgressResponse}
// @Router /api/v1/area-controllers/ota/progress/{taskId} [get]
func (c *AreaControllerController) GetUpgradeProgress(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "GetUpgradeProgress")
const actionType = "查询OTA升级进度"
taskIDStr := ctx.Param("taskId")
taskID, err := strconv.ParseUint(taskIDStr, 10, 64)
if err != nil {
logger.Errorf("%s: 无效的任务ID: %s", actionType, taskIDStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的任务ID: "+taskIDStr, actionType, "无效的ID", taskIDStr)
}
resp, err := c.areaControllerService.GetUpgradeProgress(reqCtx, uint32(taskID))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logger.Warnf("%s: 升级任务不存在, ID: %s", actionType, taskIDStr)
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "升级任务未找到", actionType, "升级任务不存在", taskIDStr)
}
logger.Errorf("%s: 服务层查询进度失败: %v, ID: %s", actionType, err, taskIDStr)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "查询进度失败: "+err.Error(), actionType, "服务层查询进度失败", taskIDStr)
}
logger.Infof("%s: 查询进度成功, 任务ID: %d", actionType, resp.TaskID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "查询进度成功", resp, actionType, "查询进度成功", resp)
}
// StopUpgrade godoc
// @Summary 停止OTA升级任务
// @Description 根据任务ID请求停止一个正在进行的OTA升级任务
// @Tags 区域主控管理
// @Security BearerAuth
// @Produce json
// @Param taskId path string true "OTA任务ID"
// @Success 200 {object} controller.Response
// @Router /api/v1/area-controllers/ota/tasks/{taskId}/stop [post]
func (c *AreaControllerController) StopUpgrade(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "StopUpgrade")
const actionType = "停止OTA升级任务"
taskIDStr := ctx.Param("taskId")
taskID, err := strconv.ParseUint(taskIDStr, 10, 64)
if err != nil {
logger.Errorf("%s: 无效的任务ID: %s", actionType, taskIDStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的任务ID: "+taskIDStr, actionType, "无效的ID", taskIDStr)
}
err = c.areaControllerService.StopUpgrade(reqCtx, uint32(taskID))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logger.Warnf("%s: 升级任务不存在, ID: %s", actionType, taskIDStr)
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "升级任务未找到", actionType, "升级任务不存在", taskIDStr)
}
logger.Errorf("%s: 服务层停止任务失败: %v, ID: %s", actionType, err, taskIDStr)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "停止任务失败: "+err.Error(), actionType, "服务层停止任务失败", taskIDStr)
}
logger.Infof("%s: 停止任务请求成功, 任务ID: %s", actionType, taskIDStr)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "停止任务请求成功", nil, actionType, "停止任务请求成功", taskIDStr)
}

View File

@@ -14,25 +14,23 @@ import (
"gorm.io/gorm"
)
// Controller 设备控制器,封装了所有与设备和区域主控相关的业务逻辑
type Controller struct {
// DeviceController 设备控制器
type DeviceController struct {
ctx context.Context
deviceService service.DeviceService
}
// NewController 创建一个新的设备控制器实例
func NewController(
// NewDeviceController 创建一个新的设备控制器实例
func NewDeviceController(
ctx context.Context,
deviceService service.DeviceService,
) *Controller {
return &Controller{
) *DeviceController {
return &DeviceController{
ctx: ctx,
deviceService: deviceService,
}
}
// --- Controller Methods: Devices ---
// CreateDevice godoc
// @Summary 创建新设备
// @Description 根据提供的信息创建一个新设备
@@ -43,7 +41,7 @@ func NewController(
// @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 {
func (c *DeviceController) CreateDevice(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "CreateDevice")
const actionType = "创建设备"
@@ -72,7 +70,7 @@ func (c *Controller) CreateDevice(ctx echo.Context) error {
// @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 {
func (c *DeviceController) GetDevice(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "GetDevice")
const actionType = "获取设备"
@@ -106,7 +104,7 @@ func (c *Controller) GetDevice(ctx echo.Context) error {
// @Produce json
// @Success 200 {object} controller.Response{data=[]dto.DeviceResponse}
// @Router /api/v1/devices [get]
func (c *Controller) ListDevices(ctx echo.Context) error {
func (c *DeviceController) ListDevices(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "ListDevices")
const actionType = "获取设备列表"
@@ -131,7 +129,7 @@ func (c *Controller) ListDevices(ctx echo.Context) error {
// @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 {
func (c *DeviceController) UpdateDevice(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "UpdateDevice")
const actionType = "更新设备"
@@ -172,7 +170,7 @@ func (c *Controller) UpdateDevice(ctx echo.Context) error {
// @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 {
func (c *DeviceController) DeleteDevice(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "DeleteDevice")
const actionType = "删除设备"
@@ -214,7 +212,7 @@ func (c *Controller) DeleteDevice(ctx echo.Context) error {
// @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 {
func (c *DeviceController) ManualControl(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "ManualControlDevice")
const actionType = "手动控制设备"
@@ -243,344 +241,3 @@ func (c *Controller) ManualControl(ctx echo.Context) error {
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 {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "CreateAreaController")
const actionType = "创建区域主控"
var req dto.CreateAreaControllerRequest
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
resp, err := c.deviceService.CreateAreaController(reqCtx, &req)
if err != nil {
logger.Errorf("%s: 服务层创建失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建区域主控失败: "+err.Error(), actionType, "服务层创建失败", req)
}
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 {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "GetAreaController")
const actionType = "获取区域主控"
acID := ctx.Param("id")
id, err := strconv.ParseUint(acID, 10, 64)
if err != nil {
logger.Errorf("%s: 无效的ID: %s", actionType, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID: "+acID, actionType, "无效的ID", acID)
}
resp, err := c.deviceService.GetAreaController(reqCtx, uint32(id))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logger.Warnf("%s: 区域主控不存在, ID: %s", actionType, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "区域主控未找到", actionType, "区域主控不存在", acID)
}
logger.Errorf("%s: 服务层获取失败: %v, ID: %s", actionType, err, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取区域主控信息失败: "+err.Error(), actionType, "服务层获取失败", acID)
}
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 {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "ListAreaControllers")
const actionType = "获取区域主控列表"
resp, err := c.deviceService.ListAreaControllers(reqCtx)
if err != nil {
logger.Errorf("%s: 服务层获取列表失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取区域主控列表失败: "+err.Error(), actionType, "服务层获取列表失败", nil)
}
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 {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "UpdateAreaController")
const actionType = "更新区域主控"
acID := ctx.Param("id")
var req dto.UpdateAreaControllerRequest
if err := ctx.Bind(&req); err != nil {
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 {
logger.Errorf("%s: 无效的ID: %s", actionType, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID: "+acID, actionType, "无效的ID", acID)
}
resp, err := c.deviceService.UpdateAreaController(reqCtx, uint32(id), &req)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logger.Warnf("%s: 区域主控不存在, ID: %s", actionType, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "区域主控未找到", actionType, "区域主控不存在", acID)
}
logger.Errorf("%s: 服务层更新失败: %v, ID: %s", actionType, err, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新区域主控失败: "+err.Error(), actionType, "服务层更新失败", acID)
}
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 {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "DeleteAreaController")
const actionType = "删除区域主控"
acID := ctx.Param("id")
id, err := strconv.ParseUint(acID, 10, 64)
if err != nil {
logger.Errorf("%s: 无效的ID: %s", actionType, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID: "+acID, actionType, "无效的ID", acID)
}
if err := c.deviceService.DeleteAreaController(reqCtx, uint32(id)); err != nil {
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
logger.Warnf("%s: 区域主控不存在, ID: %s", actionType, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "区域主控未找到", actionType, "区域主控不存在", acID)
case errors.Is(err, service.ErrAreaControllerInUse):
logger.Warnf("%s: 尝试删除正在被使用的主控, ID: %s", actionType, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), actionType, "主控正在被使用", acID)
default:
logger.Errorf("%s: 服务层删除失败: %v, ID: %s", actionType, err, acID)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除区域主控失败: "+err.Error(), actionType, "服务层删除失败", acID)
}
}
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 {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "CreateDeviceTemplate")
const actionType = "创建设备模板"
var req dto.CreateDeviceTemplateRequest
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
resp, err := c.deviceService.CreateDeviceTemplate(reqCtx, &req)
if err != nil {
logger.Errorf("%s: 服务层创建失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建设备模板失败: "+err.Error(), actionType, "服务层创建失败", req)
}
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 {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "GetDeviceTemplate")
const actionType = "获取设备模板"
dtID := ctx.Param("id")
id, err := strconv.ParseUint(dtID, 10, 64)
if err != nil {
logger.Errorf("%s: 无效的ID: %s", actionType, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID: "+dtID, actionType, "无效的ID", dtID)
}
resp, err := c.deviceService.GetDeviceTemplate(reqCtx, uint32(id))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logger.Warnf("%s: 设备模板不存在, ID: %s", actionType, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备模板未找到", actionType, "设备模板不存在", dtID)
}
logger.Errorf("%s: 服务层获取失败: %v, ID: %s", actionType, err, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备模板信息失败: "+err.Error(), actionType, "服务层获取失败", dtID)
}
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 {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "ListDeviceTemplates")
const actionType = "获取设备模板列表"
resp, err := c.deviceService.ListDeviceTemplates(reqCtx)
if err != nil {
logger.Errorf("%s: 服务层获取列表失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备模板列表失败: "+err.Error(), actionType, "服务层获取列表失败", nil)
}
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 {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "UpdateDeviceTemplate")
const actionType = "更新设备模板"
dtID := ctx.Param("id")
var req dto.UpdateDeviceTemplateRequest
if err := ctx.Bind(&req); err != nil {
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 {
logger.Errorf("%s: 无效的ID: %s", actionType, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID: "+dtID, actionType, "无效的ID", dtID)
}
resp, err := c.deviceService.UpdateDeviceTemplate(reqCtx, uint32(id), &req)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logger.Warnf("%s: 设备模板不存在, ID: %s", actionType, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备模板未找到", actionType, "设备模板不存在", dtID)
}
logger.Errorf("%s: 服务层更新失败: %v, ID: %s", actionType, err, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新设备模板失败: "+err.Error(), actionType, "服务层更新失败", dtID)
}
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 {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "DeleteDeviceTemplate")
const actionType = "删除设备模板"
dtID := ctx.Param("id")
id, err := strconv.ParseUint(dtID, 10, 64)
if err != nil {
logger.Errorf("%s: 无效的ID: %s", actionType, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID: "+dtID, actionType, "无效的ID", dtID)
}
if err := c.deviceService.DeleteDeviceTemplate(reqCtx, uint32(id)); err != nil {
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
logger.Warnf("%s: 设备模板不存在, ID: %s", actionType, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备模板未找到", actionType, "设备模板不存在", dtID)
case errors.Is(err, service.ErrDeviceTemplateInUse):
logger.Warnf("%s: 尝试删除正在被使用的模板, ID: %s", actionType, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), actionType, "模板正在被使用", dtID)
default:
logger.Errorf("%s: 服务层删除失败: %v, ID: %s", actionType, err, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除设备模板失败: "+err.Error(), actionType, "服务层删除失败", dtID)
}
}
logger.Infof("%s: 设备模板删除成功, ID: %s", actionType, dtID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "设备模板删除成功", nil, actionType, "设备模板删除成功", dtID)
}

View File

@@ -0,0 +1,201 @@
package device
import (
"context"
"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"
)
// DeviceTemplateController 设备模板控制器
type DeviceTemplateController struct {
ctx context.Context
deviceTemplateService service.DeviceTemplateService
}
// NewDeviceTemplateController 创建一个新的设备模板控制器实例
func NewDeviceTemplateController(
ctx context.Context,
deviceTemplateService service.DeviceTemplateService,
) *DeviceTemplateController {
return &DeviceTemplateController{
ctx: ctx,
deviceTemplateService: deviceTemplateService,
}
}
// 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 *DeviceTemplateController) CreateDeviceTemplate(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "CreateDeviceTemplate")
const actionType = "创建设备模板"
var req dto.CreateDeviceTemplateRequest
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
resp, err := c.deviceTemplateService.CreateDeviceTemplate(reqCtx, &req)
if err != nil {
logger.Errorf("%s: 服务层创建失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建设备模板失败: "+err.Error(), actionType, "服务层创建失败", req)
}
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 *DeviceTemplateController) GetDeviceTemplate(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "GetDeviceTemplate")
const actionType = "获取设备模板"
dtID := ctx.Param("id")
id, err := strconv.ParseUint(dtID, 10, 64)
if err != nil {
logger.Errorf("%s: 无效的ID: %s", actionType, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID: "+dtID, actionType, "无效的ID", dtID)
}
resp, err := c.deviceTemplateService.GetDeviceTemplate(reqCtx, uint32(id))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logger.Warnf("%s: 设备模板不存在, ID: %s", actionType, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备模板未找到", actionType, "设备模板不存在", dtID)
}
logger.Errorf("%s: 服务层获取失败: %v, ID: %s", actionType, err, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备模板信息失败: "+err.Error(), actionType, "服务层获取失败", dtID)
}
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 *DeviceTemplateController) ListDeviceTemplates(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "ListDeviceTemplates")
const actionType = "获取设备模板列表"
resp, err := c.deviceTemplateService.ListDeviceTemplates(reqCtx)
if err != nil {
logger.Errorf("%s: 服务层获取列表失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备模板列表失败: "+err.Error(), actionType, "服务层获取列表失败", nil)
}
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 *DeviceTemplateController) UpdateDeviceTemplate(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "UpdateDeviceTemplate")
const actionType = "更新设备模板"
dtID := ctx.Param("id")
var req dto.UpdateDeviceTemplateRequest
if err := ctx.Bind(&req); err != nil {
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 {
logger.Errorf("%s: 无效的ID: %s", actionType, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID: "+dtID, actionType, "无效的ID", dtID)
}
resp, err := c.deviceTemplateService.UpdateDeviceTemplate(reqCtx, uint32(id), &req)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logger.Warnf("%s: 设备模板不存在, ID: %s", actionType, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备模板未找到", actionType, "设备模板不存在", dtID)
}
logger.Errorf("%s: 服务层更新失败: %v, ID: %s", actionType, err, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新设备模板失败: "+err.Error(), actionType, "服务层更新失败", dtID)
}
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 *DeviceTemplateController) DeleteDeviceTemplate(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "DeleteDeviceTemplate")
const actionType = "删除设备模板"
dtID := ctx.Param("id")
id, err := strconv.ParseUint(dtID, 10, 64)
if err != nil {
logger.Errorf("%s: 无效的ID: %s", actionType, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID: "+dtID, actionType, "无效的ID", dtID)
}
if err := c.deviceTemplateService.DeleteDeviceTemplate(reqCtx, uint32(id)); err != nil {
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
logger.Warnf("%s: 设备模板不存在, ID: %s", actionType, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备模板未找到", actionType, "设备模板不存在", dtID)
case errors.Is(err, service.ErrDeviceTemplateInUse):
logger.Warnf("%s: 尝试删除正在被使用的模板, ID: %s", actionType, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), actionType, "模板正在被使用", dtID)
default:
logger.Errorf("%s: 服务层删除失败: %v, ID: %s", actionType, err, dtID)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除设备模板失败: "+err.Error(), actionType, "服务层删除失败", dtID)
}
}
logger.Infof("%s: 设备模板删除成功, ID: %s", actionType, dtID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "设备模板删除成功", nil, actionType, "设备模板删除成功", dtID)
}

View File

@@ -0,0 +1,195 @@
package feed
import (
"context"
"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"
)
// NutrientController 定义了营养种类相关的控制器
type NutrientController struct {
ctx context.Context
nutrientService service.NutrientService
}
// NewNutrientController 创建一个新的 NutrientController 实例
func NewNutrientController(ctx context.Context, feedManagementService service.NutrientService) *NutrientController {
return &NutrientController{
ctx: ctx,
nutrientService: feedManagementService,
}
}
// CreateNutrient godoc
// @Summary 创建营养种类
// @Description 创建一个新的营养种类。
// @Tags 饲料管理-营养
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param nutrient body dto.CreateNutrientRequest true "营养种类信息"
// @Success 200 {object} controller.Response{data=dto.NutrientResponse} "业务码为201代表创建成功"
// @Router /api/v1/feed/nutrients [post]
func (c *NutrientController) CreateNutrient(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "CreateNutrient")
var req dto.CreateNutrientRequest
const actionType = "创建营养种类"
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
resp, err := c.nutrientService.CreateNutrient(reqCtx, &req)
if err != nil {
logger.Errorf("%s: 服务层创建营养种类失败: %v", actionType, err)
if errors.Is(err, service.ErrNutrientNameConflict) {
return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), actionType, "营养种类名称已存在", req)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建营养种类失败: "+err.Error(), actionType, "服务层创建营养种类失败", req)
}
logger.Infof("%s: 营养种类创建成功, ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "营养种类创建成功", resp, actionType, "营养种类创建成功", resp)
}
// UpdateNutrient godoc
// @Summary 更新营养种类
// @Description 根据ID更新营养种类信息。
// @Tags 饲料管理-营养
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path int true "营养种类ID"
// @Param nutrient body dto.UpdateNutrientRequest true "更新后的营养种类信息"
// @Success 200 {object} controller.Response{data=dto.NutrientResponse} "业务码为200代表更新成功"
// @Router /api/v1/feed/nutrients/{id} [put]
func (c *NutrientController) UpdateNutrient(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "UpdateNutrient")
const actionType = "更新营养种类"
idStr := ctx.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
logger.Errorf("%s: 营养种类ID格式错误: %v, ID: %s", actionType, err, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的营养种类ID格式", actionType, "营养种类ID格式错误", idStr)
}
var req dto.UpdateNutrientRequest
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
resp, err := c.nutrientService.UpdateNutrient(reqCtx, uint32(id), &req)
if err != nil {
logger.Errorf("%s: 服务层更新营养种类失败: %v, ID: %d", actionType, err, id)
if errors.Is(err, service.ErrNutrientNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "营养种类不存在", id)
}
if errors.Is(err, service.ErrNutrientNameConflict) {
return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), actionType, "营养种类名称已存在", req)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新营养种类失败: "+err.Error(), actionType, "服务层更新营养种类失败", req)
}
logger.Infof("%s: 营养种类更新成功, ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "营养种类更新成功", resp, actionType, "营养种类更新成功", resp)
}
// DeleteNutrient godoc
// @Summary 删除营养种类
// @Description 根据ID删除营养种类。
// @Tags 饲料管理-营养
// @Security BearerAuth
// @Produce json
// @Param id path int true "营养种类ID"
// @Success 200 {object} controller.Response "业务码为200代表删除成功"
// @Router /api/v1/feed/nutrients/{id} [delete]
func (c *NutrientController) DeleteNutrient(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "DeleteNutrient")
const actionType = "删除营养种类"
idStr := ctx.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
logger.Errorf("%s: 营养种类ID格式错误: %v, ID: %s", actionType, err, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的营养种类ID格式", actionType, "营养种类ID格式错误", idStr)
}
err = c.nutrientService.DeleteNutrient(reqCtx, uint32(id))
if err != nil {
logger.Errorf("%s: 服务层删除营养种类失败: %v, ID: %d", actionType, err, id)
if errors.Is(err, service.ErrNutrientNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "营养种类不存在", id)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除营养种类失败: "+err.Error(), actionType, "服务层删除营养种类失败", id)
}
logger.Infof("%s: 营养种类删除成功, ID: %d", actionType, id)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "营养种类删除成功", nil, actionType, "营养种类删除成功", id)
}
// GetNutrient godoc
// @Summary 获取营养种类详情
// @Description 根据ID获取单个营养种类的详细信息。
// @Tags 饲料管理-营养
// @Security BearerAuth
// @Produce json
// @Param id path int true "营养种类ID"
// @Success 200 {object} controller.Response{data=dto.NutrientResponse} "业务码为200代表成功获取"
// @Router /api/v1/feed/nutrients/{id} [get]
func (c *NutrientController) GetNutrient(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "GetNutrient")
const actionType = "获取营养种类详情"
idStr := ctx.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
logger.Errorf("%s: 营养种类ID格式错误: %v, ID: %s", actionType, err, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的营养种类ID格式", actionType, "营养种类ID格式错误", idStr)
}
resp, err := c.nutrientService.GetNutrient(reqCtx, uint32(id))
if err != nil {
logger.Errorf("%s: 服务层获取营养种类详情失败: %v, ID: %d", actionType, err, id)
if errors.Is(err, service.ErrNutrientNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "营养种类不存在", id)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取营养种类详情失败: "+err.Error(), actionType, "服务层获取营养种类详情失败", id)
}
logger.Infof("%s: 获取营养种类详情成功, ID: %d", actionType, id)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取营养种类详情成功", resp, actionType, "获取营养种类详情成功", resp)
}
// ListNutrients godoc
// @Summary 获取营养种类列表
// @Description 获取所有营养种类的列表,支持分页和过滤。
// @Tags 饲料管理-营养
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListNutrientRequest false "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListNutrientResponse} "业务码为200代表成功获取列表"
// @Router /api/v1/feed/nutrients [get]
func (c *NutrientController) ListNutrients(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "ListNutrients")
const actionType = "获取营养种类列表"
var req dto.ListNutrientRequest
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 查询参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "查询参数绑定失败", req)
}
resp, err := c.nutrientService.ListNutrients(reqCtx, &req)
if err != nil {
logger.Errorf("%s: 服务层获取营养种类列表失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取营养种类列表失败: "+err.Error(), actionType, "服务层获取营养种类列表失败", nil)
}
logger.Infof("%s: 获取营养种类列表成功, 数量: %d", actionType, len(resp.List))
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取营养种类列表成功", resp, actionType, "获取营养种类列表成功", resp)
}

View File

@@ -0,0 +1,193 @@
package feed
import (
"context"
"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"
)
// PigAgeStageController 定义了猪年龄阶段相关的控制器
type PigAgeStageController struct {
ctx context.Context
pigAgeStageService service.PigAgeStageService
}
// NewPigAgeStageController 创建一个新的 PigAgeStageController 实例
func NewPigAgeStageController(ctx context.Context, feedManagementService service.PigAgeStageService) *PigAgeStageController {
return &PigAgeStageController{
ctx: ctx,
pigAgeStageService: feedManagementService,
}
}
// CreatePigAgeStage godoc
// @Summary 创建猪年龄阶段
// @Description 创建一个新的猪年龄阶段。
// @Tags 饲料管理-猪
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param pigAgeStage body dto.CreatePigAgeStageRequest true "猪年龄阶段信息"
// @Success 200 {object} controller.Response{data=dto.PigAgeStageResponse} "业务码为201代表创建成功"
// @Router /api/v1/feed/pig-age-stages [post]
func (c *PigAgeStageController) CreatePigAgeStage(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "CreatePigAgeStage")
var req dto.CreatePigAgeStageRequest
const actionType = "创建猪年龄阶段"
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
resp, err := c.pigAgeStageService.CreatePigAgeStage(reqCtx, &req)
if err != nil {
logger.Errorf("%s: 服务层创建猪年龄阶段失败: %v", actionType, err)
// 猪年龄阶段没有名称冲突的领域错误,这里直接返回内部错误
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建猪年龄阶段失败: "+err.Error(), actionType, "服务层创建猪年龄阶段失败", req)
}
logger.Infof("%s: 猪年龄阶段创建成功, ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "猪年龄阶段创建成功", resp, actionType, "猪年龄阶段创建成功", resp)
}
// UpdatePigAgeStage godoc
// @Summary 更新猪年龄阶段
// @Description 根据ID更新猪年龄阶段信息。
// @Tags 饲料管理-猪
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path int true "猪年龄阶段ID"
// @Param pigAgeStage body dto.UpdatePigAgeStageRequest true "更新后的猪年龄阶段信息"
// @Success 200 {object} controller.Response{data=dto.PigAgeStageResponse} "业务码为200代表更新成功"
// @Router /api/v1/feed/pig-age-stages/{id} [put]
func (c *PigAgeStageController) UpdatePigAgeStage(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "UpdatePigAgeStage")
const actionType = "更新猪年龄阶段"
idStr := ctx.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
logger.Errorf("%s: 猪年龄阶段ID格式错误: %v, ID: %s", actionType, err, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的猪年龄阶段ID格式", actionType, "猪年龄阶段ID格式错误", idStr)
}
var req dto.UpdatePigAgeStageRequest
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
resp, err := c.pigAgeStageService.UpdatePigAgeStage(reqCtx, uint32(id), &req)
if err != nil {
logger.Errorf("%s: 服务层更新猪年龄阶段失败: %v, ID: %d", actionType, err, id)
if errors.Is(err, service.ErrPigAgeStageNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "猪年龄阶段不存在", id)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新猪年龄阶段失败: "+err.Error(), actionType, "服务层更新猪年龄阶段失败", req)
}
logger.Infof("%s: 猪年龄阶段更新成功, ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "猪年龄阶段更新成功", resp, actionType, "猪年龄阶段更新成功", resp)
}
// DeletePigAgeStage godoc
// @Summary 删除猪年龄阶段
// @Description 根据ID删除猪年龄阶段。
// @Tags 饲料管理-猪
// @Security BearerAuth
// @Produce json
// @Param id path int true "猪年龄阶段ID"
// @Success 200 {object} controller.Response "业务码为200代表删除成功"
// @Router /api/v1/feed/pig-age-stages/{id} [delete]
func (c *PigAgeStageController) DeletePigAgeStage(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "DeletePigAgeStage")
const actionType = "删除猪年龄阶段"
idStr := ctx.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
logger.Errorf("%s: 猪年龄阶段ID格式错误: %v, ID: %s", actionType, err, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的猪年龄阶段ID格式", actionType, "猪年龄阶段ID格式错误", idStr)
}
err = c.pigAgeStageService.DeletePigAgeStage(reqCtx, uint32(id))
if err != nil {
logger.Errorf("%s: 服务层删除猪年龄阶段失败: %v, ID: %d", actionType, err, id)
if errors.Is(err, service.ErrPigAgeStageNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "猪年龄阶段不存在", id)
}
if errors.Is(err, service.ErrPigAgeStageInUse) {
return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), actionType, "猪年龄阶段正在被使用", id)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除猪年龄阶段失败: "+err.Error(), actionType, "服务层删除猪年龄阶段失败", id)
}
logger.Infof("%s: 猪年龄阶段删除成功, ID: %d", actionType, id)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "猪年龄阶段删除成功", nil, actionType, "猪年龄阶段删除成功", id)
}
// GetPigAgeStage godoc
// @Summary 获取猪年龄阶段详情
// @Description 根据ID获取单个猪年龄阶段的详细信息。
// @Tags 饲料管理-猪
// @Security BearerAuth
// @Produce json
// @Param id path int true "猪年龄阶段ID"
// @Success 200 {object} controller.Response{data=dto.PigAgeStageResponse} "业务码为200代表成功获取"
// @Router /api/v1/feed/pig-age-stages/{id} [get]
func (c *PigAgeStageController) GetPigAgeStage(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "GetPigAgeStage")
const actionType = "获取猪年龄阶段详情"
idStr := ctx.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
logger.Errorf("%s: 猪年龄阶段ID格式错误: %v, ID: %s", actionType, err, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的猪年龄阶段ID格式", actionType, "猪年龄阶段ID格式错误", idStr)
}
resp, err := c.pigAgeStageService.GetPigAgeStage(reqCtx, uint32(id))
if err != nil {
logger.Errorf("%s: 服务层获取猪年龄阶段详情失败: %v, ID: %d", actionType, err, id)
if errors.Is(err, service.ErrPigAgeStageNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "猪年龄阶段不存在", id)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪年龄阶段详情失败: "+err.Error(), actionType, "服务层获取猪年龄阶段详情失败", id)
}
logger.Infof("%s: 获取猪年龄阶段详情成功, ID: %d", actionType, id)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取猪年龄阶段详情成功", resp, actionType, "获取猪年龄阶段详情成功", resp)
}
// ListPigAgeStages godoc
// @Summary 获取猪年龄阶段列表
// @Description 获取所有猪年龄阶段的列表,支持分页和过滤。
// @Tags 饲料管理-猪
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListPigAgeStageRequest false "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListPigAgeStageResponse} "业务码为200代表成功获取列表"
// @Router /api/v1/feed/pig-age-stages [get]
func (c *PigAgeStageController) ListPigAgeStages(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "ListPigAgeStages")
const actionType = "获取猪年龄阶段列表"
var req dto.ListPigAgeStageRequest
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 查询参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "查询参数绑定失败", req)
}
resp, err := c.pigAgeStageService.ListPigAgeStages(reqCtx, &req)
if err != nil {
logger.Errorf("%s: 服务层获取猪年龄阶段列表失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪年龄阶段列表失败: "+err.Error(), actionType, "服务层获取猪年龄阶段列表失败", nil)
}
logger.Infof("%s: 获取猪年龄阶段列表成功, 数量: %d", actionType, len(resp.List))
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取猪年龄阶段列表成功", resp, actionType, "获取猪年龄阶段列表成功", resp)
}

View File

@@ -0,0 +1,193 @@
package feed
import (
"context"
"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"
)
// PigBreedController 定义了猪品种相关的控制器
type PigBreedController struct {
ctx context.Context
pigBreedService service.PigBreedService
}
// NewPigBreedController 创建一个新的 PigBreedController 实例
func NewPigBreedController(ctx context.Context, feedManagementService service.PigBreedService) *PigBreedController {
return &PigBreedController{
ctx: ctx,
pigBreedService: feedManagementService,
}
}
// CreatePigBreed godoc
// @Summary 创建猪品种
// @Description 创建一个新的猪品种。
// @Tags 饲料管理-猪
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param pigBreed body dto.CreatePigBreedRequest true "猪品种信息"
// @Success 200 {object} controller.Response{data=dto.PigBreedResponse} "业务码为201代表创建成功"
// @Router /api/v1/feed/pig-breeds [post]
func (c *PigBreedController) CreatePigBreed(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "CreatePigBreed")
var req dto.CreatePigBreedRequest
const actionType = "创建猪品种"
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
resp, err := c.pigBreedService.CreatePigBreed(reqCtx, &req)
if err != nil {
logger.Errorf("%s: 服务层创建猪品种失败: %v", actionType, err)
// 猪品种没有名称冲突的领域错误,这里直接返回内部错误
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建猪品种失败: "+err.Error(), actionType, "服务层创建猪品种失败", req)
}
logger.Infof("%s: 猪品种创建成功, ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "猪品种创建成功", resp, actionType, "猪品种创建成功", resp)
}
// UpdatePigBreed godoc
// @Summary 更新猪品种
// @Description 根据ID更新猪品种信息。
// @Tags 饲料管理-猪
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path int true "猪品种ID"
// @Param pigBreed body dto.UpdatePigBreedRequest true "更新后的猪品种信息"
// @Success 200 {object} controller.Response{data=dto.PigBreedResponse} "业务码为200代表更新成功"
// @Router /api/v1/feed/pig-breeds/{id} [put]
func (c *PigBreedController) UpdatePigBreed(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "UpdatePigBreed")
const actionType = "更新猪品种"
idStr := ctx.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
logger.Errorf("%s: 猪品种ID格式错误: %v, ID: %s", actionType, err, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的猪品种ID格式", actionType, "猪品种ID格式错误", idStr)
}
var req dto.UpdatePigBreedRequest
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
resp, err := c.pigBreedService.UpdatePigBreed(reqCtx, uint32(id), &req)
if err != nil {
logger.Errorf("%s: 服务层更新猪品种失败: %v, ID: %d", actionType, err, id)
if errors.Is(err, service.ErrPigBreedNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "猪品种不存在", id)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新猪品种失败: "+err.Error(), actionType, "服务层更新猪品种失败", req)
}
logger.Infof("%s: 猪品种更新成功, ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "猪品种更新成功", resp, actionType, "猪品种更新成功", resp)
}
// DeletePigBreed godoc
// @Summary 删除猪品种
// @Description 根据ID删除猪品种。
// @Tags 饲料管理-猪
// @Security BearerAuth
// @Produce json
// @Param id path int true "猪品种ID"
// @Success 200 {object} controller.Response "业务码为200代表删除成功"
// @Router /api/v1/feed/pig-breeds/{id} [delete]
func (c *PigBreedController) DeletePigBreed(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "DeletePigBreed")
const actionType = "删除猪品种"
idStr := ctx.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
logger.Errorf("%s: 猪品种ID格式错误: %v, ID: %s", actionType, err, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的猪品种ID格式", actionType, "猪品种ID格式错误", idStr)
}
err = c.pigBreedService.DeletePigBreed(reqCtx, uint32(id))
if err != nil {
logger.Errorf("%s: 服务层删除猪品种失败: %v, ID: %d", actionType, err, id)
if errors.Is(err, service.ErrPigBreedNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "猪品种不存在", id)
}
if errors.Is(err, service.ErrPigBreedInUse) {
return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), actionType, "猪品种正在被使用", id)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除猪品种失败: "+err.Error(), actionType, "服务层删除猪品种失败", id)
}
logger.Infof("%s: 猪品种删除成功, ID: %d", actionType, id)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "猪品种删除成功", nil, actionType, "猪品种删除成功", id)
}
// GetPigBreed godoc
// @Summary 获取猪品种详情
// @Description 根据ID获取单个猪品种的详细信息。
// @Tags 饲料管理-猪
// @Security BearerAuth
// @Produce json
// @Param id path int true "猪品种ID"
// @Success 200 {object} controller.Response{data=dto.PigBreedResponse} "业务码为200代表成功获取"
// @Router /api/v1/feed/pig-breeds/{id} [get]
func (c *PigBreedController) GetPigBreed(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "GetPigBreed")
const actionType = "获取猪品种详情"
idStr := ctx.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
logger.Errorf("%s: 猪品种ID格式错误: %v, ID: %s", actionType, err, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的猪品种ID格式", actionType, "猪品种ID格式错误", idStr)
}
resp, err := c.pigBreedService.GetPigBreed(reqCtx, uint32(id))
if err != nil {
logger.Errorf("%s: 服务层获取猪品种详情失败: %v, ID: %d", actionType, err, id)
if errors.Is(err, service.ErrPigBreedNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "猪品种不存在", id)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪品种详情失败: "+err.Error(), actionType, "服务层获取猪品种详情失败", id)
}
logger.Infof("%s: 获取猪品种详情成功, ID: %d", actionType, id)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取猪品种详情成功", resp, actionType, "获取猪品种详情成功", resp)
}
// ListPigBreeds godoc
// @Summary 获取猪品种列表
// @Description 获取所有猪品种的列表,支持分页和过滤。
// @Tags 饲料管理-猪
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListPigBreedRequest false "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListPigBreedResponse} "业务码为200代表成功获取列表"
// @Router /api/v1/feed/pig-breeds [get]
func (c *PigBreedController) ListPigBreeds(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "ListPigBreeds")
const actionType = "获取猪品种列表"
var req dto.ListPigBreedRequest
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 查询参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "查询参数绑定失败", req)
}
resp, err := c.pigBreedService.ListPigBreeds(reqCtx, &req)
if err != nil {
logger.Errorf("%s: 服务层获取猪品种列表失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪品种列表失败: "+err.Error(), actionType, "服务层获取猪品种列表失败", nil)
}
logger.Infof("%s: 获取猪品种列表成功, 数量: %d", actionType, len(resp.List))
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取猪品种列表成功", resp, actionType, "获取猪品种列表成功", resp)
}

View File

@@ -0,0 +1,243 @@
package feed
import (
"context"
"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"
)
// PigTypeController 定义了猪类型相关的控制器
type PigTypeController struct {
ctx context.Context
typeService service.PigTypeService
}
// NewPigTypeController 创建一个新的 PigTypeController 实例
func NewPigTypeController(ctx context.Context, feedManagementService service.PigTypeService) *PigTypeController {
return &PigTypeController{
ctx: ctx,
typeService: feedManagementService,
}
}
// CreatePigType godoc
// @Summary 创建猪类型
// @Description 创建一个新的猪类型。
// @Tags 饲料管理-猪
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param pigType body dto.CreatePigTypeRequest true "猪类型信息"
// @Success 200 {object} controller.Response{data=dto.PigTypeResponse} "业务码为201代表创建成功"
// @Router /api/v1/feed/pig-types [post]
func (c *PigTypeController) CreatePigType(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "CreatePigType")
var req dto.CreatePigTypeRequest
const actionType = "创建猪类型"
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
resp, err := c.typeService.CreatePigType(reqCtx, &req)
if err != nil {
logger.Errorf("%s: 服务层创建猪类型失败: %v", actionType, err)
if errors.Is(err, service.ErrPigBreedNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "关联猪品种不存在", req)
}
if errors.Is(err, service.ErrPigAgeStageNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "关联猪年龄阶段不存在", req)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建猪类型失败: "+err.Error(), actionType, "服务层创建猪类型失败", req)
}
logger.Infof("%s: 猪类型创建成功, ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "猪类型创建成功", resp, actionType, "猪类型创建成功", resp)
}
// UpdatePigType godoc
// @Summary 更新猪类型
// @Description 根据ID更新猪类型信息。
// @Tags 饲料管理-猪
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path int true "猪类型ID"
// @Param pigType body dto.UpdatePigTypeRequest true "更新后的猪类型信息"
// @Success 200 {object} controller.Response{data=dto.PigTypeResponse} "业务码为200代表更新成功"
// @Router /api/v1/feed/pig-types/{id} [put]
func (c *PigTypeController) UpdatePigType(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "UpdatePigType")
const actionType = "更新猪类型"
idStr := ctx.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
logger.Errorf("%s: 猪类型ID格式错误: %v, ID: %s", actionType, err, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的猪类型ID格式", actionType, "猪类型ID格式错误", idStr)
}
var req dto.UpdatePigTypeRequest
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
resp, err := c.typeService.UpdatePigType(reqCtx, uint32(id), &req)
if err != nil {
logger.Errorf("%s: 服务层更新猪类型失败: %v, ID: %d", actionType, err, id)
if errors.Is(err, service.ErrPigTypeNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "猪类型不存在", id)
}
if errors.Is(err, service.ErrPigBreedNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "关联猪品种不存在", req)
}
if errors.Is(err, service.ErrPigAgeStageNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "关联猪年龄阶段不存在", req)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新猪类型失败: "+err.Error(), actionType, "服务层更新猪类型失败", req)
}
logger.Infof("%s: 猪类型更新成功, ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "猪类型更新成功", resp, actionType, "猪类型更新成功", resp)
}
// DeletePigType godoc
// @Summary 删除猪类型
// @Description 根据ID删除猪类型。
// @Tags 饲料管理-猪
// @Security BearerAuth
// @Produce json
// @Param id path int true "猪类型ID"
// @Success 200 {object} controller.Response "业务码为200代表删除成功"
// @Router /api/v1/feed/pig-types/{id} [delete]
func (c *PigTypeController) DeletePigType(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "DeletePigType")
const actionType = "删除猪类型"
idStr := ctx.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
logger.Errorf("%s: 猪类型ID格式错误: %v, ID: %s", actionType, err, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的猪类型ID格式", actionType, "猪类型ID格式错误", idStr)
}
err = c.typeService.DeletePigType(reqCtx, uint32(id))
if err != nil {
logger.Errorf("%s: 服务层删除猪类型失败: %v, ID: %d", actionType, err, id)
if errors.Is(err, service.ErrPigTypeNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "猪类型不存在", id)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除猪类型失败: "+err.Error(), actionType, "服务层删除猪类型失败", id)
}
logger.Infof("%s: 猪类型删除成功, ID: %d", actionType, id)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "猪类型删除成功", nil, actionType, "猪类型删除成功", id)
}
// GetPigType godoc
// @Summary 获取猪类型详情
// @Description 根据ID获取单个猪类型的详细信息。
// @Tags 饲料管理-猪
// @Security BearerAuth
// @Produce json
// @Param id path int true "猪类型ID"
// @Success 200 {object} controller.Response{data=dto.PigTypeResponse} "业务码为200代表成功获取"
// @Router /api/v1/feed/pig-types/{id} [get]
func (c *PigTypeController) GetPigType(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "GetPigType")
const actionType = "获取猪类型详情"
idStr := ctx.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
logger.Errorf("%s: 猪类型ID格式错误: %v, ID: %s", actionType, err, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的猪类型ID格式", actionType, "猪类型ID格式错误", idStr)
}
resp, err := c.typeService.GetPigType(reqCtx, uint32(id))
if err != nil {
logger.Errorf("%s: 服务层获取猪类型详情失败: %v, ID: %d", actionType, err, id)
if errors.Is(err, service.ErrPigTypeNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "猪类型不存在", id)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪类型详情失败: "+err.Error(), actionType, "服务层获取猪类型详情失败", id)
}
logger.Infof("%s: 获取猪类型详情成功, ID: %d", actionType, id)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取猪类型详情成功", resp, actionType, "获取猪类型详情成功", resp)
}
// ListPigTypes godoc
// @Summary 获取猪类型列表
// @Description 获取所有猪类型的列表,支持分页和过滤。
// @Tags 饲料管理-猪
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListPigTypeRequest false "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListPigTypeResponse} "业务码为200代表成功获取列表"
// @Router /api/v1/feed/pig-types [get]
func (c *PigTypeController) ListPigTypes(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "ListPigTypes")
const actionType = "获取猪类型列表"
var req dto.ListPigTypeRequest
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 查询参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "查询参数绑定失败", req)
}
resp, err := c.typeService.ListPigTypes(reqCtx, &req)
if err != nil {
logger.Errorf("%s: 服务层获取猪类型列表失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪类型列表失败: "+err.Error(), actionType, "服务层获取猪类型列表失败", nil)
}
logger.Infof("%s: 获取猪类型列表成功, 数量: %d", actionType, len(resp.List))
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取猪类型列表成功", resp, actionType, "获取猪类型列表成功", resp)
}
// UpdatePigTypeNutrientRequirements godoc
// @Summary 全量更新猪类型的营养需求
// @Description 根据猪类型ID替换其所有的营养需求信息。这是一个覆盖操作。
// @Tags 饲料管理-猪
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path int true "猪类型ID"
// @Param nutrientRequirements body dto.UpdatePigTypeNutrientRequirementsRequest true "新的营养需求列表"
// @Success 200 {object} controller.Response{data=dto.PigTypeResponse} "业务码为200代表更新成功"
// @Router /api/v1/feed/pig-types/{id}/nutrient-requirements [put]
func (c *PigTypeController) UpdatePigTypeNutrientRequirements(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "UpdatePigTypeNutrientRequirements")
const actionType = "更新猪类型营养需求"
idStr := ctx.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
logger.Errorf("%s: 猪类型ID格式错误: %v, ID: %s", actionType, err, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的猪类型ID格式", actionType, "猪类型ID格式错误", idStr)
}
var req dto.UpdatePigTypeNutrientRequirementsRequest
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
resp, err := c.typeService.UpdatePigTypeNutrientRequirements(reqCtx, uint32(id), &req)
if err != nil {
logger.Errorf("%s: 服务层更新猪类型营养需求失败: %v, ID: %d", actionType, err, id)
if errors.Is(err, service.ErrPigTypeNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "猪类型不存在", id)
}
// 这里可以根据未来可能从服务层返回的其他特定错误进行处理
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新猪类型营养需求失败: "+err.Error(), actionType, "服务层更新失败", req)
}
logger.Infof("%s: 猪类型营养需求更新成功, ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "猪类型营养需求更新成功", resp, actionType, "猪类型营养需求更新成功", resp)
}

View File

@@ -0,0 +1,237 @@
package feed
import (
"context"
"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"
)
// RawMaterialController 定义了原料相关的控制器
type RawMaterialController struct {
ctx context.Context
rawMaterialService service.RawMaterialService
}
// NewRawMaterialController 创建一个新的 RawMaterialController 实例
func NewRawMaterialController(ctx context.Context, feedManagementService service.RawMaterialService) *RawMaterialController {
return &RawMaterialController{
ctx: ctx,
rawMaterialService: feedManagementService,
}
}
// CreateRawMaterial godoc
// @Summary 创建原料
// @Description 创建一个新的原料。
// @Tags 饲料管理-原料
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param rawMaterial body dto.CreateRawMaterialRequest true "原料信息,包含名称、描述和参考价格"
// @Success 200 {object} controller.Response{data=dto.RawMaterialResponse} "业务码为201代表创建成功"
// @Router /api/v1/feed/raw-materials [post]
func (c *RawMaterialController) CreateRawMaterial(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "CreateRawMaterial")
var req dto.CreateRawMaterialRequest
const actionType = "创建原料"
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
resp, err := c.rawMaterialService.CreateRawMaterial(reqCtx, &req)
if err != nil {
logger.Errorf("%s: 服务层创建原料失败: %v", actionType, err)
if errors.Is(err, service.ErrRawMaterialNameConflict) {
return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), actionType, "原料名称已存在", req)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建原料失败: "+err.Error(), actionType, "服务层创建原料失败", req)
}
logger.Infof("%s: 原料创建成功, ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "原料创建成功", resp, actionType, "原料创建成功", resp)
}
// UpdateRawMaterial godoc
// @Summary 更新原料
// @Description 根据ID更新原料信息。
// @Tags 饲料管理-原料
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path int true "原料ID"
// @Param rawMaterial body dto.UpdateRawMaterialRequest true "更新后的原料信息,包含名称、描述和参考价格"
// @Success 200 {object} controller.Response{data=dto.RawMaterialResponse} "业务码为200代表更新成功"
// @Router /api/v1/feed/raw-materials/{id} [put]
func (c *RawMaterialController) UpdateRawMaterial(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "UpdateRawMaterial")
const actionType = "更新原料"
idStr := ctx.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
logger.Errorf("%s: 原料ID格式错误: %v, ID: %s", actionType, err, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的原料ID格式", actionType, "原料ID格式错误", idStr)
}
var req dto.UpdateRawMaterialRequest
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
resp, err := c.rawMaterialService.UpdateRawMaterial(reqCtx, uint32(id), &req)
if err != nil {
logger.Errorf("%s: 服务层更新原料失败: %v, ID: %d", actionType, err, id)
if errors.Is(err, service.ErrRawMaterialNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "原料不存在", id)
}
if errors.Is(err, service.ErrRawMaterialNameConflict) {
return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), actionType, "原料名称已存在", req)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新原料失败: "+err.Error(), actionType, "服务层更新原料失败", req)
}
logger.Infof("%s: 原料更新成功, ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "原料更新成功", resp, actionType, "原料更新成功", resp)
}
// DeleteRawMaterial godoc
// @Summary 删除原料
// @Description 根据ID删除原料。
// @Tags 饲料管理-原料
// @Security BearerAuth
// @Produce json
// @Param id path int true "原料ID"
// @Success 200 {object} controller.Response "业务码为200代表删除成功"
// @Router /api/v1/feed/raw-materials/{id} [delete]
func (c *RawMaterialController) DeleteRawMaterial(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "DeleteRawMaterial")
const actionType = "删除原料"
idStr := ctx.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
logger.Errorf("%s: 原料ID格式错误: %v, ID: %s", actionType, err, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的原料ID格式", actionType, "原料ID格式错误", idStr)
}
err = c.rawMaterialService.DeleteRawMaterial(reqCtx, uint32(id))
if err != nil {
logger.Errorf("%s: 服务层删除原料失败: %v, ID: %d", actionType, err, id)
if errors.Is(err, service.ErrRawMaterialNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "原料不存在", id)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除原料失败: "+err.Error(), actionType, "服务层删除原料失败", id)
}
logger.Infof("%s: 原料删除成功, ID: %d", actionType, id)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "原料删除成功", nil, actionType, "原料删除成功", id)
}
// GetRawMaterial godoc
// @Summary 获取原料详情
// @Description 根据ID获取单个原料的详细信息。
// @Tags 饲料管理-原料
// @Security BearerAuth
// @Produce json
// @Param id path int true "原料ID"
// @Success 200 {object} controller.Response{data=dto.RawMaterialResponse} "业务码为200代表成功获取"
// @Router /api/v1/feed/raw-materials/{id} [get]
func (c *RawMaterialController) GetRawMaterial(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "GetRawMaterial")
const actionType = "获取原料详情"
idStr := ctx.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
logger.Errorf("%s: 原料ID格式错误: %v, ID: %s", actionType, err, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的原料ID格式", actionType, "原料ID格式错误", idStr)
}
resp, err := c.rawMaterialService.GetRawMaterial(reqCtx, uint32(id))
if err != nil {
logger.Errorf("%s: 服务层获取原料详情失败: %v, ID: %d", actionType, err, id)
if errors.Is(err, service.ErrRawMaterialNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "原料不存在", id)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取原料详情失败: "+err.Error(), actionType, "服务层获取原料详情失败", id)
}
logger.Infof("%s: 获取原料详情成功, ID: %d", actionType, id)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取原料详情成功", resp, actionType, "获取原料详情成功", resp)
}
// ListRawMaterials godoc
// @Summary 获取原料列表
// @Description 获取所有原料的列表,支持分页和过滤。
// @Tags 饲料管理-原料
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListRawMaterialRequest false "查询参数,支持按名称、营养名称、参考价格范围过滤"
// @Success 200 {object} controller.Response{data=dto.ListRawMaterialResponse} "业务码为200代表成功获取列表"
// @Router /api/v1/feed/raw-materials [get]
func (c *RawMaterialController) ListRawMaterials(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "ListRawMaterials")
const actionType = "获取原料列表"
var req dto.ListRawMaterialRequest
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 查询参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "查询参数绑定失败", req)
}
resp, err := c.rawMaterialService.ListRawMaterials(reqCtx, &req)
if err != nil {
logger.Errorf("%s: 服务层获取原料列表失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取原料列表失败: "+err.Error(), actionType, "服务层获取原料列表失败", nil)
}
logger.Infof("%s: 获取原料列表成功, 数量: %d", actionType, len(resp.List))
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取原料列表成功", resp, actionType, "获取原料列表成功", resp)
}
// UpdateRawMaterialNutrients godoc
// @Summary 全量更新原料的营养成分
// @Description 根据原料ID替换其所有的营养成分信息。这是一个覆盖操作。
// @Tags 饲料管理-原料
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path int true "原料ID"
// @Param nutrients body dto.UpdateRawMaterialNutrientsRequest true "新的营养成分列表"
// @Success 200 {object} controller.Response{data=dto.RawMaterialResponse} "业务码为200代表更新成功"
// @Router /api/v1/feed/raw-materials/{id}/nutrients [put]
func (c *RawMaterialController) UpdateRawMaterialNutrients(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "UpdateRawMaterialNutrients")
const actionType = "更新原料营养成分"
idStr := ctx.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
logger.Errorf("%s: 原料ID格式错误: %v, ID: %s", actionType, err, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的原料ID格式", actionType, "原料ID格式错误", idStr)
}
var req dto.UpdateRawMaterialNutrientsRequest
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
resp, err := c.rawMaterialService.UpdateRawMaterialNutrients(reqCtx, uint32(id), &req)
if err != nil {
logger.Errorf("%s: 服务层更新原料营养成分失败: %v, ID: %d", actionType, err, id)
if errors.Is(err, service.ErrRawMaterialNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "原料不存在", id)
}
// 这里可以根据未来可能从服务层返回的其他特定错误进行处理
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新原料营养成分失败: "+err.Error(), actionType, "服务层更新失败", req)
}
logger.Infof("%s: 原料营养成分更新成功, ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "原料营养成分更新成功", resp, actionType, "原料营养成分更新成功", resp)
}

View File

@@ -0,0 +1,303 @@
package feed
import (
"context"
"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/recipe"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"github.com/labstack/echo/v4"
)
// RecipeController 包含配方相关的处理器
type RecipeController struct {
ctx context.Context
recipeService service.RecipeService
}
// NewRecipeController 创建一个新的 RecipeController
func NewRecipeController(ctx context.Context, recipeService service.RecipeService) *RecipeController {
return &RecipeController{
ctx: ctx,
recipeService: recipeService,
}
}
// CreateRecipe godoc
// @Summary 创建配方
// @Description 创建一个新的配方,包含其原料组成。
// @Tags 饲料管理-配方
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param recipe body dto.CreateRecipeRequest true "配方信息"
// @Success 200 {object} controller.Response{data=dto.RecipeResponse} "业务码为201代表创建成功"
// @Router /api/v1/feed/recipes [post]
func (c *RecipeController) CreateRecipe(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "CreateRecipe")
var req dto.CreateRecipeRequest
const actionType = "创建配方"
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
resp, err := c.recipeService.CreateRecipe(reqCtx, &req)
if err != nil {
logger.Errorf("%s: 服务层创建配方失败: %v", actionType, err)
if errors.Is(err, recipe.ErrRecipeNameConflict) {
return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), actionType, "配方名称已存在", req)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建配方失败: "+err.Error(), actionType, "服务层创建配方失败", req)
}
logger.Infof("%s: 配方创建成功, ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "配方创建成功", resp, actionType, "配方创建成功", resp)
}
// UpdateRecipe godoc
// @Summary 更新配方
// @Description 根据ID更新配方信息及其原料组成。
// @Tags 饲料管理-配方
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path int true "配方ID"
// @Param recipe body dto.UpdateRecipeRequest true "更新后的配方信息"
// @Success 200 {object} controller.Response{data=dto.RecipeResponse} "业务码为200代表更新成功"
// @Router /api/v1/feed/recipes/{id} [put]
func (c *RecipeController) UpdateRecipe(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "UpdateRecipe")
const actionType = "更新配方"
idStr := ctx.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
logger.Errorf("%s: 配方ID格式错误: %v, ID: %s", actionType, err, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的配方ID格式", actionType, "配方ID格式错误", idStr)
}
var req dto.UpdateRecipeRequest
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
resp, err := c.recipeService.UpdateRecipe(reqCtx, uint32(id), &req)
if err != nil {
logger.Errorf("%s: 服务层更新配方失败: %v, ID: %d", actionType, err, id)
if errors.Is(err, recipe.ErrRecipeNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "配方不存在", id)
}
if errors.Is(err, recipe.ErrRecipeNameConflict) {
return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), actionType, "配方名称已存在", req)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新配方失败: "+err.Error(), actionType, "服务层更新配方失败", req)
}
logger.Infof("%s: 配方更新成功, ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "配方更新成功", resp, actionType, "配方更新成功", resp)
}
// DeleteRecipe godoc
// @Summary 删除配方
// @Description 根据ID删除配方。
// @Tags 饲料管理-配方
// @Security BearerAuth
// @Produce json
// @Param id path int true "配方ID"
// @Success 200 {object} controller.Response "业务码为200代表删除成功"
// @Router /api/v1/feed/recipes/{id} [delete]
func (c *RecipeController) DeleteRecipe(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "DeleteRecipe")
const actionType = "删除配方"
idStr := ctx.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
logger.Errorf("%s: 配方ID格式错误: %v, ID: %s", actionType, err, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的配方ID格式", actionType, "配方ID格式错误", idStr)
}
err = c.recipeService.DeleteRecipe(reqCtx, uint32(id))
if err != nil {
logger.Errorf("%s: 服务层删除配方失败: %v, ID: %d", actionType, err, id)
if errors.Is(err, recipe.ErrRecipeNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "配方不存在", id)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除配方失败: "+err.Error(), actionType, "服务层删除配方失败", id)
}
logger.Infof("%s: 配方删除成功, ID: %d", actionType, id)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "配方删除成功", nil, actionType, "配方删除成功", id)
}
// GetRecipe godoc
// @Summary 获取配方详情
// @Description 根据ID获取单个配方的详细信息。
// @Tags 饲料管理-配方
// @Security BearerAuth
// @Produce json
// @Param id path int true "配方ID"
// @Success 200 {object} controller.Response{data=dto.RecipeResponse} "业务码为200代表成功获取"
// @Router /api/v1/feed/recipes/{id} [get]
func (c *RecipeController) GetRecipe(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "GetRecipe")
const actionType = "获取配方详情"
idStr := ctx.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
logger.Errorf("%s: 配方ID格式错误: %v, ID: %s", actionType, err, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的配方ID格式", actionType, "配方ID格式错误", idStr)
}
resp, err := c.recipeService.GetRecipeByID(reqCtx, uint32(id))
if err != nil {
logger.Errorf("%s: 服务层获取配方详情失败: %v, ID: %d", actionType, err, id)
if errors.Is(err, recipe.ErrRecipeNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "配方不存在", id)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取配方详情失败: "+err.Error(), actionType, "服务层获取配方详情失败", id)
}
logger.Infof("%s: 获取配方详情成功, ID: %d", actionType, id)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取配方详情成功", resp, actionType, "获取配方详情成功", resp)
}
// ListRecipes godoc
// @Summary 获取配方列表
// @Description 获取所有配方的列表,支持分页和过滤。
// @Tags 饲料管理-配方
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListRecipeRequest false "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListRecipeResponse} "业务码为200代表成功获取列表"
// @Router /api/v1/feed/recipes [get]
func (c *RecipeController) ListRecipes(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "ListRecipes")
const actionType = "获取配方列表"
var req dto.ListRecipeRequest
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 查询参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "查询参数绑定失败", req)
}
resp, err := c.recipeService.ListRecipes(reqCtx, &req)
if err != nil {
logger.Errorf("%s: 服务层获取配方列表失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取配方列表失败: "+err.Error(), actionType, "服务层获取配方列表失败", nil)
}
logger.Infof("%s: 获取配方列表成功, 数量: %d", actionType, len(resp.List))
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取配方列表成功", resp, actionType, "获取配方列表成功", resp)
}
// GenerateFromAllMaterials godoc
// @Summary 使用系统中所有可用的原料一键生成配方
// @Description 根据指定的猪类型ID使用系统中所有可用的原料自动计算并创建一个成本最优的配方。
// @Tags 饲料管理-配方
// @Security BearerAuth
// @Produce json
// @Param pig_type_id path int true "猪类型ID"
// @Success 201 {object} controller.Response{data=dto.GenerateRecipeResponse} "业务码为201代表创建成功"
// @Router /api/v1/feed/recipes/generate-from-all-materials/{pig_type_id} [post]
func (c *RecipeController) GenerateFromAllMaterials(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "GenerateFromAllMaterials")
const actionType = "使用系统中所有可用的原料一键生成配方"
idStr := ctx.Param("pig_type_id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
logger.Errorf("%s: 猪类型ID格式错误: %v, ID: %s", actionType, err, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的猪类型ID格式", actionType, "猪类型ID格式错误", idStr)
}
recipe, err := c.recipeService.GenerateRecipeWithAllRawMaterials(reqCtx, uint32(id))
if err != nil {
logger.Errorf("%s: 服务层生成配方失败: %v, PigTypeID: %d", actionType, err, id)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "生成配方失败: "+err.Error(), actionType, "服务层生成配方失败", id)
}
resp := dto.ToGenerateRecipeResponse(recipe)
logger.Infof("%s: 配方生成成功, 新配方ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "配方生成成功", resp, actionType, "配方生成成功", resp)
}
// GenerateRecipeWithPrioritizedStockRawMaterials godoc
// @Summary 使用优先有库存原料的策略生成配方
// @Description 根据指定的猪类型ID优先使用有库存的原料自动计算并创建一个配方。
// @Tags 饲料管理-配方
// @Security BearerAuth
// @Produce json
// @Param pig_type_id path int true "猪类型ID"
// @Success 201 {object} controller.Response{data=dto.GenerateRecipeResponse} "业务码为201代表创建成功"
// @Router /api/v1/feed/recipes/generate-prioritized-stock/{pig_type_id} [post]
func (c *RecipeController) GenerateRecipeWithPrioritizedStockRawMaterials(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "GenerateRecipeWithPrioritizedStockRawMaterials")
const actionType = "生成优先使用库存原料的配方"
idStr := ctx.Param("pig_type_id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
logger.Errorf("%s: 猪类型ID格式错误: %v, ID: %s", actionType, err, idStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的猪类型ID格式", actionType, "猪类型ID格式错误", idStr)
}
recipe, err := c.recipeService.GenerateRecipeWithPrioritizedStockRawMaterials(reqCtx, uint32(id))
if err != nil {
logger.Errorf("%s: 服务层生成配方失败: %v, PigTypeID: %d", actionType, err, id)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "生成配方失败: "+err.Error(), actionType, "服务层生成配方失败", id)
}
resp := dto.ToGenerateRecipeResponse(recipe)
logger.Infof("%s: 配方生成成功, 新配方ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "配方生成成功", resp, actionType, "配方生成成功", resp)
}
// AIDiagnoseRecipe godoc
// @Summary AI点评配方
// @Description 使用AI对指定配方进行点评并针对目标猪类型给出建议。
// @Tags 饲料管理-配方
// @Security BearerAuth
// @Produce json
// @Param id path int true "配方ID"
// @Param pig_type_id query int true "猪类型ID"
// @Success 200 {object} controller.Response{data=dto.ReviewRecipeResponse} "业务码为200代表AI点评成功"
// @Router /api/v1/feed/recipes/{id}/ai-diagnose [get]
func (c *RecipeController) AIDiagnoseRecipe(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "AIDiagnoseRecipe")
const actionType = "AI点评配方"
// 从路径参数中获取配方ID
recipeIDStr := ctx.Param("id")
recipeID, err := strconv.ParseUint(recipeIDStr, 10, 32)
if err != nil {
logger.Errorf("%s: 配方ID格式错误: %v, ID: %s", actionType, err, recipeIDStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的配方ID格式", actionType, "配方ID格式错误", recipeIDStr)
}
// 从查询参数中获取猪类型ID
pigTypeIDStr := ctx.QueryParam("pig_type_id")
pigTypeID, err := strconv.ParseUint(pigTypeIDStr, 10, 32)
if err != nil {
logger.Errorf("%s: 猪类型ID格式错误: %v, ID: %s", actionType, err, pigTypeIDStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的猪类型ID格式", actionType, "猪类型ID格式错误", pigTypeIDStr)
}
// 调用应用服务进行AI点评
reviewResponse, err := c.recipeService.AIDiagnoseRecipe(reqCtx, uint32(recipeID), uint32(pigTypeID))
if err != nil {
logger.Errorf("%s: 服务层AI点评失败: %v, RecipeID: %d, PigTypeID: %d", actionType, err, recipeID, pigTypeID)
if errors.Is(err, service.ErrRecipeNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "配方或猪类型不存在", map[string]uint32{"recipe_id": uint32(recipeID), "pig_type_id": uint32(pigTypeID)})
}
// 对于其他错误,统一返回内部服务器错误
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "AI点评失败: "+err.Error(), actionType, "服务层AI点评失败", map[string]uint32{"recipe_id": uint32(recipeID), "pig_type_id": uint32(pigTypeID)})
}
logger.Infof("%s: AI点评成功, RecipeID: %d, PigTypeID: %d", actionType, recipeID, pigTypeID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "AI点评成功", reviewResponse, actionType, "AI点评成功", reviewResponse)
}

View File

@@ -0,0 +1,121 @@
package inventory
import (
"context"
"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"
"github.com/labstack/echo/v4"
)
// InventoryController 定义了库存相关的控制器
type InventoryController struct {
ctx context.Context
inventoryService service.InventoryService
}
// NewInventoryController 创建一个新的 InventoryController 实例
func NewInventoryController(ctx context.Context, inventoryService service.InventoryService) *InventoryController {
return &InventoryController{
ctx: ctx,
inventoryService: inventoryService,
}
}
// AdjustStock godoc
// @Summary 调整原料库存
// @Description 手动调整指定原料的库存量。
// @Tags 库存管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param request body dto.StockAdjustmentRequest true "库存调整请求"
// @Success 200 {object} controller.Response{data=dto.StockLogResponse} "业务码为200代表调整成功"
// @Router /api/v1/inventory/stock/adjust [post]
func (c *InventoryController) AdjustStock(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "AdjustStock")
var req dto.StockAdjustmentRequest
const actionType = "调整原料库存"
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req)
}
resp, err := c.inventoryService.AdjustStock(reqCtx, &req)
if err != nil {
logger.Errorf("%s: 服务层调整库存失败: %v", actionType, err)
if errors.Is(err, service.ErrInventoryRawMaterialNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "原料不存在", req)
}
if errors.Is(err, service.ErrInventoryInsufficientStock) {
return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), actionType, "原料库存不足", req)
}
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "调整库存失败: "+err.Error(), actionType, "服务层调整库存失败", req)
}
logger.Infof("%s: 库存调整成功, 原料ID: %d", actionType, resp.RawMaterialID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "库存调整成功", resp, actionType, "库存调整成功", resp)
}
// ListCurrentStock godoc
// @Summary 获取当前库存列表
// @Description 获取所有原料的当前库存列表,支持分页和过滤。
// @Tags 库存管理
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListCurrentStockRequest false "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListCurrentStockResponse} "业务码为200代表成功获取列表"
// @Router /api/v1/inventory/stock/current [get]
func (c *InventoryController) ListCurrentStock(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "ListCurrentStock")
const actionType = "获取当前库存列表"
var req dto.ListCurrentStockRequest
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 查询参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "查询参数绑定失败", req)
}
resp, err := c.inventoryService.ListCurrentStock(reqCtx, &req)
if err != nil {
logger.Errorf("%s: 服务层获取当前库存列表失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取当前库存列表失败: "+err.Error(), actionType, "服务层获取当前库存列表失败", nil)
}
logger.Infof("%s: 获取当前库存列表成功, 数量: %d", actionType, len(resp.List))
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取当前库存列表成功", resp, actionType, "获取当前库存列表成功", resp)
}
// ListStockLogs godoc
// @Summary 获取库存变动日志
// @Description 获取原料库存变动历史记录,支持分页、过滤和时间范围查询。
// @Tags 库存管理
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListStockLogRequest false "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListStockLogResponse} "业务码为200代表成功获取列表"
// @Router /api/v1/inventory/stock/logs [get]
func (c *InventoryController) ListStockLogs(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "ListStockLogs")
const actionType = "获取库存变动日志"
var req dto.ListStockLogRequest
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 查询参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "查询参数绑定失败", req)
}
resp, err := c.inventoryService.ListStockLogs(reqCtx, &req)
if err != nil {
logger.Errorf("%s: 服务层获取库存变动日志失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取库存变动日志失败: "+err.Error(), actionType, "服务层获取库存变动日志失败", nil)
}
logger.Infof("%s: 获取库存变动日志成功, 数量: %d", actionType, len(resp.List))
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取库存变动日志成功", resp, actionType, "获取库存变动日志成功", resp)
}

View File

@@ -231,108 +231,6 @@ func (c *Controller) ListUserActionLogs(ctx echo.Context) error {
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 {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "ListRawMaterialPurchases")
const actionType = "获取原料采购记录列表"
var req dto.ListRawMaterialPurchaseRequest
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req)
}
resp, err := c.monitorService.ListRawMaterialPurchases(reqCtx, &req)
if err != nil {
if errors.Is(err, repository.ErrInvalidPagination) {
logger.Warnf("%s: 无效的分页参数: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req)
}
logger.Errorf("%s: 服务层查询失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取原料采购记录失败: "+err.Error(), actionType, "服务层查询失败", req)
}
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 {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "ListRawMaterialStockLogs")
const actionType = "获取原料库存日志列表"
var req dto.ListRawMaterialStockLogRequest
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req)
}
resp, err := c.monitorService.ListRawMaterialStockLogs(reqCtx, &req)
if err != nil {
if errors.Is(err, repository.ErrInvalidPagination) {
logger.Warnf("%s: 无效的分页参数: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req)
}
logger.Errorf("%s: 服务层查询失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取原料库存日志失败: "+err.Error(), actionType, "服务层查询失败", req)
}
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 {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "ListFeedUsageRecords")
const actionType = "获取饲料使用记录列表"
var req dto.ListFeedUsageRecordRequest
if err := ctx.Bind(&req); err != nil {
logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req)
}
resp, err := c.monitorService.ListFeedUsageRecords(reqCtx, &req)
if err != nil {
if errors.Is(err, repository.ErrInvalidPagination) {
logger.Warnf("%s: 无效的分页参数: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req)
}
logger.Errorf("%s: 服务层查询失败: %v", actionType, err)
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取饲料使用记录失败: "+err.Error(), actionType, "服务层查询失败", req)
}
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 根据提供的过滤条件,分页获取用药记录

View File

@@ -1,7 +1,6 @@
package dto
import (
"encoding/json"
"fmt"
"time"
@@ -65,22 +64,34 @@ func NewAreaControllerResponse(ac *models.AreaController) (*AreaControllerRespon
return nil, nil
}
var props map[string]interface{}
// 解析 firmware_version
var firmwareVersion string
// 使用模型上的辅助方法来解析强类型属性
acProps := &models.AreaControllerProperties{}
if err := ac.ParseProperties(acProps); err == nil {
firmwareVersion = acProps.FirmwareVersion
}
// 如果解析出错firmwareVersion 将保持为空字符串,这通常是可接受的降级行为
// 解析完整的 properties 以便向后兼容或用于其他未知属性
var allProps 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)
// 这里我们使用通用的 ParseProperties 方法
if err := ac.ParseProperties(&allProps); 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),
ID: ac.ID,
Name: ac.Name,
NetworkID: ac.NetworkID,
FirmwareVersion: firmwareVersion,
Location: ac.Location,
Status: ac.Status,
Properties: allProps, // 填充完整的 properties
CreatedAt: ac.CreatedAt.Format(time.RFC3339),
UpdatedAt: ac.UpdatedAt.Format(time.RFC3339),
}, nil
}

View File

@@ -1,6 +1,10 @@
package dto
import "git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
import (
"mime/multipart"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
)
// CreateDeviceRequest 定义了创建设备时需要传入的参数
type CreateDeviceRequest struct {
@@ -78,14 +82,15 @@ type DeviceResponse struct {
// AreaControllerResponse 定义了返回给客户端的单个区域主控信息的结构
type AreaControllerResponse struct {
ID uint32 `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"`
ID uint32 `json:"id"`
Name string `json:"name"`
NetworkID string `json:"network_id"`
FirmwareVersion string `json:"firmware_version"`
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 定义了返回给客户端的单个设备模板信息的结构
@@ -100,3 +105,20 @@ type DeviceTemplateResponse struct {
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// OtaUpgradeRequest 定义了 OTA 升级请求时需要传入的参数
type OtaUpgradeRequest struct {
FirmwareFile *multipart.FileHeader `form:"firmware_file" validate:"required"` // 固件压缩包文件
}
// OtaUpgradeResponse 定义了 OTA 升级响应的结构
type OtaUpgradeResponse struct {
TaskID uint32 `json:"task_id"` // OTA 升级任务ID
}
// OtaUpgradeProgressResponse 定义了 OTA 升级进度响应的结构
type OtaUpgradeProgressResponse struct {
TaskID uint32 `json:"task_id"` // OTA 升级任务ID
CurrentStage models.OTATaskStatus `json:"current_stage"` // 当前阶段
Message string `json:"message"` // 状态消息
}

View File

@@ -0,0 +1,295 @@
package dto
import (
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
)
// ConvertNutrientToDTO 将 models.Nutrient 转换为 NutrientResponse DTO
func ConvertNutrientToDTO(nutrient *models.Nutrient) *NutrientResponse {
if nutrient == nil {
return nil
}
rawMaterials := make([]NutrientRawMaterialDTO, 0, len(nutrient.RawMaterialNutrients))
for _, rmn := range nutrient.RawMaterialNutrients {
// 根据您的反馈,移除了不必要的 nil 检查,以保持代码简洁和一致性
rawMaterials = append(rawMaterials, NutrientRawMaterialDTO{
ID: rmn.RawMaterial.ID,
Name: rmn.RawMaterial.Name,
Value: rmn.Value,
})
}
return &NutrientResponse{
ID: nutrient.ID,
Name: nutrient.Name,
Description: nutrient.Description,
RawMaterials: rawMaterials,
}
}
// ConvertNutrientListToDTO 将 []models.Nutrient 转换为 ListNutrientResponse DTO
func ConvertNutrientListToDTO(nutrients []models.Nutrient, total int64, page, pageSize int) *ListNutrientResponse {
nutrientDTOs := make([]NutrientResponse, len(nutrients))
for i, n := range nutrients {
nutrientDTOs[i] = *ConvertNutrientToDTO(&n)
}
return &ListNutrientResponse{
List: nutrientDTOs,
Pagination: PaginationDTO{
Page: page,
PageSize: pageSize,
Total: total,
},
}
}
// ConvertRawMaterialToDTO 将 models.RawMaterial 转换为 RawMaterialResponse DTO
func ConvertRawMaterialToDTO(rm *models.RawMaterial) *RawMaterialResponse {
if rm == nil {
return nil
}
rawMaterialNutrientDTOs := make([]RawMaterialNutrientDTO, len(rm.RawMaterialNutrients))
for i, rmn := range rm.RawMaterialNutrients {
rawMaterialNutrientDTOs[i] = RawMaterialNutrientDTO{
ID: rmn.ID,
NutrientID: rmn.NutrientID,
Nutrient: rmn.Nutrient.Name, // 假设 Nutrient 已经被预加载
Value: rmn.Value,
}
}
return &RawMaterialResponse{
ID: rm.ID,
Name: rm.Name,
Description: rm.Description,
ReferencePrice: rm.ReferencePrice,
MaxAdditionRatio: rm.MaxAdditionRatio,
RawMaterialNutrients: rawMaterialNutrientDTOs,
}
}
// ConvertRawMaterialListToDTO 将 []models.RawMaterial 转换为 ListRawMaterialResponse DTO
func ConvertRawMaterialListToDTO(rawMaterials []models.RawMaterial, total int64, page, pageSize int) *ListRawMaterialResponse {
rawMaterialDTOs := make([]RawMaterialResponse, len(rawMaterials))
for i, rm := range rawMaterials {
rawMaterialDTOs[i] = *ConvertRawMaterialToDTO(&rm)
}
return &ListRawMaterialResponse{
List: rawMaterialDTOs,
Pagination: PaginationDTO{
Page: page,
PageSize: pageSize,
Total: total,
},
}
}
// ConvertPigBreedToDTO 将 models.PigBreed 转换为 PigBreedResponse DTO
func ConvertPigBreedToDTO(breed *models.PigBreed) *PigBreedResponse {
if breed == nil {
return nil
}
return &PigBreedResponse{
ID: breed.ID,
Name: breed.Name,
Description: breed.Description,
ParentInfo: breed.ParentInfo,
AppearanceFeatures: breed.AppearanceFeatures,
BreedAdvantages: breed.BreedAdvantages,
BreedDisadvantages: breed.BreedDisadvantages,
}
}
// ConvertPigBreedListToDTO 将 []models.PigBreed 转换为 ListPigBreedResponse DTO
func ConvertPigBreedListToDTO(breeds []models.PigBreed, total int64, page, pageSize int) *ListPigBreedResponse {
breedDTOs := make([]PigBreedResponse, len(breeds))
for i, b := range breeds {
breedDTOs[i] = *ConvertPigBreedToDTO(&b)
}
return &ListPigBreedResponse{
List: breedDTOs,
Pagination: PaginationDTO{
Page: page,
PageSize: pageSize,
Total: total,
},
}
}
// ConvertPigAgeStageToDTO 将 models.PigAgeStage 转换为 PigAgeStageResponse DTO
func ConvertPigAgeStageToDTO(ageStage *models.PigAgeStage) *PigAgeStageResponse {
if ageStage == nil {
return nil
}
return &PigAgeStageResponse{
ID: ageStage.ID,
Name: ageStage.Name,
Description: ageStage.Description,
}
}
// ConvertPigAgeStageListToDTO 将 []models.PigAgeStage 转换为 ListPigAgeStageResponse DTO
func ConvertPigAgeStageListToDTO(ageStages []models.PigAgeStage, total int64, page, pageSize int) *ListPigAgeStageResponse {
ageStageDTOs := make([]PigAgeStageResponse, len(ageStages))
for i, as := range ageStages {
ageStageDTOs[i] = *ConvertPigAgeStageToDTO(&as)
}
return &ListPigAgeStageResponse{
List: ageStageDTOs,
Pagination: PaginationDTO{
Page: page,
PageSize: pageSize,
Total: total,
},
}
}
// ConvertPigTypeToDTO 将 models.PigType 转换为 PigTypeResponse DTO
func ConvertPigTypeToDTO(pt *models.PigType) *PigTypeResponse {
if pt == nil {
return nil
}
pigNutrientRequirementDTOs := make([]PigNutrientRequirementDTO, len(pt.PigNutrientRequirements))
for i, pnr := range pt.PigNutrientRequirements {
pigNutrientRequirementDTOs[i] = PigNutrientRequirementDTO{
ID: pnr.ID,
NutrientID: pnr.NutrientID,
NutrientName: pnr.Nutrient.Name, // 假设 Nutrient 已经被预加载
MinRequirement: pnr.MinRequirement,
MaxRequirement: pnr.MaxRequirement,
}
}
return &PigTypeResponse{
ID: pt.ID,
BreedID: pt.BreedID,
BreedName: pt.Breed.Name, // 假设 Breed 已经被预加载
AgeStageID: pt.AgeStageID,
AgeStageName: pt.AgeStage.Name, // 假设 AgeStage 已经被预加载
Description: pt.Description,
DailyFeedIntake: pt.DailyFeedIntake,
DailyGainWeight: pt.DailyGainWeight,
MinDays: pt.MinDays,
MaxDays: pt.MaxDays,
MinWeight: pt.MinWeight,
MaxWeight: pt.MaxWeight,
PigNutrientRequirements: pigNutrientRequirementDTOs,
}
}
// ConvertPigTypeListToDTO 将 []models.PigType 转换为 ListPigTypeResponse DTO
func ConvertPigTypeListToDTO(pigTypes []models.PigType, total int64, page, pageSize int) *ListPigTypeResponse {
pigTypeDTOs := make([]PigTypeResponse, len(pigTypes))
for i, pt := range pigTypes {
pigTypeDTOs[i] = *ConvertPigTypeToDTO(&pt)
}
return &ListPigTypeResponse{
List: pigTypeDTOs,
Pagination: PaginationDTO{
Page: page,
PageSize: pageSize,
Total: total,
},
}
}
// ConvertRecipeToDto 将 models.Recipe 转换为 RecipeResponse DTO
func ConvertRecipeToDto(recipe *models.Recipe) *RecipeResponse {
if recipe == nil {
return nil
}
ingredients := make([]RecipeIngredientDto, len(recipe.RecipeIngredients))
for i, ri := range recipe.RecipeIngredients {
ingredients[i] = RecipeIngredientDto{
RawMaterialID: ri.RawMaterialID,
Percentage: ri.Percentage,
}
}
return &RecipeResponse{
ID: recipe.ID,
Name: recipe.Name,
Description: recipe.Description,
RecipeIngredients: ingredients,
}
}
// ConvertRecipeListToDTO 将 []models.Recipe 转换为 ListRecipeResponse DTO
func ConvertRecipeListToDTO(recipes []models.Recipe, total int64, page, pageSize int) *ListRecipeResponse {
recipeDTOs := make([]RecipeResponse, len(recipes))
for i, r := range recipes {
recipeDTOs[i] = *ConvertRecipeToDto(&r)
}
return &ListRecipeResponse{
List: recipeDTOs,
Pagination: PaginationDTO{
Page: page,
PageSize: pageSize,
Total: total,
},
}
}
// ConvertCreateRecipeRequestToModel 将 CreateRecipeRequest DTO 转换为 models.Recipe 模型
func ConvertCreateRecipeRequestToModel(req *CreateRecipeRequest) *models.Recipe {
if req == nil {
return nil
}
ingredients := make([]models.RecipeIngredient, len(req.RecipeIngredients))
for i, ri := range req.RecipeIngredients {
ingredients[i] = models.RecipeIngredient{
RawMaterialID: ri.RawMaterialID,
Percentage: ri.Percentage,
}
}
return &models.Recipe{
Name: req.Name,
Description: req.Description,
RecipeIngredients: ingredients,
}
}
// ConvertUpdateRecipeRequestToModel 将 UpdateRecipeRequest DTO 转换为 models.Recipe 模型
func ConvertUpdateRecipeRequestToModel(req *UpdateRecipeRequest) *models.Recipe {
if req == nil {
return nil
}
ingredients := make([]models.RecipeIngredient, len(req.RecipeIngredients))
for i, ri := range req.RecipeIngredients {
ingredients[i] = models.RecipeIngredient{
RawMaterialID: ri.RawMaterialID,
Percentage: ri.Percentage,
}
}
return &models.Recipe{
Name: req.Name,
Description: req.Description,
RecipeIngredients: ingredients,
}
}
// ToGenerateRecipeResponse 将 models.Recipe 转换为 GenerateRecipeResponse DTO
func ToGenerateRecipeResponse(recipe *models.Recipe) *GenerateRecipeResponse {
if recipe == nil {
return nil
}
return &GenerateRecipeResponse{
ID: recipe.ID,
Name: recipe.Name,
Description: recipe.Description,
}
}

View File

@@ -0,0 +1,350 @@
package dto
import "git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
// =============================================================================================================
// 营养种类 (Nutrient) 相关 DTO
// =============================================================================================================
// CreateNutrientRequest 创建营养种类的请求体
type CreateNutrientRequest struct {
Name string `json:"name" validate:"required,max=100"` // 营养素名称
Description string `json:"description" validate:"max=255"` // 描述
}
// UpdateNutrientRequest 更新营养种类的请求体
type UpdateNutrientRequest struct {
Name string `json:"name" validate:"required,max=100"` // 营养素名称
Description string `json:"description" validate:"max=255"` // 描述
}
// NutrientRawMaterialDTO 用于在营养素信息中展示关联的原料及其含量
type NutrientRawMaterialDTO struct {
ID uint32 `json:"id"` // 原料ID
Name string `json:"name"` // 原料名称
Value float32 `json:"value"` // 该原料中此营养素的含量
}
// NutrientResponse 营养种类响应体
type NutrientResponse struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
RawMaterials []NutrientRawMaterialDTO `json:"raw_materials"` // 包含此营养的原料列表
}
// ListNutrientRequest 定义了获取营养种类列表的请求参数
type ListNutrientRequest struct {
Page int `json:"page" query:"page"` // 页码
PageSize int `json:"page_size" query:"page_size"` // 每页数量
Name *string `json:"name" query:"name"` // 按营养名称模糊查询
RawMaterialName *string `json:"raw_material_name" query:"raw_material_name"` // 按原料名称模糊查询
OrderBy string `json:"order_by" query:"order_by"` // 排序字段,例如 "id DESC"
}
// ListNutrientResponse 是获取营养种类列表的响应结构
type ListNutrientResponse struct {
List []NutrientResponse `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}
// =============================================================================================================
// 原料 (RawMaterial) 相关 DTO
// =============================================================================================================
// CreateRawMaterialRequest 创建原料的请求体
type CreateRawMaterialRequest struct {
Name string `json:"name" validate:"required,max=100"` // 原料名称
Description string `json:"description" validate:"max=255"` // 描述
ReferencePrice float32 `json:"reference_price"` // 参考价格(kg/元)
MaxAdditionRatio float32 `json:"max_addition_ratio"` // 最大添加比例
}
// UpdateRawMaterialRequest 更新原料的请求体
type UpdateRawMaterialRequest struct {
Name string `json:"name" validate:"required,max=100"` // 原料名称
Description string `json:"description" validate:"max=255"` // 描述
ReferencePrice float32 `json:"reference_price"` // 参考价格(kg/元)
MaxAdditionRatio *float32 `json:"max_addition_ratio"` // 最大添加比例
}
// RawMaterialNutrientDTO 原料营养素响应体
type RawMaterialNutrientDTO struct {
ID uint32 `json:"id"`
NutrientID uint32 `json:"nutrient_id"`
Nutrient string `json:"nutrient_name"` // 营养素名称
Value float32 `json:"value"` // 营养价值含量
}
// RawMaterialResponse 原料响应体
type RawMaterialResponse struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
ReferencePrice float32 `json:"reference_price"` // 参考价格(kg/元)
MaxAdditionRatio float32 `json:"max_addition_ratio"` // 最大添加比例
RawMaterialNutrients []RawMaterialNutrientDTO `json:"raw_material_nutrients"` // 关联的营养素信息
}
// ListRawMaterialRequest 定义了获取原料列表的请求参数
type ListRawMaterialRequest struct {
Page int `json:"page" query:"page"` // 页码
PageSize int `json:"page_size" query:"page_size"` // 每页数量
Name *string `json:"name" query:"name"` // 按原料名称模糊查询
NutrientName *string `json:"nutrient_name" query:"nutrient_name"` // 按营养名称模糊查询
MinReferencePrice *float32 `json:"min_reference_price" query:"min_reference_price"` // 参考价格最小值
MaxReferencePrice *float32 `json:"max_reference_price" query:"max_reference_price"` // 参考价格最大值
OrderBy string `json:"order_by" query:"order_by"` // 排序字段,例如 "id DESC"
}
// ListRawMaterialResponse 是获取原料列表的响应结构
type ListRawMaterialResponse struct {
List []RawMaterialResponse `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}
// UpdateRawMaterialNutrientsRequest 更新原料营养成分的请求体
type UpdateRawMaterialNutrientsRequest struct {
Nutrients []RawMaterialNutrientItem `json:"nutrients" validate:"required,dive"`
}
// RawMaterialNutrientItem 代表一个营养成分及其含量
type RawMaterialNutrientItem struct {
NutrientID uint32 `json:"nutrient_id" validate:"required"` // 营养素ID
Value float32 `json:"value" validate:"gte=0"` // 含量值必须大于等于0
}
// =============================================================================================================
// 猪品种 (PigBreed) 相关 DTO
// =============================================================================================================
// CreatePigBreedRequest 创建猪品种的请求体
type CreatePigBreedRequest struct {
Name string `json:"name" validate:"required,max=50"` // 品种名称
Description string `json:"description"` // 其他描述
ParentInfo string `json:"parent_info"` // 父母信息
AppearanceFeatures string `json:"appearance_features"` // 外貌特征
BreedAdvantages string `json:"breed_advantages"` // 品种优点
BreedDisadvantages string `json:"breed_disadvantages"` // 品种缺点
}
// UpdatePigBreedRequest 更新猪品种的请求体
type UpdatePigBreedRequest struct {
Name string `json:"name" validate:"required,max=50"` // 品种名称
Description string `json:"description"` // 其他描述
ParentInfo string `json:"parent_info"` // 父母信息
AppearanceFeatures string `json:"appearance_features"` // 外貌特征
BreedAdvantages string `json:"breed_advantages"` // 品种优点
BreedDisadvantages string `json:"breed_disadvantages"` // 品种缺点
}
// PigBreedResponse 猪品种响应体
type PigBreedResponse struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
ParentInfo string `json:"parent_info"`
AppearanceFeatures string `json:"appearance_features"`
BreedAdvantages string `json:"breed_advantages"`
BreedDisadvantages string `json:"breed_disadvantages"`
}
// ListPigBreedRequest 定义了获取猪品种列表的请求参数
type ListPigBreedRequest struct {
Page int `json:"page" query:"page"` // 页码
PageSize int `json:"page_size" query:"page_size"` // 每页数量
Name *string `json:"name" query:"name"` // 按名称模糊查询
OrderBy string `json:"order_by" query:"order_by"` // 排序字段,例如 "id DESC"
}
// ListPigBreedResponse 是获取猪品种列表的响应结构
type ListPigBreedResponse struct {
List []PigBreedResponse `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}
// =============================================================================================================
// 猪年龄阶段 (PigAgeStage) 相关 DTO
// =============================================================================================================
// CreatePigAgeStageRequest 创建猪年龄阶段的请求体
type CreatePigAgeStageRequest struct {
Name string `json:"name" validate:"required,max=50"` // 年龄阶段名称
Description string `json:"description" validate:"max=255"` // 阶段描述
}
// UpdatePigAgeStageRequest 更新猪年龄阶段的请求体
type UpdatePigAgeStageRequest struct {
Name string `json:"name" validate:"required,max=50"` // 年龄阶段名称
Description string `json:"description" validate:"max=255"` // 阶段描述
}
// PigAgeStageResponse 猪年龄阶段响应体
type PigAgeStageResponse struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
// ListPigAgeStageRequest 定义了获取猪年龄阶段列表的请求参数
type ListPigAgeStageRequest struct {
Page int `json:"page" query:"page"` // 页码
PageSize int `json:"page_size" query:"page_size"` // 每页数量
Name *string `json:"name" query:"name"` // 按名称模糊查询
OrderBy string `json:"order_by" query:"order_by"` // 排序字段,例如 "id DESC"
}
// ListPigAgeStageResponse 是获取猪年龄阶段列表的响应结构
type ListPigAgeStageResponse struct {
List []PigAgeStageResponse `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}
// =============================================================================================================
// 猪类型 (PigType) 相关 DTO
// =============================================================================================================
// CreatePigTypeRequest 创建猪类型的请求体
type CreatePigTypeRequest struct {
BreedID uint32 `json:"breed_id" validate:"required"` // 关联的猪品种ID
AgeStageID uint32 `json:"age_stage_id" validate:"required"` // 关联的猪年龄阶段ID
Description string `json:"description" validate:"max=255"` // 该猪类型的描述或特点
DailyFeedIntake float32 `json:"daily_feed_intake"` // 理论日均食量 (g/天)
DailyGainWeight float32 `json:"daily_gain_weight"` // 理论日增重 (g/天)
MinDays uint32 `json:"min_days"` // 该猪类型在该年龄阶段的最小日龄
MaxDays uint32 `json:"max_days"` // 该猪类型在该年龄阶段的最大日龄
MinWeight float32 `json:"min_weight"` // 该猪类型在该年龄阶段的最小体重 (g)
MaxWeight float32 `json:"max_weight"` // 该猪类型在该年龄阶段的最大体重 (g)
}
// UpdatePigTypeRequest 更新猪类型的请求体
type UpdatePigTypeRequest struct {
BreedID uint32 `json:"breed_id" validate:"required"` // 关联的猪品种ID
AgeStageID uint32 `json:"age_stage_id" validate:"required"` // 关联的猪年龄阶段ID
Description string `json:"description" validate:"max=255"` // 该猪类型的描述或特点
DailyFeedIntake float32 `json:"daily_feed_intake"` // 理论日均食量 (g/天)
DailyGainWeight float32 `json:"daily_gain_weight"` // 理论日增重 (g/天)
MinDays uint32 `json:"min_days"` // 该猪类型在该年龄阶段的最小日龄
MaxDays uint32 `json:"max_days"` // 该猪类型在该年龄阶段的最大日龄
MinWeight float32 `json:"min_weight"` // 该猪类型在该年龄阶段的最小体重 (g)
MaxWeight float32 `json:"max_weight"` // 该猪类型在该年龄阶段的最大体重 (g)
}
// PigNutrientRequirementDTO 猪营养需求响应体
type PigNutrientRequirementDTO struct {
ID uint32 `json:"id"`
NutrientID uint32 `json:"nutrient_id"`
NutrientName string `json:"nutrient_name"` // 营养素名称
MinRequirement float32 `json:"min_requirement"` // 最低营养需求量
MaxRequirement float32 `json:"max_requirement"` // 最高营养需求量
}
// PigTypeResponse 猪类型响应体
type PigTypeResponse struct {
ID uint32 `json:"id"`
BreedID uint32 `json:"breed_id"`
BreedName string `json:"breed_name"` // 猪品种名称
AgeStageID uint32 `json:"age_stage_id"`
AgeStageName string `json:"age_stage_name"` // 猪年龄阶段名称
Description string `json:"description"`
DailyFeedIntake float32 `json:"daily_feed_intake"`
DailyGainWeight float32 `json:"daily_gain_weight"`
MinDays uint32 `json:"min_days"`
MaxDays uint32 `json:"max_days"`
MinWeight float32 `json:"min_weight"`
MaxWeight float32 `json:"max_weight"`
PigNutrientRequirements []PigNutrientRequirementDTO `json:"pig_nutrient_requirements"` // 关联的营养需求
}
// ListPigTypeRequest 定义了获取猪类型列表的请求参数
type ListPigTypeRequest struct {
Page int `json:"page" query:"page"` // 页码
PageSize int `json:"page_size" query:"page_size"` // 每页数量
BreedID *uint32 `json:"breed_id" query:"breed_id"` // 关联的猪品种ID
AgeStageID *uint32 `json:"age_stage_id" query:"age_stage_id"` // 关联的猪年龄阶段ID
BreedName *string `json:"breed_name" query:"breed_name"` // 关联的猪品种名称 (用于模糊查询)
AgeStageName *string `json:"age_stage_name" query:"age_stage_name"` // 关联的猪年龄阶段名称 (用于模糊查询)
OrderBy string `json:"order_by" query:"order_by"` // 排序字段,例如 "id DESC"
}
// ListPigTypeResponse 是获取猪类型列表的响应结构
type ListPigTypeResponse struct {
List []PigTypeResponse `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}
// UpdatePigTypeNutrientRequirementsRequest 更新猪类型营养需求的请求体
type UpdatePigTypeNutrientRequirementsRequest struct {
NutrientRequirements []PigNutrientRequirementItem `json:"nutrient_requirements" validate:"required,dive"`
}
// PigNutrientRequirementItem 代表一个营养需求项
type PigNutrientRequirementItem struct {
NutrientID uint32 `json:"nutrient_id" validate:"required"` // 营养素ID
MinRequirement float32 `json:"min_requirement" validate:"gte=0"` // 最低营养需求量
MaxRequirement float32 `json:"max_requirement" validate:"gte=0"` // 最高营养需求量
}
// =============================================================================================================
// 配方 (Recipe) 相关 DTO
// =============================================================================================================
// RecipeIngredientDto 代表配方中的一个原料及其百分比
type RecipeIngredientDto struct {
RawMaterialID uint32 `json:"raw_material_id" validate:"required"` // 原料ID
Percentage float32 `json:"percentage" validate:"gte=0,lte=1"` // 原料在配方中的百分比 (0-1之间)
}
// CreateRecipeRequest 创建配方的请求体
type CreateRecipeRequest struct {
Name string `json:"name" validate:"required,max=100"` // 配方名称
Description string `json:"description" validate:"max=255"` // 配方描述
RecipeIngredients []RecipeIngredientDto `json:"recipe_ingredients" validate:"dive"` // 配方原料组成
}
// UpdateRecipeRequest 更新配方的请求体
type UpdateRecipeRequest struct {
Name string `json:"name" validate:"required,max=100"` // 配方名称
Description string `json:"description" validate:"max=255"` // 配方描述
RecipeIngredients []RecipeIngredientDto `json:"recipe_ingredients" validate:"dive"` // 配方原料组成
}
// RecipeResponse 配方响应体
type RecipeResponse struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
RecipeIngredients []RecipeIngredientDto `json:"recipe_ingredients"`
}
// ListRecipeRequest 定义了获取配方列表的请求参数
type ListRecipeRequest struct {
Page int `json:"page" query:"page"` // 页码
PageSize int `json:"page_size" query:"page_size"` // 每页数量
Name *string `json:"name" query:"name"` // 按名称模糊查询
OrderBy string `json:"order_by" query:"order_by"` // 排序字段,例如 "id DESC"
}
// ListRecipeResponse 是获取配方列表的响应结构
type ListRecipeResponse struct {
List []RecipeResponse `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}
// GenerateRecipeResponse 是一键生成配方的响应体
type GenerateRecipeResponse struct {
ID uint32 `json:"id"` // 新生成的配方ID
Name string `json:"name"` // 新生成的配方名称
Description string `json:"description"` // 新生成的配方描述
}
// ReviewRecipeRequest 定义了点评配方的请求体
type ReviewRecipeRequest struct {
PigTypeID uint32 `json:"pig_type_id" binding:"required"` // 猪类型ID
}
// ReviewRecipeResponse 定义了点评配方的响应体
type ReviewRecipeResponse struct {
ReviewMessage string `json:"review_message"` // 点评内容
AIModel models.AIModel `json:"ai_model"` // 使用的 AI 模型
}

View File

@@ -0,0 +1,69 @@
package dto
import (
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
)
// ConvertCurrentStockToDTO 将原料及其最新库存日志转换为 CurrentStockResponse DTO
func ConvertCurrentStockToDTO(material *models.RawMaterial, latestLog *models.RawMaterialStockLog) *CurrentStockResponse {
if material == nil {
return nil
}
stock := float32(0)
lastUpdated := material.CreatedAt.Format(time.RFC3339) // 默认使用创建时间
var lastOperationSourceType models.StockLogSourceType
if latestLog != nil {
stock = latestLog.AfterQuantity
lastUpdated = latestLog.HappenedAt.Format(time.RFC3339)
lastOperationSourceType = latestLog.SourceType
}
return &CurrentStockResponse{
RawMaterialID: material.ID,
RawMaterialName: material.Name,
Stock: stock,
LastUpdated: lastUpdated,
LastOperationSourceType: lastOperationSourceType,
}
}
// ConvertStockLogToDTO 将 models.RawMaterialStockLog 转换为 StockLogResponse DTO
func ConvertStockLogToDTO(log *models.RawMaterialStockLog) *StockLogResponse {
if log == nil {
return nil
}
return &StockLogResponse{
ID: log.ID,
RawMaterialID: log.RawMaterialID,
RawMaterialName: log.RawMaterial.Name, // 假设 RawMaterial 已被预加载
ChangeAmount: log.ChangeAmount,
BeforeQuantity: log.BeforeQuantity,
AfterQuantity: log.AfterQuantity,
SourceType: log.SourceType,
SourceID: log.SourceID,
HappenedAt: log.HappenedAt,
Remarks: log.Remarks,
}
}
// ConvertStockLogListToDTO 将 []models.RawMaterialStockLog 转换为 ListStockLogResponse DTO
func ConvertStockLogListToDTO(logs []models.RawMaterialStockLog, total int64, page, pageSize int) *ListStockLogResponse {
logDTOs := make([]StockLogResponse, len(logs))
for i, log := range logs {
logDTOs[i] = *ConvertStockLogToDTO(&log)
}
return &ListStockLogResponse{
List: logDTOs,
Pagination: PaginationDTO{
Page: page,
PageSize: pageSize,
Total: total,
},
}
}

View File

@@ -0,0 +1,75 @@
package dto
import (
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
)
// =============================================================================================================
// 库存 (Inventory) 相关 DTO
// =============================================================================================================
// StockAdjustmentRequest 手动调整库存的请求体
type StockAdjustmentRequest struct {
RawMaterialID uint32 `json:"raw_material_id" validate:"required"` // 要调整的原料ID
ChangeAmount float32 `json:"change_amount" validate:"required,ne=0"` // 变动数量, 正数为入库, 负数为出库, 单位: g
SourceType models.StockLogSourceType `json:"source_type" validate:"required"` // 库存变动来源类型
SourceID *uint32 `json:"source_id,omitempty"` // 来源ID, 例如: 配方ID, 采购单ID等
Remarks string `json:"remarks" validate:"max=255"` // 备注
}
// CurrentStockResponse 单个原料及其当前库存的响应体
type CurrentStockResponse struct {
RawMaterialID uint32 `json:"raw_material_id"` // 原料ID
RawMaterialName string `json:"raw_material_name"` // 原料名称
Stock float32 `json:"stock"` // 当前库存量, 单位: g
LastUpdated string `json:"last_updated"` // 最后更新时间
LastOperationSourceType models.StockLogSourceType `json:"last_operation_source_type"` // 上次库存变动的来源类型
}
// ListCurrentStockRequest 定义了获取当前库存列表的请求参数
type ListCurrentStockRequest struct {
Page int `json:"page" query:"page"` // 页码
PageSize int `json:"page_size" query:"page_size"` // 每页数量
RawMaterialName *string `json:"raw_material_name" query:"raw_material_name"` // 按原料名称模糊查询
OrderBy string `json:"order_by" query:"order_by"` // 排序字段, 例如 "stock DESC"
HasStock *bool `json:"has_stock" query:"has_stock"` // 只查询有库存的原料
}
// ListCurrentStockResponse 是获取当前库存列表的响应结构
type ListCurrentStockResponse struct {
List []CurrentStockResponse `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}
// StockLogResponse 库存变动历史记录的响应体
type StockLogResponse struct {
ID uint32 `json:"id"`
RawMaterialID uint32 `json:"raw_material_id"`
RawMaterialName string `json:"raw_material_name"`
ChangeAmount float32 `json:"change_amount"`
BeforeQuantity float32 `json:"before_quantity"`
AfterQuantity float32 `json:"after_quantity"`
SourceType models.StockLogSourceType `json:"source_type"`
SourceID *uint32 `json:"source_id,omitempty"`
HappenedAt time.Time `json:"happened_at"`
Remarks string `json:"remarks"`
}
// ListStockLogRequest 定义了获取库存变动历史的请求参数
type ListStockLogRequest struct {
Page int `json:"page" query:"page"` // 页码
PageSize int `json:"page_size" query:"page_size"` // 每页数量
RawMaterialID *uint32 `json:"raw_material_id" query:"raw_material_id"` // 按原料ID精确查询
SourceTypes []models.StockLogSourceType `json:"source_types" query:"source_types"` // 按来源类型查询
StartTime *string `json:"start_time" query:"start_time"` // 开始时间 (RFC3339格式, e.g., "2023-01-01T00:00:00Z")
EndTime *string `json:"end_time" query:"end_time"` // 结束时间 (RFC3339格式)
OrderBy string `json:"order_by" query:"order_by"` // 排序字段
}
// ListStockLogResponse 是获取库存变动历史列表的响应结构
type ListStockLogResponse struct {
List []StockLogResponse `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}

View File

@@ -170,94 +170,6 @@ func NewListUserActionLogResponse(data []models.UserActionLog, total int64, page
}
}
// 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))

View File

@@ -202,120 +202,6 @@ type ListUserActionLogResponse struct {
Pagination PaginationDTO `json:"pagination"`
}
// --- RawMaterialPurchase ---
// ListRawMaterialPurchaseRequest 定义了获取原料采购列表的请求参数
type ListRawMaterialPurchaseRequest struct {
Page int `json:"page" query:"page"`
PageSize int `json:"page_size" query:"page_size"`
RawMaterialID *uint32 `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 uint32 `json:"id"`
Name string `json:"name"`
}
// RawMaterialPurchaseDTO 是用于API响应的原料采购结构
type RawMaterialPurchaseDTO struct {
ID uint32 `json:"id"`
RawMaterialID uint32 `json:"raw_material_id"`
RawMaterial RawMaterialDTO `json:"raw_material"`
Supplier string `json:"supplier"`
Amount float32 `json:"amount"`
UnitPrice float32 `json:"unit_price"`
TotalPrice float32 `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 *uint32 `json:"raw_material_id" query:"raw_material_id"`
SourceType *string `json:"source_type" query:"source_type"`
SourceID *uint32 `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 uint32 `json:"id"`
RawMaterialID uint32 `json:"raw_material_id"`
ChangeAmount float32 `json:"change_amount"`
SourceType models.StockLogSourceType `json:"source_type"`
SourceID uint32 `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 *uint32 `json:"pen_id" query:"pen_id"`
FeedFormulaID *uint32 `json:"feed_formula_id" query:"feed_formula_id"`
OperatorID *uint32 `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 uint32 `json:"id"`
Name string `json:"name"`
}
// FeedFormulaDTO 是用于API响应的简化版饲料配方结构
type FeedFormulaDTO struct {
ID uint32 `json:"id"`
Name string `json:"name"`
}
// FeedUsageRecordDTO 是用于API响应的饲料使用记录结构
type FeedUsageRecordDTO struct {
ID uint32 `json:"id"`
PenID uint32 `json:"pen_id"`
Pen PenDTO `json:"pen"`
FeedFormulaID uint32 `json:"feed_formula_id"`
FeedFormula FeedFormulaDTO `json:"feed_formula"`
Amount float32 `json:"amount"`
RecordedAt time.Time `json:"recorded_at"`
OperatorID uint32 `json:"operator_id"`
Remarks string `json:"remarks"`
}
// ListFeedUsageRecordResponse 是获取饲料使用记录列表的响应结构
type ListFeedUsageRecordResponse struct {
List []FeedUsageRecordDTO `json:"list"`
Pagination PaginationDTO `json:"pagination"`
}
// --- MedicationLog ---
// ListMedicationLogRequest 定义了获取用药记录列表的请求参数

View File

@@ -0,0 +1,175 @@
package chirp_stack
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"git.huangwc.com/pig/pig-farm-controller/internal/app/listener"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport/proto"
gproto "google.golang.org/protobuf/proto"
)
// ChirpStackListener 主动发送的请求的event字段, 这个字段代表事件类型
const (
eventTypeUp = "up" // 上行数据事件:当接收到设备发送的数据时触发,这是最核心的事件。
eventTypeStatus = "status" // 设备状态事件:当设备报告其状态时触发(例如电池电量、信号强度)。
eventTypeJoin = "join" // 入网事件:当设备成功加入网络时触发。
eventTypeAck = "ack" // 下行确认事件:当设备确认收到下行消息时触发。
eventTypeTxAck = "txack" // 网关发送确认事件:当网关确认已发送下行消息时触发(不代表设备已收到)。
eventTypeLog = "log" // 日志事件:当设备或 ChirpStack 产生日志信息时触发。
eventTypeLocation = "location" // 位置事件:当设备的位置被解析或更新时触发。
eventTypeIntegration = "integration" // 集成事件:当其他集成(如第三方服务)处理数据后触发。
)
// ChirpStackListener 是一个监听器, 用于将ChirpStack的Webhook事件适配到统一的UpstreamHandler。
type ChirpStackListener struct {
selfCtx context.Context
handler transport.UpstreamHandler // 依赖注入的统一业务处理器
}
// NewChirpStackListener 创建一个新的 ChirpStackListener 实例。
func NewChirpStackListener(
ctx context.Context,
handler transport.UpstreamHandler,
) listener.ListenHandler {
return &ChirpStackListener{
selfCtx: logs.AddCompName(ctx, "ChirpStackListener"),
handler: handler,
}
}
// Handler 监听ChirpStack反馈的事件, 因为这是个Webhook, 所以直接回复掉再慢慢处理信息
func (c *ChirpStackListener) Handler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// 注意:这里的 selfCtx 是 r.Context()因为它包含了HTTP请求的追踪信息
ctx, logger := logs.Trace(r.Context(), c.selfCtx, "Handler")
defer r.Body.Close()
b, err := io.ReadAll(r.Body)
if err != nil {
logger.Errorw("读取请求体失败", "error", err)
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}
event := r.URL.Query().Get("event")
w.WriteHeader(http.StatusOK)
// 使用分离的上下文进行异步处理,防止原始请求取消导致处理中断
detachedCtx := logs.DetachContext(ctx)
go c.dispatch(detachedCtx, b, event)
}
}
// dispatch 用于解析并分发 ChirpStack 发送的事件
func (c *ChirpStackListener) dispatch(ctx context.Context, data []byte, eventType string) {
reqCtx, logger := logs.Trace(ctx, c.selfCtx, "dispatch")
var err error
switch eventType {
case eventTypeUp:
var msg UpEvent
if err = json.Unmarshal(data, &msg); err == nil {
c.adaptUpEvent(reqCtx, &msg)
}
case eventTypeStatus:
var msg StatusEvent
if err = json.Unmarshal(data, &msg); err == nil {
c.adaptStatusEvent(reqCtx, &msg)
}
case eventTypeAck:
var msg AckEvent
if err = json.Unmarshal(data, &msg); err == nil {
c.adaptAckEvent(reqCtx, &msg)
}
// --- 其他事件只记录日志,不进行业务处理 ---
case eventTypeJoin, eventTypeTxAck, eventTypeLog, eventTypeLocation, eventTypeIntegration:
logger.Infow("收到一个非业务处理的ChirpStack事件", "type", eventType)
default:
logger.Warnw("未知的ChirpStack事件类型", "type", eventType)
}
if err != nil {
logger.Errorw("解析ChirpStack事件失败", "type", eventType, "error", err, "data", string(data))
}
}
// --- 适配器函数 ---
// adaptUpEvent 将 'up' 事件适配并委托给 UpstreamHandler
func (c *ChirpStackListener) adaptUpEvent(ctx context.Context, event *UpEvent) {
reqCtx, logger := logs.Trace(ctx, c.selfCtx, "adaptUpEvent")
// 1. 优先处理并委托旁路状态信息(如信号强度)
if len(event.RxInfo) > 0 {
rx := event.RxInfo[0]
status := map[string]interface{}{
"rssi": float64(rx.Rssi),
"snr": float64(rx.Snr),
}
if err := c.handler.HandleStatus(reqCtx, event.DeviceInfo.DevEui, status); err != nil {
logger.Errorw("委托 'up' 事件中的状态信息失败", "error", err)
}
}
// 2. 如果没有业务数据,则直接返回
if event.Data == "" {
return
}
// 3. 解码并解析业务指令
decodedData, err := base64.StdEncoding.DecodeString(event.Data)
if err != nil {
logger.Errorw("Base64解码 'up' 事件的Data失败", "error", err, "data", event.Data)
return
}
var instruction proto.Instruction
if err := gproto.Unmarshal(decodedData, &instruction); err != nil {
logger.Errorw("解析上行Instruction Protobuf失败", "error", err, "decodedData", fmt.Sprintf("%x", decodedData))
return
}
// 4. 委托给统一处理器
if err := c.handler.HandleInstruction(reqCtx, event.DeviceInfo.DevEui, &instruction); err != nil {
logger.Errorw("委托 'up' 事件中的业务指令失败", "error", err)
}
}
// adaptStatusEvent 将 'status' 事件适配并委托给 UpstreamHandler
func (c *ChirpStackListener) adaptStatusEvent(ctx context.Context, event *StatusEvent) {
reqCtx, logger := logs.Trace(ctx, c.selfCtx, "adaptStatusEvent")
status := map[string]interface{}{
"margin": float64(event.Margin),
"batteryLevel": float64(event.BatteryLevel),
"batteryLevelUnavailable": event.BatteryLevelUnavailable,
"externalPower": event.ExternalPower,
}
if err := c.handler.HandleStatus(reqCtx, event.DeviceInfo.DevEui, status); err != nil {
logger.Errorw("委托 'status' 事件失败", "error", err)
}
}
// adaptAckEvent 将 'ack' 事件适配并委托给 UpstreamHandler
func (c *ChirpStackListener) adaptAckEvent(ctx context.Context, event *AckEvent) {
reqCtx, logger := logs.Trace(ctx, c.selfCtx, "adaptAckEvent")
if err := c.handler.HandleAck(reqCtx, event.DeviceInfo.DevEui, event.DeduplicationID, event.Acknowledged, event.Time); err != nil {
logger.Errorw("委托 'ack' 事件失败", "error", err)
}
}

View File

@@ -1,4 +1,4 @@
package webhook
package chirp_stack
import (
"encoding/json"

View File

@@ -1,9 +1,10 @@
package webhook
package chirp_stack
import (
"context"
"net/http"
"git.huangwc.com/pig/pig-farm-controller/internal/app/listener"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
)
@@ -13,8 +14,8 @@ type PlaceholderListener struct {
}
// NewPlaceholderListener 创建一个新的 PlaceholderListener 实例
// 它只打印一条日志, 表明 ChirpStack webhook 未被激活
func NewPlaceholderListener(ctx context.Context) ListenHandler {
// 它只打印一条日志, 表明 ChirpStack listener 未被激活
func NewPlaceholderListener(ctx context.Context) listener.ListenHandler {
return &PlaceholderListener{
ctx: ctx,
}

View File

@@ -0,0 +1,445 @@
package listener
import (
"context"
"encoding/json"
"fmt"
"math"
"path/filepath"
"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"
"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/file"
"gorm.io/datatypes"
)
// loraListener 是一个统一的LoRa上行业务处理器实现了 transport.UpstreamHandler 接口。
// 它包含了处理业务所需的所有依赖是项目中唯一处理LoRa上行业务的地方。
type loraListener struct {
selfCtx context.Context
areaControllerRepo repository.AreaControllerRepository
pendingCollectionRepo repository.PendingCollectionRepository
deviceRepo repository.DeviceRepository
sensorDataRepo repository.SensorDataRepository
deviceCommandLogRepo repository.DeviceCommandLogRepository
otaRepo repository.OtaRepository
deviceCommunicator device.DeviceCommunicator
}
// NewLoRaListener 创建一个新的 loraListener 实例。
// 注意:返回的是 transport.UpstreamHandler 接口,向上层隐藏具体实现。
func NewLoRaListener(
ctx context.Context,
areaControllerRepo repository.AreaControllerRepository,
pendingCollectionRepo repository.PendingCollectionRepository,
deviceRepo repository.DeviceRepository,
sensorDataRepo repository.SensorDataRepository,
deviceCommandLogRepo repository.DeviceCommandLogRepository,
otaRepo repository.OtaRepository,
deviceCommunicator device.DeviceCommunicator,
) transport.UpstreamHandler {
return &loraListener{
selfCtx: logs.AddCompName(ctx, "LoRaListener"),
areaControllerRepo: areaControllerRepo,
pendingCollectionRepo: pendingCollectionRepo,
deviceRepo: deviceRepo,
sensorDataRepo: sensorDataRepo,
deviceCommandLogRepo: deviceCommandLogRepo,
otaRepo: otaRepo,
deviceCommunicator: deviceCommunicator,
}
}
// HandleInstruction 处理来自设备的、已解析为Instruction的业务指令。
func (l *loraListener) HandleInstruction(upstreamCtx context.Context, sourceAddr string, instruction *proto.Instruction) error {
ctx, logger := logs.Trace(upstreamCtx, l.selfCtx, "HandleInstruction")
logger.Infow("接收到设备指令", "来源地址", sourceAddr)
switch p := instruction.Payload.(type) {
case *proto.Instruction_CollectResult:
return l.handleCollectResult(ctx, sourceAddr, p.CollectResult)
case *proto.Instruction_Pong:
return l.handlePong(ctx, sourceAddr, p.Pong)
case *proto.Instruction_UpdateStatusReport:
return l.handleUpdateStatusReport(ctx, sourceAddr, p.UpdateStatusReport)
case *proto.Instruction_RequestFile:
return l.handleRequestFile(ctx, sourceAddr, p.RequestFile)
default:
logger.Warnw("收到一个当前未处理的上行指令类型", "来源地址", sourceAddr, "类型", fmt.Sprintf("%T", p))
return nil
}
}
// HandleStatus 处理非业务指令的设备状态更新,例如信号强度、电量等。
func (l *loraListener) HandleStatus(upstreamCtx context.Context, sourceAddr string, status map[string]interface{}) error {
ctx, logger := logs.Trace(upstreamCtx, l.selfCtx, "HandleStatus")
logger.Infow("接收到设备状态更新", "来源地址", sourceAddr, "状态", status)
areaController, err := l.areaControllerRepo.FindByNetworkID(ctx, sourceAddr)
if err != nil {
return fmt.Errorf("处理 'status' 事件失败:无法通过源地址 '%s' 找到区域主控设备: %w", sourceAddr, err)
}
eventTime := time.Now() // 状态事件通常是实时的,使用当前时间
// 尝试记录信号强度
if rssi, ok := status["rssi"].(float64); ok {
if snr, ok := status["snr"].(float64); ok {
signalMetrics := models.SignalMetrics{
RssiDbm: int(rssi),
SnrDb: float32(snr),
}
if margin, ok := status["margin"].(float64); ok {
signalMetrics.MarginDb = int(margin)
}
l.recordSensorData(ctx, areaController.ID, areaController.ID, eventTime, models.SensorTypeSignalMetrics, signalMetrics)
logger.Infow("已记录区域主控的信号强度", "主控ID", areaController.ID, "指标", signalMetrics)
}
}
// 尝试记录电池电量
if batteryLevel, ok := status["batteryLevel"].(float64); ok {
batteryData := models.BatteryLevel{
BatteryLevelRatio: float32(batteryLevel),
}
if unavailable, ok := status["batteryLevelUnavailable"].(bool); ok {
batteryData.BatteryLevelUnavailable = unavailable
}
if externalPower, ok := status["externalPower"].(bool); ok {
batteryData.ExternalPower = externalPower
}
l.recordSensorData(ctx, areaController.ID, areaController.ID, eventTime, models.SensorTypeBatteryLevel, batteryData)
logger.Infow("已记录区域主控的电池状态", "主控ID", areaController.ID, "状态", batteryData)
}
return nil
}
// HandleAck 处理对下行指令的确认ACK事件。
func (l *loraListener) HandleAck(upstreamCtx context.Context, sourceAddr string, deduplicationID string, acknowledged bool, eventTime time.Time) error {
ctx, logger := logs.Trace(upstreamCtx, l.selfCtx, "HandleAck")
err := l.deviceCommandLogRepo.UpdateAcknowledgedAt(ctx, deduplicationID, eventTime, acknowledged)
if err != nil {
logger.Errorw("更新下行任务记录的确认状态失败",
"MessageID", deduplicationID,
"DevEui", sourceAddr,
"Acknowledged", acknowledged,
"error", err,
)
return fmt.Errorf("更新下行任务记录失败: %w", err)
}
logger.Infow("成功更新下行任务记录确认状态",
"MessageID", deduplicationID,
"DevEui", sourceAddr,
"Acknowledged", acknowledged,
"AcknowledgedAt", eventTime.Format(time.RFC3339),
)
return nil
}
// handleCollectResult 是处理采集结果的核心业务逻辑
func (l *loraListener) handleCollectResult(ctx context.Context, sourceAddr string, collectResp *proto.CollectResult) error {
if collectResp == nil {
return fmt.Errorf("传入的CollectResult为nil")
}
correlationID := collectResp.GetCorrelationId()
logger := logs.GetLogger(ctx).With("correlationID", correlationID, "来源地址", sourceAddr)
logger.Infow("开始处理采集响应", "数据点数量", len(collectResp.GetValues()))
// 1. 查找区域主控
areaController, err := l.areaControllerRepo.FindByNetworkID(ctx, sourceAddr)
if err != nil {
return fmt.Errorf("处理采集响应失败:无法通过源地址 '%s' 找到区域主控设备: %w", sourceAddr, err)
}
if err := areaController.SelfCheck(); err != nil {
return fmt.Errorf("处理采集响应失败:区域主控 %v(ID: %d) 未通过自检: %w", areaController.Name, areaController.ID, err)
}
// 2. 根据 CorrelationID 查找待处理请求
pendingReq, err := l.pendingCollectionRepo.FindByCorrelationID(ctx, correlationID)
if err != nil {
return fmt.Errorf("处理采集响应失败:无法找到待处理请求: %w", err)
}
// 3. 检查状态,防止重复处理
if pendingReq.Status != models.PendingStatusPending && pendingReq.Status != models.PendingStatusTimedOut {
logger.Warnw("收到一个已处理过的采集响应,将忽略。", "状态", string(pendingReq.Status))
return nil // 返回 nil因为这不是一个错误只是一个重复的请求
}
// 4. 匹配数据并存入数据库
deviceIDs := pendingReq.CommandMetadata
values := collectResp.GetValues()
if len(deviceIDs) != len(values) {
err := fmt.Errorf("数据不匹配:下行指令要求采集 %d 个设备,但上行响应包含 %d 个值", len(deviceIDs), len(values))
// 即使数量不匹配,也尝试更新状态为完成,以防止请求永远 pending
if updateErr := l.pendingCollectionRepo.UpdateStatusToFulfilled(ctx, correlationID, time.Now()); updateErr != nil {
logger.Errorw("更新待采集请求状态为 'fulfilled' 失败", "error", updateErr)
}
return err
}
eventTime := time.Now() // 对整个采集批次使用统一的时间戳
for i, deviceID := range deviceIDs {
rawSensorValue := values[i]
devLogger := logger.With("设备ID", deviceID)
if math.IsNaN(float64(rawSensorValue)) {
devLogger.Warnw("设备上报了一个无效的 NaN 值,已跳过当前值的记录。")
continue
}
dev, err := l.deviceRepo.FindByID(ctx, deviceID)
if err != nil {
devLogger.Errorw("处理采集数据失败:无法找到设备", "error", err)
continue
}
if err := dev.SelfCheck(); err != nil {
devLogger.Warnw("跳过设备,因其未通过自检或设备模板无效", "error", err)
continue
}
var valueDescriptors []*models.ValueDescriptor
if err := dev.DeviceTemplate.ParseValues(&valueDescriptors); err != nil {
devLogger.Warnw("跳过设备,因其设备模板的 Values 属性解析失败", "error", err)
continue
}
if len(valueDescriptors) == 0 {
devLogger.Warnw("跳过设备,因其设备模板缺少 ValueDescriptor 定义")
continue
}
valueDescriptor := valueDescriptors[0]
parsedValue := rawSensorValue*valueDescriptor.Multiplier + valueDescriptor.Offset
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:
devLogger.Warnw("未知的传感器类型,将使用通用格式记录", "传感器类型", string(valueDescriptor.Type))
dataToRecord = map[string]float32{"value": parsedValue}
}
l.recordSensorData(ctx, areaController.ID, dev.ID, eventTime, valueDescriptor.Type, dataToRecord)
devLogger.Infow("成功记录传感器数据",
"类型", string(valueDescriptor.Type),
"原始值", rawSensorValue,
"解析值", parsedValue,
)
}
// 5. 更新请求状态为“已完成”
if err := l.pendingCollectionRepo.UpdateStatusToFulfilled(ctx, correlationID, eventTime); err != nil {
logger.Errorw("更新待采集请求状态为 'fulfilled' 失败", "error", err)
return fmt.Errorf("更新待采集请求状态失败: %w", err)
}
logger.Infow("成功完成并关闭采集请求")
return nil
}
// recordSensorData 是一个通用方法,用于将传感器数据存入数据库。
func (l *loraListener) recordSensorData(ctx context.Context, areaControllerID uint32, sensorDeviceID uint32, eventTime time.Time, sensorType models.SensorType, data interface{}) {
logger := logs.GetLogger(ctx).With("方法", "recordSensorData")
jsonData, err := json.Marshal(data)
if err != nil {
logger.Errorw("记录传感器数据失败:序列化数据为 JSON 时出错", "error", err)
return
}
sensorData := &models.SensorData{
Time: eventTime,
DeviceID: sensorDeviceID,
AreaControllerID: areaControllerID,
SensorType: sensorType,
Data: datatypes.JSON(jsonData),
}
if err := l.sensorDataRepo.Create(ctx, sensorData); err != nil {
logger.Errorw("记录传感器数据失败:存入数据库时出错",
"设备ID", sensorDeviceID,
"传感器类型", string(sensorType),
"error", err,
)
}
}
// handlePong 处理设备上报的Pong响应或主动心跳。
func (l *loraListener) handlePong(ctx context.Context, sourceAddr string, pong *proto.Pong) error {
reqCtx, logger := logs.Trace(ctx, l.selfCtx, "handlePong")
logger.Infow("开始处理Pong", "来源地址", sourceAddr, "携带版本", pong.GetFirmwareVersion())
// 1. 查找区域主控
areaController, err := l.areaControllerRepo.FindByNetworkID(reqCtx, sourceAddr)
if err != nil {
return fmt.Errorf("处理Pong失败无法找到区域主控: %w", err)
}
// 2. 如果 Pong 中包含版本号,则更新
if firmwareVersion := pong.GetFirmwareVersion(); firmwareVersion != "" {
err := l.areaControllerRepo.UpdateFirmwareVersion(reqCtx, areaController.ID, firmwareVersion)
if err != nil {
// 只记录错误,不中断流程,因为还要记录在线状态
logger.Errorw("处理Pong时更新固件版本失败", "主控ID", areaController.ID, "error", err)
} else {
logger.Infow("处理Pong时成功更新固件版本", "主控ID", areaController.ID, "新版本", firmwareVersion)
}
}
// 3. 记录在线状态
onlineStatus := models.OnlineStatusData{State: models.StateOnline}
l.recordSensorData(reqCtx, areaController.ID, areaController.ID, time.Now(), models.SensorTypeOnlineStatus, onlineStatus)
logger.Infow("已记录区域主控为在线状态", "主控ID", areaController.ID)
return nil
}
// handleUpdateStatusReport 处理设备上报的 OTA 升级状态报告。
func (l *loraListener) handleUpdateStatusReport(ctx context.Context, sourceAddr string, report *proto.UpdateStatusReport) error {
reqCtx, logger := logs.Trace(ctx, l.selfCtx, "handleUpdateStatusReport")
if report == nil {
return fmt.Errorf("传入的UpdateStatusReport为nil")
}
logger.With("任务ID", report.GetTaskId(), "来源地址", sourceAddr)
logger.Infow("开始处理 OTA 状态报告", "当前版本", report.GetCurrentVersion(), "状态", report.GetStatus().String())
// 1. 根据 task_id 查找对应的 OTA 任务
task, err := l.otaRepo.FindByID(reqCtx, report.GetTaskId())
if err != nil {
logger.Errorw("处理 OTA 状态报告失败:未找到对应的 OTA 任务", "error", err)
return fmt.Errorf("处理 OTA 状态报告失败:未找到任务 %d: %w", report.GetTaskId(), err)
}
// 2. 检查任务是否已处于终态,防止重复处理
if task.IsOver() {
logger.Warnw("OTA 任务已处于终态,忽略本次状态报告", "当前状态", task.Status)
return nil
}
// 3. 根据报告状态更新 OTA 任务
now := time.Now()
task.CompletedAt = &now
task.FinalReportedVersion = report.GetCurrentVersion()
task.FailedFilePath = report.GetFailedFile()
// 初始化错误信息,优先使用设备上报的原始信息
errorMessage := report.GetErrorMessage()
switch report.GetStatus() {
case proto.UpdateStatusReport_SUCCESS:
task.Status = models.OTATaskStatusSuccess
case proto.UpdateStatusReport_SUCCESS_ALREADY_UP_TO_DATE:
task.Status = models.OTATaskStatusAlreadyUpToDate
case proto.UpdateStatusReport_FAILED_PRE_CHECK:
task.Status = models.OTATaskStatusFailedPreCheck
case proto.UpdateStatusReport_FAILED_MANIFEST_VERIFY, proto.UpdateStatusReport_FAILED_DOWNLOAD:
task.Status = models.OTATaskStatusFailedDownload
case proto.UpdateStatusReport_FAILED_ROLLED_BACK:
task.Status = models.OTATaskStatusFailedRollback
case proto.UpdateStatusReport_FAILED_TIMEOUT:
task.Status = models.OTATaskStatusPlatformError
if errorMessage != "" {
errorMessage = fmt.Sprintf("平台诊断信息: 设备上报了平台推断的超时状态,可能存在逻辑错误。(设备原始报错: %s)", errorMessage)
} else {
errorMessage = "平台诊断信息: 设备上报了平台推断的超时状态,可能存在逻辑错误。"
}
case proto.UpdateStatusReport_STATUS_UNSPECIFIED:
task.Status = models.OTATaskStatusPlatformError
if errorMessage != "" {
errorMessage = fmt.Sprintf("平台诊断信息: 设备上报了未指定状态。(设备原始报错: %s)", errorMessage)
} else {
errorMessage = "平台诊断信息: 设备上报了未指定状态。"
}
default:
task.Status = models.OTATaskStatusPlatformError
if errorMessage != "" {
errorMessage = fmt.Sprintf("平台诊断信息: 设备上报了未知状态 '%s'。(设备原始报错: %s)", report.GetStatus().String(), errorMessage)
} else {
errorMessage = fmt.Sprintf("平台诊断信息: 设备上报了未知状态 '%s'。", report.GetStatus().String())
}
}
task.ErrorMessage = errorMessage
if err := l.otaRepo.Update(reqCtx, task); err != nil {
logger.Errorw("更新 OTA 任务状态失败", "error", err)
return fmt.Errorf("更新 OTA 任务 %d 状态失败: %w", report.GetTaskId(), err)
}
logger.Infow("成功更新 OTA 任务状态", "新状态", task.Status)
// 4. 如果任务已完成(成功或失败),则清理临时文件目录
if task.IsOver() {
dirToRemove := filepath.Join(models.OTADir, fmt.Sprintf("%d", report.GetTaskId()))
if err := file.RemoveDir(dirToRemove); err != nil {
logger.Warnw("清理 OTA 任务临时文件目录失败", "目录", dirToRemove, "error", err)
} else {
logger.Infow("成功清理 OTA 任务临时文件目录", "目录", dirToRemove)
}
}
return nil
}
// handleRequestFile 处理设备的文件请求。
func (l *loraListener) handleRequestFile(ctx context.Context, sourceAddr string, req *proto.RequestFile) error {
reqCtx, logger := logs.Trace(ctx, l.selfCtx, "handleRequestFile")
if req == nil {
return fmt.Errorf("传入的RequestFile为nil")
}
logger.With("任务ID", req.GetTaskId(), "文件路径", req.GetFilepath(), "来源地址", sourceAddr)
logger.Infow("开始处理文件请求")
// 1. 根据 task_id 查找对应的 OTA 任务
task, err := l.otaRepo.FindByID(reqCtx, req.GetTaskId())
if err != nil {
logger.Errorw("处理文件请求失败:未找到对应的 OTA 任务", "error", err)
return fmt.Errorf("处理文件请求失败:未找到任务 %d: %w", req.GetTaskId(), err)
}
// 2. 构造文件路径并读取文件内容
subDir := filepath.Join(models.OTADir, fmt.Sprintf("%d", req.GetTaskId()))
content, err := file.ReadTempFile(subDir, req.GetFilepath())
if err != nil {
logger.Errorw("处理文件请求失败:读取文件失败", "error", err)
return fmt.Errorf("处理文件请求失败:读取文件 %s 失败: %w", req.GetFilepath(), err)
}
// 3. 构造 FileResponse
fileResp := &proto.FileResponse{
TaskId: req.GetTaskId(),
Filepath: req.GetFilepath(),
Content: content,
}
// 4. 将 FileResponse 包装成 InstructionPayload
payload := &proto.Instruction_FileResponse{
FileResponse: fileResp,
}
// 5. 使用领域层的 DeviceCommunicator 发送指令,它会处理所有底层细节
err = l.deviceCommunicator.Send(reqCtx, task.AreaControllerID, payload, device.WithoutTracking())
if err != nil {
logger.Errorw("处理文件请求失败:发送指令失败", "error", err)
return fmt.Errorf("处理文件请求失败:发送指令到区域主控 %d 失败: %w", task.AreaControllerID, err)
}
logger.Infow("成功处理文件请求并发送文件响应")
return nil
}

View File

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

View File

@@ -0,0 +1,233 @@
package service
import (
"context"
"encoding/json"
"fmt"
"io"
"path/filepath"
"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/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/file"
"github.com/google/uuid"
)
// AreaControllerService 定义了应用层的区域主控服务接口。
type AreaControllerService interface {
CreateAreaController(ctx context.Context, req *dto.CreateAreaControllerRequest) (*dto.AreaControllerResponse, error)
GetAreaController(ctx context.Context, id uint32) (*dto.AreaControllerResponse, error)
ListAreaControllers(ctx context.Context) ([]*dto.AreaControllerResponse, error)
UpdateAreaController(ctx context.Context, id uint32, req *dto.UpdateAreaControllerRequest) (*dto.AreaControllerResponse, error)
DeleteAreaController(ctx context.Context, id uint32) error
// StartUpgrade 用于启动一个 OTA 升级任务。
StartUpgrade(ctx context.Context, areaControllerID uint32, firmware *dto.OtaUpgradeRequest) (*dto.OtaUpgradeResponse, error)
// GetUpgradeProgress 用于查询指定 OTA 任务的进度。
GetUpgradeProgress(ctx context.Context, taskID uint32) (*dto.OtaUpgradeProgressResponse, error)
// StopUpgrade 用于请求停止一个正在进行的 OTA 升级任务。
StopUpgrade(ctx context.Context, taskID uint32) error
}
// areaControllerService 是 AreaControllerService 接口的具体实现。
type areaControllerService struct {
ctx context.Context
areaControllerRepo repository.AreaControllerRepository
otaRepo repository.OtaRepository
thresholdAlarmService ThresholdAlarmService
otaService device.OtaService
}
// NewAreaControllerService 创建一个新的 AreaControllerService 实例。
func NewAreaControllerService(
ctx context.Context,
areaControllerRepo repository.AreaControllerRepository,
otaRepo repository.OtaRepository,
thresholdAlarmService ThresholdAlarmService,
otaService device.OtaService,
) AreaControllerService {
return &areaControllerService{
ctx: ctx,
areaControllerRepo: areaControllerRepo,
otaRepo: otaRepo,
thresholdAlarmService: thresholdAlarmService,
otaService: otaService,
}
}
func (s *areaControllerService) CreateAreaController(ctx context.Context, req *dto.CreateAreaControllerRequest) (*dto.AreaControllerResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "CreateAreaController")
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(serviceCtx, ac); err != nil {
return nil, err
}
return dto.NewAreaControllerResponse(ac)
}
func (s *areaControllerService) GetAreaController(ctx context.Context, id uint32) (*dto.AreaControllerResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "GetAreaController")
ac, err := s.areaControllerRepo.FindByID(serviceCtx, id)
if err != nil {
return nil, err
}
return dto.NewAreaControllerResponse(ac)
}
func (s *areaControllerService) ListAreaControllers(ctx context.Context) ([]*dto.AreaControllerResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "ListAreaControllers")
acs, err := s.areaControllerRepo.ListAll(serviceCtx)
if err != nil {
return nil, err
}
return dto.NewListAreaControllerResponse(acs)
}
func (s *areaControllerService) UpdateAreaController(ctx context.Context, id uint32, req *dto.UpdateAreaControllerRequest) (*dto.AreaControllerResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "UpdateAreaController")
existingAC, err := s.areaControllerRepo.FindByID(serviceCtx, 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(serviceCtx, existingAC); err != nil {
return nil, err
}
return dto.NewAreaControllerResponse(existingAC)
}
func (s *areaControllerService) DeleteAreaController(ctx context.Context, id uint32) error {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "DeleteAreaController")
// 1. 检查是否存在
_, err := s.areaControllerRepo.FindByID(serviceCtx, id)
if err != nil {
return err // 如果未找到gorm会返回 ErrRecordNotFound
}
// 2. 检查是否被使用(业务逻辑)
inUse, err := s.areaControllerRepo.IsAreaControllerUsedByTasks(serviceCtx, id, []models.TaskType{models.TaskTypeAreaCollectorThresholdCheck})
if err != nil {
return err // 返回数据库检查错误
}
if inUse {
return ErrAreaControllerInUse // 返回业务错误
}
// TODO 这个应该用事务处理
err = s.thresholdAlarmService.DeleteAreaThresholdAlarmByAreaControllerID(serviceCtx, id)
if err != nil {
return fmt.Errorf("删除区域阈值告警失败: %w", err)
}
// 3. 执行删除
return s.areaControllerRepo.Delete(serviceCtx, id)
}
// StartUpgrade 用于启动一个 OTA 升级任务。
func (s *areaControllerService) StartUpgrade(ctx context.Context, areaControllerID uint32, firmware *dto.OtaUpgradeRequest) (*dto.OtaUpgradeResponse, error) {
serviceCtx, logger := logs.Trace(ctx, s.ctx, "StartUpgrade")
src, err := firmware.FirmwareFile.Open()
if err != nil {
return nil, fmt.Errorf("打开固件文件失败: %w", err)
}
defer src.Close()
data, err := io.ReadAll(src)
if err != nil {
return nil, fmt.Errorf("读取固件文件内容失败: %w", err)
}
subDir := filepath.Join(models.OTADir, uuid.New().String())
var filePath string
var actionErr error
err = file.ExecuteWithLock(func() error {
filePath, actionErr = file.WriteTempFile(subDir, firmware.FirmwareFile.Filename, data)
return actionErr
}, func(err error) {
removeErr := file.RemoveTempDir(subDir)
if removeErr != nil {
logger.Errorf("回滚失败, 删除临时目录失败: %v, 目标地址: %v", removeErr, filePath)
}
})
if err != nil {
return nil, fmt.Errorf("保存固件文件失败: %w", err)
}
taskID, err := s.otaService.StartUpgrade(serviceCtx, areaControllerID, filePath)
if err != nil {
return nil, fmt.Errorf("启动升级任务失败: %w", err)
}
return &dto.OtaUpgradeResponse{TaskID: taskID}, nil
}
// GetUpgradeProgress 用于查询指定 OTA 任务的进度。
func (s *areaControllerService) GetUpgradeProgress(ctx context.Context, taskID uint32) (*dto.OtaUpgradeProgressResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "GetUpgradeProgress")
// 直接调用 otaRepo 查询任务状态
task, err := s.otaRepo.FindByID(serviceCtx, taskID)
if err != nil {
return nil, fmt.Errorf("查找 OTA 任务失败: %w", err)
}
// 构造响应 DTO
response := &dto.OtaUpgradeProgressResponse{
TaskID: task.ID,
CurrentStage: task.Status,
Message: string(task.Status), // 默认使用状态作为消息
}
// 如果任务失败,使用更详细的错误信息
if task.ErrorMessage != "" {
response.Message = task.ErrorMessage
}
return response, nil
}
// StopUpgrade 用于请求停止一个正在进行的 OTA 升级任务。
func (s *areaControllerService) StopUpgrade(ctx context.Context, taskID uint32) error {
err := s.otaService.StopUpgrade(ctx, taskID)
if err != nil {
return fmt.Errorf("停止升级任务失败: %w", err)
}
return nil
}

View File

@@ -24,7 +24,6 @@ var (
ErrDeviceTemplateInUse = errors.New("设备模板正在被一个或多个设备使用,无法删除")
)
// DeviceService 定义了应用层的设备服务接口,用于协调设备相关的业务逻辑。
// DeviceService 定义了应用层的设备服务接口,用于协调设备相关的业务逻辑。
type DeviceService interface {
CreateDevice(ctx context.Context, req *dto.CreateDeviceRequest) (*dto.DeviceResponse, error)
@@ -33,27 +32,13 @@ type DeviceService interface {
UpdateDevice(ctx context.Context, id uint32, req *dto.UpdateDeviceRequest) (*dto.DeviceResponse, error)
DeleteDevice(ctx context.Context, id uint32) error
ManualControl(ctx context.Context, id uint32, req *dto.ManualControlDeviceRequest) error
CreateAreaController(ctx context.Context, req *dto.CreateAreaControllerRequest) (*dto.AreaControllerResponse, error)
GetAreaController(ctx context.Context, id uint32) (*dto.AreaControllerResponse, error)
ListAreaControllers(ctx context.Context) ([]*dto.AreaControllerResponse, error)
UpdateAreaController(ctx context.Context, id uint32, req *dto.UpdateAreaControllerRequest) (*dto.AreaControllerResponse, error)
DeleteAreaController(ctx context.Context, id uint32) error
CreateDeviceTemplate(ctx context.Context, req *dto.CreateDeviceTemplateRequest) (*dto.DeviceTemplateResponse, error)
GetDeviceTemplate(ctx context.Context, id uint32) (*dto.DeviceTemplateResponse, error)
ListDeviceTemplates(ctx context.Context) ([]*dto.DeviceTemplateResponse, error)
UpdateDeviceTemplate(ctx context.Context, id uint32, req *dto.UpdateDeviceTemplateRequest) (*dto.DeviceTemplateResponse, error)
DeleteDeviceTemplate(ctx context.Context, id uint32) error
}
// deviceService 是 DeviceService 接口的具体实现。
type deviceService struct {
ctx context.Context
deviceRepo repository.DeviceRepository
areaControllerRepo repository.AreaControllerRepository
deviceTemplateRepo repository.DeviceTemplateRepository
deviceDomainSvc device.Service
deviceDomainSvc device.DeviceOperator
thresholdAlarmService ThresholdAlarmService
}
@@ -61,16 +46,12 @@ type deviceService struct {
func NewDeviceService(
ctx context.Context,
deviceRepo repository.DeviceRepository,
areaControllerRepo repository.AreaControllerRepository,
deviceTemplateRepo repository.DeviceTemplateRepository,
deviceDomainSvc device.Service,
deviceDomainSvc device.DeviceOperator,
thresholdAlarmService ThresholdAlarmService,
) DeviceService {
return &deviceService{
ctx: ctx,
deviceRepo: deviceRepo,
areaControllerRepo: areaControllerRepo,
deviceTemplateRepo: deviceTemplateRepo,
deviceDomainSvc: deviceDomainSvc,
thresholdAlarmService: thresholdAlarmService,
}
@@ -213,213 +194,3 @@ func (s *deviceService) ManualControl(ctx context.Context, id uint32, req *dto.M
return s.deviceDomainSvc.Switch(serviceCtx, dev, action)
}
}
// --- Area Controllers ---
func (s *deviceService) CreateAreaController(ctx context.Context, req *dto.CreateAreaControllerRequest) (*dto.AreaControllerResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "CreateAreaController")
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(serviceCtx, ac); err != nil {
return nil, err
}
return dto.NewAreaControllerResponse(ac)
}
func (s *deviceService) GetAreaController(ctx context.Context, id uint32) (*dto.AreaControllerResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "GetAreaController")
ac, err := s.areaControllerRepo.FindByID(serviceCtx, id)
if err != nil {
return nil, err
}
return dto.NewAreaControllerResponse(ac)
}
func (s *deviceService) ListAreaControllers(ctx context.Context) ([]*dto.AreaControllerResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "ListAreaControllers")
acs, err := s.areaControllerRepo.ListAll(serviceCtx)
if err != nil {
return nil, err
}
return dto.NewListAreaControllerResponse(acs)
}
func (s *deviceService) UpdateAreaController(ctx context.Context, id uint32, req *dto.UpdateAreaControllerRequest) (*dto.AreaControllerResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "UpdateAreaController")
existingAC, err := s.areaControllerRepo.FindByID(serviceCtx, 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(serviceCtx, existingAC); err != nil {
return nil, err
}
return dto.NewAreaControllerResponse(existingAC)
}
func (s *deviceService) DeleteAreaController(ctx context.Context, id uint32) error {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "DeleteAreaController")
// 1. 检查是否存在
_, err := s.areaControllerRepo.FindByID(serviceCtx, id)
if err != nil {
return err // 如果未找到gorm会返回 ErrRecordNotFound
}
// 2. 检查是否被使用(业务逻辑)
inUse, err := s.areaControllerRepo.IsAreaControllerUsedByTasks(serviceCtx, id, []models.TaskType{models.TaskTypeAreaCollectorThresholdCheck})
if err != nil {
return err // 返回数据库检查错误
}
if inUse {
return ErrAreaControllerInUse // 返回业务错误
}
// TODO 这个应该用事务处理
err = s.thresholdAlarmService.DeleteAreaThresholdAlarmByAreaControllerID(serviceCtx, id)
if err != nil {
return fmt.Errorf("删除区域阈值告警失败: %w", err)
}
// 3. 执行删除
return s.areaControllerRepo.Delete(serviceCtx, id)
}
// --- Device Templates ---
func (s *deviceService) CreateDeviceTemplate(ctx context.Context, req *dto.CreateDeviceTemplateRequest) (*dto.DeviceTemplateResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "CreateDeviceTemplate")
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(serviceCtx, deviceTemplate); err != nil {
return nil, err
}
return dto.NewDeviceTemplateResponse(deviceTemplate)
}
func (s *deviceService) GetDeviceTemplate(ctx context.Context, id uint32) (*dto.DeviceTemplateResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "GetDeviceTemplate")
deviceTemplate, err := s.deviceTemplateRepo.FindByID(serviceCtx, id)
if err != nil {
return nil, err
}
return dto.NewDeviceTemplateResponse(deviceTemplate)
}
func (s *deviceService) ListDeviceTemplates(ctx context.Context) ([]*dto.DeviceTemplateResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "ListDeviceTemplates")
deviceTemplates, err := s.deviceTemplateRepo.ListAll(serviceCtx)
if err != nil {
return nil, err
}
return dto.NewListDeviceTemplateResponse(deviceTemplates)
}
func (s *deviceService) UpdateDeviceTemplate(ctx context.Context, id uint32, req *dto.UpdateDeviceTemplateRequest) (*dto.DeviceTemplateResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "UpdateDeviceTemplate")
existingDeviceTemplate, err := s.deviceTemplateRepo.FindByID(serviceCtx, 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(serviceCtx, existingDeviceTemplate); err != nil {
return nil, err
}
return dto.NewDeviceTemplateResponse(existingDeviceTemplate)
}
func (s *deviceService) DeleteDeviceTemplate(ctx context.Context, id uint32) error {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "DeleteDeviceTemplate")
// 1. 检查是否存在
_, err := s.deviceTemplateRepo.FindByID(serviceCtx, id)
if err != nil {
return err
}
// 2. 检查是否被使用(业务逻辑)
inUse, err := s.deviceTemplateRepo.IsInUse(serviceCtx, id)
if err != nil {
return err
}
if inUse {
return ErrDeviceTemplateInUse // 返回业务错误
}
// 3. 执行删除
return s.deviceTemplateRepo.Delete(serviceCtx, id)
}

View File

@@ -0,0 +1,144 @@
package service
import (
"context"
"encoding/json"
"git.huangwc.com/pig/pig-farm-controller/internal/app/dto"
"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"
)
// DeviceTemplateService 定义了应用层的设备模板服务接口。
type DeviceTemplateService interface {
CreateDeviceTemplate(ctx context.Context, req *dto.CreateDeviceTemplateRequest) (*dto.DeviceTemplateResponse, error)
GetDeviceTemplate(ctx context.Context, id uint32) (*dto.DeviceTemplateResponse, error)
ListDeviceTemplates(ctx context.Context) ([]*dto.DeviceTemplateResponse, error)
UpdateDeviceTemplate(ctx context.Context, id uint32, req *dto.UpdateDeviceTemplateRequest) (*dto.DeviceTemplateResponse, error)
DeleteDeviceTemplate(ctx context.Context, id uint32) error
}
// deviceTemplateService 是 DeviceTemplateService 接口的具体实现。
type deviceTemplateService struct {
ctx context.Context
deviceTemplateRepo repository.DeviceTemplateRepository
}
// NewDeviceTemplateService 创建一个新的 DeviceTemplateService 实例。
func NewDeviceTemplateService(
ctx context.Context,
deviceTemplateRepo repository.DeviceTemplateRepository,
) DeviceTemplateService {
return &deviceTemplateService{
ctx: ctx,
deviceTemplateRepo: deviceTemplateRepo,
}
}
func (s *deviceTemplateService) CreateDeviceTemplate(ctx context.Context, req *dto.CreateDeviceTemplateRequest) (*dto.DeviceTemplateResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "CreateDeviceTemplate")
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(serviceCtx, deviceTemplate); err != nil {
return nil, err
}
return dto.NewDeviceTemplateResponse(deviceTemplate)
}
func (s *deviceTemplateService) GetDeviceTemplate(ctx context.Context, id uint32) (*dto.DeviceTemplateResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "GetDeviceTemplate")
deviceTemplate, err := s.deviceTemplateRepo.FindByID(serviceCtx, id)
if err != nil {
return nil, err
}
return dto.NewDeviceTemplateResponse(deviceTemplate)
}
func (s *deviceTemplateService) ListDeviceTemplates(ctx context.Context) ([]*dto.DeviceTemplateResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "ListDeviceTemplates")
deviceTemplates, err := s.deviceTemplateRepo.ListAll(serviceCtx)
if err != nil {
return nil, err
}
return dto.NewListDeviceTemplateResponse(deviceTemplates)
}
func (s *deviceTemplateService) UpdateDeviceTemplate(ctx context.Context, id uint32, req *dto.UpdateDeviceTemplateRequest) (*dto.DeviceTemplateResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "UpdateDeviceTemplate")
existingDeviceTemplate, err := s.deviceTemplateRepo.FindByID(serviceCtx, 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(serviceCtx, existingDeviceTemplate); err != nil {
return nil, err
}
return dto.NewDeviceTemplateResponse(existingDeviceTemplate)
}
func (s *deviceTemplateService) DeleteDeviceTemplate(ctx context.Context, id uint32) error {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "DeleteDeviceTemplate")
// 1. 检查是否存在
_, err := s.deviceTemplateRepo.FindByID(serviceCtx, id)
if err != nil {
return err
}
// 2. 检查是否被使用(业务逻辑)
inUse, err := s.deviceTemplateRepo.IsInUse(serviceCtx, id)
if err != nil {
return err
}
if inUse {
return ErrDeviceTemplateInUse // 返回业务错误
}
// 3. 执行删除
return s.deviceTemplateRepo.Delete(serviceCtx, id)
}

View File

@@ -0,0 +1,158 @@
package service
import (
"context"
"errors"
"fmt"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/app/dto"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/inventory"
"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"
)
// 定义库存应用服务特定的错误
var (
ErrInventoryRawMaterialNotFound = errors.New("原料不存在")
ErrInventoryInsufficientStock = errors.New("原料库存不足")
)
// InventoryService 定义了库存相关的应用服务接口
type InventoryService interface {
AdjustStock(ctx context.Context, req *dto.StockAdjustmentRequest) (*dto.StockLogResponse, error)
ListCurrentStock(ctx context.Context, req *dto.ListCurrentStockRequest) (*dto.ListCurrentStockResponse, error)
ListStockLogs(ctx context.Context, req *dto.ListStockLogRequest) (*dto.ListStockLogResponse, error)
}
// inventoryServiceImpl 是 InventoryService 接口的实现
type inventoryServiceImpl struct {
ctx context.Context
invSvc inventory.InventoryCoreService
rawMatRepo repository.RawMaterialRepository
}
// NewInventoryService 创建一个新的 InventoryService 实例
func NewInventoryService(ctx context.Context, invSvc inventory.InventoryCoreService, rawMatRepo repository.RawMaterialRepository) InventoryService {
return &inventoryServiceImpl{
ctx: ctx,
invSvc: invSvc,
rawMatRepo: rawMatRepo,
}
}
// AdjustStock 手动调整库存
func (s *inventoryServiceImpl) AdjustStock(ctx context.Context, req *dto.StockAdjustmentRequest) (*dto.StockLogResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "AdjustStock")
// 调用领域服务执行核心业务逻辑
log, err := s.invSvc.AdjustStock(serviceCtx, req.RawMaterialID, req.ChangeAmount, req.SourceType, req.SourceID, req.Remarks)
if err != nil {
if errors.Is(err, inventory.ErrRawMaterialNotFound) {
return nil, ErrInventoryRawMaterialNotFound
}
if errors.Is(err, inventory.ErrInsufficientStock) {
return nil, ErrInventoryInsufficientStock
}
return nil, fmt.Errorf("调整库存失败: %w", err)
}
// 手动加载 RawMaterial 信息,因为 CreateRawMaterialStockLog 不会预加载它
rawMaterial, err := s.rawMatRepo.GetRawMaterialByID(serviceCtx, log.RawMaterialID)
if err != nil {
// 理论上不应该发生,因为 AdjustStock 内部已经检查过
return nil, fmt.Errorf("获取原料信息失败: %w", err)
}
log.RawMaterial = *rawMaterial
return dto.ConvertStockLogToDTO(log), nil
}
// ListCurrentStock 列出所有原料的当前库存
func (s *inventoryServiceImpl) ListCurrentStock(ctx context.Context, req *dto.ListCurrentStockRequest) (*dto.ListCurrentStockResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "ListCurrentStock")
// 1. 获取分页的原料列表
rawMatOpts := repository.RawMaterialListOptions{
Name: req.RawMaterialName,
OrderBy: req.OrderBy, // 注意:这里的排序可能需要调整,比如按原料名排序
HasStock: req.HasStock,
}
rawMaterials, total, err := s.rawMatRepo.ListRawMaterials(serviceCtx, rawMatOpts, req.Page, req.PageSize)
if err != nil {
return nil, fmt.Errorf("获取原料列表失败: %w", err)
}
if len(rawMaterials) == 0 {
return &dto.ListCurrentStockResponse{
List: []dto.CurrentStockResponse{},
Pagination: dto.PaginationDTO{Page: req.Page, PageSize: req.PageSize, Total: total},
}, nil
}
// 2. 提取原料ID并批量获取它们的最新库存日志
materialIDs := make([]uint32, len(rawMaterials))
for i, rm := range rawMaterials {
materialIDs[i] = rm.ID
}
latestLogMap, err := s.rawMatRepo.BatchGetLatestStockLogsForMaterials(serviceCtx, materialIDs)
if err != nil {
return nil, fmt.Errorf("批量获取最新库存日志失败: %w", err)
}
// 3. 组合原料信息和库存信息
stockDTOs := make([]dto.CurrentStockResponse, len(rawMaterials))
for i, rm := range rawMaterials {
log, _ := latestLogMap[rm.ID] // 如果找不到log会是零值
stockDTOs[i] = *dto.ConvertCurrentStockToDTO(&rm, &log)
}
return &dto.ListCurrentStockResponse{
List: stockDTOs,
Pagination: dto.PaginationDTO{Page: req.Page, PageSize: req.PageSize, Total: total},
}, nil
}
// ListStockLogs 列出库存变动历史
func (s *inventoryServiceImpl) ListStockLogs(ctx context.Context, req *dto.ListStockLogRequest) (*dto.ListStockLogResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "ListStockLogs")
// 解析时间字符串
var startTime, endTime *time.Time
if req.StartTime != nil && *req.StartTime != "" {
t, err := time.Parse(time.RFC3339, *req.StartTime)
if err != nil {
return nil, fmt.Errorf("无效的开始时间格式: %w", err)
}
startTime = &t
}
if req.EndTime != nil && *req.EndTime != "" {
t, err := time.Parse(time.RFC3339, *req.EndTime)
if err != nil {
return nil, fmt.Errorf("无效的结束时间格式: %w", err)
}
endTime = &t
}
// 转换 source types
sourceTypes := make([]models.StockLogSourceType, len(req.SourceTypes))
for i, st := range req.SourceTypes {
sourceTypes[i] = models.StockLogSourceType(st)
}
opts := repository.StockLogListOptions{
RawMaterialID: req.RawMaterialID,
SourceTypes: sourceTypes,
StartTime: startTime,
EndTime: endTime,
OrderBy: req.OrderBy,
}
logs, total, err := s.invSvc.ListStockLogs(serviceCtx, opts, req.Page, req.PageSize)
if err != nil {
return nil, fmt.Errorf("获取库存日志列表失败: %w", err)
}
return dto.ConvertStockLogListToDTO(logs, total, req.Page, req.PageSize), nil
}

View File

@@ -17,9 +17,6 @@ type MonitorService interface {
ListTaskExecutionLogs(ctx context.Context, req *dto.ListTaskExecutionLogRequest) (*dto.ListTaskExecutionLogResponse, error)
ListPendingCollections(ctx context.Context, req *dto.ListPendingCollectionRequest) (*dto.ListPendingCollectionResponse, error)
ListUserActionLogs(ctx context.Context, req *dto.ListUserActionLogRequest) (*dto.ListUserActionLogResponse, error)
ListRawMaterialPurchases(ctx context.Context, req *dto.ListRawMaterialPurchaseRequest) (*dto.ListRawMaterialPurchaseResponse, error)
ListRawMaterialStockLogs(ctx context.Context, req *dto.ListRawMaterialStockLogRequest) (*dto.ListRawMaterialStockLogResponse, error)
ListFeedUsageRecords(ctx context.Context, req *dto.ListFeedUsageRecordRequest) (*dto.ListFeedUsageRecordResponse, error)
ListMedicationLogs(ctx context.Context, req *dto.ListMedicationLogRequest) (*dto.ListMedicationLogResponse, error)
ListPigBatchLogs(ctx context.Context, req *dto.ListPigBatchLogRequest) (*dto.ListPigBatchLogResponse, error)
ListWeighingBatches(ctx context.Context, req *dto.ListWeighingBatchRequest) (*dto.ListWeighingBatchResponse, error)
@@ -40,7 +37,6 @@ type monitorService struct {
planRepository repository.PlanRepository
pendingCollectionRepo repository.PendingCollectionRepository
userActionLogRepo repository.UserActionLogRepository
rawMaterialRepo repository.RawMaterialRepository
medicationRepo repository.MedicationLogRepository
pigBatchRepo repository.PigBatchRepository
pigBatchLogRepo repository.PigBatchLogRepository
@@ -59,7 +55,6 @@ func NewMonitorService(
planRepository repository.PlanRepository,
pendingCollectionRepo repository.PendingCollectionRepository,
userActionLogRepo repository.UserActionLogRepository,
rawMaterialRepo repository.RawMaterialRepository,
medicationRepo repository.MedicationLogRepository,
pigBatchRepo repository.PigBatchRepository,
pigBatchLogRepo repository.PigBatchLogRepository,
@@ -76,7 +71,6 @@ func NewMonitorService(
planRepository: planRepository,
pendingCollectionRepo: pendingCollectionRepo,
userActionLogRepo: userActionLogRepo,
rawMaterialRepo: rawMaterialRepo,
medicationRepo: medicationRepo,
pigBatchRepo: pigBatchRepo,
pigBatchLogRepo: pigBatchLogRepo,
@@ -236,68 +230,6 @@ func (s *monitorService) ListUserActionLogs(ctx context.Context, req *dto.ListUs
return dto.NewListUserActionLogResponse(data, total, req.Page, req.PageSize), nil
}
// ListRawMaterialPurchases 负责处理查询原料采购记录列表的业务逻辑
func (s *monitorService) ListRawMaterialPurchases(ctx context.Context, req *dto.ListRawMaterialPurchaseRequest) (*dto.ListRawMaterialPurchaseResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "ListRawMaterialPurchases")
opts := repository.RawMaterialPurchaseListOptions{
RawMaterialID: req.RawMaterialID,
Supplier: req.Supplier,
OrderBy: req.OrderBy,
StartTime: req.StartTime,
EndTime: req.EndTime,
}
data, total, err := s.rawMaterialRepo.ListRawMaterialPurchases(serviceCtx, 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(ctx context.Context, req *dto.ListRawMaterialStockLogRequest) (*dto.ListRawMaterialStockLogResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "ListRawMaterialStockLogs")
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(serviceCtx, 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(ctx context.Context, req *dto.ListFeedUsageRecordRequest) (*dto.ListFeedUsageRecordResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "ListFeedUsageRecords")
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(serviceCtx, 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(ctx context.Context, req *dto.ListMedicationLogRequest) (*dto.ListMedicationLogResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "ListMedicationLogs")

View File

@@ -0,0 +1,116 @@
package service
import (
"context"
"errors"
"fmt"
"git.huangwc.com/pig/pig-farm-controller/internal/app/dto"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/recipe"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
)
// 定义营养种类服务特定的错误
var (
ErrNutrientNameConflict = errors.New("营养种类名称已存在")
ErrNutrientNotFound = errors.New("营养种类不存在")
)
// NutrientService 定义了营养种类相关的应用服务接口
type NutrientService interface {
CreateNutrient(ctx context.Context, req *dto.CreateNutrientRequest) (*dto.NutrientResponse, error)
UpdateNutrient(ctx context.Context, id uint32, req *dto.UpdateNutrientRequest) (*dto.NutrientResponse, error)
DeleteNutrient(ctx context.Context, id uint32) error
GetNutrient(ctx context.Context, id uint32) (*dto.NutrientResponse, error)
ListNutrients(ctx context.Context, req *dto.ListNutrientRequest) (*dto.ListNutrientResponse, error)
}
// nutrientServiceImpl 是 NutrientService 接口的实现
type nutrientServiceImpl struct {
ctx context.Context
recipeSvc recipe.Service
}
// NewNutrientService 创建一个新的 NutrientService 实例
func NewNutrientService(ctx context.Context, recipeSvc recipe.Service) NutrientService {
return &nutrientServiceImpl{
ctx: ctx,
recipeSvc: recipeSvc,
}
}
// CreateNutrient 创建营养种类
func (s *nutrientServiceImpl) CreateNutrient(ctx context.Context, req *dto.CreateNutrientRequest) (*dto.NutrientResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "CreateNutrient")
nutrient, err := s.recipeSvc.CreateNutrient(serviceCtx, req.Name, req.Description)
if err != nil {
if errors.Is(err, recipe.ErrNutrientNameConflict) {
return nil, ErrNutrientNameConflict
}
return nil, fmt.Errorf("创建营养种类失败: %w", err)
}
return dto.ConvertNutrientToDTO(nutrient), nil
}
// UpdateNutrient 更新营养种类
func (s *nutrientServiceImpl) UpdateNutrient(ctx context.Context, id uint32, req *dto.UpdateNutrientRequest) (*dto.NutrientResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "UpdateNutrient")
nutrient, err := s.recipeSvc.UpdateNutrient(serviceCtx, id, req.Name, req.Description)
if err != nil {
if errors.Is(err, recipe.ErrNutrientNotFound) {
return nil, ErrNutrientNotFound
}
if errors.Is(err, recipe.ErrNutrientNameConflict) {
return nil, ErrNutrientNameConflict
}
return nil, fmt.Errorf("更新营养种类失败: %w", err)
}
return dto.ConvertNutrientToDTO(nutrient), nil
}
// DeleteNutrient 删除营养种类
func (s *nutrientServiceImpl) DeleteNutrient(ctx context.Context, id uint32) error {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "DeleteNutrient")
err := s.recipeSvc.DeleteNutrient(serviceCtx, id)
if err != nil {
if errors.Is(err, recipe.ErrNutrientNotFound) {
return ErrNutrientNotFound
}
return fmt.Errorf("删除营养种类失败: %w", err)
}
return nil
}
// GetNutrient 获取单个营养种类
func (s *nutrientServiceImpl) GetNutrient(ctx context.Context, id uint32) (*dto.NutrientResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "GetNutrient")
nutrient, err := s.recipeSvc.GetNutrient(serviceCtx, id)
if err != nil {
if errors.Is(err, recipe.ErrNutrientNotFound) {
return nil, ErrNutrientNotFound
}
return nil, fmt.Errorf("获取营养种类失败: %w", err)
}
return dto.ConvertNutrientToDTO(nutrient), nil
}
// ListNutrients 列出营养种类
func (s *nutrientServiceImpl) ListNutrients(ctx context.Context, req *dto.ListNutrientRequest) (*dto.ListNutrientResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "ListNutrients")
opts := repository.NutrientListOptions{
Name: req.Name,
RawMaterialName: req.RawMaterialName,
OrderBy: req.OrderBy,
}
nutrients, total, err := s.recipeSvc.ListNutrients(serviceCtx, opts, req.Page, req.PageSize)
if err != nil {
return nil, fmt.Errorf("获取营养种类列表失败: %w", err)
}
return dto.ConvertNutrientListToDTO(nutrients, total, req.Page, req.PageSize), nil
}

View File

@@ -0,0 +1,122 @@
package service
import (
"context"
"errors"
"fmt"
"git.huangwc.com/pig/pig-farm-controller/internal/app/dto"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/recipe"
"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"
)
// 定义猪年龄阶段服务特定的错误
var (
ErrPigAgeStageInUse = errors.New("猪年龄阶段正在被猪类型使用,无法删除")
ErrPigAgeStageNotFound = errors.New("猪年龄阶段不存在")
)
// PigAgeStageService 定义了猪年龄阶段相关的应用服务接口
type PigAgeStageService interface {
CreatePigAgeStage(ctx context.Context, req *dto.CreatePigAgeStageRequest) (*dto.PigAgeStageResponse, error)
UpdatePigAgeStage(ctx context.Context, id uint32, req *dto.UpdatePigAgeStageRequest) (*dto.PigAgeStageResponse, error)
DeletePigAgeStage(ctx context.Context, id uint32) error
GetPigAgeStage(ctx context.Context, id uint32) (*dto.PigAgeStageResponse, error)
ListPigAgeStages(ctx context.Context, req *dto.ListPigAgeStageRequest) (*dto.ListPigAgeStageResponse, error)
}
// pigAgeStageServiceImpl 是 PigAgeStageService 接口的实现
type pigAgeStageServiceImpl struct {
ctx context.Context
recipeSvc recipe.Service
}
// NewPigAgeStageService 创建一个新的 PigAgeStageService 实例
func NewPigAgeStageService(ctx context.Context, recipeSvc recipe.Service) PigAgeStageService {
return &pigAgeStageServiceImpl{
ctx: ctx,
recipeSvc: recipeSvc,
}
}
// CreatePigAgeStage 创建猪年龄阶段
func (s *pigAgeStageServiceImpl) CreatePigAgeStage(ctx context.Context, req *dto.CreatePigAgeStageRequest) (*dto.PigAgeStageResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "CreatePigAgeStage")
ageStage := &models.PigAgeStage{
Name: req.Name,
Description: req.Description,
}
if err := s.recipeSvc.CreatePigAgeStage(serviceCtx, ageStage); err != nil {
return nil, fmt.Errorf("创建猪年龄阶段失败: %w", err)
}
return dto.ConvertPigAgeStageToDTO(ageStage), nil
}
// UpdatePigAgeStage 更新猪年龄阶段
func (s *pigAgeStageServiceImpl) UpdatePigAgeStage(ctx context.Context, id uint32, req *dto.UpdatePigAgeStageRequest) (*dto.PigAgeStageResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "UpdatePigAgeStage")
ageStage := &models.PigAgeStage{
Model: models.Model{ID: id},
Name: req.Name,
Description: req.Description,
}
if err := s.recipeSvc.UpdatePigAgeStage(serviceCtx, ageStage); err != nil {
if errors.Is(err, recipe.ErrPigAgeStageNotFound) {
return nil, ErrPigAgeStageNotFound
}
return nil, fmt.Errorf("更新猪年龄阶段失败: %w", err)
}
return dto.ConvertPigAgeStageToDTO(ageStage), nil
}
// DeletePigAgeStage 删除猪年龄阶段
func (s *pigAgeStageServiceImpl) DeletePigAgeStage(ctx context.Context, id uint32) error {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "DeletePigAgeStage")
err := s.recipeSvc.DeletePigAgeStage(serviceCtx, id)
if err != nil {
if errors.Is(err, recipe.ErrPigAgeStageNotFound) {
return ErrPigAgeStageNotFound
}
if errors.Is(err, recipe.ErrPigAgeStageInUse) {
return ErrPigAgeStageInUse
}
return fmt.Errorf("删除猪年龄阶段失败: %w", err)
}
return nil
}
// GetPigAgeStage 获取单个猪年龄阶段
func (s *pigAgeStageServiceImpl) GetPigAgeStage(ctx context.Context, id uint32) (*dto.PigAgeStageResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "GetPigAgeStage")
ageStage, err := s.recipeSvc.GetPigAgeStageByID(serviceCtx, id)
if err != nil {
if errors.Is(err, recipe.ErrPigAgeStageNotFound) {
return nil, ErrPigAgeStageNotFound
}
return nil, fmt.Errorf("获取猪年龄阶段失败: %w", err)
}
return dto.ConvertPigAgeStageToDTO(ageStage), nil
}
// ListPigAgeStages 列出猪年龄阶段
func (s *pigAgeStageServiceImpl) ListPigAgeStages(ctx context.Context, req *dto.ListPigAgeStageRequest) (*dto.ListPigAgeStageResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "ListPigAgeStages")
opts := repository.PigAgeStageListOptions{
Name: req.Name,
OrderBy: req.OrderBy,
}
ageStages, total, err := s.recipeSvc.ListPigAgeStages(serviceCtx, opts, req.Page, req.PageSize)
if err != nil {
return nil, fmt.Errorf("获取猪年龄阶段列表失败: %w", err)
}
return dto.ConvertPigAgeStageListToDTO(ageStages, total, req.Page, req.PageSize), nil
}

View File

@@ -0,0 +1,130 @@
package service
import (
"context"
"errors"
"fmt"
"git.huangwc.com/pig/pig-farm-controller/internal/app/dto"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/recipe"
"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"
)
// 定义猪品种服务特定的错误
var (
ErrPigBreedInUse = errors.New("猪品种正在被猪类型使用,无法删除")
ErrPigBreedNotFound = errors.New("猪品种不存在")
)
// PigBreedService 定义了猪品种相关的应用服务接口
type PigBreedService interface {
CreatePigBreed(ctx context.Context, req *dto.CreatePigBreedRequest) (*dto.PigBreedResponse, error)
UpdatePigBreed(ctx context.Context, id uint32, req *dto.UpdatePigBreedRequest) (*dto.PigBreedResponse, error)
DeletePigBreed(ctx context.Context, id uint32) error
GetPigBreed(ctx context.Context, id uint32) (*dto.PigBreedResponse, error)
ListPigBreeds(ctx context.Context, req *dto.ListPigBreedRequest) (*dto.ListPigBreedResponse, error)
}
// pigBreedServiceImpl 是 PigBreedService 接口的实现
type pigBreedServiceImpl struct {
ctx context.Context
recipeSvc recipe.Service
}
// NewPigBreedService 创建一个新的 PigBreedService 实例
func NewPigBreedService(ctx context.Context, recipeSvc recipe.Service) PigBreedService {
return &pigBreedServiceImpl{
ctx: ctx,
recipeSvc: recipeSvc,
}
}
// CreatePigBreed 创建猪品种
func (s *pigBreedServiceImpl) CreatePigBreed(ctx context.Context, req *dto.CreatePigBreedRequest) (*dto.PigBreedResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "CreatePigBreed")
breed := &models.PigBreed{
Name: req.Name,
Description: req.Description,
ParentInfo: req.ParentInfo,
AppearanceFeatures: req.AppearanceFeatures,
BreedAdvantages: req.BreedAdvantages,
BreedDisadvantages: req.BreedDisadvantages,
}
if err := s.recipeSvc.CreatePigBreed(serviceCtx, breed); err != nil {
return nil, fmt.Errorf("创建猪品种失败: %w", err)
}
return dto.ConvertPigBreedToDTO(breed), nil
}
// UpdatePigBreed 更新猪品种
func (s *pigBreedServiceImpl) UpdatePigBreed(ctx context.Context, id uint32, req *dto.UpdatePigBreedRequest) (*dto.PigBreedResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "UpdatePigBreed")
breed := &models.PigBreed{
Model: models.Model{ID: id},
Name: req.Name,
Description: req.Description,
ParentInfo: req.ParentInfo,
AppearanceFeatures: req.AppearanceFeatures,
BreedAdvantages: req.BreedAdvantages,
BreedDisadvantages: req.BreedDisadvantages,
}
if err := s.recipeSvc.UpdatePigBreed(serviceCtx, breed); err != nil {
if errors.Is(err, recipe.ErrPigBreedNotFound) {
return nil, ErrPigBreedNotFound
}
return nil, fmt.Errorf("更新猪品种失败: %w", err)
}
return dto.ConvertPigBreedToDTO(breed), nil
}
// DeletePigBreed 删除猪品种
func (s *pigBreedServiceImpl) DeletePigBreed(ctx context.Context, id uint32) error {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "DeletePigBreed")
err := s.recipeSvc.DeletePigBreed(serviceCtx, id)
if err != nil {
if errors.Is(err, recipe.ErrPigBreedNotFound) {
return ErrPigBreedNotFound
}
if errors.Is(err, recipe.ErrPigBreedInUse) {
return ErrPigBreedInUse
}
return fmt.Errorf("删除猪品种失败: %w", err)
}
return nil
}
// GetPigBreed 获取单个猪品种
func (s *pigBreedServiceImpl) GetPigBreed(ctx context.Context, id uint32) (*dto.PigBreedResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "GetPigBreed")
breed, err := s.recipeSvc.GetPigBreedByID(serviceCtx, id)
if err != nil {
if errors.Is(err, recipe.ErrPigBreedNotFound) {
return nil, ErrPigBreedNotFound
}
return nil, fmt.Errorf("获取猪品种失败: %w", err)
}
return dto.ConvertPigBreedToDTO(breed), nil
}
// ListPigBreeds 列出猪品种
func (s *pigBreedServiceImpl) ListPigBreeds(ctx context.Context, req *dto.ListPigBreedRequest) (*dto.ListPigBreedResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "ListPigBreeds")
opts := repository.PigBreedListOptions{
Name: req.Name,
OrderBy: req.OrderBy,
}
breeds, total, err := s.recipeSvc.ListPigBreeds(serviceCtx, opts, req.Page, req.PageSize)
if err != nil {
return nil, fmt.Errorf("获取猪品种列表失败: %w", err)
}
return dto.ConvertPigBreedListToDTO(breeds, total, req.Page, req.PageSize), nil
}

View File

@@ -0,0 +1,203 @@
package service
import (
"context"
"errors"
"fmt"
"git.huangwc.com/pig/pig-farm-controller/internal/app/dto"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/recipe"
"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"
)
// 定义猪类型服务特定的错误
var (
ErrPigTypeNotFound = errors.New("猪类型不存在")
)
// PigTypeService 定义了猪类型相关的应用服务接口
type PigTypeService interface {
CreatePigType(ctx context.Context, req *dto.CreatePigTypeRequest) (*dto.PigTypeResponse, error)
UpdatePigType(ctx context.Context, id uint32, req *dto.UpdatePigTypeRequest) (*dto.PigTypeResponse, error)
DeletePigType(ctx context.Context, id uint32) error
GetPigType(ctx context.Context, id uint32) (*dto.PigTypeResponse, error)
ListPigTypes(ctx context.Context, req *dto.ListPigTypeRequest) (*dto.ListPigTypeResponse, error)
UpdatePigTypeNutrientRequirements(ctx context.Context, id uint32, req *dto.UpdatePigTypeNutrientRequirementsRequest) (*dto.PigTypeResponse, error)
}
// pigTypeServiceImpl 是 PigTypeService 接口的实现
type pigTypeServiceImpl struct {
ctx context.Context
recipeSvc recipe.Service
}
// NewPigTypeService 创建一个新的 PigTypeService 实例
func NewPigTypeService(ctx context.Context, recipeSvc recipe.Service) PigTypeService {
return &pigTypeServiceImpl{
ctx: ctx,
recipeSvc: recipeSvc,
}
}
// CreatePigType 创建猪类型
func (s *pigTypeServiceImpl) CreatePigType(ctx context.Context, req *dto.CreatePigTypeRequest) (*dto.PigTypeResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "CreatePigType")
pigType := &models.PigType{
BreedID: req.BreedID,
AgeStageID: req.AgeStageID,
Description: req.Description,
DailyFeedIntake: req.DailyFeedIntake,
DailyGainWeight: req.DailyGainWeight,
MinDays: req.MinDays,
MaxDays: req.MaxDays,
MinWeight: req.MinWeight,
MaxWeight: req.MaxWeight,
}
if err := s.recipeSvc.CreatePigType(serviceCtx, pigType); err != nil {
if errors.Is(err, recipe.ErrPigBreedNotFound) {
return nil, ErrPigBreedNotFound
}
if errors.Is(err, recipe.ErrPigAgeStageNotFound) {
return nil, ErrPigAgeStageNotFound
}
return nil, fmt.Errorf("创建猪类型失败: %w", err)
}
// 创建后需要重新获取,以包含关联数据
createdPigType, err := s.recipeSvc.GetPigTypeByID(serviceCtx, pigType.ID)
if err != nil {
if errors.Is(err, recipe.ErrPigTypeNotFound) { // 理论上不应该发生,因为刚创建
return nil, ErrPigTypeNotFound
}
return nil, fmt.Errorf("创建猪类型后获取详情失败: %w", err)
}
return dto.ConvertPigTypeToDTO(createdPigType), nil
}
// UpdatePigType 更新猪类型
func (s *pigTypeServiceImpl) UpdatePigType(ctx context.Context, id uint32, req *dto.UpdatePigTypeRequest) (*dto.PigTypeResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "UpdatePigType")
pigType := &models.PigType{
Model: models.Model{ID: id},
BreedID: req.BreedID,
AgeStageID: req.AgeStageID,
Description: req.Description,
DailyFeedIntake: req.DailyFeedIntake,
DailyGainWeight: req.DailyGainWeight,
MinDays: req.MinDays,
MaxDays: req.MaxDays,
MinWeight: req.MinWeight,
MaxWeight: req.MaxWeight,
}
if err := s.recipeSvc.UpdatePigType(serviceCtx, pigType); err != nil {
if errors.Is(err, recipe.ErrPigTypeNotFound) {
return nil, ErrPigTypeNotFound
}
if errors.Is(err, recipe.ErrPigBreedNotFound) {
return nil, ErrPigBreedNotFound
}
if errors.Is(err, recipe.ErrPigAgeStageNotFound) {
return nil, ErrPigAgeStageNotFound
}
return nil, fmt.Errorf("更新猪类型失败: %w", err)
}
// 更新后需要重新获取,以包含关联数据
updatedPigType, err := s.recipeSvc.GetPigTypeByID(serviceCtx, id)
if err != nil {
if errors.Is(err, recipe.ErrPigTypeNotFound) { // 理论上不应该发生,因为刚更新成功
return nil, ErrPigTypeNotFound
}
return nil, fmt.Errorf("更新猪类型后获取详情失败: %w", err)
}
return dto.ConvertPigTypeToDTO(updatedPigType), nil
}
// DeletePigType 删除猪类型
func (s *pigTypeServiceImpl) DeletePigType(ctx context.Context, id uint32) error {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "DeletePigType")
err := s.recipeSvc.DeletePigType(serviceCtx, id)
if err != nil {
if errors.Is(err, recipe.ErrPigTypeNotFound) {
return ErrPigTypeNotFound
}
return fmt.Errorf("删除猪类型失败: %w", err)
}
return nil
}
// GetPigType 获取单个猪类型
func (s *pigTypeServiceImpl) GetPigType(ctx context.Context, id uint32) (*dto.PigTypeResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "GetPigType")
pigType, err := s.recipeSvc.GetPigTypeByID(serviceCtx, id)
if err != nil {
if errors.Is(err, recipe.ErrPigTypeNotFound) {
return nil, ErrPigTypeNotFound
}
return nil, fmt.Errorf("获取猪类型失败: %w", err)
}
return dto.ConvertPigTypeToDTO(pigType), nil
}
// ListPigTypes 列出猪类型
func (s *pigTypeServiceImpl) ListPigTypes(ctx context.Context, req *dto.ListPigTypeRequest) (*dto.ListPigTypeResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "ListPigTypes")
opts := repository.PigTypeListOptions{
BreedID: req.BreedID,
AgeStageID: req.AgeStageID,
BreedName: req.BreedName,
AgeStageName: req.AgeStageName,
OrderBy: req.OrderBy,
}
pigTypes, total, err := s.recipeSvc.ListPigTypes(serviceCtx, opts, req.Page, req.PageSize)
if err != nil {
return nil, fmt.Errorf("获取猪类型列表失败: %w", err)
}
return dto.ConvertPigTypeListToDTO(pigTypes, total, req.Page, req.PageSize), nil
}
// UpdatePigTypeNutrientRequirements 全量更新猪类型的营养需求
func (s *pigTypeServiceImpl) UpdatePigTypeNutrientRequirements(ctx context.Context, id uint32, req *dto.UpdatePigTypeNutrientRequirementsRequest) (*dto.PigTypeResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "UpdatePigTypeNutrientRequirements")
// 1. 将 DTO 转换为领域模型
requirements := make([]models.PigNutrientRequirement, len(req.NutrientRequirements))
for i, item := range req.NutrientRequirements {
requirements[i] = models.PigNutrientRequirement{
PigTypeID: id, // 设置所属的 PigTypeID
NutrientID: item.NutrientID,
MinRequirement: item.MinRequirement,
MaxRequirement: item.MaxRequirement,
}
}
// 2. 调用领域服务执行更新命令
err := s.recipeSvc.UpdatePigTypeNutrientRequirements(serviceCtx, id, requirements)
if err != nil {
if errors.Is(err, recipe.ErrPigTypeNotFound) {
return nil, ErrPigTypeNotFound
}
// 此处可以根据领域层可能返回的其他特定错误进行转换
return nil, fmt.Errorf("更新猪类型营养需求失败: %w", err)
}
// 3. 更新成功后,调用查询服务获取最新的猪类型信息
updatedPigType, err := s.recipeSvc.GetPigTypeByID(serviceCtx, id)
if err != nil {
if errors.Is(err, recipe.ErrPigTypeNotFound) {
// 理论上不应该发生,因为刚更新成功
return nil, ErrPigTypeNotFound
}
return nil, fmt.Errorf("更新后获取猪类型信息失败: %w", err)
}
// 4. 将领域模型转换为 DTO 并返回
return dto.ConvertPigTypeToDTO(updatedPigType), nil
}

View File

@@ -0,0 +1,159 @@
package service
import (
"context"
"errors"
"fmt"
"git.huangwc.com/pig/pig-farm-controller/internal/app/dto"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/recipe"
"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"
)
// 定义原料服务特定的错误
var (
ErrRawMaterialNameConflict = errors.New("原料名称已存在")
ErrRawMaterialNotFound = errors.New("原料不存在")
)
// RawMaterialService 定义了原料相关的应用服务接口
type RawMaterialService interface {
CreateRawMaterial(ctx context.Context, req *dto.CreateRawMaterialRequest) (*dto.RawMaterialResponse, error)
UpdateRawMaterial(ctx context.Context, id uint32, req *dto.UpdateRawMaterialRequest) (*dto.RawMaterialResponse, error)
DeleteRawMaterial(ctx context.Context, id uint32) error
GetRawMaterial(ctx context.Context, id uint32) (*dto.RawMaterialResponse, error)
ListRawMaterials(ctx context.Context, req *dto.ListRawMaterialRequest) (*dto.ListRawMaterialResponse, error)
UpdateRawMaterialNutrients(ctx context.Context, id uint32, req *dto.UpdateRawMaterialNutrientsRequest) (*dto.RawMaterialResponse, error)
}
// rawMaterialServiceImpl 是 RawMaterialService 接口的实现
type rawMaterialServiceImpl struct {
ctx context.Context
recipeSvc recipe.Service
}
// NewRawMaterialService 创建一个新的 RawMaterialService 实例
func NewRawMaterialService(ctx context.Context, recipeSvc recipe.Service) RawMaterialService {
return &rawMaterialServiceImpl{
ctx: ctx,
recipeSvc: recipeSvc,
}
}
// CreateRawMaterial 创建原料
func (s *rawMaterialServiceImpl) CreateRawMaterial(ctx context.Context, req *dto.CreateRawMaterialRequest) (*dto.RawMaterialResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "CreateRawMaterial")
rawMaterial, err := s.recipeSvc.CreateRawMaterial(serviceCtx, req.Name, req.Description, req.ReferencePrice, req.MaxAdditionRatio)
if err != nil {
if errors.Is(err, recipe.ErrRawMaterialNameConflict) {
return nil, ErrRawMaterialNameConflict
}
return nil, fmt.Errorf("创建原料失败: %w", err)
}
return dto.ConvertRawMaterialToDTO(rawMaterial), nil
}
// UpdateRawMaterial 更新原料
func (s *rawMaterialServiceImpl) UpdateRawMaterial(ctx context.Context, id uint32, req *dto.UpdateRawMaterialRequest) (*dto.RawMaterialResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "UpdateRawMaterial")
rawMaterial, err := s.recipeSvc.UpdateRawMaterial(serviceCtx, id, req.Name, req.Description, req.ReferencePrice, req.MaxAdditionRatio)
if err != nil {
if errors.Is(err, recipe.ErrRawMaterialNotFound) {
return nil, ErrRawMaterialNotFound
}
if errors.Is(err, recipe.ErrRawMaterialNameConflict) {
return nil, ErrRawMaterialNameConflict
}
return nil, fmt.Errorf("更新原料失败: %w", err)
}
return dto.ConvertRawMaterialToDTO(rawMaterial), nil
}
// DeleteRawMaterial 删除原料
func (s *rawMaterialServiceImpl) DeleteRawMaterial(ctx context.Context, id uint32) error {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "DeleteRawMaterial")
err := s.recipeSvc.DeleteRawMaterial(serviceCtx, id)
if err != nil {
if errors.Is(err, recipe.ErrRawMaterialNotFound) {
return ErrRawMaterialNotFound
}
return fmt.Errorf("删除原料失败: %w", err)
}
return nil
}
// GetRawMaterial 获取单个原料
func (s *rawMaterialServiceImpl) GetRawMaterial(ctx context.Context, id uint32) (*dto.RawMaterialResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "GetRawMaterial")
rawMaterial, err := s.recipeSvc.GetRawMaterial(serviceCtx, id)
if err != nil {
if errors.Is(err, recipe.ErrRawMaterialNotFound) {
return nil, ErrRawMaterialNotFound
}
return nil, fmt.Errorf("获取原料失败: %w", err)
}
return dto.ConvertRawMaterialToDTO(rawMaterial), nil
}
// ListRawMaterials 列出原料
func (s *rawMaterialServiceImpl) ListRawMaterials(ctx context.Context, req *dto.ListRawMaterialRequest) (*dto.ListRawMaterialResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "ListRawMaterials")
opts := repository.RawMaterialListOptions{
Name: req.Name,
NutrientName: req.NutrientName,
MinReferencePrice: req.MinReferencePrice,
MaxReferencePrice: req.MaxReferencePrice,
OrderBy: req.OrderBy,
}
rawMaterials, total, err := s.recipeSvc.ListRawMaterials(serviceCtx, opts, req.Page, req.PageSize)
if err != nil {
return nil, fmt.Errorf("获取原料列表失败: %w", err)
}
return dto.ConvertRawMaterialListToDTO(rawMaterials, total, req.Page, req.PageSize), nil
}
// UpdateRawMaterialNutrients 全量更新原料的营养成分
func (s *rawMaterialServiceImpl) UpdateRawMaterialNutrients(ctx context.Context, id uint32, req *dto.UpdateRawMaterialNutrientsRequest) (*dto.RawMaterialResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "UpdateRawMaterialNutrients")
// 1. 将 DTO 转换为领域模型
nutrients := make([]models.RawMaterialNutrient, len(req.Nutrients))
for i, item := range req.Nutrients {
nutrients[i] = models.RawMaterialNutrient{
RawMaterialID: id,
NutrientID: item.NutrientID,
Value: item.Value,
}
}
// 2. 调用领域服务执行更新命令
err := s.recipeSvc.UpdateRawMaterialNutrients(serviceCtx, id, nutrients)
if err != nil {
if errors.Is(err, recipe.ErrRawMaterialNotFound) {
return nil, ErrRawMaterialNotFound
}
// 此处可以根据领域层可能返回的其他特定错误进行转换
return nil, fmt.Errorf("更新原料营养成分失败: %w", err)
}
// 3. 更新成功后,调用查询服务获取最新的原料信息
updatedRawMaterial, err := s.recipeSvc.GetRawMaterial(serviceCtx, id)
if err != nil {
if errors.Is(err, recipe.ErrRawMaterialNotFound) {
// 理论上不应该发生,因为刚更新成功
return nil, ErrRawMaterialNotFound
}
return nil, fmt.Errorf("更新后获取原料信息失败: %w", err)
}
// 4. 将领域模型转换为 DTO 并返回
return dto.ConvertRawMaterialToDTO(updatedRawMaterial), nil
}

View File

@@ -0,0 +1,194 @@
package service
import (
"context"
"errors"
"fmt"
"git.huangwc.com/pig/pig-farm-controller/internal/app/dto"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/recipe"
"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"
)
// 定义配方服务特定的错误
var (
ErrRecipeNameConflict = errors.New("配方名称已存在")
ErrRecipeNotFound = errors.New("配方不存在")
)
// RecipeService 定义了配方相关的应用服务接口
type RecipeService interface {
CreateRecipe(ctx context.Context, req *dto.CreateRecipeRequest) (*dto.RecipeResponse, error)
UpdateRecipe(ctx context.Context, id uint32, req *dto.UpdateRecipeRequest) (*dto.RecipeResponse, error)
DeleteRecipe(ctx context.Context, id uint32) error
GetRecipeByID(ctx context.Context, id uint32) (*dto.RecipeResponse, error)
ListRecipes(ctx context.Context, req *dto.ListRecipeRequest) (*dto.ListRecipeResponse, error)
// GenerateRecipeWithAllRawMaterials 添加新方法
GenerateRecipeWithAllRawMaterials(ctx context.Context, pigTypeID uint32) (*models.Recipe, error)
// GenerateRecipeWithPrioritizedStockRawMaterials 生成新配方,优先使用有库存的原料
GenerateRecipeWithPrioritizedStockRawMaterials(ctx context.Context, pigTypeID uint32) (*models.Recipe, error)
// AIDiagnoseRecipe 智能诊断配方, 返回智能诊断结果和使用的AI模型
AIDiagnoseRecipe(ctx context.Context, recipeID uint32, pigTypeID uint32) (*dto.ReviewRecipeResponse, error)
}
// recipeServiceImpl 是 RecipeService 接口的实现
type recipeServiceImpl struct {
ctx context.Context
recipeSvc recipe.Service
}
// NewRecipeService 创建一个新的 RecipeService 实例
func NewRecipeService(ctx context.Context, recipeSvc recipe.Service) RecipeService {
return &recipeServiceImpl{
ctx: ctx,
recipeSvc: recipeSvc,
}
}
// GenerateRecipeWithAllRawMaterials 实现新方法
func (s *recipeServiceImpl) GenerateRecipeWithAllRawMaterials(ctx context.Context, pigTypeID uint32) (*models.Recipe, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "GenerateRecipeWithAllRawMaterials")
// 直接调用领域服务的方法
return s.recipeSvc.GenerateRecipeWithAllRawMaterials(serviceCtx, pigTypeID)
}
// GenerateRecipeWithPrioritizedStockRawMaterials 实现生成优先使用库存原料配方的方法
func (s *recipeServiceImpl) GenerateRecipeWithPrioritizedStockRawMaterials(ctx context.Context, pigTypeID uint32) (*models.Recipe, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "GenerateRecipeWithPrioritizedStockRawMaterials")
// 直接调用领域服务的方法
return s.recipeSvc.GenerateRecipeWithPrioritizedStockRawMaterials(serviceCtx, pigTypeID)
}
// CreateRecipe 创建配方
func (s *recipeServiceImpl) CreateRecipe(ctx context.Context, req *dto.CreateRecipeRequest) (*dto.RecipeResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "CreateRecipe")
recipeModel := dto.ConvertCreateRecipeRequestToModel(req)
createdRecipe, err := s.recipeSvc.CreateRecipe(serviceCtx, recipeModel)
if err != nil {
if errors.Is(err, recipe.ErrRecipeNameConflict) {
return nil, ErrRecipeNameConflict
}
return nil, fmt.Errorf("创建配方失败: %w", err)
}
// 创建成功后,获取包含完整信息的配方
fullRecipe, err := s.recipeSvc.GetRecipeByID(serviceCtx, createdRecipe.ID)
if err != nil {
// 理论上不应该发生,因为刚创建成功
return nil, fmt.Errorf("创建后获取配方信息失败: %w", err)
}
return dto.ConvertRecipeToDto(fullRecipe), nil
}
// UpdateRecipe 更新配方
func (s *recipeServiceImpl) UpdateRecipe(ctx context.Context, id uint32, req *dto.UpdateRecipeRequest) (*dto.RecipeResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "UpdateRecipe")
// 1. 转换 DTO 为模型
recipeModel := dto.ConvertUpdateRecipeRequestToModel(req)
recipeModel.ID = id
// 2. 更新配方基础信息
_, err := s.recipeSvc.UpdateRecipe(serviceCtx, recipeModel)
if err != nil {
if errors.Is(err, recipe.ErrRecipeNotFound) {
return nil, ErrRecipeNotFound
}
if errors.Is(err, recipe.ErrRecipeNameConflict) {
return nil, ErrRecipeNameConflict
}
return nil, fmt.Errorf("更新配方基础信息失败: %w", err)
}
// 3. 更新配方原料
ingredients := make([]models.RecipeIngredient, len(req.RecipeIngredients))
for i, item := range req.RecipeIngredients {
ingredients[i] = models.RecipeIngredient{
RecipeID: id,
RawMaterialID: item.RawMaterialID,
Percentage: item.Percentage,
}
}
err = s.recipeSvc.UpdateRecipeIngredients(serviceCtx, id, ingredients)
if err != nil {
if errors.Is(err, recipe.ErrRecipeNotFound) {
return nil, ErrRecipeNotFound
}
return nil, fmt.Errorf("更新配方原料失败: %w", err)
}
// 4. 更新成功后,获取最新的完整配方信息并返回
updatedRecipe, err := s.recipeSvc.GetRecipeByID(serviceCtx, id)
if err != nil {
if errors.Is(err, recipe.ErrRecipeNotFound) {
// 理论上不应该发生,因为刚更新成功
return nil, ErrRecipeNotFound
}
return nil, fmt.Errorf("更新后获取配方信息失败: %w", err)
}
return dto.ConvertRecipeToDto(updatedRecipe), nil
}
// DeleteRecipe 删除配方
func (s *recipeServiceImpl) DeleteRecipe(ctx context.Context, id uint32) error {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "DeleteRecipe")
err := s.recipeSvc.DeleteRecipe(serviceCtx, id)
if err != nil {
if errors.Is(err, recipe.ErrRecipeNotFound) {
return ErrRecipeNotFound
}
return fmt.Errorf("删除配方失败: %w", err)
}
return nil
}
// GetRecipeByID 获取单个配方
func (s *recipeServiceImpl) GetRecipeByID(ctx context.Context, id uint32) (*dto.RecipeResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "GetRecipeByID")
recipeModel, err := s.recipeSvc.GetRecipeByID(serviceCtx, id)
if err != nil {
if errors.Is(err, recipe.ErrRecipeNotFound) {
return nil, ErrRecipeNotFound
}
return nil, fmt.Errorf("获取配方失败: %w", err)
}
return dto.ConvertRecipeToDto(recipeModel), nil
}
// ListRecipes 列出配方
func (s *recipeServiceImpl) ListRecipes(ctx context.Context, req *dto.ListRecipeRequest) (*dto.ListRecipeResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "ListRecipes")
opts := repository.RecipeListOptions{
Name: req.Name,
OrderBy: req.OrderBy,
}
recipes, total, err := s.recipeSvc.ListRecipes(serviceCtx, opts, req.Page, req.PageSize)
if err != nil {
return nil, fmt.Errorf("获取配方列表失败: %w", err)
}
return dto.ConvertRecipeListToDTO(recipes, total, req.Page, req.PageSize), nil
}
// AIDiagnoseRecipe 实现智能诊断配方的方法
func (s *recipeServiceImpl) AIDiagnoseRecipe(ctx context.Context, recipeID uint32, pigTypeID uint32) (*dto.ReviewRecipeResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "AIDiagnoseRecipe")
reviewMessage, aiModel, err := s.recipeSvc.AIDiagnoseRecipe(serviceCtx, recipeID, pigTypeID)
if err != nil {
return nil, fmt.Errorf("AI 诊断配方失败: %w", err)
}
return &dto.ReviewRecipeResponse{
ReviewMessage: reviewMessage,
AIModel: aiModel,
}, nil
}

View File

@@ -1,454 +0,0 @@
package webhook
import (
"context"
"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 {
ctx context.Context
sensorDataRepo repository.SensorDataRepository
deviceRepo repository.DeviceRepository
areaControllerRepo repository.AreaControllerRepository
deviceCommandLogRepo repository.DeviceCommandLogRepository
pendingCollectionRepo repository.PendingCollectionRepository
}
// NewChirpStackListener 创建一个新的 ChirpStackListener 实例
func NewChirpStackListener(
ctx context.Context,
sensorDataRepo repository.SensorDataRepository,
deviceRepo repository.DeviceRepository,
areaControllerRepo repository.AreaControllerRepository,
deviceCommandLogRepo repository.DeviceCommandLogRepository,
pendingCollectionRepo repository.PendingCollectionRepository,
) ListenHandler {
return &ChirpStackListener{
ctx: ctx,
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) {
ctx, logger := logs.Trace(r.Context(), c.ctx, "ChirpStackListener")
defer r.Body.Close()
b, err := io.ReadAll(r.Body)
if err != nil {
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(ctx, b, event)
}
}
// handler 用于处理 ChirpStack 发送的事件
func (c *ChirpStackListener) handler(ctx context.Context, data []byte, eventType string) {
reqCtx, logger := logs.Trace(ctx, c.ctx, "ChirpStackListener.handler")
switch eventType {
case eventTypeUp:
var msg UpEvent
if err := json.Unmarshal(data, &msg); err != nil {
logger.Errorf("解析 'up' 事件失败: %v, data: %s", err, string(data))
return
}
c.handleUpEvent(reqCtx, &msg)
case eventTypeJoin:
var msg JoinEvent
if err := json.Unmarshal(data, &msg); err != nil {
logger.Errorf("解析 'join' 事件失败: %v, data: %s", err, string(data))
return
}
c.handleJoinEvent(reqCtx, &msg)
case eventTypeAck:
var msg AckEvent
if err := json.Unmarshal(data, &msg); err != nil {
logger.Errorf("解析 'ack' 事件失败: %v, data: %s", err, string(data))
return
}
c.handleAckEvent(reqCtx, &msg)
case eventTypeTxAck:
var msg TxAckEvent
if err := json.Unmarshal(data, &msg); err != nil {
logger.Errorf("解析 'txack' 事件失败: %v, data: %s", err, string(data))
return
}
c.handleTxAckEvent(reqCtx, &msg)
case eventTypeStatus:
var msg StatusEvent
if err := json.Unmarshal(data, &msg); err != nil {
logger.Errorf("解析 'status' 事件失败: %v, data: %s", err, string(data))
return
}
c.handleStatusEvent(reqCtx, &msg)
case eventTypeLog:
var msg LogEvent
if err := json.Unmarshal(data, &msg); err != nil {
logger.Errorf("解析 'log' 事件失败: %v, data: %s", err, string(data))
return
}
c.handleLogEvent(reqCtx, &msg)
case eventTypeLocation:
var msg LocationEvent
if err := json.Unmarshal(data, &msg); err != nil {
logger.Errorf("解析 'location' 事件失败: %v, data: %s", err, string(data))
return
}
c.handleLocationEvent(reqCtx, &msg)
case eventTypeIntegration:
var msg IntegrationEvent
if err := json.Unmarshal(data, &msg); err != nil {
logger.Errorf("解析 'integration' 事件失败: %v, data: %s", err, string(data))
return
}
c.handleIntegrationEvent(reqCtx, &msg)
default:
logger.Errorf("未知的ChirpStack事件: %s, data: %s", eventType, string(data))
}
}
// --- 业务处理函数 ---
// handleUpEvent 处理上行数据事件
func (c *ChirpStackListener) handleUpEvent(ctx context.Context, event *UpEvent) {
reqCtx, logger := logs.Trace(ctx, c.ctx, "ChirpStackListener.handleUpEvent")
logger.Infof("开始处理 'up' 事件, DevEui: %s", event.DeviceInfo.DevEui)
// 1. 查找区域主控设备
areaController, err := c.areaControllerRepo.FindByNetworkID(reqCtx, event.DeviceInfo.DevEui)
if err != nil {
logger.Errorf("处理 'up' 事件失败:无法通过 DevEui '%s' 找到区域主控设备: %v", event.DeviceInfo.DevEui, err)
return
}
// 依赖 SelfCheck 确保区域主控有效
if err := areaController.SelfCheck(); err != nil {
logger.Errorf("处理 'up' 事件失败:区域主控 %v(ID: %d) 未通过自检: %v", areaController.Name, areaController.ID, err)
return
}
logger.Infof("找到区域主控: %s (ID: %d)", areaController.Name, areaController.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(reqCtx, areaController.ID, areaController.ID, event.Time, models.SensorTypeSignalMetrics, signalMetrics)
logger.Infof("已记录区域主控 (ID: %d) 的信号强度: RSSI=%d, SNR=%.2f", areaController.ID, rx.Rssi, rx.Snr)
} else {
logger.Warnf("处理 'up' 事件时未找到 RxInfo无法记录信号数据。DevEui: %s", event.DeviceInfo.DevEui)
}
// 3. 处理上报的传感器数据
if event.Data == "" {
logger.Warnf("处理 'up' 事件时 Data 字段为空无需记录上行数据。DevEui: %s", event.DeviceInfo.DevEui)
return
}
// 3.1 Base64 解码
decodedData, err := base64.StdEncoding.DecodeString(event.Data)
if err != nil {
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 {
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:
// 如果上行的数据不是采集结果,记录日志并忽略
logger.Infof("收到一个非采集响应的上行指令 (Type: %T),无需处理。", p)
return
}
// 检查 collectResp 是否为 nil虽然在 type switch 成功的情况下不太可能
if collectResp == nil {
logger.Errorf("从 Instruction 中提取的 CollectResult 为 nil")
return
}
correlationID := collectResp.CorrelationId
logger.Infof("成功解析采集响应 (CorrelationID: %s),包含 %d 个值。", correlationID, len(collectResp.Values))
// 4. 根据 CorrelationID 查找待处理请求
pendingReq, err := c.pendingCollectionRepo.FindByCorrelationID(reqCtx, correlationID)
if err != nil {
logger.Errorf("处理采集响应失败:无法找到待处理请求 (CorrelationID: %s): %v", correlationID, err)
return
}
// 检查状态,防止重复处理
if pendingReq.Status != models.PendingStatusPending && pendingReq.Status != models.PendingStatusTimedOut {
logger.Warnf("收到一个已处理过的采集响应 (CorrelationID: %s, Status: %s),将忽略。", correlationID, pendingReq.Status)
return
}
// 5. 匹配数据并存入数据库
deviceIDs := pendingReq.CommandMetadata
values := collectResp.Values
if len(deviceIDs) != len(values) {
logger.Errorf("数据不匹配:下行指令要求采集 %d 个设备,但上行响应包含 %d 个值 (CorrelationID: %s)", len(deviceIDs), len(values), correlationID)
// 即使数量不匹配,也更新状态为完成,以防止请求永远 pending
err = c.pendingCollectionRepo.UpdateStatusToFulfilled(reqCtx, correlationID, event.Time)
if err != nil {
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)) {
logger.Warnf("设备 (ID: %d) 上报了一个无效的 NaN 值,已跳过当前值的记录。", deviceID)
continue
}
// 5.1 获取设备及其模板
dev, err := c.deviceRepo.FindByID(reqCtx, deviceID)
if err != nil {
logger.Errorf("处理采集数据失败:无法找到设备 (ID: %d): %v", deviceID, err)
continue
}
// 依赖 SelfCheck 确保设备和模板有效
if err := dev.SelfCheck(); err != nil {
logger.Warnf("跳过设备 %d因其未通过自检: %v", dev.ID, err)
continue
}
if err := dev.DeviceTemplate.SelfCheck(); err != nil {
logger.Warnf("跳过设备 %d因其设备模板未通过自检: %v", dev.ID, err)
continue
}
// 5.2 从设备模板中解析 ValueDescriptor
var valueDescriptors []*models.ValueDescriptor
if err := dev.DeviceTemplate.ParseValues(&valueDescriptors); err != nil {
logger.Warnf("跳过设备 %d因其设备模板的 Values 属性解析失败: %v", dev.ID, err)
continue
}
// 根据 DeviceTemplate.SelfCheck这里应该只有一个 ValueDescriptor
if len(valueDescriptors) == 0 {
logger.Warnf("跳过设备 %d因其设备模板缺少 ValueDescriptor 定义", dev.ID)
continue
}
valueDescriptor := valueDescriptors[0]
// 5.3 应用乘数和偏移量计算最终值
parsedValue := 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 未知传感器的数据需要记录吗
logger.Warnf("未知的传感器类型 '%s',将使用通用格式记录", valueDescriptor.Type)
dataToRecord = map[string]float32{"value": parsedValue}
}
// 5.5 记录传感器数据
c.recordSensorData(reqCtx, areaController.ID, dev.ID, event.Time, valueDescriptor.Type, dataToRecord)
logger.Infof("成功记录传感器数据: 设备ID=%d, 类型=%s, 原始值=%f, 解析值=%.2f", dev.ID, valueDescriptor.Type, rawSensorValue, parsedValue)
}
// 6. 更新请求状态为“已完成”
if err := c.pendingCollectionRepo.UpdateStatusToFulfilled(reqCtx, correlationID, event.Time); err != nil {
logger.Errorf("更新待采集请求状态为 'fulfilled' 失败 (CorrelationID: %s): %v", correlationID, err)
} else {
logger.Infof("成功完成并关闭采集请求 (CorrelationID: %s)", correlationID)
}
}
// handleStatusEvent 处理设备状态事件
func (c *ChirpStackListener) handleStatusEvent(ctx context.Context, event *StatusEvent) {
reqCtx, logger := logs.Trace(ctx, c.ctx, "handleStatusEvent")
logger.Infof("处接收到理 'status' 事件: %+v", event)
// 查找区域主控设备
areaController, err := c.areaControllerRepo.FindByNetworkID(reqCtx, event.DeviceInfo.DevEui)
if err != nil {
logger.Errorf("处理 'status' 事件失败:无法通过 DevEui '%s' 找到区域主控设备: %v", event.DeviceInfo.DevEui, err)
return
}
// 记录信号强度
signalMetrics := models.SignalMetrics{
MarginDb: event.Margin,
}
c.recordSensorData(reqCtx, areaController.ID, areaController.ID, event.Time, models.SensorTypeSignalMetrics, signalMetrics)
logger.Infof("已记录区域主控 (ID: %d) 的信号状态: %+v", areaController.ID, signalMetrics)
// 记录电量
batteryLevel := models.BatteryLevel{
BatteryLevelRatio: event.BatteryLevel,
BatteryLevelUnavailable: event.BatteryLevelUnavailable,
ExternalPower: event.ExternalPower,
}
c.recordSensorData(reqCtx, areaController.ID, areaController.ID, event.Time, models.SensorTypeBatteryLevel, batteryLevel)
logger.Infof("已记录区域主控 (ID: %d) 的电池状态: %+v", areaController.ID, batteryLevel)
}
// handleAckEvent 处理下行确认事件
func (c *ChirpStackListener) handleAckEvent(ctx context.Context, event *AckEvent) {
reqCtx, logger := logs.Trace(ctx, c.ctx, "handleAckEvent")
logger.Infof("接收到 'ack' 事件: %+v", event)
// 更新下行任务记录的确认时间及接收成功状态
err := c.deviceCommandLogRepo.UpdateAcknowledgedAt(reqCtx, event.DeduplicationID, event.Time, event.Acknowledged)
if err != nil {
logger.Errorf("更新下行任务记录的确认时间及接收成功状态失败 (MessageID: %s, DevEui: %s, Acknowledged: %t): %v",
event.DeduplicationID, event.DeviceInfo.DevEui, event.Acknowledged, err)
return
}
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(ctx context.Context, event *LogEvent) {
logger := logs.TraceLogger(ctx, c.ctx, "handleLogEvent")
// 首先,打印完整的事件结构体,用于详细排查
logger.Infof("接收到 'log' 事件的完整内容: %+v", event)
// 接着,根据 ChirpStack 日志的级别,使用我们自己的 logger 对应级别来打印核心信息
logMessage := "ChirpStack 日志: [%s] %s (DevEui: %s)"
switch event.Level {
case "INFO":
logger.Infof(logMessage, event.Code, event.Description, event.DeviceInfo.DevEui)
case "WARNING":
logger.Warnf(logMessage, event.Code, event.Description, event.DeviceInfo.DevEui)
case "ERROR":
logger.Errorf(logMessage, event.Code, event.Description, event.DeviceInfo.DevEui)
default:
// 对于未知级别,使用 Warn 级别打印,并明确指出级别未知
logger.Warnf("ChirpStack 日志: [未知级别: %s] %s %s (DevEui: %s)",
event.Level, event.Code, event.Description, event.DeviceInfo.DevEui)
}
}
// handleJoinEvent 处理入网事件
func (c *ChirpStackListener) handleJoinEvent(ctx context.Context, event *JoinEvent) {
logger := logs.TraceLogger(ctx, c.ctx, "handleJoinEvent")
logger.Infof("接收到 'join' 事件: %+v", event)
// 在这里添加您的业务逻辑
}
// handleTxAckEvent 处理网关发送确认事件
func (c *ChirpStackListener) handleTxAckEvent(ctx context.Context, event *TxAckEvent) {
logger := logs.TraceLogger(ctx, c.ctx, "handleTxAckEvent")
logger.Infof("接收到 'txack' 事件: %+v", event)
// 在这里添加您的业务逻辑
}
// handleLocationEvent 处理位置事件
func (c *ChirpStackListener) handleLocationEvent(ctx context.Context, event *LocationEvent) {
logger := logs.TraceLogger(ctx, c.ctx, "handleLocationEvent")
logger.Infof("接收到 'location' 事件: %+v", event)
// 在这里添加您的业务逻辑
}
// handleIntegrationEvent 处理集成事件
func (c *ChirpStackListener) handleIntegrationEvent(ctx context.Context, event *IntegrationEvent) {
logger := logs.TraceLogger(ctx, c.ctx, "handleIntegrationEvent")
logger.Infof("接收到 'integration' 事件: %+v", event)
// 在这里添加您的业务逻辑
}
// recordSensorData 是一个通用方法,用于将传感器数据存入数据库。
// areaControllerID: 区域主控设备的ID
// sensorDeviceID: 实际产生传感器数据的普通设备的ID
// sensorType: 传感器值的类型 (例如 models.SensorTypeTemperature)
// data: 具体的传感器数据结构体实例 (例如 models.TemperatureData)
func (c *ChirpStackListener) recordSensorData(ctx context.Context, areaControllerID uint32, sensorDeviceID uint32, eventTime time.Time, sensorType models.SensorType, data interface{}) {
reqCtx, logger := logs.Trace(ctx, c.ctx, "recordSensorData")
// 1. 将传入的结构体序列化为 JSON
jsonData, err := json.Marshal(data)
if err != nil {
logger.Errorf("记录传感器数据失败:序列化数据为 JSON 时出错: %v", err)
return
}
// 2. 构建 SensorData 模型
sensorData := &models.SensorData{
Time: eventTime,
DeviceID: sensorDeviceID,
AreaControllerID: areaControllerID,
SensorType: sensorType,
Data: datatypes.JSON(jsonData),
}
// 3. 调用仓库创建记录
if err := c.sensorDataRepo.Create(reqCtx, sensorData); err != nil {
logger.Errorf("记录传感器数据失败:存入数据库时出错: %v", err)
}
}

View File

@@ -8,15 +8,19 @@ import (
"syscall"
"git.huangwc.com/pig/pig-farm-controller/internal/app/api"
"git.huangwc.com/pig/pig-farm-controller/internal/app/listener"
"git.huangwc.com/pig/pig-farm-controller/internal/app/listener/chirp_stack"
"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/transport/lora"
)
// Application 是整个应用的核心,封装了所有组件和生命周期。
type Application struct {
Config *config.Config
Ctx context.Context
API *api.API
cfgPath string
Config *config.Config
Ctx context.Context
API *api.API
Infra *Infrastructure
Domain *DomainServices
@@ -31,14 +35,11 @@ func NewApplication(configPath string) (*Application, error) {
if err := cfg.Load(configPath); err != nil {
return nil, fmt.Errorf("无法加载配置: %w", err)
}
// 初始化全局日志记录器
logs.InitDefaultLogger(cfg.Log)
// 为 Application 本身创建 Ctx
selfCtx := logs.AddCompName(context.Background(), "Application")
ctx := logs.AddFuncName(selfCtx, selfCtx, "NewApplication")
// 2. 初始化所有分层服务
// 2. 初始化基础设施和领域服务 (此时它们是解耦的)
infra, err := initInfrastructure(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("初始化基础设施失败: %w", err)
@@ -49,7 +50,31 @@ func NewApplication(configPath string) (*Application, error) {
}
appServices := initAppServices(ctx, infra, domain)
// 3. 初始化 API 入口点
// 3. 【核心组装逻辑】创建应用层监听器并注入到基础设施层
// 此时所有依赖项repos, domain services, comm都已准备就绪
upstreamHandler := listener.NewLoRaListener(
selfCtx,
infra.repos.areaControllerRepo,
infra.repos.pendingCollectionRepo,
infra.repos.deviceRepo,
infra.repos.sensorDataRepo,
infra.repos.deviceCommandLogRepo,
infra.repos.otaRepo,
domain.deviceCommunicator,
)
// 根据 LoRa 模式完成最终的绑定
if cfg.Lora.Mode == config.LoraMode_LoRaWAN {
// 对于 LoRaWAN创建真正的 Webhook 处理器并替换掉占位符
infra.lora.listenHandler = chirp_stack.NewChirpStackListener(selfCtx, upstreamHandler)
} else {
// 对于 LoRa Mesh将处理器注入到已创建的 transport 实例中
if tp, ok := infra.lora.loraListener.(*lora.LoRaMeshUartPassthroughTransport); ok {
tp.SetHandler(upstreamHandler)
}
}
// 4. 初始化 API 入口点 (现在可以安全地传入 listenHandler)
apiServer := api.NewAPI(
cfg.Server,
logs.AddCompName(context.Background(), "API"),
@@ -58,22 +83,32 @@ func NewApplication(configPath string) (*Application, error) {
appServices.pigBatchService,
appServices.monitorService,
appServices.deviceService,
appServices.deviceTemplateService,
appServices.areaControllerService,
appServices.planService,
appServices.userService,
appServices.auditService,
appServices.thresholdAlarmService,
appServices.nutrientService,
appServices.rawMaterialService,
appServices.pigBreedService,
appServices.pigAgeStageService,
appServices.pigTypeService,
appServices.recipeService,
appServices.inventoryService,
infra.tokenGenerator,
infra.lora.listenHandler,
infra.lora.listenHandler, // 此处传入的是最终组装好的 handler
)
// 4. 组装 Application 对象
// 5. 组装 Application 对象
app := &Application{
Config: cfg,
Ctx: selfCtx,
API: apiServer,
Infra: infra,
Domain: domain,
App: appServices,
cfgPath: configPath,
Config: cfg,
Ctx: selfCtx,
API: apiServer,
Infra: infra,
Domain: domain,
App: appServices,
}
return app, nil
@@ -90,7 +125,7 @@ func (app *Application) Start() error {
}
// 2. 初始化应用状态 (清理、刷新任务等)
if err := app.initializeState(startCtx); err != nil {
if err := app.initializeState(startCtx, app.cfgPath); err != nil {
return fmt.Errorf("初始化应用状态失败: %w", err)
}

View File

@@ -5,14 +5,18 @@ import (
"fmt"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/app/listener"
"git.huangwc.com/pig/pig-farm-controller/internal/app/listener/chirp_stack"
"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/alarm"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/device"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/inventory"
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/recipe"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/task"
infra_ai "git.huangwc.com/pig/pig-farm-controller/internal/infra/ai"
"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"
@@ -21,9 +25,11 @@ import (
"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"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/utils/file"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/utils/token"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
// Infrastructure 聚合了所有基础设施层的组件。
@@ -31,11 +37,14 @@ type Infrastructure struct {
storage database.Storage
repos *Repositories
lora *LoraComponents
ai infra_ai.AI
tokenGenerator token.Generator
}
// initInfrastructure 初始化所有基础设施层组件。
func initInfrastructure(ctx context.Context, cfg *config.Config) (*Infrastructure, error) {
file.SetTempRoot(cfg.App.TempPath)
storage, err := initStorage(ctx, cfg.Database)
if err != nil {
return nil, err
@@ -43,17 +52,24 @@ func initInfrastructure(ctx context.Context, cfg *config.Config) (*Infrastructur
repos := initRepositories(ctx, storage.GetDB(ctx))
lora, err := initLora(ctx, cfg, repos)
lora, err := initLora(ctx, cfg)
if err != nil {
return nil, err
}
tokenGenerator := token.NewTokenGenerator([]byte(cfg.App.JWTSecret))
// 初始化 AI
ai, err := initAI(ctx, cfg.AI)
if err != nil {
return nil, fmt.Errorf("初始化 AI 管理器失败: %w", err)
}
return &Infrastructure{
storage: storage,
repos: repos,
lora: lora,
ai: ai,
tokenGenerator: tokenGenerator,
}, nil
}
@@ -79,9 +95,13 @@ type Repositories struct {
pigTradeRepo repository.PigTradeRepository
pigSickPigLogRepo repository.PigSickLogRepository
medicationLogRepo repository.MedicationLogRepository
rawMaterialRepo repository.RawMaterialRepository
notificationRepo repository.NotificationRepository
alarmRepo repository.AlarmRepository
pigTypeRepo repository.PigTypeRepository
rawMaterialRepo repository.RawMaterialRepository
nutrientRepo repository.NutrientRepository
recipeRepo repository.RecipeRepository
otaRepo repository.OtaRepository
unitOfWork repository.UnitOfWork
}
@@ -108,9 +128,13 @@ func initRepositories(ctx context.Context, db *gorm.DB) *Repositories {
pigTradeRepo: repository.NewGormPigTradeRepository(logs.AddCompName(baseCtx, "PigTradeRepo"), db),
pigSickPigLogRepo: repository.NewGormPigSickLogRepository(logs.AddCompName(baseCtx, "PigSickPigLogRepo"), db),
medicationLogRepo: repository.NewGormMedicationLogRepository(logs.AddCompName(baseCtx, "MedicationLogRepo"), db),
rawMaterialRepo: repository.NewGormRawMaterialRepository(logs.AddCompName(baseCtx, "RawMaterialRepo"), db),
notificationRepo: repository.NewGormNotificationRepository(logs.AddCompName(baseCtx, "NotificationRepo"), db),
alarmRepo: repository.NewGormAlarmRepository(logs.AddCompName(baseCtx, "AlarmRepo"), db),
pigTypeRepo: repository.NewGormPigTypeRepository(logs.AddCompName(baseCtx, "PigTypeRepo"), db),
rawMaterialRepo: repository.NewGormRawMaterialRepository(logs.AddCompName(baseCtx, "RawMaterialRepo"), db),
nutrientRepo: repository.NewGormNutrientRepository(logs.AddCompName(baseCtx, "NutrientRepo"), db),
recipeRepo: repository.NewGormRecipeRepository(logs.AddCompName(baseCtx, "RecipeRepo"), db),
otaRepo: repository.NewGormOtaRepository(logs.AddCompName(baseCtx, "OtaRepo"), db),
unitOfWork: repository.NewGormUnitOfWork(logs.AddCompName(baseCtx, "UnitOfWork"), db),
}
}
@@ -121,13 +145,17 @@ type DomainServices struct {
pigTradeManager pig.PigTradeManager
pigSickManager pig.SickPigManager
pigBatchDomain pig.PigBatchService
generalDeviceService device.Service
deviceOperator device.DeviceOperator
deviceCommunicator device.DeviceCommunicator
otaService device.OtaService
taskFactory plan.TaskFactory
planExecutionManager plan.ExecutionManager
analysisPlanTaskManager plan.AnalysisPlanTaskManager
planService plan.Service
notifyService domain_notify.Service
alarmService alarm.AlarmService
recipeService recipe.Service
inventoryService inventory.InventoryCoreService
}
// initDomainServices 初始化所有的领域服务。
@@ -139,6 +167,9 @@ func initDomainServices(ctx context.Context, cfg *config.Config, infra *Infrastr
return nil, fmt.Errorf("初始化通知服务失败: %w", err)
}
// 库存管理
inventoryService := inventory.NewInventoryCoreService(logs.AddCompName(baseCtx, "InventoryCoreService"), infra.repos.unitOfWork, infra.repos.rawMaterialRepo)
// 猪群管理相关
pigPenTransferManager := pig.NewPigPenTransferManager(logs.AddCompName(baseCtx, "PigPenTransferManager"), infra.repos.pigPenRepo, infra.repos.pigTransferLogRepo, infra.repos.pigBatchRepo)
pigTradeManager := pig.NewPigTradeManager(logs.AddCompName(baseCtx, "PigTradeManager"), infra.repos.pigTradeRepo)
@@ -156,10 +187,20 @@ func initDomainServices(ctx context.Context, cfg *config.Config, infra *Infrastr
generalDeviceService := device.NewGeneralDeviceService(
logs.AddCompName(baseCtx, "GeneralDeviceService"),
infra.repos.deviceRepo,
infra.repos.areaControllerRepo,
infra.repos.deviceCommandLogRepo,
infra.repos.pendingCollectionRepo,
infra.lora.comm,
)
otaService := device.NewOtaService(
logs.AddCompName(baseCtx, "OtaService"),
device.OtaConfig{
DefaultRetryCount: uint32(cfg.OTA.DefaultRetryCount),
DefaultRequestTimeoutS: uint32(cfg.OTA.DefaultRequestTimeoutSeconds),
},
infra.repos.otaRepo,
generalDeviceService,
)
// 告警服务
alarmService := alarm.NewAlarmService(
@@ -173,6 +214,9 @@ func initDomainServices(ctx context.Context, cfg *config.Config, infra *Infrastr
infra.repos.sensorDataRepo,
infra.repos.deviceRepo,
infra.repos.alarmRepo,
infra.repos.areaControllerRepo,
infra.repos.otaRepo,
generalDeviceService,
generalDeviceService,
notifyService,
alarmService,
@@ -191,7 +235,6 @@ func initDomainServices(ctx context.Context, cfg *config.Config, infra *Infrastr
infra.repos.planRepo,
analysisPlanTaskManager,
taskFactory,
generalDeviceService,
time.Duration(cfg.Task.Interval)*time.Second,
cfg.Task.NumWorkers,
)
@@ -207,18 +250,43 @@ func initDomainServices(ctx context.Context, cfg *config.Config, infra *Infrastr
taskFactory,
)
// 配方管理相关
nutrientService := recipe.NewNutrientService(logs.AddCompName(baseCtx, "NutrientService"), infra.repos.nutrientRepo)
pigAgeStageService := recipe.NewPigAgeStageService(logs.AddCompName(baseCtx, "PigAgeStageService"), infra.repos.pigTypeRepo)
pigBreedService := recipe.NewPigBreedService(logs.AddCompName(baseCtx, "PigBreedService"), infra.repos.pigTypeRepo)
pigTypeService := recipe.NewPigTypeService(logs.AddCompName(baseCtx, "PigTypeService"), infra.repos.unitOfWork, infra.repos.pigTypeRepo)
rawMaterialService := recipe.NewRawMaterialService(logs.AddCompName(baseCtx, "RawMaterialService"), infra.repos.unitOfWork, infra.repos.rawMaterialRepo, inventoryService)
recipeCoreService := recipe.NewRecipeCoreService(logs.AddCompName(baseCtx, "RecipeCoreService"), infra.repos.unitOfWork, infra.repos.recipeRepo)
recipeGenerateManager := recipe.NewRecipeGenerateManager(logs.AddCompName(baseCtx, "RecipeGenerateManager"))
recipeService := recipe.NewRecipeService(
logs.AddCompName(baseCtx, "RecipeService"),
nutrientService,
rawMaterialService,
pigBreedService,
pigAgeStageService,
pigTypeService,
recipeCoreService,
recipeGenerateManager,
infra.repos.recipeRepo,
infra.ai,
)
return &DomainServices{
pigPenTransferManager: pigPenTransferManager,
pigTradeManager: pigTradeManager,
pigSickManager: pigSickManager,
pigBatchDomain: pigBatchDomain,
generalDeviceService: generalDeviceService,
deviceOperator: generalDeviceService,
deviceCommunicator: generalDeviceService,
otaService: otaService,
analysisPlanTaskManager: analysisPlanTaskManager,
taskFactory: taskFactory,
planExecutionManager: planExecutionManager,
planService: planService,
notifyService: notifyService,
alarmService: alarmService,
recipeService: recipeService,
inventoryService: inventoryService,
}, nil
}
@@ -228,10 +296,19 @@ type AppServices struct {
pigBatchService service.PigBatchService
monitorService service.MonitorService
deviceService service.DeviceService
deviceTemplateService service.DeviceTemplateService
areaControllerService service.AreaControllerService
planService service.PlanService
userService service.UserService
auditService service.AuditService
thresholdAlarmService service.ThresholdAlarmService
nutrientService service.NutrientService
pigAgeStageService service.PigAgeStageService
pigBreedService service.PigBreedService
pigTypeService service.PigTypeService
rawMaterialService service.RawMaterialService
recipeService service.RecipeService
inventoryService service.InventoryService
}
// initAppServices 初始化所有的应用服务。
@@ -247,7 +324,6 @@ func initAppServices(ctx context.Context, infra *Infrastructure, domainServices
infra.repos.planRepo,
infra.repos.pendingCollectionRepo,
infra.repos.userActionLogRepo,
infra.repos.rawMaterialRepo,
infra.repos.medicationLogRepo,
infra.repos.pigBatchRepo,
infra.repos.pigBatchLogRepo,
@@ -271,56 +347,91 @@ func initAppServices(ctx context.Context, infra *Infrastructure, domainServices
deviceService := service.NewDeviceService(
logs.AddCompName(baseCtx, "DeviceService"),
infra.repos.deviceRepo,
infra.repos.areaControllerRepo,
infra.repos.deviceTemplateRepo,
domainServices.generalDeviceService,
domainServices.deviceOperator,
thresholdAlarmService,
)
deviceTemplateService := service.NewDeviceTemplateService(logs.AddCompName(baseCtx, "DeviceTemplateService"), infra.repos.deviceTemplateRepo)
areaControllerService := service.NewAreaControllerService(
logs.AddCompName(baseCtx, "AreaControllerService"),
infra.repos.areaControllerRepo,
infra.repos.otaRepo,
thresholdAlarmService,
domainServices.otaService,
)
auditService := service.NewAuditService(logs.AddCompName(baseCtx, "AuditService"), infra.repos.userActionLogRepo)
planService := service.NewPlanService(logs.AddCompName(baseCtx, "AppPlanService"), domainServices.planService)
userService := service.NewUserService(logs.AddCompName(baseCtx, "UserService"), infra.repos.userRepo, infra.tokenGenerator, domainServices.notifyService)
nutrientService := service.NewNutrientService(logs.AddCompName(baseCtx, "NutrientService"), domainServices.recipeService)
pigAgeStageService := service.NewPigAgeStageService(logs.AddCompName(baseCtx, "PigAgeStageService"), domainServices.recipeService)
pigBreedService := service.NewPigBreedService(logs.AddCompName(baseCtx, "PigBreedService"), domainServices.recipeService)
pigTypeService := service.NewPigTypeService(logs.AddCompName(baseCtx, "PigTypeService"), domainServices.recipeService)
rawMaterialService := service.NewRawMaterialService(logs.AddCompName(baseCtx, "RawMaterialService"), domainServices.recipeService)
recipeService := service.NewRecipeService(logs.AddCompName(baseCtx, "RecipeService"), domainServices.recipeService)
inventoryService := service.NewInventoryService(logs.AddCompName(baseCtx, "InventoryService"), domainServices.inventoryService, infra.repos.rawMaterialRepo)
return &AppServices{
pigFarmService: pigFarmService,
pigBatchService: pigBatchService,
monitorService: monitorService,
deviceService: deviceService,
deviceTemplateService: deviceTemplateService,
areaControllerService: areaControllerService,
auditService: auditService,
planService: planService,
userService: userService,
thresholdAlarmService: thresholdAlarmService,
nutrientService: nutrientService,
pigAgeStageService: pigAgeStageService,
pigBreedService: pigBreedService,
pigTypeService: pigTypeService,
rawMaterialService: rawMaterialService,
recipeService: recipeService,
inventoryService: inventoryService,
}
}
// LoraComponents 聚合了所有 LoRa 相关组件。
type LoraComponents struct {
listenHandler webhook.ListenHandler
listenHandler listener.ListenHandler
comm transport.Communicator
loraListener transport.Listener
}
// initLora 根据配置初始化 LoRa 相关组件。
// 此函数只负责创建和返回底层的传输和监听组件,不关心业务处理器的创建。
func initLora(
ctx context.Context,
cfg *config.Config,
repos *Repositories,
) (*LoraComponents, error) {
var listenHandler webhook.ListenHandler
var listenHandler listener.ListenHandler
var comm transport.Communicator
var loraListener transport.Listener
baseCtx := context.Background()
logger := logs.GetLogger(ctx)
// 1. 根据配置初始化具体的传输层和监听器
if cfg.Lora.Mode == config.LoraMode_LoRaWAN {
logger.Info("当前运行模式: lora_wan。初始化 ChirpStack 监听器和传输层。")
listenHandler = webhook.NewChirpStackListener(logs.AddCompName(baseCtx, "ChirpStackListener"), repos.sensorDataRepo, repos.deviceRepo, repos.areaControllerRepo, repos.deviceCommandLogRepo, repos.pendingCollectionRepo)
logger.Info("当前运行模式: lora_wan。初始化 ChirpStack 传输层和占位符监听器。")
// 1a. 创建 ChirpStack 的发送器 (infra)
comm = lora.NewChirpStackTransport(logs.AddCompName(baseCtx, "ChirpStackTransport"), cfg.ChirpStack)
// 1b. LoRaWAN 模式下没有主动监听的 Listener使用占位符
loraListener = lora.NewPlaceholderTransport(logs.AddCompName(baseCtx, "PlaceholderTransport"))
// 1c. Webhook 监听器将在 Application 层组装时创建,此处使用占位符
listenHandler = chirp_stack.NewPlaceholderListener(logs.AddCompName(baseCtx, "PlaceholderListener"))
} else {
logger.Info("当前运行模式: lora_mesh。初始化 LoRa Mesh 传输层和占位符监听器。")
listenHandler = webhook.NewPlaceholderListener(logs.AddCompName(baseCtx, "PlaceholderListener"))
tp, err := lora.NewLoRaMeshUartPassthroughTransport(logs.AddCompName(baseCtx, "LoRaMeshTransport"), cfg.LoraMesh, repos.areaControllerRepo, repos.pendingCollectionRepo, repos.deviceRepo, repos.sensorDataRepo)
// 1a. LoRa Mesh 模式下没有 Webhook 监听器,使用占位符
listenHandler = chirp_stack.NewPlaceholderListener(logs.AddCompName(baseCtx, "PlaceholderListener"))
// 1b. 创建串口的传输工具 (infra),它同时实现了发送和监听
// 注意:此处传入 nil业务处理器将在 Application 层组装时被注入
tp, err := lora.NewLoRaMeshUartPassthroughTransport(baseCtx, cfg.LoraMesh, nil)
if err != nil {
return nil, fmt.Errorf("无法初始化 LoRa Mesh 模块: %w", err)
}
@@ -328,6 +439,7 @@ func initLora(
comm = tp
}
// 2. 返回只包含基础设施组件的 LoraComponents
return &LoraComponents{
listenHandler: listenHandler,
comm: comm,
@@ -424,6 +536,8 @@ func initNotifyService(
// initStorage 封装了数据库的初始化、连接和迁移逻辑。
func initStorage(ctx context.Context, cfg config.DatabaseConfig) (database.Storage, error) {
logger := logs.GetLogger(ctx)
// 创建存储实例
storage := database.NewStorage(logs.AddCompName(context.Background(), "Storage"), cfg)
if err := storage.Connect(ctx); err != nil {
@@ -431,11 +545,32 @@ func initStorage(ctx context.Context, cfg config.DatabaseConfig) (database.Stora
return nil, fmt.Errorf("数据库连接失败: %w", err)
}
// 获取所有模型
allModels := models.GetAllModels()
// -- 启动时检查:确保所有模型都实现了 schema.Tabler 接口 --
// 这是一个硬性要求,用于保证代码质量和表名定义的明确性。
// 如果一个模型没有实现 TableName() string 方法,程序将在此处 panic。
for _, model := range allModels {
if _, ok := model.(schema.Tabler); !ok {
logger.Panicf(fmt.Sprintf("启动失败:模型 %T 未实现 schema.Tabler 接口。请为该模型添加 TableName() string 方法,以显式指定其数据库表名。", model))
}
}
// 执行数据库迁移
if err := storage.Migrate(ctx, models.GetAllModels()...); err != nil {
if err := storage.Migrate(ctx, allModels...); err != nil {
return nil, fmt.Errorf("数据库迁移失败: %w", err)
}
logs.GetLogger(ctx).Info("数据库初始化完成。")
return storage, nil
}
func initAI(ctx context.Context, cfg config.AIConfig) (infra_ai.AI, error) {
switch cfg.Model {
case models.AI_MODEL_GEMINI:
return infra_ai.NewGeminiAI(ctx, cfg.Gemini)
default:
return infra_ai.NewNoneAI(ctx), nil
}
}

View File

@@ -2,9 +2,13 @@ package core
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"git.huangwc.com/pig/pig-farm-controller/internal/core/seeder"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/task"
"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/repository"
@@ -12,27 +16,41 @@ import (
// initializeState 在应用启动时准备其初始数据状态。
// 它遵循一个严格的顺序:清理 -> 更新 -> 刷新,以确保数据的一致性和正确性。
func (app *Application) initializeState(ctx context.Context) error {
func (app *Application) initializeState(ctx context.Context, cfgPath string) error {
appCtx, logger := logs.Trace(ctx, app.Ctx, "InitializeState")
// 1. 清理所有上次运行时遗留的待执行任务和相关日志。
// 1. 播种预设数据
logger.Info("开始播种预设数据...")
presetDir := filepath.Join(filepath.Dir(cfgPath), "presets-data")
// 这是一个有顺序的播种器数组
// 确保 "nutrient" 在 "pig_nutrient_requirements" 之前处理, 因为后者依赖前者
seeders := []database.Seeder{
&seeder.NutrientSeeder{},
&seeder.PigNutrientRequirementSeeder{},
}
if err := database.SeedFromPreset(appCtx, app.Infra.storage.GetDB(appCtx), presetDir, seeders); err != nil {
return fmt.Errorf("预设数据播种失败: %w", err)
}
logger.Info("预设数据播种成功。")
// 2. 清理所有上次运行时遗留的待执行任务和相关日志。
// 这一步必须在任何可能修改计划结构的操作之前执行,以避免外键约束冲突。
if err := app.cleanupStaleTasksAndLogs(appCtx); err != nil {
return fmt.Errorf("清理过期的任务及日志失败: %w", err)
}
// 2. 清理待采集任务 (非致命错误)。
// 3. 清理待采集任务 (非致命错误)。
if err := app.initializePendingCollections(appCtx); err != nil {
logger.Errorw("清理待采集任务时发生非致命错误", "error", err)
}
// 3. 初始化并更新系统计划。
// 4. 初始化并更新系统计划。
// 此时,所有旧的待执行任务已被清除,可以安全地更新计划结构。
if err := app.initializeSystemPlans(ctx); err != nil {
return fmt.Errorf("初始化预定义系统计划失败: %w", err)
}
// 4. 最后,根据最新的计划状态,统一刷新所有计划的触发器。
// 5. 最后,根据最新的计划状态,统一刷新所有计划的触发器。
// 这一步确保了新创建或更新的系统计划能够被正确地调度。
logger.Info("正在刷新所有计划的触发器...")
if err := app.Domain.planService.RefreshPlanTriggers(appCtx); err != nil {
@@ -73,6 +91,14 @@ func (app *Application) initializeSystemPlans(ctx context.Context) error {
return err
}
if err := app.initializeHeartbeatCheckPlan(appCtx, existingPlanMap); err != nil {
return err
}
if err := app.initializeOtaCheckPlan(appCtx, existingPlanMap); err != nil {
return err
}
logger.Info("预定义系统计划检查完成。")
return nil
}
@@ -234,6 +260,57 @@ func (app *Application) initializeAlarmNotificationPlan(ctx context.Context, exi
return nil
}
// initializeHeartbeatCheckPlan 负责初始化 "周期性心跳检测" 计划。
func (app *Application) initializeHeartbeatCheckPlan(ctx context.Context, existingPlanMap map[models.PlanName]*models.Plan) error {
appCtx, logger := logs.Trace(ctx, app.Ctx, "initializeHeartbeatCheckPlan")
cron := 5
predefinedPlan := &models.Plan{
Name: models.PlanNamePeriodicHeartbeatCheck,
Description: fmt.Sprintf("这是一个系统预定义的计划, 每%d分钟自动触发一次区域主控心跳检测。", cron),
PlanType: models.PlanTypeSystem,
ExecutionType: models.PlanExecutionTypeAutomatic,
CronExpression: fmt.Sprintf("*/%d * * * *", cron),
Status: models.PlanStatusEnabled,
ContentType: models.PlanContentTypeTasks,
Tasks: []models.Task{
{
Name: "心跳检测",
Description: "向所有区域主控发送Ping指令",
ExecutionOrder: 1,
Type: models.TaskTypeHeartbeat,
},
},
}
if foundExistingPlan, ok := existingPlanMap[predefinedPlan.Name]; ok {
// 如果计划存在,则进行无差别更新
logger.Infof("预定义计划 '%s' 已存在,正在进行无差别更新...", predefinedPlan.Name)
predefinedPlan.ID = foundExistingPlan.ID
predefinedPlan.ExecuteCount = foundExistingPlan.ExecuteCount
if err := app.Infra.repos.planRepo.UpdatePlanMetadataAndStructure(appCtx, predefinedPlan); err != nil {
return fmt.Errorf("更新预定义计划 '%s' 的元数据和结构失败: %w", predefinedPlan.Name, err)
}
if err := app.Infra.repos.planRepo.UpdatePlan(appCtx, predefinedPlan); err != nil {
return fmt.Errorf("更新预定义计划 '%s' 的所有顶层字段失败: %w", predefinedPlan.Name, err)
}
logger.Infof("成功更新预定义计划 '%s'。", predefinedPlan.Name)
} else {
// 如果计划不存在, 则创建
logger.Infof("预定义计划 '%s' 不存在,正在创建...", predefinedPlan.Name)
if err := app.Infra.repos.planRepo.CreatePlan(appCtx, predefinedPlan); err != nil {
return fmt.Errorf("创建预定义计划 '%s' 失败: %w", predefinedPlan.Name, err)
} else {
logger.Infof("成功创建预定义计划 '%s'。", predefinedPlan.Name)
}
}
return nil
}
// initializePendingCollections 在应用启动时处理所有未完成的采集请求。
// 我们的策略是:任何在程序重启前仍处于“待处理”状态的请求,都应被视为已失败。
// 这保证了系统在每次启动时都处于一个干净、确定的状态。
@@ -354,3 +431,72 @@ func (app *Application) cleanupStaleTasksAndLogs(ctx context.Context) error {
logger.Info("过期的任务及日志清理完成。")
return nil
}
// initializeOtaCheckPlan 负责初始化 "定时检查OTA升级超时" 计划。
func (app *Application) initializeOtaCheckPlan(ctx context.Context, existingPlanMap map[models.PlanName]*models.Plan) error {
appCtx, logger := logs.Trace(ctx, app.Ctx, "initializeOtaCheckPlan")
// 1. 从应用配置中获取超时时间,并提供一个安全默认值
timeout := app.Config.OTA.DefaultTimeoutSeconds
if timeout <= 0 {
timeout = 300 // 如果配置不合法,则使用默认值 300 秒
}
// 2. 定义任务参数并序列化
params := task.OtaCheckTaskParams{
TimeoutSeconds: timeout,
}
paramsJSON, err := json.Marshal(params)
if err != nil {
return fmt.Errorf("序列化OTA检查任务参数失败: %w", err)
}
// 3. 构建预定义的计划对象
cron := 10
predefinedPlan := &models.Plan{
Name: models.PlanNameOtaCheck,
Description: fmt.Sprintf("每%d分钟执行一次扫描所有正在进行的OTA升级任务并将超时的任务标记为失败。当前超时时间设置为 %d 秒。", cron, timeout),
PlanType: models.PlanTypeSystem,
ExecutionType: models.PlanExecutionTypeAutomatic,
CronExpression: fmt.Sprintf("*/%d * * * *", cron),
Status: models.PlanStatusEnabled,
ContentType: models.PlanContentTypeTasks,
Tasks: []models.Task{
{
Name: "OTA升级检查",
Description: "扫描并处理超时的OTA升级任务",
ExecutionOrder: 1,
Type: models.TaskTypeOTACheck,
Parameters: paramsJSON,
},
},
}
// 4. 检查计划是否存在,并执行创建或更新操作
if foundExistingPlan, ok := existingPlanMap[predefinedPlan.Name]; ok {
// 如果计划存在,则进行无差别更新
logger.Infof("预定义计划 '%s' 已存在,正在进行无差别更新...", predefinedPlan.Name)
predefinedPlan.ID = foundExistingPlan.ID
predefinedPlan.ExecuteCount = foundExistingPlan.ExecuteCount
if err := app.Infra.repos.planRepo.UpdatePlanMetadataAndStructure(appCtx, predefinedPlan); err != nil {
return fmt.Errorf("更新预定义计划 '%s' 的元数据和结构失败: %w", predefinedPlan.Name, err)
}
if err := app.Infra.repos.planRepo.UpdatePlan(appCtx, predefinedPlan); err != nil {
return fmt.Errorf("更新预定义计划 '%s' 的所有顶层字段失败: %w", predefinedPlan.Name, err)
}
logger.Infof("成功更新预定义计划 '%s'。", predefinedPlan.Name)
} else {
// 如果计划不存在, 则创建
logger.Infof("预定义计划 '%s' 不存在,正在创建...", predefinedPlan.Name)
if err := app.Infra.repos.planRepo.CreatePlan(appCtx, predefinedPlan); err != nil {
return fmt.Errorf("创建预定义计划 '%s' 失败: %w", predefinedPlan.Name, err)
} else {
logger.Infof("成功创建预定义计划 '%s'。", predefinedPlan.Name)
}
}
return nil
}

View File

@@ -0,0 +1,293 @@
package seeder
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"github.com/tidwall/gjson"
"gorm.io/gorm"
)
// rawMaterialInfo 用于临时存储解析后的原料描述、价格和最大添加量信息。
type rawMaterialInfo struct {
Description string
UnitPrice float32
MaxRatio float32
}
// NutrientSeeder 实现了 database.Seeder 接口,
// 负责播种原料和营养相关的预设数据。
type NutrientSeeder struct{}
// Type 返回此 Seeder 负责处理的数据类型标识符。
func (*NutrientSeeder) Type() string {
return "nutrient"
}
// IsRequired 标记此 Seeder 对应的预设文件是必需的。
func (*NutrientSeeder) IsRequired() bool {
return true
}
// Seed 先严格校验JSON源文件然后以“有则跳过”的模式播种数据。
func (*NutrientSeeder) Seed(ctx context.Context, tx *gorm.DB, jsonData []byte) error {
logger := logs.GetLogger(ctx)
// 检查 Nutrient 表是否为空,如果非空则跳过播种
isEmpty, err := isTableEmpty(tx, &models.Nutrient{})
if err != nil {
return fmt.Errorf("检查 Nutrient 表是否为空失败: %w", err)
}
if !isEmpty {
logger.Info("已存在原料数据, 跳过数据播种")
return nil
}
// 1. 严格校验JSON文件检查内部重复键
if err := validateAndParseNutrientJSON(jsonData); err != nil {
return fmt.Errorf("JSON源文件校验失败: %w", err)
}
// 2. 解析简介信息
descriptionsNode := gjson.GetBytes(jsonData, "descriptions")
rawMaterialInfos := make(map[string]rawMaterialInfo)
nutrientDescriptions := make(map[string]string)
if descriptionsNode.Exists() {
// 解析 raw_materials 描述、价格和最大添加量
descriptionsNode.Get("raw_materials").ForEach(func(key, value gjson.Result) bool {
rawMaterialInfos[key.String()] = rawMaterialInfo{
Description: value.Get("descriptions").String(),
UnitPrice: float32(value.Get("unit_price").Float()),
MaxRatio: float32(value.Get("max_ratio").Float()),
}
return true
})
descriptionsNode.Get("nutrients").ForEach(func(key, value gjson.Result) bool {
nutrientDescriptions[key.String()] = value.String()
return true
})
}
// 3. 将通过校验的、干净的数据写入数据库
dataNode := gjson.GetBytes(jsonData, "data")
dataNode.ForEach(func(rawMaterialKey, rawMaterialValue gjson.Result) bool {
rawMaterialName := rawMaterialKey.String()
var rawMaterial models.RawMaterial
// 获取原料的描述、价格和最大添加量信息
info := rawMaterialInfos[rawMaterialName]
// 将 Description, ReferencePrice 和 MaxAdditionRatio 放入 Create 对象中
err = tx.Where(models.RawMaterial{Name: rawMaterialName}).
FirstOrCreate(&rawMaterial, models.RawMaterial{
Name: rawMaterialName,
Description: info.Description,
ReferencePrice: info.UnitPrice,
MaxAdditionRatio: info.MaxRatio,
}).Error
if err != nil {
// 返回 false 停止 ForEach 遍历
return false
}
rawMaterialValue.ForEach(func(nutrientKey, nutrientValue gjson.Result) bool {
nutrientName := nutrientKey.String()
value := float32(nutrientValue.Float())
var nutrient models.Nutrient
// 将 Description 放入 Create 对象中
err = tx.Where(models.Nutrient{Name: nutrientName}).
FirstOrCreate(&nutrient, models.Nutrient{
Name: nutrientName,
Description: nutrientDescriptions[nutrientName],
}).Error
if err != nil {
// 返回 false 停止 ForEach 遍历
return false
}
linkData := models.RawMaterialNutrient{
RawMaterialID: rawMaterial.ID,
NutrientID: nutrient.ID,
}
// 使用 FirstOrCreate 确保关联的唯一性
err = tx.Where(linkData).FirstOrCreate(&linkData, models.RawMaterialNutrient{
RawMaterialID: linkData.RawMaterialID,
NutrientID: linkData.NutrientID,
Value: value,
}).Error
if err != nil {
// 返回 false 停止 ForEach 遍历
return false
}
return true
})
return err == nil // 如果内部遍历有错误,则停止外部遍历
})
return err // 返回捕获到的错误
}
// validateAndParseNutrientJSON 严格校验JSON文件
func validateAndParseNutrientJSON(jsonData []byte) error {
descriptionsNode := gjson.GetBytes(jsonData, "descriptions")
if !descriptionsNode.Exists() {
return errors.New("JSON文件中缺少 'descriptions' 字段")
}
if !descriptionsNode.IsObject() {
return errors.New("'descriptions' 字段必须是一个JSON对象")
}
rawMaterialsNode := descriptionsNode.Get("raw_materials")
if !rawMaterialsNode.Exists() {
return errors.New("JSON文件中缺少 'descriptions.raw_materials' 字段")
}
if !rawMaterialsNode.IsObject() {
return errors.New("'descriptions.raw_materials' 字段必须是一个JSON对象")
}
// 使用 json.Decoder 严格校验 raw_materials 的结构
decoder := json.NewDecoder(bytes.NewReader([]byte(rawMaterialsNode.Raw)))
decoder.UseNumber()
if t, err := decoder.Token(); err != nil || t != json.Delim('{') {
return fmt.Errorf("'descriptions.raw_materials' 字段解析起始符失败: %v", err)
}
seenRawMaterials := make(map[string]bool)
for decoder.More() {
// 1. 解析原料名称
t, err := decoder.Token()
if err != nil {
return fmt.Errorf("解析原料名称失败: %w", err)
}
rawMaterialName := t.(string)
if seenRawMaterials[rawMaterialName] {
return fmt.Errorf("原料名称 '%s' 重复", rawMaterialName)
}
seenRawMaterials[rawMaterialName] = true
// 2. 解析该原料的描述和价格对象
if t, err := decoder.Token(); err != nil || t != json.Delim('{') {
return fmt.Errorf("期望原料 '%s' 的值是一个JSON对象", rawMaterialName)
}
for decoder.More() {
t, err := decoder.Token()
if err != nil {
return fmt.Errorf("解析原料 '%s' 内部键失败: %w", rawMaterialName, err)
}
key := t.(string)
switch key {
case "descriptions":
t, err = decoder.Token()
if err != nil {
return fmt.Errorf("解析原料 '%s' 的 'descriptions' 值失败: %w", rawMaterialName, err)
}
if _, ok := t.(string); !ok {
return fmt.Errorf("期望原料 '%s' 的 'descriptions' 值是字符串, 但实际得到的类型是 %T, 值为 '%v'", rawMaterialName, t, t)
}
case "unit_price":
t, err = decoder.Token()
if err != nil {
return fmt.Errorf("解析原料 '%s' 的 'unit_price' 值失败: %w", rawMaterialName, err)
}
if _, ok := t.(json.Number); !ok {
return fmt.Errorf("期望原料 '%s' 的 'unit_price' 值是数字, 但实际得到的类型是 %T, 值为 '%v'", rawMaterialName, t, t)
}
case "max_ratio":
t, err = decoder.Token()
if err != nil {
return fmt.Errorf("解析原料 '%s' 的 'max_ratio' 值失败: %w", rawMaterialName, err)
}
if _, ok := t.(json.Number); !ok {
return fmt.Errorf("期望原料 '%s' 的 'max_ratio' 值是数字, 但实际得到的类型是 %T, 值为 '%v'", rawMaterialName, t, t)
}
default:
// 忽略其他未知字段,但仍需读取其值以继续解析
if _, err := decoder.Token(); err != nil {
return fmt.Errorf("解析原料 '%s' 的未知键 '%s' 的值失败: %w", rawMaterialName, key, err)
}
}
}
// 读取原料描述和价格对象的 "}"
if t, err := decoder.Token(); err != nil || t != json.Delim('}') {
return fmt.Errorf("解析原料 '%s' 的值结束符 '}' 失败", rawMaterialName)
}
}
// 校验 data 节点
dataNode := gjson.GetBytes(jsonData, "data")
if !dataNode.Exists() {
return errors.New("JSON文件中缺少 'data' 字段")
}
if !dataNode.IsObject() {
return errors.New("'data' 字段必须是一个JSON对象")
}
// 重新初始化 decoder 用于 data 节点的校验
decoder = json.NewDecoder(bytes.NewReader([]byte(dataNode.Raw)))
decoder.UseNumber()
if t, err := decoder.Token(); err != nil || t != json.Delim('{') {
return errors.New("'data' 字段解析起始符失败")
}
seenRawMaterials = make(map[string]bool) // 重置 seenRawMaterials 用于 data 节点校验
for decoder.More() {
// 1. 解析原料名称
t, err := decoder.Token()
if err != nil {
return fmt.Errorf("解析原料名称失败: %w", err)
}
rawMaterialName := t.(string)
if seenRawMaterials[rawMaterialName] {
return fmt.Errorf("原料名称 '%s' 重复", rawMaterialName)
}
seenRawMaterials[rawMaterialName] = true
// 2. 解析该原料的营养成分对象
if t, err := decoder.Token(); err != nil || t != json.Delim('{') {
return fmt.Errorf("期望原料 '%s' 的值是一个JSON对象", rawMaterialName)
}
seenNutrients := make(map[string]bool)
for decoder.More() {
// 解析营养素名称
t, err := decoder.Token()
if err != nil {
return fmt.Errorf("在原料 '%s' 中解析营养素名称失败: %w", rawMaterialName, err)
}
nutrientName := t.(string)
if seenNutrients[nutrientName] {
return fmt.Errorf("在原料 '%s' 中, 营养素名称 '%s' 重复", rawMaterialName, nutrientName)
}
seenNutrients[nutrientName] = true
// 解析营养素含量
t, err = decoder.Token()
if err != nil {
return fmt.Errorf("在原料 '%s' 中解析营养素 '%s' 的含量值失败: %w", rawMaterialName, nutrientName, err)
}
if _, ok := t.(json.Number); !ok {
return fmt.Errorf("期望营养素 '%s' 的含量值是数字, 但实际得到的类型是 %T, 值为 '%v'", nutrientName, t, t)
}
}
// 读取营养成分对象的 "}"
if t, err := decoder.Token(); err != nil || t != json.Delim('}') {
return fmt.Errorf("解析原料 '%s' 的值结束符 '}' 失败", rawMaterialName)
}
}
return nil
}

View File

@@ -0,0 +1,295 @@
package seeder
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"github.com/tidwall/gjson"
"gorm.io/gorm"
)
// PigNutrientRequirementSeeder 实现了 database.Seeder 接口,
// 负责播种猪的营养需求相关的预设数据。
type PigNutrientRequirementSeeder struct{}
// Type 返回此 Seeder 负责处理的数据类型标识符。
func (*PigNutrientRequirementSeeder) Type() string {
return "pig_nutrient_requirements"
}
// IsRequired 标记此 Seeder 对应的预设文件是必需的。
func (*PigNutrientRequirementSeeder) IsRequired() bool {
return true
}
// Seed 先严格校验JSON源文件然后以“有则跳过”的模式播种数据。
func (*PigNutrientRequirementSeeder) Seed(ctx context.Context, tx *gorm.DB, jsonData []byte) error {
logger := logs.GetLogger(ctx)
// 检查 PigBreed 表是否为空,如果非空则跳过播种
isEmpty, err := isTableEmpty(tx, &models.PigBreed{})
if err != nil {
return fmt.Errorf("检查 PigBreed 表是否为空失败: %w", err)
}
if !isEmpty {
logger.Info("已存在猪种数据, 跳过数据播种")
return nil
}
// 1. 严格校验JSON文件检查内部重复键
if err := validateAndParsePigNutrientRequirementJSON(jsonData); err != nil {
return fmt.Errorf("JSON源文件校验失败: %w", err)
}
// 2. 解析简介信息
descriptionsNode := gjson.GetBytes(jsonData, "descriptions")
pigBreedDescriptions := make(map[string]models.PigBreed)
pigAgeStageDescriptions := make(map[string]models.PigAgeStage)
pigTypeDescriptions := make(map[string]map[string]models.PigType)
if descriptionsNode.Exists() {
// 解析 pig_breeds 描述
descriptionsNode.Get("pig_breeds").ForEach(func(key, value gjson.Result) bool {
var pb models.PigBreed
pb.Name = key.String()
pb.Description = value.Get("description").String()
pb.ParentInfo = value.Get("parent_info").String()
pb.AppearanceFeatures = value.Get("appearance_features").String()
pb.BreedAdvantages = value.Get("breed_advantages").String()
pb.BreedDisadvantages = value.Get("breed_disadvantages").String()
pigBreedDescriptions[key.String()] = pb
return true
})
// 解析 pig_age_stages 描述
descriptionsNode.Get("pig_age_stages").ForEach(func(key, value gjson.Result) bool {
var pas models.PigAgeStage
pas.Name = key.String()
pas.Description = value.String()
pigAgeStageDescriptions[key.String()] = pas
return true
})
// 解析 pig_breed_age_stages (PigType) 描述
descriptionsNode.Get("pig_breed_age_stages").ForEach(func(breedKey, breedValue gjson.Result) bool {
if _, ok := pigTypeDescriptions[breedKey.String()]; !ok {
pigTypeDescriptions[breedKey.String()] = make(map[string]models.PigType)
}
breedValue.ForEach(func(ageStageKey, ageStageValue gjson.Result) bool {
var pt models.PigType
pt.Description = ageStageValue.Get("description").String()
pt.DailyFeedIntake = float32(ageStageValue.Get("daily_feed_intake").Float())
pt.DailyGainWeight = float32(ageStageValue.Get("daily_gain_weight").Float())
pt.MinDays = uint32(ageStageValue.Get("min_days").Uint())
pt.MaxDays = uint32(ageStageValue.Get("max_days").Uint())
pt.MinWeight = float32(ageStageValue.Get("min_weight").Float())
pt.MaxWeight = float32(ageStageValue.Get("max_weight").Float())
pigTypeDescriptions[breedKey.String()][ageStageKey.String()] = pt
return true
})
return true
})
}
// 3. 将通过校验的、干净的数据写入数据库
dataNode := gjson.GetBytes(jsonData, "data")
dataNode.ForEach(func(breedKey, breedValue gjson.Result) bool {
breedName := breedKey.String()
var pigBreed models.PigBreed
// 查找或创建 PigBreed
pbDesc := pigBreedDescriptions[breedName]
err = tx.Where(models.PigBreed{Name: breedName}).
FirstOrCreate(&pigBreed, models.PigBreed{
Name: breedName,
Description: pbDesc.Description,
ParentInfo: pbDesc.ParentInfo,
AppearanceFeatures: pbDesc.AppearanceFeatures,
BreedAdvantages: pbDesc.BreedAdvantages,
BreedDisadvantages: pbDesc.BreedDisadvantages,
}).Error
if err != nil {
return false
}
breedValue.ForEach(func(ageStageKey, ageStageValue gjson.Result) bool {
ageStageName := ageStageKey.String()
var pigAgeStage models.PigAgeStage
// 查找或创建 PigAgeStage
pasDesc := pigAgeStageDescriptions[ageStageName]
err = tx.Where(models.PigAgeStage{Name: ageStageName}).
FirstOrCreate(&pigAgeStage, models.PigAgeStage{
Name: ageStageName,
Description: pasDesc.Description,
}).Error
if err != nil {
return false
}
var pigType models.PigType
// 查找或创建 PigType
ptDesc := pigTypeDescriptions[breedName][ageStageName]
err = tx.Where(models.PigType{BreedID: pigBreed.ID, AgeStageID: pigAgeStage.ID}).
FirstOrCreate(&pigType, models.PigType{
BreedID: pigBreed.ID,
AgeStageID: pigAgeStage.ID,
Description: ptDesc.Description,
DailyFeedIntake: ptDesc.DailyFeedIntake,
DailyGainWeight: ptDesc.DailyGainWeight,
MinDays: ptDesc.MinDays,
MaxDays: ptDesc.MaxDays,
MinWeight: ptDesc.MinWeight,
MaxWeight: ptDesc.MaxWeight,
}).Error
if err != nil {
return false
}
ageStageValue.ForEach(func(nutrientKey, nutrientValue gjson.Result) bool {
nutrientName := nutrientKey.String()
minReq := float32(nutrientValue.Get("min_requirement").Float())
maxReq := float32(nutrientValue.Get("max_requirement").Float())
var nutrient models.Nutrient
// 查找或创建 Nutrient (这里假设 Nutrient 已经在 SeedNutrients 中处理,但为了健壮性,再次 FirstOrCreate)
err = tx.Where(models.Nutrient{Name: nutrientName}).
FirstOrCreate(&nutrient, models.Nutrient{
Name: nutrientName,
// Description 字段在 nutrient seeder 中处理,这里不设置
}).Error
if err != nil {
return false
}
linkData := models.PigNutrientRequirement{
PigTypeID: pigType.ID,
NutrientID: nutrient.ID,
MinRequirement: minReq,
MaxRequirement: maxReq,
}
// 使用 FirstOrCreate 确保关联的唯一性
err = tx.Where(models.PigNutrientRequirement{
PigTypeID: pigType.ID,
NutrientID: nutrient.ID,
}).FirstOrCreate(&linkData, linkData).Error
if err != nil {
return false
}
return true
})
return err == nil // 如果内部遍历有错误,则停止外部遍历
})
return err == nil // 如果内部遍历有错误,则停止外部遍历
})
return err // 返回捕获到的错误
}
// validateAndParsePigNutrientRequirementJSON 严格校验猪营养需求JSON文件
func validateAndParsePigNutrientRequirementJSON(jsonData []byte) error {
dataNode := gjson.GetBytes(jsonData, "data")
if !dataNode.Exists() {
return errors.New("JSON文件中缺少 'data' 字段")
}
if !dataNode.IsObject() {
return errors.New("'data' 字段必须是一个JSON对象")
}
decoder := json.NewDecoder(bytes.NewReader([]byte(dataNode.Raw)))
decoder.UseNumber()
if t, err := decoder.Token(); err != nil || t != json.Delim('{') {
return fmt.Errorf("'data' 字段解析起始符失败: %v", err)
}
seenBreeds := make(map[string]bool)
for decoder.More() {
// 解析 PigBreed 名称
t, err := decoder.Token()
if err != nil {
return fmt.Errorf("解析猪品种名称失败: %w", err)
}
breedName := t.(string)
if seenBreeds[breedName] {
return fmt.Errorf("猪品种名称 '%s' 重复", breedName)
}
seenBreeds[breedName] = true
// 解析该品种的年龄阶段对象
if t, err := decoder.Token(); err != nil || t != json.Delim('{') {
return fmt.Errorf("期望猪品种 '%s' 的值是一个JSON对象", breedName)
}
seenAgeStages := make(map[string]bool)
for decoder.More() {
// 解析 PigAgeStage 名称
t, err := decoder.Token()
if err != nil {
return fmt.Errorf("在猪品种 '%s' 中解析年龄阶段名称失败: %w", breedName, err)
}
ageStageName := t.(string)
if seenAgeStages[ageStageName] {
return fmt.Errorf("在猪品种 '%s' 中, 年龄阶段名称 '%s' 重复", breedName, ageStageName)
}
seenAgeStages[ageStageName] = true
// 解析该年龄阶段的营养成分对象
if t, err := decoder.Token(); err != nil || t != json.Delim('{') {
return fmt.Errorf("期望年龄阶段 '%s' 的值是一个JSON对象", ageStageName)
}
seenNutrients := make(map[string]bool)
for decoder.More() {
// 解析 Nutrient 名称
t, err := decoder.Token()
if err != nil {
return fmt.Errorf("在年龄阶段 '%s' 中解析营养素名称失败: %w", ageStageName, err)
}
nutrientName := t.(string)
if seenNutrients[nutrientName] {
return fmt.Errorf("在年龄阶段 '%s' 中, 营养素名称 '%s' 重复", ageStageName, nutrientName)
}
seenNutrients[nutrientName] = true
// 解析 min_requirement 和 max_requirement 对象
if t, err := decoder.Token(); err != nil || t != json.Delim('{') {
return fmt.Errorf("期望营养素 '%s' 的值是一个JSON对象", nutrientName)
}
for decoder.More() {
t, err := decoder.Token()
if err != nil {
return fmt.Errorf("解析营养素 '%s' 的需求键失败: %w", nutrientName, err)
}
// key := t.(string) // 校验时不需要使用 key 的值
t, err = decoder.Token()
if err != nil {
return fmt.Errorf("解析营养素 '%s' 的需求值失败: %w", nutrientName, err)
}
if _, ok := t.(json.Number); !ok {
return fmt.Errorf("期望营养素 '%s' 的需求值是数字, 但实际得到的类型是 %T, 值为 '%v'", nutrientName, t, t)
}
}
if t, err := decoder.Token(); err != nil || t != json.Delim('}') {
return fmt.Errorf("解析营养素 '%s' 的值结束符 '}' 失败", nutrientName)
}
}
if t, err := decoder.Token(); err != nil || t != json.Delim('}') {
return fmt.Errorf("解析年龄阶段 '%s' 的值结束符 '}' 失败", ageStageName)
}
}
if t, err := decoder.Token(); err != nil || t != json.Delim('}') {
return fmt.Errorf("解析猪品种 '%s' 的值结束符 '}' 失败", breedName)
}
}
return nil
}

View File

@@ -0,0 +1,18 @@
package seeder
import (
"fmt"
"gorm.io/gorm"
)
// isTableEmpty 检查给定模型对应的数据库表是否为空。
// 注意:此函数需要从 database 包中移动过来,或者在 seeder 包中重新定义,
// 为了避免循环依赖,这里选择在 seeder 包中重新定义。
func isTableEmpty(tx *gorm.DB, model interface{}) (bool, error) {
var count int64
if err := tx.Model(model).Count(&count).Error; err != nil {
return false, fmt.Errorf("查询表记录数失败: %w", err)
}
return count == 0, nil
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport/proto"
)
// 设备行为
@@ -21,16 +22,44 @@ var (
MethodSwitch Method = "switch" // 启停指令
)
// Service 抽象了一组方法用于控制设备行为
type Service interface {
// SendOptions 包含了发送通用指令时的可选参数。
type SendOptions struct {
// NotTrackable 如果为 true则指示本次发送无需被追踪。
// 这将阻止系统为本次发送创建 device_command_logs 记录。
// 默认为 false即需要追踪。
NotTrackable bool
}
// Switch 用于切换指定设备的状态, 比如启动和停止
// SendOption 是一个函数类型,用于修改 SendOptions。
// 这是实现 "Functional Options Pattern" 的核心。
type SendOption func(*SendOptions)
// WithoutTracking 是一个公开的选项函数,用于明确指示本次发送无需追踪。
// 调用方在发送 Ping 等无需响应确认的指令时,应使用此选项。
func WithoutTracking() SendOption {
return func(opts *SendOptions) {
opts.NotTrackable = true
}
}
// DeviceOperator 提供了对单个或多个设备进行具体操作的接口,
// 如开关、触发采集等。它通常用于响应用户的直接指令或执行具体的业务任务。
type DeviceOperator interface {
// Switch 用于切换指定设备的状态, 比如启动和停止。
Switch(ctx context.Context, device *models.Device, action DeviceAction) error
// Collect 用于发起对指定区域主控下的多个设备的批量采集请求。
Collect(ctx context.Context, areaControllerID uint32, devicesToCollect []*models.Device) error
}
// DeviceCommunicator 抽象了与设备进行底层通信的能力。
// 它负责将一个标准的指令载荷发送到指定的区域主控。
type DeviceCommunicator interface {
// Send 是一个通用的发送方法,它负责将载荷包装、序列化、
// 调用底层发送器,并默认记录下行命令日志。
Send(ctx context.Context, areaControllerID uint32, payload proto.InstructionPayload, opts ...SendOption) error
}
// 设备操作指令通用结构(最外层)
type DeviceRequest struct {
MessageID int // 消息ID, 用于后续匹配响应
@@ -44,3 +73,17 @@ type DeviceResponse struct {
Message string
Data interface{} // 响应内容
}
// OtaService 定义了设备 OTA 升级相关的业务逻辑。
type OtaService interface {
// StartUpgrade 用于启动一个 OTA 升级任务。
// areaControllerID: 目标区域主控的设备 ID。
// firmwarePath: 新固件文件所在的临时目录的绝对路径。
// 返回创建的 OTA 任务 ID 和可能发生的错误。
StartUpgrade(ctx context.Context, areaControllerID uint32, firmwarePath string) (uint32, error)
// StopUpgrade 用于请求停止一个正在进行的 OTA 升级任务。
// taskID: 要停止的 OTA 任务 ID。
// 注意:这只是一个尽力而为的操作。如果设备已开始下载或处理,可能无法立即中止。
StopUpgrade(ctx context.Context, taskID uint32) error
}

View File

@@ -20,6 +20,7 @@ import (
type GeneralDeviceService struct {
ctx context.Context
deviceRepo repository.DeviceRepository
areaControllerRepo repository.AreaControllerRepository
deviceCommandLogRepo repository.DeviceCommandLogRepository
pendingCollectionRepo repository.PendingCollectionRepository
comm transport.Communicator
@@ -29,13 +30,15 @@ type GeneralDeviceService struct {
func NewGeneralDeviceService(
ctx context.Context,
deviceRepo repository.DeviceRepository,
areaControllerRepo repository.AreaControllerRepository,
deviceCommandLogRepo repository.DeviceCommandLogRepository,
pendingCollectionRepo repository.PendingCollectionRepository,
comm transport.Communicator,
) Service {
) *GeneralDeviceService {
return &GeneralDeviceService{
ctx: ctx,
deviceRepo: deviceRepo,
areaControllerRepo: areaControllerRepo,
deviceCommandLogRepo: deviceCommandLogRepo,
pendingCollectionRepo: pendingCollectionRepo,
comm: comm,
@@ -223,7 +226,7 @@ func (g *GeneralDeviceService) Collect(ctx context.Context, areaControllerID uin
logger.Errorf("创建待采集请求失败 (CorrelationID: %s): %v", correlationID, err)
return err
}
logger.Infof("成功创建待采集请求 (CorrelationID: %s, DeviceID: %d)", correlationID, areaController.ID)
logger.Debugf("成功创建待采集请求 (CorrelationID: %s, DeviceID: %d)", correlationID, areaController.ID)
// 5. 构建最终的空中载荷
batchCmd := &proto.BatchCollectCommand{
@@ -240,12 +243,79 @@ func (g *GeneralDeviceService) Collect(ctx context.Context, areaControllerID uin
logger.Errorf("序列化采集指令失败 (CorrelationID: %s): %v", correlationID, err)
return err
}
logger.Infof("构造空中载荷成功: networkID: %v, payload: %v", networkID, instruction)
logger.Debugf("构造空中载荷成功: networkID: %v, payload: %v", networkID, instruction)
if _, err := g.comm.Send(serviceCtx, networkID, payload); err != nil {
logger.DPanicf("待采集请求 (CorrelationID: %s) 已创建,但发送到设备失败: %v。数据可能不一致", correlationID, err)
return err
}
logger.Infof("成功将采集请求 (CorrelationID: %s) 发送到设备 %s", correlationID, networkID)
logger.Debugf("成功将采集请求 (CorrelationID: %s) 发送到设备 %s", correlationID, networkID)
return nil
}
// Send 实现了 Service 接口,用于发送一个通用的指令载荷。
// 它将载荷包装成顶层指令,然后执行查找网络地址、序列化、发送和记录日志的完整流程。
func (g *GeneralDeviceService) Send(ctx context.Context, areaControllerID uint32, payload proto.InstructionPayload, opts ...SendOption) error {
serviceCtx, logger := logs.Trace(ctx, g.ctx, "Send")
// 1. 应用选项
options := &SendOptions{}
for _, opt := range opts {
opt(options)
}
// 2. 查找区域主控以获取 NetworkID
areaController, err := g.areaControllerRepo.FindByID(serviceCtx, areaControllerID)
if err != nil {
return fmt.Errorf("发送通用指令失败无法找到ID为 %d 的区域主控: %w", areaControllerID, err)
}
// 3. 将载荷包装进顶层 Instruction 结构体
instruction := &proto.Instruction{
Payload: payload,
}
// 4. 序列化指令
message, err := gproto.Marshal(instruction)
if err != nil {
return fmt.Errorf("序列化通用指令失败: %w", err)
}
// 5. 发送指令
networkID := areaController.NetworkID
sendResult, err := g.comm.Send(serviceCtx, networkID, message)
if err != nil {
return fmt.Errorf("发送通用指令到 %s 失败: %w", networkID, err)
}
// 6. 始终创建 DeviceCommandLog 记录,但根据选项设置其初始状态
logRecord := &models.DeviceCommandLog{
MessageID: sendResult.MessageID,
DeviceID: areaController.ID, // 将日志与区域主控关联
SentAt: time.Now(),
}
if options.NotTrackable {
// 对于无需追踪的指令,直接标记为已完成
now := time.Now()
logRecord.AcknowledgedAt = &now
logRecord.ReceivedSuccess = true
logger.Infow("成功发送一个无需追踪的通用指令,并记录为已完成日志", "networkID", networkID, "MessageID", sendResult.MessageID)
} else {
// 对于需要追踪的指令,记录其发送结果,等待异步确认
if sendResult.AcknowledgedAt != nil {
logRecord.AcknowledgedAt = sendResult.AcknowledgedAt
}
if sendResult.ReceivedSuccess != nil {
logRecord.ReceivedSuccess = *sendResult.ReceivedSuccess
}
logger.Infow("成功发送通用指令,并创建追踪日志", "networkID", networkID, "MessageID", sendResult.MessageID)
}
if err := g.deviceCommandLogRepo.Create(serviceCtx, logRecord); err != nil {
// 记录日志失败是一个需要关注的问题,但可能不应该中断主流程。
logger.Errorw("创建通用指令的日志失败", "MessageID", sendResult.MessageID, "error", err)
}
return nil
}

View File

@@ -0,0 +1,361 @@
package device
import (
"context"
"crypto/ecdsa"
"crypto/md5"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"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"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/utils/file"
"github.com/gibson042/canonicaljson-go"
)
// Manifest 代表 OTA 升级的清单文件 (manifest.json) 的结构。
// 它包含了固件的元数据和所有待更新文件的详细信息。
type Manifest struct {
Version string `json:"version"` // 新固件的版本号
Files []ManifestFile `json:"files"` // 待更新的文件列表
// Signature 是对 Manifest 内容(不含 Signature 字段本身)的数字签名。
//
// **签名生成流程 (平台侧)**:
// 1. 将此结构体的 Signature 字段设置为空字符串 ""。
// 2. 使用确定性 JSON 库 (如 canonicaljson) 将结构体序列化。
// 3. 对序列化后的字节流计算 SHA-256 哈希。
// 4. 使用平台私钥对哈希进行签名。
// 5. 将签名结果进行 Base64 编码后,填充到此字段。
//
// **签名校验流程 (设备侧)**:
// 1. 从接收到的 manifest.json 中解析出 Manifest 结构。
// 2. 暂存 Signature 字段的值。
// 3. **关键:将结构体中的 Signature 字段置为空字符串 "" (而不是移除该字段)。**
// 4. 使用确定性 JSON 规则(如 Python 的 json.dumps(sort_keys=True))将修改后的结构体序列化。
// 5. 对序列化后的字节流计算 SHA-256 哈希。
// 6. 使用平台公钥、暂存的签名和计算出的哈希进行验签。
Signature string `json:"signature"`
}
// ManifestFile 定义了清单文件中单个文件的元数据。
type ManifestFile struct {
Path string `json:"path"` // 文件在设备上的目标绝对路径
MD5 string `json:"md5"` // 文件的 MD5 校验和
Size int64 `json:"size"` // 文件的大小(字节)
}
// OtaConfig 封装了 OTA 服务所需的可配置参数。
type OtaConfig struct {
DefaultRetryCount uint32 // 默认的设备端文件下载重试次数
DefaultRequestTimeoutS uint32 // 默认的设备端文件下载请求超时时间(秒)
}
// otaServiceImpl 是 OtaService 接口的实现。
type otaServiceImpl struct {
ctx context.Context
config OtaConfig
otaRepo repository.OtaRepository
generalDeviceService *GeneralDeviceService
}
// NewOtaService 创建一个新的 OtaService 实例。
func NewOtaService(
ctx context.Context,
config OtaConfig,
otaRepo repository.OtaRepository,
generalDeviceService *GeneralDeviceService,
) OtaService {
return &otaServiceImpl{
ctx: ctx,
config: config,
otaRepo: otaRepo,
generalDeviceService: generalDeviceService,
}
}
// upgradeTask 封装了单次升级任务的所有上下文和操作,以提高代码的可读性和模块化。
type upgradeTask struct {
service *otaServiceImpl
ctx context.Context
logger *logs.Logger
task *models.OTATask
firmwarePath string
tempSubDir string
}
// run 执行核心的升级准备流程。
// 此方法内的所有操作都处于一个文件锁的保护下。
func (t *upgradeTask) run() error {
// 步骤 1: 解压固件
tempDestPath, err := file.CreateTempDir(t.tempSubDir)
if err != nil {
return fmt.Errorf("创建临时目录失败: %w", err)
}
if err := file.Decompress(t.firmwarePath, tempDestPath); err != nil {
return fmt.Errorf("解压固件失败: %w", err)
}
t.logger.Infof("为任务 %d 成功解压固件到 %s", t.task.ID, tempDestPath)
// 步骤 2: 生成、签名并写入 manifest 文件
manifest, err := t.service.generateManifest(t.tempSubDir)
if err != nil {
return fmt.Errorf("生成 manifest 失败: %w", err)
}
if err := t.service.signManifest(manifest); err != nil {
return fmt.Errorf("签名 manifest 失败: %w", err)
}
manifestBytes, err := json.Marshal(manifest)
if err != nil {
return fmt.Errorf("序列化 manifest.json 失败: %w", err)
}
if _, err := file.WriteTempFile(t.tempSubDir, "manifest.json", manifestBytes); err != nil {
return fmt.Errorf("写入 manifest.json 失败: %w", err)
}
t.logger.Infof("为任务 %d 成功生成并签名 manifest.json", t.task.ID)
// 步骤 3: 发送升级指令
manifestMD5 := fmt.Sprintf("%x", md5.Sum(manifestBytes))
prepareReq := &proto.PrepareUpdateReq{
Version: manifest.Version,
TaskId: t.task.ID,
ManifestMd5: manifestMD5,
RetryCount: t.service.config.DefaultRetryCount,
RequestTimeoutSeconds: t.service.config.DefaultRequestTimeoutS,
}
instructionPayload := &proto.Instruction_PrepareUpdateReq{PrepareUpdateReq: prepareReq}
if err := t.service.generalDeviceService.Send(t.ctx, t.task.AreaControllerID, instructionPayload, WithoutTracking()); err != nil {
return fmt.Errorf("发送升级指令失败: %w", err)
}
t.logger.Infof("为任务 %d 成功发送升级指令", t.task.ID)
// 步骤 4: 更新任务状态为“进行中”
t.task.Status = models.OTATaskStatusInProgress
t.task.TargetVersion = manifest.Version // 回填目标版本号
if err := t.service.otaRepo.Update(t.ctx, t.task); err != nil {
return fmt.Errorf("更新任务状态为 '进行中' 失败: %w", err)
}
return nil
}
// rollback 在 run 方法失败时执行清理和状态更新操作。
func (t *upgradeTask) rollback(originalErr error) {
t.logger.Errorf("任务 %d 文件准备阶段失败,执行回滚: %v", t.task.ID, originalErr)
// 更新数据库状态为“准备文件失败”
t.task.Status = models.OTATaskStatusFailedPreparation
t.task.ErrorMessage = fmt.Sprintf("文件准备阶段失败: %v", originalErr)
now := time.Now()
t.task.CompletedAt = &now
if updateErr := t.service.otaRepo.Update(t.ctx, t.task); updateErr != nil {
t.logger.DPanicf("CRITICAL: 任务 %d 回滚失败后,更新其状态也失败了: %v", t.task.ID, updateErr)
}
// 清理临时解压目录
if removeDirErr := file.RemoveTempDir(t.tempSubDir); removeDirErr != nil {
t.logger.Warnf("回滚操作:清理任务 %d 的临时目录 %s 失败: %v", t.task.ID, t.tempSubDir, removeDirErr)
}
// 清理原始固件压缩包
if removeSrcErr := os.Remove(t.firmwarePath); removeSrcErr != nil {
t.logger.Warnf("回滚操作:清理任务 %d 的源固件 %s 失败: %v", t.task.ID, t.firmwarePath, removeSrcErr)
}
}
func (o *otaServiceImpl) StartUpgrade(ctx context.Context, areaControllerID uint32, firmwarePath string) (uint32, error) {
serviceCtx, logger := logs.Trace(ctx, o.ctx, "StartUpgrade")
// 步骤 1: 预创建数据库记录
task := &models.OTATask{
AreaControllerID: areaControllerID,
Status: models.OTATaskStatusPending,
CreatedAt: time.Now(),
}
if err := o.otaRepo.Create(serviceCtx, task); err != nil {
logger.Errorf("预创建 OTA 任务记录失败: %v", err)
return 0, fmt.Errorf("预创建 OTA 任务记录失败: %w", err)
}
logger.Infof("成功预创建 OTA 任务记录, ID: %d", task.ID)
// 步骤 2: 初始化升级任务执行器
upgrade := &upgradeTask{
service: o,
ctx: serviceCtx,
logger: logger,
task: task,
firmwarePath: firmwarePath,
tempSubDir: filepath.Join(models.OTADir, fmt.Sprintf("%d", task.ID)),
}
// 步骤 3: 在文件锁的保护下,原子化地执行升级准备流程
if err := file.ExecuteWithLock(upgrade.run, upgrade.rollback); err != nil {
// 此处的错误已在 rollback 中处理和记录,这里只向调用方返回失败信号
logger.Errorf("OTA 任务 %d 未能成功启动: %v", task.ID, err)
return 0, err
}
logger.Infof("OTA 升级任务 %d 已成功启动", task.ID)
return task.ID, nil
}
func (o *otaServiceImpl) StopUpgrade(ctx context.Context, taskID uint32) error {
serviceCtx, logger := logs.Trace(ctx, o.ctx, "StopUpgrade")
task, err := o.otaRepo.FindByID(serviceCtx, taskID)
if err != nil {
logger.Errorf("查找 OTA 任务失败: %v, 任务ID: %d", err, taskID)
return fmt.Errorf("查找 OTA 任务失败: %w", err)
}
// 幂等性检查:如果任务已处于终态,则直接返回成功
if task.IsOver() {
logger.Infof("OTA 任务 %d 已处于终态 %s无需停止", taskID, task.Status)
return nil
}
now := time.Now()
task.Status = models.OTATaskStatusStopped
task.CompletedAt = &now
task.ErrorMessage = "任务被用户手动停止"
if err := o.otaRepo.Update(serviceCtx, task); err != nil {
logger.Errorf("更新 OTA 任务状态失败: %v, 任务ID: %d", err, taskID)
return fmt.Errorf("更新 OTA 任务状态失败: %w", err)
}
// 清理相关文件目录
dirToRemove := filepath.Join(models.OTADir, fmt.Sprintf("%d", taskID))
if err := file.RemoveTempDir(dirToRemove); err != nil {
// 文件清理失败不应阻塞主流程,但需要记录日志
logger.Warnf("清理 OTA 任务 %d 的文件目录 %s 失败: %v", taskID, dirToRemove, err)
}
logger.Infof("OTA 任务 %d 已被成功标记为手动停止", taskID)
return nil
}
// generateManifest 遍历指定的固件包子目录,生成一个完整的 Manifest 对象。
func (o *otaServiceImpl) generateManifest(packageSubDir string) (*Manifest, error) {
// 1. 读取版本文件
versionBytes, err := file.ReadTempFile(packageSubDir, "version")
if err != nil {
return nil, fmt.Errorf("读取 version 文件失败: %w", err)
}
version := strings.TrimSpace(string(versionBytes))
var files []ManifestFile
// 2. 使用 WalkTempDir 遍历
err = file.WalkTempDir(packageSubDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.Name() == "version" || d.Name() == "manifest.json" {
return nil
}
// 3. 获取逻辑相对路径
relPath, err := file.GetRelativePathInTemp(path, packageSubDir)
if err != nil {
return fmt.Errorf("无法计算相对路径 '%s': %w", path, err)
}
// 业务转换: 转换为设备端路径
devicePath := filepath.ToSlash(relPath)
// 跳过目录和忽略config目录下的所有文件
if d.IsDir() {
if devicePath == "config" {
return fs.SkipDir
}
return nil
}
// 4. 读取文件内容用于计算 (直接使用绝对路径,最高效)
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("读取文件失败 '%s': %w", path, err)
}
// 计算元数据
md5Sum := fmt.Sprintf("%x", md5.Sum(data))
// 5. 添加到列表
files = append(files, ManifestFile{
Path: devicePath,
MD5: md5Sum,
Size: int64(len(data)),
})
return nil
})
if err != nil {
return nil, fmt.Errorf("生成清单时遍历目录失败: %w", err)
}
// 6. 创建 Manifest 对象
manifest := &Manifest{
Version: version,
Files: files,
}
return manifest, nil
}
// --- 数字签名常量 ---
// TODO在生产环境中强烈建议使用更安全的方式如环境变量或密钥管理服务来管理私钥。
const pemEncodedPrivateKey = `-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIFDRC/3W22Fw1M/v36w8kO/n8a9A8sUnY2zD1bCgR6eBoAoGCCqGSM49
AwEHoUQDQgAEWbV3aG6g6Fv5a3p4Y5N5a2b3aG6g6Fv5a3p4Y5N5a2b3aG6g6Fv5
a3p4Y5N5a2b3aG6g6Fv5a3p4Y5N5a2Y=
-----END EC PRIVATE KEY-----`
// signManifest 使用硬编码的 ECDSA 私钥对 manifest 进行签名。
// 它遵循确定性 JSON 规范,以确保平台和设备之间可以生成完全一致的待签名数据。
func (o *otaServiceImpl) signManifest(manifest *Manifest) error {
// 1. 加载私钥
block, _ := pem.Decode([]byte(pemEncodedPrivateKey))
if block == nil {
return fmt.Errorf("无法解码 PEM 格式的私钥")
}
privateKey, err := x509.ParseECPrivateKey(block.Bytes)
if err != nil {
return fmt.Errorf("无法解析 ECDSA 私钥: %w", err)
}
// 2. 关键:将 Signature 字段置为空字符串,以准备用于签名的“纯净”数据。
manifest.Signature = ""
// 3. 使用 canonicaljson 库将“纯净”数据序列化为确定性的字节流。
// 这确保了无论执行多少次,只要内容不变,生成的字节流就完全一样。
signableData, err := canonicaljson.Marshal(manifest)
if err != nil {
return fmt.Errorf("无法将 manifest 序列化为确定性 JSON: %w", err)
}
// 4. 对确定性的字节流进行哈希
hash := sha256.Sum256(signableData)
// 5. 使用私钥对哈希进行签名
signatureBytes, err := ecdsa.SignASN1(rand.Reader, privateKey, hash[:])
if err != nil {
return fmt.Errorf("无法对哈希进行签名: %w", err)
}
// 6. 将签名结果进行 Base64 编码后,填充回 manifest 对象。
// 此时 manifest 对象已包含所有信息,可以被序列化并写入最终的 manifest.json 文件。
manifest.Signature = base64.StdEncoding.EncodeToString(signatureBytes)
return nil
}

View File

@@ -0,0 +1,170 @@
package inventory
import (
"context"
"errors"
"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"
"gorm.io/gorm"
)
// 定义领域特定的错误
var (
ErrRawMaterialNotFound = errors.New("原料不存在")
ErrInsufficientStock = errors.New("原料库存不足")
)
// InventoryCoreService 定义了库存领域的核心业务服务接口
type InventoryCoreService interface {
// AdjustStock 调整指定原料的库存
AdjustStock(ctx context.Context, rawMaterialID uint32, changeAmount float32, sourceType models.StockLogSourceType, sourceID *uint32, remarks string) (*models.RawMaterialStockLog, error)
// GetCurrentStock 获取单个原料的当前库存量
GetCurrentStock(ctx context.Context, rawMaterialID uint32) (float32, error)
// BatchGetCurrentStock 批量获取多个原料的当前库存量
BatchGetCurrentStock(ctx context.Context, rawMaterialIDs []uint32) (map[uint32]float32, error)
// ListStockLogs 分页查询库存变动日志
ListStockLogs(ctx context.Context, opts repository.StockLogListOptions, page, pageSize int) ([]models.RawMaterialStockLog, int64, error)
}
// inventoryCoreServiceImpl 是 InventoryCoreService 的实现
type inventoryCoreServiceImpl struct {
ctx context.Context
uow repository.UnitOfWork
rawMatRepo repository.RawMaterialRepository
// 全局库存调整锁,确保所有 AdjustStock 操作串行执行
adjustStockMutex sync.Mutex
}
// NewInventoryCoreService 创建一个新的 InventoryCoreService 实例
func NewInventoryCoreService(ctx context.Context, uow repository.UnitOfWork, rawMatRepo repository.RawMaterialRepository) InventoryCoreService {
return &inventoryCoreServiceImpl{
ctx: ctx,
uow: uow,
rawMatRepo: rawMatRepo,
}
}
// AdjustStock 调整指定原料的库存
func (s *inventoryCoreServiceImpl) AdjustStock(ctx context.Context, rawMaterialID uint32, changeAmount float32, sourceType models.StockLogSourceType, sourceID *uint32, remarks string) (*models.RawMaterialStockLog, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "AdjustStock")
// 使用全局锁确保所有库存调整操作串行执行
s.adjustStockMutex.Lock()
defer s.adjustStockMutex.Unlock()
var createdLog *models.RawMaterialStockLog
err := s.uow.ExecuteInTransaction(serviceCtx, func(tx *gorm.DB) error {
// 在事务中创建 RawMaterialRepository 的新实例
txRawMatRepo := repository.NewGormRawMaterialRepository(serviceCtx, tx)
// 1. 检查原料是否存在
_, err := txRawMatRepo.GetRawMaterialByID(serviceCtx, rawMaterialID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrRawMaterialNotFound
}
return fmt.Errorf("检查原料是否存在时出错: %w", err)
}
// 2. 获取当前库存 (在程序锁的保护下,这里是安全的)
latestLog, err := txRawMatRepo.GetLatestRawMaterialStockLog(serviceCtx, rawMaterialID)
if err != nil {
return fmt.Errorf("获取最新库存日志失败: %w", err)
}
var beforeQuantity float32 = 0
if latestLog != nil {
beforeQuantity = latestLog.AfterQuantity
}
// 3. 计算新库存并检查是否充足
afterQuantity := beforeQuantity + changeAmount
if afterQuantity < 0 {
return ErrInsufficientStock
}
// 4. 创建新的库存日志
newLog := &models.RawMaterialStockLog{
RawMaterialID: rawMaterialID,
ChangeAmount: changeAmount,
BeforeQuantity: beforeQuantity,
AfterQuantity: afterQuantity,
SourceType: sourceType,
SourceID: sourceID,
HappenedAt: time.Now(),
Remarks: remarks,
}
if err := txRawMatRepo.CreateRawMaterialStockLog(serviceCtx, newLog); err != nil {
return fmt.Errorf("创建库存日志失败: %w", err)
}
createdLog = newLog
return nil
})
if err != nil {
return nil, err // 直接返回事务中发生的错误
}
return createdLog, nil
}
// GetCurrentStock 获取单个原料的当前库存量
func (s *inventoryCoreServiceImpl) GetCurrentStock(ctx context.Context, rawMaterialID uint32) (float32, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "GetCurrentStock")
latestLog, err := s.rawMatRepo.GetLatestRawMaterialStockLog(serviceCtx, rawMaterialID)
if err != nil {
return 0, fmt.Errorf("获取最新库存日志失败: %w", err)
}
if latestLog == nil {
// 如果没有日志说明从未入库库存为0
return 0, nil
}
return latestLog.AfterQuantity, nil
}
// BatchGetCurrentStock 批量获取多个原料的当前库存量
func (s *inventoryCoreServiceImpl) BatchGetCurrentStock(ctx context.Context, rawMaterialIDs []uint32) (map[uint32]float32, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "BatchGetCurrentStock")
logMap, err := s.rawMatRepo.BatchGetLatestStockLogsForMaterials(serviceCtx, rawMaterialIDs)
if err != nil {
return nil, fmt.Errorf("批量获取最新库存日志失败: %w", err)
}
stockMap := make(map[uint32]float32, len(rawMaterialIDs))
for _, id := range rawMaterialIDs {
if log, ok := logMap[id]; ok {
stockMap[id] = log.AfterQuantity
} else {
// 如果某个原料在 logMap 中不存在说明它没有任何库存记录库存为0
stockMap[id] = 0
}
}
return stockMap, nil
}
// ListStockLogs 分页查询库存变动日志
func (s *inventoryCoreServiceImpl) ListStockLogs(ctx context.Context, opts repository.StockLogListOptions, page, pageSize int) ([]models.RawMaterialStockLog, int64, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "ListStockLogs")
logs, total, err := s.rawMatRepo.ListStockLogs(serviceCtx, opts, page, pageSize)
if err != nil {
return nil, 0, fmt.Errorf("获取库存日志列表失败: %w", err)
}
return logs, total, nil
}

View File

@@ -6,7 +6,6 @@ import (
"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"
@@ -95,7 +94,6 @@ type planExecutionManagerImpl struct {
taskFactory TaskFactory
analysisPlanTaskManager AnalysisPlanTaskManager
progressTracker *ProgressTracker
deviceService device.Service
pool *ants.Pool // 使用 ants 协程池来管理并发
wg sync.WaitGroup
@@ -112,7 +110,6 @@ func NewPlanExecutionManager(
planRepo repository.PlanRepository,
analysisPlanTaskManager AnalysisPlanTaskManager,
taskFactory TaskFactory,
deviceService device.Service,
interval time.Duration,
numWorkers int,
) ExecutionManager {
@@ -125,7 +122,6 @@ func NewPlanExecutionManager(
planRepo: planRepo,
analysisPlanTaskManager: analysisPlanTaskManager,
taskFactory: taskFactory,
deviceService: deviceService,
pollingInterval: interval,
workers: numWorkers,
progressTracker: NewProgressTracker(),

View File

@@ -0,0 +1,146 @@
package recipe
import (
"context"
"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 (
ErrNutrientNameConflict = fmt.Errorf("营养种类名称已存在")
ErrNutrientNotFound = fmt.Errorf("营养种类不存在")
)
// NutrientService 定义了营养种类领域的核心业务服务接口
type NutrientService interface {
CreateNutrient(ctx context.Context, name, description string) (*models.Nutrient, error)
UpdateNutrient(ctx context.Context, id uint32, name, description string) (*models.Nutrient, error)
DeleteNutrient(ctx context.Context, id uint32) error
GetNutrient(ctx context.Context, id uint32) (*models.Nutrient, error)
ListNutrients(ctx context.Context, opts repository.NutrientListOptions, page, pageSize int) ([]models.Nutrient, int64, error)
}
// nutrientServiceImpl 是 NutrientService 的实现
type nutrientServiceImpl struct {
ctx context.Context
nutrientRepo repository.NutrientRepository
}
// NewNutrientService 创建一个新的 NutrientService 实例
func NewNutrientService(ctx context.Context, nutrientRepo repository.NutrientRepository) NutrientService {
return &nutrientServiceImpl{
ctx: ctx,
nutrientRepo: nutrientRepo,
}
}
// CreateNutrient 实现了创建营养种类的核心业务逻辑
func (s *nutrientServiceImpl) CreateNutrient(ctx context.Context, name, description string) (*models.Nutrient, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "CreateNutrient")
// 检查名称是否已存在
existing, err := s.nutrientRepo.GetNutrientByName(serviceCtx, name)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { // 只有不是记录未找到的错误才返回
return nil, fmt.Errorf("检查营养种类名称失败: %w", err)
}
if existing != nil {
return nil, ErrNutrientNameConflict
}
nutrient := &models.Nutrient{
Name: name,
Description: description,
}
if err := s.nutrientRepo.CreateNutrient(serviceCtx, nutrient); err != nil {
return nil, fmt.Errorf("创建营养种类失败: %w", err)
}
return nutrient, nil
}
// UpdateNutrient 实现了更新营养种类的核心业务逻辑
func (s *nutrientServiceImpl) UpdateNutrient(ctx context.Context, id uint32, name, description string) (*models.Nutrient, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "UpdateNutrient")
// 检查要更新的实体是否存在
nutrient, err := s.nutrientRepo.GetNutrientByID(serviceCtx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { // 如果是记录未找到错误,则返回领域错误
return nil, ErrNutrientNotFound
}
return nil, fmt.Errorf("获取待更新的营养种类失败: %w", err)
}
// 如果名称有变动,检查新名称是否与其它记录冲突
if nutrient.Name != name {
existing, err := s.nutrientRepo.GetNutrientByName(serviceCtx, name)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("检查新的营养种类名称失败: %w", err)
}
if existing != nil && existing.ID != id {
return nil, ErrNutrientNameConflict
}
}
nutrient.Name = name
nutrient.Description = description
if err := s.nutrientRepo.UpdateNutrient(serviceCtx, nutrient); err != nil {
return nil, fmt.Errorf("更新营养种类失败: %w", err)
}
return nutrient, nil
}
// DeleteNutrient 实现了删除营养种类的核心业务逻辑
func (s *nutrientServiceImpl) DeleteNutrient(ctx context.Context, id uint32) error {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "DeleteNutrient")
// 检查实体是否存在
_, err := s.nutrientRepo.GetNutrientByID(serviceCtx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrNutrientNotFound
}
return fmt.Errorf("获取待删除的营养种类失败: %w", err)
}
if err := s.nutrientRepo.DeleteNutrient(serviceCtx, id); err != nil {
return fmt.Errorf("删除营养种类失败: %w", err)
}
return nil
}
// GetNutrient 实现了获取单个营养种类的逻辑
func (s *nutrientServiceImpl) GetNutrient(ctx context.Context, id uint32) (*models.Nutrient, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "GetNutrient")
nutrient, err := s.nutrientRepo.GetNutrientByID(serviceCtx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNutrientNotFound
}
return nil, fmt.Errorf("获取营养种类失败: %w", err)
}
return nutrient, nil
}
// ListNutrients 实现了列出营养种类的逻辑
func (s *nutrientServiceImpl) ListNutrients(ctx context.Context, opts repository.NutrientListOptions, page, pageSize int) ([]models.Nutrient, int64, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "ListNutrients")
nutrients, total, err := s.nutrientRepo.ListNutrients(serviceCtx, opts, page, pageSize)
if err != nil {
return nil, 0, fmt.Errorf("获取营养种类列表失败: %w", err)
}
return nutrients, total, nil
}

View File

@@ -0,0 +1,113 @@
package recipe
import (
"context"
"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 (
ErrPigAgeStageInUse = fmt.Errorf("猪年龄阶段正在被猪类型使用,无法删除")
ErrPigAgeStageNotFound = fmt.Errorf("猪年龄阶段不存在")
)
// PigAgeStageService 定义了猪年龄阶段领域的核心业务服务接口
type PigAgeStageService interface {
CreatePigAgeStage(ctx context.Context, ageStage *models.PigAgeStage) error
GetPigAgeStageByID(ctx context.Context, id uint32) (*models.PigAgeStage, error)
UpdatePigAgeStage(ctx context.Context, ageStage *models.PigAgeStage) error
DeletePigAgeStage(ctx context.Context, id uint32) error
ListPigAgeStages(ctx context.Context, opts repository.PigAgeStageListOptions, page, pageSize int) ([]models.PigAgeStage, int64, error)
}
// pigAgeStageServiceImpl 是 PigAgeStageService 的实现
type pigAgeStageServiceImpl struct {
ctx context.Context
pigTypeRepo repository.PigTypeRepository // PigAgeStage 相关的操作目前在 PigTypeRepository 中
}
// NewPigAgeStageService 创建一个新的 PigAgeStageService 实例
func NewPigAgeStageService(ctx context.Context, pigTypeRepo repository.PigTypeRepository) PigAgeStageService {
return &pigAgeStageServiceImpl{
ctx: ctx,
pigTypeRepo: pigTypeRepo,
}
}
// CreatePigAgeStage 实现了创建猪年龄阶段的核心业务逻辑
func (s *pigAgeStageServiceImpl) CreatePigAgeStage(ctx context.Context, ageStage *models.PigAgeStage) error {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "CreatePigAgeStage")
if err := s.pigTypeRepo.CreatePigAgeStage(serviceCtx, ageStage); err != nil {
return fmt.Errorf("创建猪年龄阶段失败: %w", err)
}
return nil
}
// GetPigAgeStageByID 实现了获取单个猪年龄阶段的逻辑
func (s *pigAgeStageServiceImpl) GetPigAgeStageByID(ctx context.Context, id uint32) (*models.PigAgeStage, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "GetPigAgeStageByID")
ageStage, err := s.pigTypeRepo.GetPigAgeStageByID(serviceCtx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrPigAgeStageNotFound
}
return nil, fmt.Errorf("获取猪年龄阶段失败: %w", err)
}
return ageStage, nil
}
// UpdatePigAgeStage 实现了更新猪年龄阶段的核心业务逻辑
func (s *pigAgeStageServiceImpl) UpdatePigAgeStage(ctx context.Context, ageStage *models.PigAgeStage) error {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "UpdatePigAgeStage")
if err := s.pigTypeRepo.UpdatePigAgeStage(serviceCtx, ageStage); err != nil {
return fmt.Errorf("更新猪年龄阶段失败: %w", err)
}
return nil
}
// DeletePigAgeStage 实现了删除猪年龄阶段的核心业务逻辑
func (s *pigAgeStageServiceImpl) DeletePigAgeStage(ctx context.Context, id uint32) error {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "DeletePigAgeStage")
// 检查是否有猪类型关联到该年龄阶段
opts := repository.PigTypeListOptions{AgeStageID: &id}
pigTypes, _, err := s.pigTypeRepo.ListPigTypes(serviceCtx, opts, 1, 1) // 只需检查是否存在所以取1条
if err != nil {
return fmt.Errorf("检查猪年龄阶段关联失败: %w", err)
}
if len(pigTypes) > 0 {
return ErrPigAgeStageInUse
}
// 检查实体是否存在
_, err = s.pigTypeRepo.GetPigAgeStageByID(serviceCtx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPigAgeStageNotFound
}
return fmt.Errorf("获取待删除的猪年龄阶段失败: %w", err)
}
if err := s.pigTypeRepo.DeletePigAgeStage(serviceCtx, id); err != nil {
return fmt.Errorf("删除猪年龄阶段失败: %w", err)
}
return nil
}
// ListPigAgeStages 实现了列出猪年龄阶段的逻辑
func (s *pigAgeStageServiceImpl) ListPigAgeStages(ctx context.Context, opts repository.PigAgeStageListOptions, page, pageSize int) ([]models.PigAgeStage, int64, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "ListPigAgeStages")
ageStages, total, err := s.pigTypeRepo.ListPigAgeStages(serviceCtx, opts, page, pageSize)
if err != nil {
return nil, 0, fmt.Errorf("获取猪年龄阶段列表失败: %w", err)
}
return ageStages, total, nil
}

View File

@@ -0,0 +1,112 @@
package recipe
import (
"context"
"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 (
ErrPigBreedInUse = fmt.Errorf("猪品种正在被猪类型使用,无法删除")
ErrPigBreedNotFound = fmt.Errorf("猪品种不存在")
)
// PigBreedService 定义了猪品种领域的核心业务服务接口
type PigBreedService interface {
CreatePigBreed(ctx context.Context, breed *models.PigBreed) error
GetPigBreedByID(ctx context.Context, id uint32) (*models.PigBreed, error)
UpdatePigBreed(ctx context.Context, breed *models.PigBreed) error
DeletePigBreed(ctx context.Context, id uint32) error
ListPigBreeds(ctx context.Context, opts repository.PigBreedListOptions, page, pageSize int) ([]models.PigBreed, int64, error)
}
// pigBreedServiceImpl 是 PigBreedService 的实现
type pigBreedServiceImpl struct {
ctx context.Context
pigTypeRepo repository.PigTypeRepository // PigBreed 相关的操作目前在 PigTypeRepository 中
}
// NewPigBreedService 创建一个新的 PigBreedService 实例
func NewPigBreedService(ctx context.Context, pigTypeRepo repository.PigTypeRepository) PigBreedService {
return &pigBreedServiceImpl{
ctx: ctx,
pigTypeRepo: pigTypeRepo,
}
}
// CreatePigBreed 实现了创建猪品种的核心业务逻辑
func (s *pigBreedServiceImpl) CreatePigBreed(ctx context.Context, breed *models.PigBreed) error {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "CreatePigBreed")
if err := s.pigTypeRepo.CreatePigBreed(serviceCtx, breed); err != nil {
return fmt.Errorf("创建猪品种失败: %w", err)
}
return nil
}
// GetPigBreedByID 实现了获取单个猪品种的逻辑
func (s *pigBreedServiceImpl) GetPigBreedByID(ctx context.Context, id uint32) (*models.PigBreed, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "GetPigBreedByID")
breed, err := s.pigTypeRepo.GetPigBreedByID(serviceCtx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrPigBreedNotFound
}
return nil, fmt.Errorf("获取猪品种失败: %w", err)
}
return breed, nil
}
// UpdatePigBreed 实现了更新猪品种的核心业务逻辑
func (s *pigBreedServiceImpl) UpdatePigBreed(ctx context.Context, breed *models.PigBreed) error {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "UpdatePigBreed")
if err := s.pigTypeRepo.UpdatePigBreed(serviceCtx, breed); err != nil {
return fmt.Errorf("更新猪品种失败: %w", err)
}
return nil
}
// DeletePigBreed 实现了删除猪品种的核心业务逻辑
func (s *pigBreedServiceImpl) DeletePigBreed(ctx context.Context, id uint32) error {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "DeletePigBreed")
// 检查是否有猪类型关联到该品种
opts := repository.PigTypeListOptions{BreedID: &id}
pigTypes, _, err := s.pigTypeRepo.ListPigTypes(serviceCtx, opts, 1, 1) // 只需检查是否存在所以取1条
if err != nil {
return fmt.Errorf("检查猪品种关联失败: %w", err)
}
if len(pigTypes) > 0 {
return ErrPigBreedInUse
}
// 检查实体是否存在
_, err = s.pigTypeRepo.GetPigBreedByID(serviceCtx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPigBreedNotFound
}
return fmt.Errorf("获取待删除的猪品种失败: %w", err)
}
if err := s.pigTypeRepo.DeletePigBreed(serviceCtx, id); err != nil {
return fmt.Errorf("删除猪品种失败: %w", err)
}
return nil
}
// ListPigBreeds 实现了列出猪品种的逻辑
func (s *pigBreedServiceImpl) ListPigBreeds(ctx context.Context, opts repository.PigBreedListOptions, page, pageSize int) ([]models.PigBreed, int64, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "ListPigBreeds")
breeds, total, err := s.pigTypeRepo.ListPigBreeds(serviceCtx, opts, page, pageSize)
if err != nil {
return nil, 0, fmt.Errorf("获取猪品种列表失败: %w", err)
}
return breeds, total, nil
}

View File

@@ -0,0 +1,139 @@
package recipe
import (
"context"
"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 (
ErrPigTypeNotFound = fmt.Errorf("猪类型不存在")
)
// PigTypeService 定义了猪类型领域的核心业务服务接口
type PigTypeService interface {
CreatePigType(ctx context.Context, pigType *models.PigType) error
GetPigTypeByID(ctx context.Context, id uint32) (*models.PigType, error)
UpdatePigType(ctx context.Context, pigType *models.PigType) error
DeletePigType(ctx context.Context, id uint32) error
ListPigTypes(ctx context.Context, opts repository.PigTypeListOptions, page, pageSize int) ([]models.PigType, int64, error)
UpdatePigTypeNutrientRequirements(ctx context.Context, pigTypeID uint32, requirements []models.PigNutrientRequirement) error
}
// pigTypeServiceImpl 是 PigTypeService 的实现
type pigTypeServiceImpl struct {
ctx context.Context
uow repository.UnitOfWork
pigTypeRepo repository.PigTypeRepository
}
// NewPigTypeService 创建一个新的 PigTypeService 实例
func NewPigTypeService(ctx context.Context, uow repository.UnitOfWork, pigTypeRepo repository.PigTypeRepository) PigTypeService {
return &pigTypeServiceImpl{
ctx: ctx,
uow: uow,
pigTypeRepo: pigTypeRepo,
}
}
// CreatePigType 实现了创建猪类型的核心业务逻辑
func (s *pigTypeServiceImpl) CreatePigType(ctx context.Context, pigType *models.PigType) error {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "CreatePigType")
if err := s.pigTypeRepo.CreatePigType(serviceCtx, pigType); err != nil {
return fmt.Errorf("创建猪类型失败: %w", err)
}
return nil
}
// GetPigTypeByID 实现了获取单个猪类型的逻辑
func (s *pigTypeServiceImpl) GetPigTypeByID(ctx context.Context, id uint32) (*models.PigType, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "GetPigTypeByID")
pigType, err := s.pigTypeRepo.GetPigTypeByID(serviceCtx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrPigTypeNotFound
}
return nil, fmt.Errorf("获取猪类型失败: %w", err)
}
return pigType, nil
}
// UpdatePigType 实现了更新猪类型的核心业务逻辑
func (s *pigTypeServiceImpl) UpdatePigType(ctx context.Context, pigType *models.PigType) error {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "UpdatePigType")
if err := s.pigTypeRepo.UpdatePigType(serviceCtx, pigType); err != nil {
return fmt.Errorf("更新猪类型失败: %m", err)
}
return nil
}
// DeletePigType 实现了删除猪类型的核心业务逻辑
func (s *pigTypeServiceImpl) DeletePigType(ctx context.Context, id uint32) error {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "DeletePigType")
// 检查实体是否存在
_, err := s.pigTypeRepo.GetPigTypeByID(serviceCtx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPigTypeNotFound
}
return fmt.Errorf("获取待删除的猪类型失败: %w", err)
}
if err := s.pigTypeRepo.DeletePigType(serviceCtx, id); err != nil {
return fmt.Errorf("删除猪类型失败: %w", err)
}
return nil
}
// ListPigTypes 实现了列出猪类型的逻辑
func (s *pigTypeServiceImpl) ListPigTypes(ctx context.Context, opts repository.PigTypeListOptions, page, pageSize int) ([]models.PigType, int64, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "ListPigTypes")
pigTypes, total, err := s.pigTypeRepo.ListPigTypes(serviceCtx, opts, page, pageSize)
if err != nil {
return nil, 0, fmt.Errorf("获取猪类型列表失败: %w", err)
}
return pigTypes, total, nil
}
// UpdatePigTypeNutrientRequirements 实现了全量更新猪类型营养需求的核心业务逻辑
func (s *pigTypeServiceImpl) UpdatePigTypeNutrientRequirements(ctx context.Context, pigTypeID uint32, requirements []models.PigNutrientRequirement) error {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "UpdatePigTypeNutrientRequirements")
// 1. 检查猪类型是否存在
if _, err := s.pigTypeRepo.GetPigTypeByID(serviceCtx, pigTypeID); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPigTypeNotFound
}
return fmt.Errorf("获取待更新营养需求的猪类型失败: %w", err)
}
// 2. 在事务中执行替换操作
err := s.uow.ExecuteInTransaction(serviceCtx, func(tx *gorm.DB) error {
// 2.1. 删除旧的关联记录
if err := s.pigTypeRepo.DeletePigNutrientRequirementsByPigTypeIDTx(serviceCtx, tx, pigTypeID); err != nil {
return err // 错误已在仓库层封装,直接返回
}
// 2.2. 创建新的关联记录
if err := s.pigTypeRepo.CreateBatchPigNutrientRequirementsTx(serviceCtx, tx, requirements); err != nil {
return err // 错误已在仓库层封装,直接返回
}
return nil
})
if err != nil {
return fmt.Errorf("更新猪类型营养需求事务执行失败: %w", err)
}
// 3. 操作成功,直接返回 nil
return nil
}

View File

@@ -0,0 +1,220 @@
package recipe
import (
"context"
"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"
)
// StockQuerier 定义了从外部领域查询库存的接口。
// 这样,配方领域就不需要知道库存是如何存储或计算的。
type StockQuerier interface {
// GetCurrentStock 根据原料ID获取当前库存量。
GetCurrentStock(ctx context.Context, rawMaterialID uint32) (float32, error)
}
// 定义领域特定的错误
var (
ErrRawMaterialNameConflict = fmt.Errorf("原料名称已存在")
ErrRawMaterialNotFound = fmt.Errorf("原料不存在")
ErrStockNotEmpty = fmt.Errorf("原料尚有库存,无法删除")
ErrRawMaterialInUseByRecipe = fmt.Errorf("原料已被配方使用,无法删除")
)
// RawMaterialService 定义了原料领域的核心业务服务接口
type RawMaterialService interface {
CreateRawMaterial(ctx context.Context, name, description string, referencePrice, maxAdditionRatio float32) (*models.RawMaterial, error)
UpdateRawMaterial(ctx context.Context, id uint32, name, description string, referencePrice float32, maxAdditionRatio *float32) (*models.RawMaterial, error)
DeleteRawMaterial(ctx context.Context, id uint32) error
GetRawMaterial(ctx context.Context, id uint32) (*models.RawMaterial, error)
ListRawMaterials(ctx context.Context, opts repository.RawMaterialListOptions, page, pageSize int) ([]models.RawMaterial, int64, error)
UpdateRawMaterialNutrients(ctx context.Context, rawMaterialID uint32, nutrients []models.RawMaterialNutrient) error
}
// rawMaterialServiceImpl 是 RawMaterialService 的实现
type rawMaterialServiceImpl struct {
ctx context.Context
uow repository.UnitOfWork
rawMaterialRepo repository.RawMaterialRepository
stockQuerier StockQuerier
}
// NewRawMaterialService 创建一个新的 RawMaterialService 实例
func NewRawMaterialService(ctx context.Context, uow repository.UnitOfWork, rawMaterialRepo repository.RawMaterialRepository, stockQuerier StockQuerier) RawMaterialService {
return &rawMaterialServiceImpl{
ctx: ctx,
uow: uow,
rawMaterialRepo: rawMaterialRepo,
stockQuerier: stockQuerier,
}
}
// CreateRawMaterial 实现了创建原料的核心业务逻辑
func (s *rawMaterialServiceImpl) CreateRawMaterial(ctx context.Context, name, description string, referencePrice, maxAdditionRatio float32) (*models.RawMaterial, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "CreateRawMaterial")
// 检查名称是否已存在
existing, err := s.rawMaterialRepo.GetRawMaterialByName(serviceCtx, name)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("检查原料名称失败: %w", err)
}
if existing != nil {
return nil, ErrRawMaterialNameConflict
}
rawMaterial := &models.RawMaterial{
Name: name,
Description: description,
ReferencePrice: referencePrice,
MaxAdditionRatio: maxAdditionRatio,
}
if err := s.rawMaterialRepo.CreateRawMaterial(serviceCtx, rawMaterial); err != nil {
return nil, fmt.Errorf("创建原料失败: %w", err)
}
return rawMaterial, nil
}
// UpdateRawMaterial 实现了更新原料的核心业务逻辑
func (s *rawMaterialServiceImpl) UpdateRawMaterial(ctx context.Context, id uint32, name, description string, referencePrice float32, maxAdditionRatio *float32) (*models.RawMaterial, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "UpdateRawMaterial")
// 检查要更新的实体是否存在
rawMaterial, err := s.rawMaterialRepo.GetRawMaterialByID(serviceCtx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrRawMaterialNotFound
}
return nil, fmt.Errorf("获取待更新的原料失败: %w", err)
}
// 如果名称有变动,检查新名称是否与其它记录冲突
if rawMaterial.Name != name {
existing, err := s.rawMaterialRepo.GetRawMaterialByName(serviceCtx, name)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("检查新的原料名称失败: %w", err)
}
if existing != nil && existing.ID != id {
return nil, ErrRawMaterialNameConflict
}
}
rawMaterial.Name = name
rawMaterial.Description = description
rawMaterial.ReferencePrice = referencePrice
if maxAdditionRatio != nil {
rawMaterial.MaxAdditionRatio = *maxAdditionRatio
}
if err := s.rawMaterialRepo.UpdateRawMaterial(serviceCtx, rawMaterial); err != nil {
return nil, fmt.Errorf("更新原料失败: %w", err)
}
return rawMaterial, nil
}
// DeleteRawMaterial 实现了删除原料的核心业务逻辑
func (s *rawMaterialServiceImpl) DeleteRawMaterial(ctx context.Context, id uint32) error {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "DeleteRawMaterial")
// 检查实体是否存在
_, err := s.rawMaterialRepo.GetRawMaterialByID(serviceCtx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrRawMaterialNotFound
}
return fmt.Errorf("获取待删除的原料失败: %w", err)
}
// 检查原料是否有库存
stock, err := s.stockQuerier.GetCurrentStock(serviceCtx, id)
if err != nil {
return fmt.Errorf("检查原料库存失败: %w", err)
}
if stock > 0 {
// 如果库存大于0返回业务错误阻止删除
return ErrStockNotEmpty
}
// 检查原料是否被配方使用
isUsed, err := s.rawMaterialRepo.IsRawMaterialUsedInRecipes(serviceCtx, id)
if err != nil {
return fmt.Errorf("检查原料是否被配方使用失败: %w", err)
}
if isUsed {
return ErrRawMaterialInUseByRecipe
}
if err := s.rawMaterialRepo.DeleteRawMaterial(serviceCtx, id); err != nil {
return fmt.Errorf("删除原料失败: %w", err)
}
return nil
}
// GetRawMaterial 实现了获取单个原料的逻辑
func (s *rawMaterialServiceImpl) GetRawMaterial(ctx context.Context, id uint32) (*models.RawMaterial, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "GetRawMaterial")
rawMaterial, err := s.rawMaterialRepo.GetRawMaterialByID(serviceCtx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrRawMaterialNotFound
}
return nil, fmt.Errorf("获取原料失败: %w", err)
}
return rawMaterial, nil
}
// ListRawMaterials 实现了列出原料的逻辑
func (s *rawMaterialServiceImpl) ListRawMaterials(ctx context.Context, opts repository.RawMaterialListOptions, page, pageSize int) ([]models.RawMaterial, int64, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "ListRawMaterials")
rawMaterials, total, err := s.rawMaterialRepo.ListRawMaterials(serviceCtx, opts, page, pageSize)
if err != nil {
return nil, 0, fmt.Errorf("获取原料列表失败: %w", err)
}
return rawMaterials, total, nil
}
// UpdateRawMaterialNutrients 实现了全量更新原料营养成分的业务逻辑
func (s *rawMaterialServiceImpl) UpdateRawMaterialNutrients(ctx context.Context, rawMaterialID uint32, nutrients []models.RawMaterialNutrient) error {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "UpdateRawMaterialNutrients")
// 1. 检查原料是否存在
if _, err := s.rawMaterialRepo.GetRawMaterialByID(serviceCtx, rawMaterialID); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrRawMaterialNotFound
}
return fmt.Errorf("获取待更新的原料失败: %w", err)
}
// 2. 在事务中执行替换操作
err := s.uow.ExecuteInTransaction(serviceCtx, func(tx *gorm.DB) error {
// 2.1. 删除旧的关联记录
if err := s.rawMaterialRepo.DeleteNutrientsByRawMaterialIDTx(serviceCtx, tx, rawMaterialID); err != nil {
return err // 错误已在仓库层封装,直接返回
}
// 2.2. 创建新的关联记录
if err := s.rawMaterialRepo.CreateBatchRawMaterialNutrientsTx(serviceCtx, tx, nutrients); err != nil {
return err // 错误已在仓库层封装,直接返回
}
return nil
})
if err != nil {
return fmt.Errorf("更新原料营养成分事务执行失败: %w", err)
}
// 3. 操作成功,直接返回 nil
return nil
}

View File

@@ -0,0 +1,189 @@
package recipe
import (
"context"
"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 (
ErrRecipeNotFound = fmt.Errorf("配方不存在")
ErrRecipeNameConflict = fmt.Errorf("配方名称已存在")
)
// RecipeCoreService 定义了配方领域的核心业务服务接口
type RecipeCoreService interface {
CreateRecipe(ctx context.Context, recipe *models.Recipe) (*models.Recipe, error)
GetRecipeByID(ctx context.Context, id uint32) (*models.Recipe, error)
GetRecipeByName(ctx context.Context, name string) (*models.Recipe, error)
UpdateRecipe(ctx context.Context, recipe *models.Recipe) (*models.Recipe, error)
DeleteRecipe(ctx context.Context, id uint32) error
ListRecipes(ctx context.Context, opts repository.RecipeListOptions, page, pageSize int) ([]models.Recipe, int64, error)
UpdateRecipeIngredients(ctx context.Context, recipeID uint32, ingredients []models.RecipeIngredient) error
}
// recipeCoreServiceImpl 是 RecipeCoreService 的实现
type recipeCoreServiceImpl struct {
ctx context.Context
uow repository.UnitOfWork
recipeRepo repository.RecipeRepository
}
// NewRecipeCoreService 创建一个新的 RecipeCoreService 实例
func NewRecipeCoreService(ctx context.Context, uow repository.UnitOfWork, recipeRepo repository.RecipeRepository) RecipeCoreService {
return &recipeCoreServiceImpl{
ctx: ctx,
uow: uow,
recipeRepo: recipeRepo,
}
}
// CreateRecipe 实现了创建配方的核心业务逻辑
func (s *recipeCoreServiceImpl) CreateRecipe(ctx context.Context, recipe *models.Recipe) (*models.Recipe, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "CreateRecipe")
// 检查名称是否已存在
existing, err := s.recipeRepo.GetRecipeByName(serviceCtx, recipe.Name)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("检查配方名称失败: %w", err)
}
if existing != nil {
return nil, ErrRecipeNameConflict
}
if err := s.recipeRepo.CreateRecipe(serviceCtx, recipe); err != nil {
return nil, fmt.Errorf("创建配方失败: %w", err)
}
return recipe, nil
}
// GetRecipeByID 实现了获取单个配方的逻辑
func (s *recipeCoreServiceImpl) GetRecipeByID(ctx context.Context, id uint32) (*models.Recipe, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "GetRecipeByID")
recipe, err := s.recipeRepo.GetRecipeByID(serviceCtx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrRecipeNotFound
}
return nil, fmt.Errorf("获取配方失败: %w", err)
}
return recipe, nil
}
// GetRecipeByName 实现了根据名称获取单个配方的逻辑
func (s *recipeCoreServiceImpl) GetRecipeByName(ctx context.Context, name string) (*models.Recipe, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "GetRecipeByName")
recipe, err := s.recipeRepo.GetRecipeByName(serviceCtx, name)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrRecipeNotFound
}
return nil, fmt.Errorf("获取配方失败: %w", err)
}
return recipe, nil
}
// UpdateRecipe 实现了更新配方的核心业务逻辑
func (s *recipeCoreServiceImpl) UpdateRecipe(ctx context.Context, recipe *models.Recipe) (*models.Recipe, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "UpdateRecipe")
// 检查要更新的实体是否存在
existingRecipe, err := s.recipeRepo.GetRecipeByID(serviceCtx, recipe.ID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrRecipeNotFound
}
return nil, fmt.Errorf("获取待更新的配方失败: %w", err)
}
// 如果名称有变动,检查新名称是否与其它记录冲突
if existingRecipe.Name != recipe.Name {
existing, err := s.recipeRepo.GetRecipeByName(serviceCtx, recipe.Name)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("检查新的配方名称失败: %w", err)
}
if existing != nil && existing.ID != recipe.ID {
return nil, ErrRecipeNameConflict
}
}
if err := s.recipeRepo.UpdateRecipe(serviceCtx, recipe); err != nil {
return nil, fmt.Errorf("更新配方失败: %w", err)
}
return recipe, nil
}
// DeleteRecipe 实现了删除配方的核心业务逻辑
func (s *recipeCoreServiceImpl) DeleteRecipe(ctx context.Context, id uint32) error {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "DeleteRecipe")
// 检查实体是否存在
_, err := s.recipeRepo.GetRecipeByID(serviceCtx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrRecipeNotFound
}
return fmt.Errorf("获取待删除的配方失败: %w", err)
}
if err := s.recipeRepo.DeleteRecipe(serviceCtx, id); err != nil {
return fmt.Errorf("删除配方失败: %w", err)
}
return nil
}
// ListRecipes 实现了列出配方的逻辑
func (s *recipeCoreServiceImpl) ListRecipes(ctx context.Context, opts repository.RecipeListOptions, page, pageSize int) ([]models.Recipe, int64, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "ListRecipes")
recipes, total, err := s.recipeRepo.ListRecipes(serviceCtx, opts, page, pageSize)
if err != nil {
return nil, 0, fmt.Errorf("获取配方列表失败: %w", err)
}
return recipes, total, nil
}
// UpdateRecipeIngredients 实现了全量更新配方原料的业务逻辑
func (s *recipeCoreServiceImpl) UpdateRecipeIngredients(ctx context.Context, recipeID uint32, ingredients []models.RecipeIngredient) error {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "UpdateRecipeIngredients")
// 1. 检查配方是否存在
if _, err := s.recipeRepo.GetRecipeByID(serviceCtx, recipeID); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrRecipeNotFound
}
return fmt.Errorf("获取待更新原料的配方失败: %w", err)
}
// 2. 在事务中执行替换操作
err := s.uow.ExecuteInTransaction(serviceCtx, func(tx *gorm.DB) error {
// 2.1. 删除旧的关联记录
if err := s.recipeRepo.DeleteRecipeIngredientsByRecipeIDTx(serviceCtx, tx, recipeID); err != nil {
return err // 错误已在仓库层封装,直接返回
}
// 2.2. 创建新的关联记录
// 确保每个原料都设置了正确的 RecipeID
for i := range ingredients {
ingredients[i].RecipeID = recipeID
}
if err := s.recipeRepo.CreateBatchRecipeIngredientsTx(serviceCtx, tx, ingredients); err != nil {
return err // 错误已在仓库层封装,直接返回
}
return nil
})
if err != nil {
return fmt.Errorf("更新配方原料事务执行失败: %w", err)
}
// 3. 操作成功,直接返回 nil
return nil
}

View File

@@ -0,0 +1,332 @@
package recipe
import (
"context"
"errors"
"fmt"
"math"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"gonum.org/v1/gonum/mat"
"gonum.org/v1/gonum/optimize/convex/lp"
)
// RecipeGenerateManager 定义了配方生成器的能力。
// 它可以有多种实现,例如基于成本优化、基于生长性能优化等。
type RecipeGenerateManager interface {
// GenerateRecipe 根据猪的营养需求和可用原料,生成一个配方。
GenerateRecipe(ctx context.Context, pigType models.PigType, materials []models.RawMaterial) (*models.Recipe, error)
}
// recipeGenerateManagerImpl 是 RecipeGenerateManager 的默认实现。
// 它实现了基于成本最优的配方生成逻辑。
type recipeGenerateManagerImpl struct {
ctx context.Context
}
// NewRecipeGenerateManager 创建一个默认的配方生成器实例。
func NewRecipeGenerateManager(ctx context.Context) RecipeGenerateManager {
return &recipeGenerateManagerImpl{
ctx: ctx,
}
}
const (
// internalFillerRawMaterialName 是内部虚拟填充料的名称。
// 该填充料用于线性规划计算确保总比例为100%,但不会出现在最终配方中。
internalFillerRawMaterialName = "内部填充料_InternalFiller"
// internalFillerNutrientID 是内部虚拟填充营养素的ID。
// 使用 math.MaxUint32 作为一个极大的、不可能与实际ID冲突的值用于关联填充料。
internalFillerNutrientID = math.MaxUint32
)
// GenerateRecipe 根据猪的营养需求和可用原料,使用线性规划计算出成本最低的饲料配方。
func (r *recipeGenerateManagerImpl) GenerateRecipe(ctx context.Context, pigType models.PigType, materials []models.RawMaterial) (*models.Recipe, error) {
// 1. 基础校验
if len(materials) == 0 {
return nil, errors.New("无法生成配方:未提供任何原料")
}
if len(pigType.PigNutrientRequirements) == 0 {
return nil, errors.New("无法生成配方:猪类型未设置营养需求")
}
// 收集猪类型所有有需求的营养素ID (包括min_requirement或max_requirement不为0的)。
// 用于后续过滤掉完全不相关的原料。
requiredNutrientIDs := make(map[uint32]bool)
for _, req := range pigType.PigNutrientRequirements {
requiredNutrientIDs[req.NutrientID] = true
}
// 过滤掉那些不包含猪类型任何所需营养素的原料。
var filteredMaterials []models.RawMaterial
for _, mat := range materials {
hasRelevantNutrient := false
for _, matNut := range mat.RawMaterialNutrients {
// 检查原料是否包含猪类型所需的任何营养素
if requiredNutrientIDs[matNut.NutrientID] {
hasRelevantNutrient = true
break
}
}
// 如果原料包含至少一个猪类型需求的营养素,则保留
if hasRelevantNutrient {
filteredMaterials = append(filteredMaterials, mat)
}
}
materials = filteredMaterials // 使用过滤后的原料列表
if len(materials) == 0 {
return nil, errors.New("无法生成配方:所有提供的原料都不包含猪类型所需的任何营养素,请检查原料配置或猪类型营养需求")
}
// 创建一个虚拟的、价格为0、不含任何实际营养素的填充料。
// 其唯一目的是在LP求解中作为“凑数”的选项确保总比例为100%,且不影响实际配方成本。
fillerRawMaterial := models.RawMaterial{
Model: models.Model{
ID: math.MaxUint32 - 1, // 使用一个极大的、不可能与实际原料ID冲突的值
},
Name: internalFillerRawMaterialName,
Description: "内部虚拟填充料用于线性规划凑足100%比例不含实际营养价格为0。",
ReferencePrice: 0.0, // 价格为0确保LP优先选择它来凑数
RawMaterialNutrients: []models.RawMaterialNutrient{
{
NutrientID: internalFillerNutrientID, // 关联一个虚拟营养素确保其在LP中被识别但其含量为0
Value: 0.0,
},
},
}
materials = append(materials, fillerRawMaterial) // 将填充料添加到原料列表中
// ---------------------------------------------------------
// 2. 准备数据结构
// ---------------------------------------------------------
// materialNutrients 映射: 为了快速查找原料的营养含量 [RawMaterialID][NutrientID] => Value
materialNutrients := make(map[uint32]map[uint32]float64)
// materialIndex 映射: 原料ID到矩阵列索引的映射 (前 N 列对应 N 种原料)
materialIndex := make(map[uint32]int)
// materialIDs 列表: 记录原料ID以便结果回溯
materialIDs := make([]uint32, len(materials))
for i, m := range materials {
materialIndex[m.ID] = i
materialIDs[i] = m.ID
materialNutrients[m.ID] = make(map[uint32]float64)
for _, n := range m.RawMaterialNutrients {
// 注意:这里假设 float32 转 float64 精度足够
materialNutrients[m.ID][n.NutrientID] = float64(n.Value)
}
}
// nutrientConstraints 存储营养素的下限和上限约束信息。
type nutrientConstraintInfo struct {
isMax bool // true=上限约束(<=), false=下限约束(>=)
nutrientID uint32
limit float64
}
var nutrientConstraints []nutrientConstraintInfo
// 添加营养约束
for _, req := range pigType.PigNutrientRequirements {
// 排除内部虚拟填充营养素的约束,因为它不应有实际需求
if req.NutrientID == internalFillerNutrientID {
continue
}
// 添加下限约束 (Value >= Min)
// 逻辑: Sum(Mat * x) >= Min -> Sum(Mat * x) - slack = Min
nutrientConstraints = append(nutrientConstraints, nutrientConstraintInfo{
isMax: false,
nutrientID: req.NutrientID,
limit: float64(req.MinRequirement),
})
// 添加上限约束 (Value <= Max)
// 逻辑: Sum(Mat * x) <= Max -> Sum(Mat * x) + slack = Max
if req.MaxRequirement > 0 {
// 简单的校验,如果 Min > Max 则是逻辑矛盾,直接报错
if req.MinRequirement > req.MaxRequirement {
return nil, fmt.Errorf("营养素 %d 的需求配置无效: 最小需求 (%f) 大于最大需求 (%f)", req.NutrientID, req.MinRequirement, req.MaxRequirement)
}
nutrientConstraints = append(nutrientConstraints, nutrientConstraintInfo{
isMax: true,
nutrientID: req.NutrientID,
limit: float64(req.MaxRequirement),
})
}
}
// maxAdditionConstraints 存储每个原料的最大添加比例约束 (x_i <= limit)。
type maxAdditionConstraintInfo struct {
materialColIndex int // 原料在 A 矩阵中的列索引
limit float64
}
var maxAdditionConstraints []maxAdditionConstraintInfo
// 遍历所有原料,包括填充料,添加 MaxAdditionRatio 约束
for _, mat := range materials {
// 填充料不应受 MaxAdditionRatio 限制
if mat.ID == fillerRawMaterial.ID {
continue
}
// 只有当 MaxAdditionRatio > 0 时才添加约束。
// 如果 MaxAdditionRatio 为 0 或负数,则表示该原料没有最大添加比例限制。
if mat.MaxAdditionRatio > 0 {
materialColIndex, ok := materialIndex[mat.ID]
if !ok {
return nil, fmt.Errorf("内部错误:未找到原料 %d (%s) 的列索引", mat.ID, mat.Name)
}
maxAdditionConstraints = append(maxAdditionConstraints, maxAdditionConstraintInfo{
materialColIndex: materialColIndex,
limit: float64(mat.MaxAdditionRatio) / 100.0,
})
}
}
// ---------------------------------------------------------
// 3. 构建线性规划矩阵 (Ax = b) 和 目标函数 (c)
// ---------------------------------------------------------
numMaterials := len(materials) // 此时已包含填充料
numNutrientConstraints := len(nutrientConstraints)
numMaxAdditionConstraints := len(maxAdditionConstraints)
// 松弛变量数量 = 营养约束数量 + 最大添加比例约束数量
numSlack := numNutrientConstraints + numMaxAdditionConstraints
numCols := numMaterials + numSlack
// 行数 = 1 (总量约束) + 营养约束数量 + 最大添加比例约束数量
numRows := 1 + numNutrientConstraints + numMaxAdditionConstraints
// A: 约束系数矩阵
A := mat.NewDense(numRows, numCols, nil)
// b: 约束值向量
b := make([]float64, numRows)
// c: 成本向量 (目标函数系数)
c := make([]float64, numCols)
// 填充 c (成本)
for i, m := range materials {
c[i] = float64(m.ReferencePrice)
}
// 松弛变量的成本为 0Go 默认初始化为 0无需操作
// 填充 Row 0: 总量约束 (Sum(x) = 1)
// 系数: 所有原料对应列为 1松弛变量列为 0
for j := 0; j < numMaterials; j++ {
A.Set(0, j, 1.0)
}
b[0] = 1.0
// currentConstraintRowIndex 记录当前正在填充的约束行索引从1开始0行被总量约束占用
currentConstraintRowIndex := 1
// 填充营养约束行
for i, cons := range nutrientConstraints {
rowIndex := currentConstraintRowIndex + i
// 营养约束的松弛变量列紧跟在原料列之后
slackColIndex := numMaterials + i
b[rowIndex] = cons.limit
// 设置原料系数
for j, m := range materials {
// 获取该原料这种营养素的含量如果没有则为0
val := materialNutrients[m.ID][cons.nutrientID]
A.Set(rowIndex, j, val)
}
// 设置松弛变量系数
// 如果是下限 (>=): Sum - s = Limit => s系数为 -1
// 如果是上限 (<=): Sum + s = Limit => s系数为 +1
if cons.isMax {
A.Set(rowIndex, slackColIndex, 1.0)
} else {
A.Set(rowIndex, slackColIndex, -1.0)
}
}
currentConstraintRowIndex += numNutrientConstraints // 推进当前约束行索引
// 填充 MaxAdditionRatio 约束行
for i, cons := range maxAdditionConstraints {
rowIndex := currentConstraintRowIndex + i
// MaxAdditionRatio 约束的松弛变量列在营养约束的松弛变量之后
slackColIndex := numMaterials + numNutrientConstraints + i
// 约束形式: x_j + s_k = Limit_j (其中 x_j 是原料 j 的比例s_k 是松弛变量)
A.Set(rowIndex, cons.materialColIndex, 1.0) // 原料本身的系数
A.Set(rowIndex, slackColIndex, 1.0) // 松弛变量的系数
b[rowIndex] = cons.limit
}
// ---------------------------------------------------------
// 4. 执行单纯形法求解
// ---------------------------------------------------------
// lp.Simplex 求解: minimize c^T * x subject to A * x = b, x >= 0
_, x, err := lp.Simplex(c, A, b, 1e-8, nil)
if err != nil {
if errors.Is(err, lp.ErrInfeasible) {
return nil, errors.New("无法生成配方:根据提供的原料,无法满足所有营养需求或最大添加比例限制 (无可行解),请检查原料配置、营养需求或最大添加比例")
}
if errors.Is(err, lp.ErrUnbounded) {
return nil, errors.New("计算错误:解无界 (可能数据配置有误,例如某个营养素没有上限约束且成本为负)")
}
return nil, fmt.Errorf("配方计算失败: %w", err)
}
// ---------------------------------------------------------
// 5. 结果解析与构建
// ---------------------------------------------------------
// 统计实际原料数量(排除填充料)
actualMaterialCount := 0
for _, m := range materials {
if m.ID != fillerRawMaterial.ID {
actualMaterialCount++
}
}
recipe := &models.Recipe{
Name: fmt.Sprintf("%s-%s - 自动计算配方", pigType.Breed.Name, pigType.AgeStage.Name), // 提供一个默认的名称
Description: fmt.Sprintf("基于 %d 种原料计算的最优成本配方。", actualMaterialCount), // 提供一个默认的描述
RecipeIngredients: []models.RecipeIngredient{},
}
// 遍历原料部分的解 (前 numMaterials 个变量)
totalPercentage := 0.0
for i := 0; i < numMaterials; i++ {
// 排除内部虚拟填充料,不将其加入最终配方
if materialIDs[i] == fillerRawMaterial.ID {
continue
}
proportion := x[i]
// 忽略极小值 (浮点数误差)。
// 调整过滤阈值到万分之一 (0.01%)即小于0.0001的比例将被忽略。
if proportion < 1e-4 {
continue
}
// 记录总和用于最后的校验
totalPercentage += proportion
recipe.RecipeIngredients = append(recipe.RecipeIngredients, models.RecipeIngredient{
RawMaterialID: materialIDs[i],
// 比例: float64 -> float32
Percentage: float32(proportion * 100.0),
})
}
// 二次校验: 确保实际原料总量不超过 100% (允许小于100%因为填充料被移除)。
// 允许略微超过100%的浮点误差,但不能显著超过。
if totalPercentage > 1.0+1e-3 {
return nil, fmt.Errorf("计算结果异常:实际原料总量超过 100%% (计算值: %.2f),请检查算法或数据配置", totalPercentage)
}
return recipe, nil
}

View File

@@ -0,0 +1,349 @@
package recipe
import (
"context"
"encoding/json"
"fmt"
"strings"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/ai"
"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"
)
// Service 定义了配方与原料领域的核心业务服务接口
// 该接口聚合了所有子领域的服务接口
type Service interface {
NutrientService
RawMaterialService
PigBreedService
PigAgeStageService
PigTypeService
RecipeCoreService
RecipeGenerateManager
GenerateRecipeWithAllRawMaterials(ctx context.Context, pigTypeID uint32) (*models.Recipe, error)
// GenerateRecipeWithPrioritizedStockRawMaterials 生成新配方,优先使用有库存的原料
GenerateRecipeWithPrioritizedStockRawMaterials(ctx context.Context, pigTypeID uint32) (*models.Recipe, error)
// AIDiagnoseRecipe 智能诊断配方, 返回智能诊断结果
AIDiagnoseRecipe(ctx context.Context, recipeID uint32, pigTypeID uint32) (string, models.AIModel, error)
}
// recipeServiceImpl 是 Service 的实现,通过组合各个子服务来实现
type recipeServiceImpl struct {
ctx context.Context
NutrientService
RawMaterialService
PigBreedService
PigAgeStageService
PigTypeService
RecipeCoreService
RecipeGenerateManager
recipeRepo repository.RecipeRepository
ai ai.AI
}
// NewRecipeService 创建一个新的 Service 实例
func NewRecipeService(
ctx context.Context,
nutrientService NutrientService,
rawMaterialService RawMaterialService,
pigBreedService PigBreedService,
pigAgeStageService PigAgeStageService,
pigTypeService PigTypeService,
recipeCoreService RecipeCoreService,
recipeGenerateManager RecipeGenerateManager,
recipeRepo repository.RecipeRepository,
ai ai.AI,
) Service {
return &recipeServiceImpl{
ctx: ctx,
NutrientService: nutrientService,
RawMaterialService: rawMaterialService,
PigBreedService: pigBreedService,
PigAgeStageService: pigAgeStageService,
PigTypeService: pigTypeService,
RecipeCoreService: recipeCoreService,
RecipeGenerateManager: recipeGenerateManager,
recipeRepo: recipeRepo,
ai: ai,
}
}
// GenerateRecipeWithAllRawMaterials 使用所有已知原料为特定猪类型生成一个新配方。
// pigTypeID: 目标猪类型的ID。
// 返回: 生成的配方对象指针和可能的错误。
func (r *recipeServiceImpl) GenerateRecipeWithAllRawMaterials(ctx context.Context, pigTypeID uint32) (*models.Recipe, error) {
serviceCtx, logger := logs.Trace(ctx, r.ctx, "GenerateRecipeWithAllRawMaterials")
// 1. 获取猪只类型信息,确保包含了营养需求
pigType, err := r.GetPigTypeByID(serviceCtx, pigTypeID)
if err != nil {
return nil, fmt.Errorf("获取猪类型信息失败: %w", err)
}
// 2. 获取所有原料
// 我们通过传递一个非常大的 pageSize 来获取所有原料,这在大多数情况下是可行的。
// 对于超大规模系统,可能需要考虑分页迭代,但目前这是一个简单有效的策略。
materials, _, err := r.ListRawMaterials(serviceCtx, repository.RawMaterialListOptions{}, 1, 9999)
if err != nil {
return nil, fmt.Errorf("获取所有原料列表失败: %w", err)
}
// 3. 调用生成器生成配方
recipe, err := r.GenerateRecipe(serviceCtx, *pigType, materials)
if err != nil {
return nil, fmt.Errorf("生成配方失败: %w", err)
}
// 4. 丰富配方描述:计算并添加参考价格信息
recipe.Name = fmt.Sprintf("%s - 使用所有已知原料", recipe.Name)
// 填充 RecipeIngredients 中的 RawMaterial 字段,以便后续计算成本
rawMaterialMap := make(map[uint32]models.RawMaterial)
for _, mat := range materials {
rawMaterialMap[mat.ID] = mat
}
for i := range recipe.RecipeIngredients {
if rawMat, ok := rawMaterialMap[recipe.RecipeIngredients[i].RawMaterialID]; ok {
recipe.RecipeIngredients[i].RawMaterial = rawMat
} else {
// 理论上 GenerateRecipe 应该只使用传入的 materials 中的 RawMaterialID
// 如果出现此情况,说明 GenerateRecipe 生成了不在当前 materials 列表中的 RawMaterialID
// 这可能是一个数据不一致或逻辑错误,记录警告以便排查
logger.Warnf("未找到 RecipeIngredient (RawMaterialID: %d) 对应的 RawMaterial成本计算可能不准确", recipe.RecipeIngredients[i].RawMaterialID)
}
}
referencePrice := recipe.CalculateReferencePricePerKilogram() / 100
recipe.Description = fmt.Sprintf("%s 计算时预估成本: %.2f元/kg。", recipe.Description, referencePrice)
// 如果 totalPercentage 小于 100%,说明填充料被使用,这是符合预期的。
// 此时需要在描述中说明需要添加的廉价填充料的百分比。
totalPercentage := recipe.CalculateTotalRawMaterialProportion()
if totalPercentage < 99.99 { // 允许微小的浮点误差
fillerPercentage := 100 - totalPercentage
recipe.Description = fmt.Sprintf("%s 注意:配方中实际原料占比 %.2f%%,需额外补充 %.2f%% 廉价填充料", recipe.Description, totalPercentage, fillerPercentage)
}
// 5. 保存新生成的配方到数据库
// CreateRecipe 会处理配方及其成分的保存
if recipe, err = r.CreateRecipe(serviceCtx, recipe); err != nil {
return nil, fmt.Errorf("保存生成的配方失败: %w", err)
}
logger.Infof("成功生成配方: 配方名称: %v | 配方简介: %v", recipe.Name, recipe.Description)
// 6. 返回创建的配方 (现在它应该已经有了ID)
return recipe, nil
}
// GenerateRecipeWithPrioritizedStockRawMaterials 使用优先有库存原料的策略为特定猪类型生成一个新配方。
// 通过大幅调低有库存原料的参考价格,诱导生成器优先使用。
// pigTypeID: 目标猪类型的ID。
// 返回: 生成的配方对象指针和可能的错误。
func (r *recipeServiceImpl) GenerateRecipeWithPrioritizedStockRawMaterials(ctx context.Context, pigTypeID uint32) (*models.Recipe, error) {
serviceCtx, logger := logs.Trace(ctx, r.ctx, "GenerateRecipeWithPrioritizedStockRawMaterials")
// 1. 获取猪只类型信息,确保包含了营养需求
pigType, err := r.GetPigTypeByID(serviceCtx, pigTypeID)
if err != nil {
return nil, fmt.Errorf("获取猪类型信息失败: %w", err)
}
// 2. 获取所有原料,并区分有库存和无库存的原料
// 获取有库存的原料
hasStock := true
stockOpts := repository.RawMaterialListOptions{HasStock: &hasStock}
stockMaterials, _, err := r.ListRawMaterials(serviceCtx, stockOpts, 1, 9999)
if err != nil {
return nil, fmt.Errorf("获取有库存原料列表失败: %w", err)
}
// 获取无库存的原料
hasStock = false
noStockOpts := repository.RawMaterialListOptions{HasStock: &hasStock}
noStockMaterials, _, err := r.ListRawMaterials(serviceCtx, noStockOpts, 1, 9999)
if err != nil {
return nil, fmt.Errorf("获取无库存原料列表失败: %w", err)
}
// 合并有库存和无库存的原料,作为所有原始原料的列表,用于后续计算最终参考价格
allOriginalMaterials := make([]models.RawMaterial, 0, len(stockMaterials)+len(noStockMaterials))
allOriginalMaterials = append(allOriginalMaterials, stockMaterials...)
allOriginalMaterials = append(allOriginalMaterials, noStockMaterials...)
// 3. 创建一个用于配方生成的原料列表,并调整有库存原料的价格
var materialsForGeneration []models.RawMaterial
// 先添加有库存的原料,并调整价格
for _, mat := range stockMaterials {
adjustedMat := mat // 复制一份
// 大幅调低有库存原料的参考价格,诱导生成器优先使用
// TODO 按理说应该尽量优先使用已有原料, 但如果搭配后购买缺失原料花的钱还不如不用已有原料的另一个组合钱少怎么办
adjustedMat.ReferencePrice = adjustedMat.ReferencePrice * 0.1
materialsForGeneration = append(materialsForGeneration, adjustedMat)
logger.Debugf("原料 '%s' (ID: %d) 有库存,生成配方时参考价格调整为 %.2f", mat.Name, mat.ID, adjustedMat.ReferencePrice)
}
// 再添加无库存的原料,保持原价
for _, mat := range noStockMaterials {
materialsForGeneration = append(materialsForGeneration, mat)
}
// 4. 调用生成器生成配方
recipe, err := r.GenerateRecipe(serviceCtx, *pigType, materialsForGeneration)
if err != nil {
return nil, fmt.Errorf("生成配方失败: %w", err)
}
// 5. 丰富配方描述:计算并添加参考价格信息
recipe.Name = fmt.Sprintf("%s - 优先使用库存已有原料", recipe.Name)
// 注意:这里需要使用原始的、未调整价格的原料信息来计算最终的参考价格
// rawMaterialMap 从 allOriginalMaterials 构建,确保使用原始价格
rawMaterialMap := make(map[uint32]models.RawMaterial)
for _, mat := range allOriginalMaterials {
rawMaterialMap[mat.ID] = mat
}
// 填充 RecipeIngredients 中的 RawMaterial 字段,以便后续计算成本
for i := range recipe.RecipeIngredients {
if rawMat, ok := rawMaterialMap[recipe.RecipeIngredients[i].RawMaterialID]; ok {
recipe.RecipeIngredients[i].RawMaterial = rawMat
} else {
logger.Warnf("未找到 RecipeIngredient (RawMaterialID: %d) 对应的原始 RawMaterial成本计算可能不准确", recipe.RecipeIngredients[i].RawMaterialID)
}
}
referencePrice := recipe.CalculateReferencePricePerKilogram() / 100
recipe.Description = fmt.Sprintf("使用 %v 种有库存原料和 %v 种无库存原料计算的库存原料优先使用的配方。 计算时预估成本: %.2f元/kg。", len(stockMaterials), len(noStockMaterials), referencePrice)
// 如果 totalPercentage 小于 100%,说明填充料被使用,这是符合预期的。
// 此时需要在描述中说明需要添加的廉价填充料的百分比。
totalPercentage := recipe.CalculateTotalRawMaterialProportion()
if totalPercentage < 99.99 { // 允许微小的浮点误差
fillerPercentage := 100 - totalPercentage
recipe.Description = fmt.Sprintf("%s 注意:配方中实际原料占比 %.2f%%,需额外补充 %.2f%% 廉价填充料", recipe.Description, totalPercentage, fillerPercentage)
}
// 6. 保存新生成的配方到数据库
if recipe, err = r.CreateRecipe(serviceCtx, recipe); err != nil {
return nil, fmt.Errorf("保存生成的配方失败: %w", err)
}
logger.Infof("成功生成优先使用库存原料的配方: 配方名称: %v | 配方简介: %v", recipe.Name, recipe.Description)
// 7. 返回创建的配方
return recipe, nil
}
// AIDiagnoseRecipe 使用 AI 为指定食谱生成诊断。
func (s *recipeServiceImpl) AIDiagnoseRecipe(ctx context.Context, recipeID uint32, pigTypeID uint32) (string, models.AIModel, error) {
serviceCtx, logger := logs.Trace(ctx, context.Background(), "AIDiagnoseRecipe")
// 1. 根据 recipeID 获取配方详情
recipe, err := s.recipeRepo.GetRecipeByID(serviceCtx, recipeID)
if err != nil {
logger.Errorf("获取配方详情失败: %v", err)
return "", s.ai.AIModel(), fmt.Errorf("获取配方详情失败: %w", err)
}
if recipe == nil {
logger.Warnf("未找到配方ID: %d", recipeID)
return "", s.ai.AIModel(), fmt.Errorf("未找到配方ID: %d", recipeID)
}
// 2. 获取目标猪只类型信息
pigType, err := s.GetPigTypeByID(serviceCtx, pigTypeID)
if err != nil {
logger.Errorf("获取猪只类型信息失败: %v", err)
return "", s.ai.AIModel(), fmt.Errorf("获取猪只类型信息失败: %w", err)
}
if pigType == nil {
logger.Warnf("未找到猪只类型ID: %d", pigTypeID)
return "", s.ai.AIModel(), fmt.Errorf("未找到猪只类型ID: %d", pigTypeID)
}
// 3. 定义 AI 输入结构体
type ingredientNutrient struct {
NutrientName string `json:"nutrient_name"`
Value float32 `json:"value"`
}
type recipeIngredient struct {
RawMaterialName string `json:"raw_material_name"`
Percentage float32 `json:"percentage"`
Nutrients []ingredientNutrient `json:"nutrients"`
}
type aiDiagnosisInput struct {
RecipeName string `json:"recipe_name"`
TargetPigType struct {
Name string `json:"name"`
} `json:"target_pig_type"`
Ingredients []recipeIngredient `json:"ingredients"`
}
// 4. 填充 AI 输入结构体
input := aiDiagnosisInput{
RecipeName: recipe.Name,
}
input.TargetPigType.Name = fmt.Sprintf("%s-%s", pigType.Breed.Name, pigType.AgeStage.Name)
for _, ingredient := range recipe.RecipeIngredients {
if ingredient.RawMaterial.ID == 0 {
logger.Warnf("配方成分中存在未加载的原料信息RecipeIngredientID: %d", ingredient.ID)
continue
}
ing := recipeIngredient{
RawMaterialName: ingredient.RawMaterial.Name,
Percentage: ingredient.Percentage,
}
for _, rmn := range ingredient.RawMaterial.RawMaterialNutrients {
if rmn.Nutrient.ID == 0 {
logger.Warnf("原料营养成分中存在未加载的营养素信息RawMaterialNutrientID: %d", rmn.ID)
continue
}
ing.Nutrients = append(ing.Nutrients, ingredientNutrient{
NutrientName: rmn.Nutrient.Name,
Value: rmn.Value,
})
}
input.Ingredients = append(input.Ingredients, ing)
}
// 5. 序列化为 JSON 字符串
jsonBytes, err := json.Marshal(input)
if err != nil {
logger.Errorf("序列化配方和猪只类型信息为 JSON 失败: %v", err)
return "", s.ai.AIModel(), fmt.Errorf("序列化数据失败: %w", err)
}
jsonString := string(jsonBytes)
// 6. 构建 AI Prompt
var promptBuilder strings.Builder
promptBuilder.WriteString(`
你是一个专业的动物营养师。请根据以下猪饲料配方数据,生成一份详细的、对养殖户友好的说明报告。
说明报告应包括以下部分:
1. 诊断猪只配方是否合理,如合理需要说明为什么合理, 如不合理需给出详细的改进建议。
2. 关键成分分析:分析主要原料和营养成分的作用
3. 使用建议:提供使用此配方的最佳实践和注意事项。
\n`)
promptBuilder.WriteString("```")
promptBuilder.WriteString(jsonString)
promptBuilder.WriteString("```")
prompt := promptBuilder.String()
logger.Debugf("生成的 AI 诊断 Prompt: \n%s", prompt)
// 7. 调用 AI Manager 进行诊断
diagnosisResult, err := s.ai.GenerateReview(serviceCtx, prompt)
if err != nil {
logger.Errorf("调用 AI Manager 诊断配方失败: %v", err)
return "", s.ai.AIModel(), fmt.Errorf("AI 诊断失败: %w", err)
}
logger.Infof("成功对配方 ID: %d (目标猪只类型 ID: %d) 进行 AI 诊断。", recipeID, pigTypeID)
return diagnosisResult, s.ai.AIModel(), nil
}

View File

@@ -16,7 +16,7 @@ type FullCollectionTask struct {
ctx context.Context
log *models.TaskExecutionLog
deviceRepo repository.DeviceRepository
deviceService device.Service
deviceService device.DeviceOperator
}
// NewFullCollectionTask 创建一个全量采集任务实例
@@ -24,7 +24,7 @@ func NewFullCollectionTask(
ctx context.Context,
log *models.TaskExecutionLog,
deviceRepo repository.DeviceRepository,
deviceService device.Service,
deviceService device.DeviceOperator,
) plan.Task {
return &FullCollectionTask{
ctx: ctx,

View File

@@ -0,0 +1,93 @@
package task
import (
"context"
"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"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport/proto"
)
// HeartbeatTask 实现了 plan.Task 接口用于执行一次区域主控心跳检测发送Ping
type HeartbeatTask struct {
ctx context.Context
log *models.TaskExecutionLog
areaControllerRepo repository.AreaControllerRepository
deviceService device.DeviceCommunicator
}
// NewHeartbeatTask 创建一个心跳检测任务实例
func NewHeartbeatTask(
ctx context.Context,
log *models.TaskExecutionLog,
areaControllerRepo repository.AreaControllerRepository,
deviceService device.DeviceCommunicator,
) plan.Task {
return &HeartbeatTask{
ctx: ctx,
log: log,
areaControllerRepo: areaControllerRepo,
deviceService: deviceService,
}
}
// Execute 是任务的核心执行逻辑
func (t *HeartbeatTask) Execute(ctx context.Context) error {
taskCtx, logger := logs.Trace(ctx, t.ctx, "Execute")
logger.Infow("开始执行区域主控心跳检测任务", "task_id", t.log.TaskID, "task_type", t.log.Task.Type, "log_id", t.log.ID)
controllers, err := t.areaControllerRepo.ListAll(taskCtx)
if err != nil {
return fmt.Errorf("心跳检测任务:获取所有区域主控失败: %w", err)
}
if len(controllers) == 0 {
logger.Infow("心跳检测任务:未发现任何区域主控,跳过本次检测")
return nil
}
// 构建 Ping 指令
pingInstruction := &proto.Instruction_Ping{
Ping: &proto.Ping{},
}
var firstError error
for _, controller := range controllers {
logger.Infow("向区域主控发送Ping指令", "controller_id", controller.ID)
err := t.deviceService.Send(taskCtx, controller.ID, pingInstruction, device.WithoutTracking())
if err != nil {
logger.Errorw("向区域主控发送Ping指令失败", "controller_id", controller.ID, "error", err)
if firstError == nil {
firstError = err // 保存第一个发生的错误
}
}
}
if firstError != nil {
return fmt.Errorf("心跳检测任务执行期间发生错误: %w", firstError)
}
logger.Infow("区域主控心跳检测任务执行完成", "task_id", t.log.TaskID, "task_type", t.log.Task.Type, "log_id", t.log.ID)
return nil
}
// OnFailure 定义了当 Execute 方法返回错误时,需要执行的回滚或清理逻辑
func (t *HeartbeatTask) OnFailure(ctx context.Context, executeErr error) {
logger := logs.TraceLogger(ctx, t.ctx, "OnFailure")
logger.Errorw("区域主控心跳检测任务执行失败",
"task_id", t.log.TaskID,
"task_type", t.log.Task.Type,
"log_id", t.log.ID,
"error", executeErr,
)
}
// ResolveDeviceIDs 获取当前任务需要使用的设备ID列表
func (t *HeartbeatTask) ResolveDeviceIDs(ctx context.Context) ([]uint32, error) {
// 心跳检测任务不和任何特定设备绑定
return []uint32{}, nil
}

View File

@@ -0,0 +1,150 @@
package task
import (
"context"
"fmt"
"path/filepath"
"sync"
"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"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/utils/file"
)
// OtaCheckTaskParams 定义了 OTA 检查任务所需的参数。
// 这些参数从任务的 Parameters JSON 字段中解析而来。
type OtaCheckTaskParams struct {
// TimeoutSeconds 定义了任务的全局超时时间(秒)。
// 如果一个升级任务在此时间内没有完成,将被标记为超时。
TimeoutSeconds int `json:"timeout_seconds"`
}
// otaCheckTask 实现了扫描和处理超时 OTA 升级任务的逻辑。
type otaCheckTask struct {
ctx context.Context
onceParse sync.Once
taskLog *models.TaskExecutionLog
params OtaCheckTaskParams
otaRepo repository.OtaRepository
}
// NewOtaCheckTask 创建一个新的 otaCheckTask 实例。
func NewOtaCheckTask(
ctx context.Context,
taskLog *models.TaskExecutionLog,
otaRepo repository.OtaRepository,
) plan.Task {
return &otaCheckTask{
ctx: ctx,
taskLog: taskLog,
otaRepo: otaRepo,
}
}
// Execute 是任务的核心执行逻辑。
func (t *otaCheckTask) Execute(ctx context.Context) error {
taskCtx, logger := logs.Trace(ctx, t.ctx, "Execute")
// 1. 解析并验证任务参数
if err := t.parseParameters(taskCtx); err != nil {
return err
}
logger.Infof("开始执行OTA升级超时检查任务超时设置为 %d 秒...", t.params.TimeoutSeconds)
timeoutDuration := time.Duration(t.params.TimeoutSeconds) * time.Second
timeoutBefore := time.Now().Add(-timeoutDuration)
// 2. 定义需要检查的状态
inProgressStatuses := []models.OTATaskStatus{
models.OTATaskStatusInProgress,
}
// 3. 查找所有超时的、仍在进行中的任务
tasks, err := t.otaRepo.FindTasksByStatusesAndCreationTime(taskCtx, inProgressStatuses, timeoutBefore)
if err != nil {
logger.Errorf("查找超时的OTA升级任务失败: %v", err)
return fmt.Errorf("查找超时的OTA升级任务失败: %w", err)
}
if len(tasks) == 0 {
logger.Info("没有发现超时的OTA升级任务。")
return nil
}
logger.Infof("发现 %d 个超时的OTA升级任务正在逐一处理...", len(tasks))
message := fmt.Sprintf("任务因超过全局超时时间(%d秒)未完成而被系统自动标记为超时。", t.params.TimeoutSeconds)
// 4. 逐一更新任务状态
for _, task := range tasks {
logger.Warnf("正在处理超时的OTA升级任务: ID=%d, 区域主控ID=%d, 目标版本=%s, 创建于=%v",
task.ID, task.AreaControllerID, task.TargetVersion, task.CreatedAt)
task.Status = models.OTATaskStatusTimedOut
task.ErrorMessage = message
completedTime := time.Now()
task.CompletedAt = &completedTime
if err := t.otaRepo.Update(taskCtx, task); err != nil {
// 仅记录错误,不中断整个检查任务,以确保其他超时任务能被处理
logger.Errorf("更新超时的OTA任务 #%d 状态失败: %v", task.ID, err)
} else {
// 数据库更新成功后,清理文件
logger.Infof("OTA任务 #%d 状态已更新为超时,现在开始清理文件。", task.ID)
dirToRemove := filepath.Join(models.OTADir, fmt.Sprintf("%d", task.ID))
if removeErr := file.RemoveTempDir(dirToRemove); removeErr != nil {
logger.Warnf("清理超时的OTA任务 #%d 的文件目录 %s 失败: %v", task.ID, dirToRemove, removeErr)
}
}
}
logger.Infof("成功处理了 %d 个超时的OTA升级任务。", len(tasks))
return nil
}
// parseParameters 使用 sync.Once 确保任务参数只被解析一次。
func (t *otaCheckTask) parseParameters(ctx context.Context) error {
logger := logs.TraceLogger(ctx, t.ctx, "parseParameters")
var err error
t.onceParse.Do(func() {
if t.taskLog.Task.Parameters == nil {
err = fmt.Errorf("任务 %d: 缺少参数", t.taskLog.TaskID)
logger.Error(err.Error())
return
}
var params OtaCheckTaskParams
if pErr := t.taskLog.Task.ParseParameters(&params); pErr != nil {
err = fmt.Errorf("任务 %d: 解析参数失败: %w", t.taskLog.TaskID, pErr)
logger.Error(err.Error())
return
}
// 验证参数
if params.TimeoutSeconds <= 0 {
err = fmt.Errorf("任务 %d: 参数 'timeout_seconds' 必须是一个正整数", t.taskLog.TaskID)
logger.Error(err.Error())
return
}
t.params = params
})
return err
}
// OnFailure 定义了当 Execute 方法返回错误时的回滚或清理逻辑。
func (t *otaCheckTask) OnFailure(ctx context.Context, executeErr error) {
logger := logs.TraceLogger(ctx, t.ctx, "OnFailure")
logger.Errorf("OTA升级超时检查任务执行失败, 任务ID: %d: %v", t.taskLog.TaskID, executeErr)
}
// ResolveDeviceIDs 从任务配置中解析并返回所有关联的设备ID列表。
func (t *otaCheckTask) ResolveDeviceIDs(ctx context.Context) ([]uint32, error) {
// 这是一个系统级的任务,不与任何特定设备直接关联。
return []uint32{}, nil
}

View File

@@ -32,7 +32,7 @@ type ReleaseFeedWeightTask struct {
releaseWeight float32
mixingTankDeviceID uint32
feedPort device.Service
feedPort device.DeviceOperator
// onceParse 保证解析参数只执行一次
onceParse sync.Once
@@ -44,7 +44,7 @@ func NewReleaseFeedWeightTask(
claimedLog *models.TaskExecutionLog,
sensorDataRepo repository.SensorDataRepository,
deviceRepo repository.DeviceRepository,
deviceService device.Service,
deviceService device.DeviceOperator,
) plan.Task {
return &ReleaseFeedWeightTask{
ctx: ctx,

View File

@@ -14,20 +14,28 @@ import (
)
const (
CompNameDelayTask = "DelayTask"
CompNameReleaseFeedWeight = "ReleaseFeedWeightTask"
CompNameFullCollectionTask = "FullCollectionTask"
CompNameAlarmNotification = "AlarmNotificationTask"
CompNameDelayTask = "DelayTask"
CompNameReleaseFeedWeight = "ReleaseFeedWeightTask"
CompNameFullCollectionTask = "FullCollectionTask"
CompNameAlarmNotification = "AlarmNotificationTask"
CompNameHeartbeatTask = "HeartbeatTask"
CompNameOtaCheck = "OtaCheckTask"
CompNameDeviceThresholdCheck = "DeviceThresholdCheckTask"
CompNameAreaCollectorThresholdCheck = "AreaCollectorThresholdCheckTask"
CompNameNotificationRefresh = "NotificationRefreshTask"
)
type taskFactory struct {
ctx context.Context
sensorDataRepo repository.SensorDataRepository
deviceRepo repository.DeviceRepository
alarmRepo repository.AlarmRepository
sensorDataRepo repository.SensorDataRepository
deviceRepo repository.DeviceRepository
alarmRepo repository.AlarmRepository
areaControllerRepo repository.AreaControllerRepository
otaRepo repository.OtaRepository
deviceService device.Service
deviceOperator device.DeviceOperator
deviceCommunicator device.DeviceCommunicator
notificationService notify.Service
alarmService alarm.AlarmService
}
@@ -37,7 +45,10 @@ func NewTaskFactory(
sensorDataRepo repository.SensorDataRepository,
deviceRepo repository.DeviceRepository,
alarmRepo repository.AlarmRepository,
deviceService device.Service,
areaControllerRepo repository.AreaControllerRepository,
otaRepo repository.OtaRepository,
deviceOperator device.DeviceOperator,
deviceCommunicator device.DeviceCommunicator,
notifyService notify.Service,
alarmService alarm.AlarmService,
) plan.TaskFactory {
@@ -46,7 +57,10 @@ func NewTaskFactory(
sensorDataRepo: sensorDataRepo,
deviceRepo: deviceRepo,
alarmRepo: alarmRepo,
deviceService: deviceService,
areaControllerRepo: areaControllerRepo,
otaRepo: otaRepo,
deviceOperator: deviceOperator,
deviceCommunicator: deviceCommunicator,
notificationService: notifyService,
alarmService: alarmService,
}
@@ -59,19 +73,22 @@ func (t *taskFactory) Production(ctx context.Context, claimedLog *models.TaskExe
case models.TaskTypeWaiting:
return NewDelayTask(logs.AddCompName(baseCtx, CompNameDelayTask), claimedLog)
case models.TaskTypeReleaseFeedWeight:
return NewReleaseFeedWeightTask(logs.AddCompName(baseCtx, CompNameReleaseFeedWeight), claimedLog, t.sensorDataRepo, t.deviceRepo, t.deviceService)
return NewReleaseFeedWeightTask(logs.AddCompName(baseCtx, CompNameReleaseFeedWeight), claimedLog, t.sensorDataRepo, t.deviceRepo, t.deviceOperator)
case models.TaskTypeFullCollection:
return NewFullCollectionTask(logs.AddCompName(baseCtx, CompNameFullCollectionTask), claimedLog, t.deviceRepo, t.deviceService)
return NewFullCollectionTask(logs.AddCompName(baseCtx, CompNameFullCollectionTask), claimedLog, t.deviceRepo, t.deviceOperator)
case models.TaskTypeHeartbeat:
return NewHeartbeatTask(logs.AddCompName(baseCtx, CompNameHeartbeatTask), claimedLog, t.areaControllerRepo, t.deviceCommunicator)
case models.TaskTypeAlarmNotification:
return NewAlarmNotificationTask(logs.AddCompName(baseCtx, CompNameAlarmNotification), claimedLog, t.notificationService, t.alarmRepo)
case models.TaskTypeDeviceThresholdCheck:
return NewDeviceThresholdCheckTask(logs.AddCompName(baseCtx, "DeviceThresholdCheckTask"), claimedLog, t.sensorDataRepo, t.alarmService)
return NewDeviceThresholdCheckTask(logs.AddCompName(baseCtx, CompNameDeviceThresholdCheck), claimedLog, t.sensorDataRepo, t.alarmService)
case models.TaskTypeAreaCollectorThresholdCheck:
return NewAreaThresholdCheckTask(logs.AddCompName(baseCtx, "AreaCollectorThresholdCheckTask"), claimedLog, t.sensorDataRepo, t.deviceRepo, t.alarmService)
return NewAreaThresholdCheckTask(logs.AddCompName(baseCtx, CompNameAreaCollectorThresholdCheck), claimedLog, t.sensorDataRepo, t.deviceRepo, t.alarmService)
case models.TaskTypeNotificationRefresh:
return NewRefreshNotificationTask(logs.AddCompName(baseCtx, "NotificationRefreshTask"), claimedLog, t.alarmService)
return NewRefreshNotificationTask(logs.AddCompName(baseCtx, CompNameNotificationRefresh), claimedLog, t.alarmService)
case models.TaskTypeOTACheck:
return NewOtaCheckTask(logs.AddCompName(baseCtx, CompNameOtaCheck), claimedLog, t.otaRepo)
default:
// TODO 这里直接panic合适吗? 不过这个场景确实不该出现任何异常的任务类型
logger.Panicf("不支持的任务类型: %s", claimedLog.Task.Type)
panic("不支持的任务类型") // 显式panic防编译器报错
}
@@ -79,8 +96,6 @@ func (t *taskFactory) Production(ctx context.Context, claimedLog *models.TaskExe
// CreateTaskFromModel 实现了 TaskFactory 接口,用于从模型创建任务实例。
func (t *taskFactory) CreateTaskFromModel(ctx context.Context, taskModel *models.Task) (plan.TaskDeviceIDResolver, error) {
// 这个方法不关心 claimedLog 的其他字段,所以可以构造一个临时的
// 它只用于访问那些不依赖于执行日志的方法,比如 ResolveDeviceIDs
tempLog := &models.TaskExecutionLog{Task: *taskModel}
baseCtx := context.Background()
@@ -93,18 +108,22 @@ func (t *taskFactory) CreateTaskFromModel(ctx context.Context, taskModel *models
tempLog,
t.sensorDataRepo,
t.deviceRepo,
t.deviceService,
t.deviceOperator,
), nil
case models.TaskTypeFullCollection:
return NewFullCollectionTask(logs.AddCompName(baseCtx, CompNameFullCollectionTask), tempLog, t.deviceRepo, t.deviceService), nil
return NewFullCollectionTask(logs.AddCompName(baseCtx, CompNameFullCollectionTask), tempLog, t.deviceRepo, t.deviceOperator), nil
case models.TaskTypeHeartbeat:
return NewHeartbeatTask(logs.AddCompName(baseCtx, CompNameHeartbeatTask), tempLog, t.areaControllerRepo, t.deviceCommunicator), nil
case models.TaskTypeAlarmNotification:
return NewAlarmNotificationTask(logs.AddCompName(baseCtx, CompNameAlarmNotification), tempLog, t.notificationService, t.alarmRepo), nil
case models.TaskTypeDeviceThresholdCheck:
return NewDeviceThresholdCheckTask(logs.AddCompName(baseCtx, "DeviceThresholdCheckTask"), tempLog, t.sensorDataRepo, t.alarmService), nil
return NewDeviceThresholdCheckTask(logs.AddCompName(baseCtx, CompNameDeviceThresholdCheck), tempLog, t.sensorDataRepo, t.alarmService), nil
case models.TaskTypeAreaCollectorThresholdCheck:
return NewAreaThresholdCheckTask(logs.AddCompName(baseCtx, "AreaCollectorThresholdCheckTask"), tempLog, t.sensorDataRepo, t.deviceRepo, t.alarmService), nil
return NewAreaThresholdCheckTask(logs.AddCompName(baseCtx, CompNameAreaCollectorThresholdCheck), tempLog, t.sensorDataRepo, t.deviceRepo, t.alarmService), nil
case models.TaskTypeNotificationRefresh:
return NewRefreshNotificationTask(logs.AddCompName(baseCtx, "NotificationRefreshTask"), tempLog, t.alarmService), nil
return NewRefreshNotificationTask(logs.AddCompName(baseCtx, CompNameNotificationRefresh), tempLog, t.alarmService), nil
case models.TaskTypeOTACheck:
return NewOtaCheckTask(logs.AddCompName(baseCtx, CompNameOtaCheck), tempLog, t.otaRepo), nil
default:
return nil, fmt.Errorf("不支持为类型 '%s' 的任务创建模型实例", taskModel.Type)
}

19
internal/infra/ai/ai.go Normal file
View File

@@ -0,0 +1,19 @@
package ai
import (
"context"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
)
// AI 定义了通用的 AI 管理接口。
// 它可以用于处理各种 AI 相关的任务,例如文本生成、内容审核等。
type AI interface {
// GenerateReview 根据提供的文本内容生成评论。
// prompt: 用于生成评论的输入文本。
// 返回生成的评论字符串和可能发生的错误。
GenerateReview(ctx context.Context, prompt string) (string, error)
// AIModel 返回当前使用的 AI 模型。
AIModel() models.AIModel
}

View File

@@ -0,0 +1,73 @@
package ai
import (
"context"
"fmt"
"time"
"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/models"
"github.com/google/generative-ai-go/genai"
"google.golang.org/api/option"
)
// geminiImpl 是 Gemini AI 服务的实现。
type geminiImpl struct {
client *genai.GenerativeModel
cfg config.Gemini
}
// NewGeminiAI 创建一个新的 geminiImpl 实例。
func NewGeminiAI(ctx context.Context, cfg config.Gemini) (AI, error) {
// 检查 API Key 是否存在
if cfg.APIKey == "" {
return nil, fmt.Errorf("Gemini API Key 未配置")
}
// 创建 Gemini 客户端
genaiClient, err := genai.NewClient(ctx, option.WithAPIKey(cfg.APIKey))
if err != nil {
return nil, fmt.Errorf("创建 Gemini 客户端失败: %w", err)
}
return &geminiImpl{
client: genaiClient.GenerativeModel(cfg.ModelName),
cfg: cfg,
}, nil
}
// GenerateReview 根据提供的文本内容生成评论。
func (g *geminiImpl) GenerateReview(ctx context.Context, prompt string) (string, error) {
serviceCtx, logger := logs.Trace(ctx, context.Background(), "GenerateReview")
logger.Debugf("开始调用 Gemini 生成评论prompt: %s", prompt)
timeoutCtx, cancel := context.WithTimeout(serviceCtx, time.Duration(g.cfg.Timeout)*time.Second)
defer cancel()
resp, err := g.client.GenerateContent(timeoutCtx, genai.Text(prompt))
if err != nil {
logger.Errorf("调用 Gemini API 失败: %v", err)
return "", fmt.Errorf("调用 Gemini API 失败: %w", err)
}
if resp == nil || len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 {
logger.Warn("Gemini API 返回空内容或无候选评论")
return "", fmt.Errorf("Gemini API 返回空内容或无候选评论")
}
var review string
for _, part := range resp.Candidates[0].Content.Parts {
if txt, ok := part.(genai.Text); ok {
review += string(txt)
}
}
logger.Debugf("成功从 Gemini 生成评论: %s", review)
return review, nil
}
func (g *geminiImpl) AIModel() models.AIModel {
return models.AI_MODEL_GEMINI
}

View File

@@ -0,0 +1,31 @@
package ai
import (
"context"
"errors"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
)
var NoneAIError = errors.New("当前没有配置AI, 暂不支持此功能")
type NoneAI struct {
ctx context.Context
}
func NewNoneAI(ctx context.Context) AI {
return &NoneAI{
ctx: ctx,
}
}
func (n *NoneAI) GenerateReview(ctx context.Context, prompt string) (string, error) {
logger := logs.TraceLogger(ctx, n.ctx, "GenerateReview")
logger.Warnf("当前没有配置AI, 无法处理AI请求, 消息: %s", prompt)
return "", NoneAIError
}
func (n *NoneAI) AIModel() models.AIModel {
return models.AI_MODEL_NONE
}

View File

@@ -4,10 +4,8 @@
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v2"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/utils/file"
)
// Config 代表应用的完整配置结构
@@ -50,6 +48,12 @@ type Config struct {
// AlarmNotification 告警通知配置
AlarmNotification AlarmNotificationConfig `yaml:"alarm_notification"`
// AI AI服务配置
AI AIConfig `yaml:"ai"`
// OTA OTA升级配置
OTA OTAConfig `yaml:"ota"`
}
// AppConfig 代表应用基础配置
@@ -57,6 +61,7 @@ type AppConfig struct {
Name string `yaml:"name"`
Version string `yaml:"version"`
JWTSecret string `yaml:"jwt_secret"` // JWT 密钥
TempPath string `yaml:"temp_path"`
}
// ServerConfig 代表服务器配置
@@ -231,6 +236,29 @@ type AlarmNotificationConfig struct {
NotificationIntervals NotificationIntervalsConfig `yaml:"notification_intervals"`
}
// AIConfig AI 服务配置
type AIConfig struct {
Model models.AIModel `yaml:"model"`
Gemini Gemini `yaml:"gemini"`
}
// Gemini 代表 Gemini AI 服务的配置
type Gemini struct {
APIKey string `yaml:"api_key"` // Gemini API Key
ModelName string `yaml:"model_name"` // Gemini 模型名称,例如 "gemini-pro"
Timeout int `yaml:"timeout"` // AI 请求超时时间 (秒)
}
// OTAConfig 代表 OTA 升级配置
type OTAConfig struct {
// DefaultTimeoutSeconds 升级任务的全局超时时间(秒)
DefaultTimeoutSeconds int `yaml:"default_timeout_seconds"`
// DefaultRequestTimeoutSeconds 等待设备响应的单次请求超时时间(秒)
DefaultRequestTimeoutSeconds int `yaml:"default_request_timeout_seconds"`
// DefaultRetryCount 默认的固件块请求重试次数
DefaultRetryCount int `yaml:"default_retry_count"`
}
// NewConfig 创建并返回一个新的配置实例
func NewConfig() *Config {
// 默认值可以在这里设置,但我们优先使用配置文件中的值
@@ -243,18 +271,7 @@ func NewConfig() *Config {
// Load 从指定路径加载配置文件
func (c *Config) Load(path string) error {
// 读取配置文件
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("配置文件读取失败: %v", err)
}
// 解析YAML配置
if err := yaml.Unmarshal(data, c); err != nil {
return fmt.Errorf("配置文件解析失败: %v", err)
}
return nil
return file.LoadYaml(path, c)
}
// GenerateAPIKey 用于补齐API Key作为请求头时缺失的部分

View File

@@ -6,6 +6,7 @@ package database
import (
"context"
"fmt"
"strings"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
@@ -167,9 +168,6 @@ func (ps *PostgresStorage) creatingHyperTable(ctx context.Context) error {
{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"},
@@ -180,6 +178,7 @@ func (ps *PostgresStorage) creatingHyperTable(ctx context.Context) error {
{models.PigSale{}, "sale_date"},
{models.Notification{}, "alarm_timestamp"},
{models.HistoricalAlarm{}, "trigger_time"},
{models.RawMaterialStockLog{}, "happened_at"},
}
for _, table := range tablesToConvert {
@@ -210,9 +209,6 @@ func (ps *PostgresStorage) applyCompressionPolicies(ctx context.Context) error {
{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"},
@@ -223,6 +219,7 @@ func (ps *PostgresStorage) applyCompressionPolicies(ctx context.Context) error {
{models.PigSale{}, "pig_batch_id"},
{models.Notification{}, "user_id"},
{models.HistoricalAlarm{}, "source_id"},
{models.RawMaterialStockLog{}, "raw_material_id"},
}
for _, policy := range policies {
@@ -254,27 +251,198 @@ func (ps *PostgresStorage) applyCompressionPolicies(ctx context.Context) error {
// creatingIndex 用于创建gorm无法处理的索引, 如gin索引
func (ps *PostgresStorage) creatingIndex(ctx context.Context) error {
storageCtx, logger := logs.Trace(ctx, ps.ctx, "creatingIndex")
storageCtx := logs.AddFuncName(ctx, ps.ctx, "creatingIndex")
// 使用 IF NOT EXISTS 保证幂等性
// 如果索引已存在,此命令不会报错
// 为 sensor_data 表的 data 字段创建 GIN 索引
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.WithContext(storageCtx).Exec(ginSensorDataIndexSQL).Error; err != nil {
logger.Errorw("为 sensor_data 的 data 字段创建 GIN 索引失败", "error", err)
return fmt.Errorf("为 sensor_data 的 data 字段创建 GIN 索引失败: %w", err)
if err := ps.creatingUniqueIndex(storageCtx); err != nil {
return err
}
logger.Debug("成功为 sensor_data 的 data 字段创建 GIN 索引 (或已存在)")
// 为 tasks.parameters 创建 GIN 索引
logger.Debug("正在为 tasks 表的 parameters 字段创建 GIN 索引")
taskGinIndexSQL := "CREATE INDEX IF NOT EXISTS idx_tasks_parameters_gin ON tasks USING GIN (parameters);"
if err := ps.db.WithContext(storageCtx).Exec(taskGinIndexSQL).Error; err != nil {
logger.Errorw("为 tasks 的 parameters 字段创建 GIN 索引失败", "error", err)
return fmt.Errorf("为 tasks 的 parameters 字段创建 GIN 索引失败: %w", err)
if err := ps.createGinIndexes(storageCtx); err != nil {
return err
}
return nil
}
// uniqueIndexDefinition 结构体定义了唯一索引的详细信息
type uniqueIndexDefinition struct {
tableName string // 索引所属的表名
columns []string // 构成唯一索引的列名
indexName string // 唯一索引的名称
whereClause string // 可选的 WHERE 子句,用于创建部分索引
description string // 索引的描述,用于日志记录
}
// ginIndexDefinition 结构体定义了 GIN 索引的详细信息
type ginIndexDefinition struct {
tableName string // 索引所属的表名
columnName string // 需要创建 GIN 索引的列名
indexName string // GIN 索引的名称
description string // 索引的描述,用于日志记录
}
func (ps *PostgresStorage) creatingUniqueIndex(ctx context.Context) error {
storageCtx, logger := logs.Trace(ctx, ps.ctx, "creatingUniqueIndex")
// 定义所有需要创建的唯一索引
uniqueIndexesToCreate := []uniqueIndexDefinition{
{
tableName: models.RawMaterialNutrient{}.TableName(),
columns: []string{"raw_material_id", "nutrient_id"},
indexName: "idx_raw_material_nutrients_unique_when_not_deleted",
whereClause: "WHERE deleted_at IS NULL",
description: "确保同一原料中的每种营养成分不重复",
},
{
tableName: models.PigBreed{}.TableName(),
columns: []string{"name"},
indexName: "idx_pig_breeds_unique_name_when_not_deleted",
whereClause: "WHERE deleted_at IS NULL",
description: "pig_breeds 表的部分唯一索引 (name 唯一)",
},
{
tableName: models.PigAgeStage{}.TableName(),
columns: []string{"name"},
indexName: "idx_pig_age_stages_unique_name_when_not_deleted",
whereClause: "WHERE deleted_at IS NULL",
description: "pig_age_stages 表的部分唯一索引 (name 唯一)",
},
{
tableName: models.PigType{}.TableName(),
columns: []string{"breed_id", "age_stage_id"},
indexName: "idx_pig_types_unique_breed_age_stage_when_not_deleted",
whereClause: "WHERE deleted_at IS NULL",
description: "pig_types 表的部分唯一索引 (breed_id, age_stage_id 组合唯一)",
},
{
tableName: models.PigNutrientRequirement{}.TableName(),
columns: []string{"pig_type_id", "nutrient_id"},
indexName: "idx_pig_nutrient_requirements_unique_type_nutrient_when_not_deleted",
whereClause: "WHERE deleted_at IS NULL",
description: "pig_nutrient_requirements 表的部分唯一索引 (pig_type_id, nutrient_id 组合唯一)",
},
{
tableName: models.User{}.TableName(),
columns: []string{"username"},
indexName: "idx_users_unique_username_when_not_deleted",
whereClause: "WHERE deleted_at IS NULL",
description: "users 表的部分唯一索引 (username 唯一)",
},
{
tableName: models.AreaController{}.TableName(),
columns: []string{"name"},
indexName: "idx_area_controllers_unique_name_when_not_deleted",
whereClause: "WHERE deleted_at IS NULL",
description: "area_controllers 表的部分唯一索引 (Name 唯一)",
},
{
tableName: models.AreaController{}.TableName(),
columns: []string{"network_id"},
indexName: "idx_area_controllers_unique_network_id_when_not_deleted",
whereClause: "WHERE deleted_at IS NULL",
description: "area_controllers 表的部分唯一索引 (NetworkID 唯一)",
},
{
tableName: models.DeviceTemplate{}.TableName(),
columns: []string{"name"},
indexName: "idx_device_templates_unique_name_when_not_deleted",
whereClause: "WHERE deleted_at IS NULL",
description: "device_templates 表的部分唯一索引 (name 唯一)",
},
{
tableName: models.PigBatch{}.TableName(),
columns: []string{"batch_number"},
indexName: "idx_pig_batches_unique_batch_number_when_not_deleted",
whereClause: "WHERE deleted_at IS NULL",
description: "pig_batches 表的部分唯一索引 (batch_number 唯一)",
},
{
tableName: models.PigHouse{}.TableName(),
columns: []string{"name"},
indexName: "idx_pig_houses_unique_name_when_not_deleted",
whereClause: "WHERE deleted_at IS NULL",
description: "pig_houses 表的部分唯一索引 (name 唯一)",
},
{
tableName: models.RawMaterial{}.TableName(),
columns: []string{"name"},
indexName: "idx_raw_materials_unique_name_when_not_deleted",
whereClause: "WHERE deleted_at IS NULL",
description: "raw_materials 表的部分唯一索引 (name 唯一)",
},
{
tableName: models.Nutrient{}.TableName(),
columns: []string{"name"},
indexName: "idx_nutrients_unique_name_when_not_deleted",
whereClause: "WHERE deleted_at IS NULL",
description: "nutrients 表的部分唯一索引 (name 唯一)",
},
{
tableName: models.Recipe{}.TableName(),
columns: []string{"name"},
indexName: "idx_recipes_unique_name_when_not_deleted",
whereClause: "WHERE deleted_at IS NULL",
description: "recipes 表的部分唯一索引 (name 唯一)",
},
{
tableName: models.RecipeIngredient{}.TableName(),
columns: []string{"recipe_id", "raw_material_id"},
indexName: "idx_recipe_ingredients_unique_recipe_raw_material_when_not_deleted",
whereClause: "WHERE deleted_at IS NULL",
description: "recipe_ingredients 表的部分唯一索引 (recipe_id, raw_material_id 组合唯一)",
},
}
for _, indexDef := range uniqueIndexesToCreate {
logger.Debugw("正在为表创建部分唯一索引", "表名", indexDef.tableName, "索引名", indexDef.indexName, "描述", indexDef.description)
// 拼接列名字符串
columnsStr := strings.Join(indexDef.columns, ", ")
// 构建 SQL 语句
sql := fmt.Sprintf("CREATE UNIQUE INDEX IF NOT EXISTS %s ON %s (%s) %s;",
indexDef.indexName, indexDef.tableName, columnsStr, indexDef.whereClause)
if err := ps.db.WithContext(storageCtx).Exec(sql).Error; err != nil {
logger.Errorw("创建部分唯一索引失败", "表名", indexDef.tableName, "索引名", indexDef.indexName, "错误", err)
return fmt.Errorf("为 %s 表创建部分唯一索引 %s 失败: %w", indexDef.tableName, indexDef.indexName, err)
}
logger.Debugw("成功为表创建部分唯一索引 (或已存在)", "表名", indexDef.tableName, "索引名", indexDef.indexName)
}
return nil
}
func (ps *PostgresStorage) createGinIndexes(ctx context.Context) error {
storageCtx, logger := logs.Trace(ctx, ps.ctx, "createGinIndexes")
// 定义所有需要创建的 GIN 索引
ginIndexesToCreate := []ginIndexDefinition{
{
tableName: "sensor_data",
columnName: "data",
indexName: "idx_sensor_data_data_gin",
description: "为 sensor_data 表的 data 字段创建 GIN 索引",
},
{
tableName: "tasks",
columnName: "parameters",
indexName: "idx_tasks_parameters_gin",
description: "为 tasks 表的 parameters 字段创建 GIN 索引",
},
}
for _, indexDef := range ginIndexesToCreate {
logger.Debugw("正在创建 GIN 索引", "表名", indexDef.tableName, "列名", indexDef.columnName, "描述", indexDef.description)
// 构建 SQL 语句
sql := fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s ON %s USING GIN (%s);",
indexDef.indexName, indexDef.tableName, indexDef.columnName)
if err := ps.db.WithContext(storageCtx).Exec(sql).Error; err != nil {
logger.Errorw("创建 GIN 索引失败", "表名", indexDef.tableName, "索引名", indexDef.indexName, "错误", err)
return fmt.Errorf("为 %s 表的 %s 字段创建 GIN 索引 %s 失败: %w", indexDef.tableName, indexDef.columnName, indexDef.indexName, err)
}
logger.Debugw("成功创建 GIN 索引 (或已存在)", "表名", indexDef.tableName, "索引名", indexDef.indexName)
}
logger.Debug("成功为 tasks 的 parameters 字段创建 GIN 索引 (或已存在)")
return nil
}

View File

@@ -0,0 +1,145 @@
package database
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"github.com/tidwall/gjson"
"gorm.io/gorm"
)
// Seeder 定义了数据播种器的通用接口。
// 每个播种器负责处理一种特定类型的预设数据。
type Seeder interface {
// Type 返回此播种器能处理的唯一类型标识符,
// 这个标识符应与预设 JSON 文件中的 "type" 字段相匹配。
Type() string
// IsRequired 标记此播种器对应的预设文件是否为必需的。
// 如果为 true 且对应的 .json 文件不存在,整个播种过程将失败并报错。
IsRequired() bool
// Seed 执行具体的数据播种逻辑。
// 它接收数据库事务、上下文和从 JSON 文件读取的原始字节数据。
Seed(ctx context.Context, tx *gorm.DB, jsonData []byte) error
}
// SeedFromPreset 是一个通用的数据播种函数。
// 它会读取指定目录下的所有 .json 文件,并根据文件内容中的 "type" 字段,
// 将其分发给由外部注入的相应 Seeder 进行处理。
// Seeder 的处理顺序由其在注入切片中的顺序决定。
func SeedFromPreset(ctx context.Context, db *gorm.DB, presetDir string, seeders []Seeder) error {
seedCtx, logger := logs.Trace(ctx, ctx, "SeedFromPreset")
// --- 步骤 1: 校验注入的 Seeders 是否有重复类型 ---
tempSeederTypes := make(map[string]bool)
for _, s := range seeders {
if tempSeederTypes[s.Type()] {
return fmt.Errorf("播种器初始化失败: 存在重复的 Seeder 类型 '%s'", s.Type())
}
tempSeederTypes[s.Type()] = true
}
// --- 步骤 2: 读取并分组所有预设文件 ---
groupedFiles := make(map[string][][]byte)
typeToFileMap := make(map[string]string)
files, err := os.ReadDir(presetDir)
if err != nil {
if os.IsNotExist(err) { // 目录不存在
// 检查是否有必需的 Seeder如果目录不存在但需要文件则报错
var requiredSeederTypes []string
for _, s := range seeders {
if s.IsRequired() {
requiredSeederTypes = append(requiredSeederTypes, s.Type())
}
}
if len(requiredSeederTypes) > 0 {
return fmt.Errorf("预设数据校验失败: 预设目录 '%s' 不存在, 但系统需要以下必需类型: [%s]", presetDir, strings.Join(requiredSeederTypes, ", "))
}
logger.Warnf("预设数据目录 '%s' 不存在,跳过播种。", presetDir)
return nil
}
return fmt.Errorf("读取预设数据目录 '%s' 失败: %w", presetDir, err)
}
// 处理目录为空的情况
if len(files) == 0 {
var requiredSeederTypes []string
for _, s := range seeders {
if s.IsRequired() {
requiredSeederTypes = append(requiredSeederTypes, s.Type())
}
}
if len(requiredSeederTypes) > 0 {
return fmt.Errorf("预设数据校验失败: 预设目录 '%s' 为空, 但系统需要以下必需类型: [%s]", presetDir, strings.Join(requiredSeederTypes, ", "))
}
logger.Warnf("预设数据目录 '%s' 为空,跳过播种。", presetDir)
return nil
}
// 读取所有文件并按 type 分组
for _, file := range files {
if filepath.Ext(file.Name()) != ".json" {
continue
}
filePath := filepath.Join(presetDir, file.Name())
jsonData, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("读取文件 '%s' 失败: %w", filePath, err)
}
dataType := gjson.GetBytes(jsonData, "type")
if !dataType.Exists() {
logger.Warnf("警告: 文件 '%s' 中缺少 'type' 字段,已跳过。", filePath)
continue
}
dataTypeStr := dataType.String()
// 检查是否存在重复的 type
if existingFile, found := typeToFileMap[dataTypeStr]; found {
return fmt.Errorf("预设数据校验失败: type '%s' 在文件 '%s' 和 '%s' 中重复定义", dataTypeStr, existingFile, filePath)
}
typeToFileMap[dataTypeStr] = filePath // 记录该 type 对应的文件路径
groupedFiles[dataTypeStr] = append(groupedFiles[dataTypeStr], jsonData)
}
// --- 步骤 3: 在事务中按注入顺序执行播种,并采用快速失败策略 ---
return db.Transaction(func(tx *gorm.DB) error {
logger.Info("开始执行数据播种事务...")
// 直接遍历注入的 seeders顺序由调用方保证
for _, seeder := range seeders {
dataTypeStr := seeder.Type()
jsonDatas, fileExists := groupedFiles[dataTypeStr]
if fileExists {
// --- 文件存在,执行播种 ---
logger.Infof("正在使用播种器处理类型: '%s'...", dataTypeStr)
for _, jsonData := range jsonDatas {
originalFilePath := typeToFileMap[dataTypeStr]
if err := seeder.Seed(seedCtx, tx, jsonData); err != nil {
return fmt.Errorf("处理文件 (type: %s, path: %s) 时发生错误: %w", dataTypeStr, originalFilePath, err)
}
}
logger.Infof("类型 '%s' 处理完成。", dataTypeStr)
} else {
// --- 文件不存在,检查是否必需 ---
if seeder.IsRequired() {
// 快速失败:如果是必需的但文件不存在,立即报错并回滚事务
return fmt.Errorf("预设数据校验失败: 缺少必需的预设文件类型: '%s'", dataTypeStr)
}
// 如果不是必需的,则只记录日志
logger.Infof("未找到可选类型为 '%s' 的预设文件,跳过该播种器。", dataTypeStr)
}
}
logger.Info("数据播种事务成功完成。")
return nil
})
}

View File

@@ -13,6 +13,7 @@ import (
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/config"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gopkg.in/natefinch/lumberjack.v2"
@@ -65,8 +66,8 @@ func NewLogger(cfg config.LogConfig) *Logger {
// 5. 构建 Logger
// zap.AddCaller() 会记录调用日志的代码行
// zap.AddCallerSkip(1) 可以向上跳层调用栈,如果我们将 logger.Info 等方法再封装一层,这个选项会很有用
zapLogger := zap.New(core, zap.AddCaller(), zap.AddCallerSkip(1))
// zap.AddCallerSkip(2) 可以向上跳层调用栈,因为我们的日志方法被封装了两层 (Logger.Info -> Logger.logWithTrace)
zapLogger := zap.New(core, zap.AddCaller(), zap.AddCallerSkip(2))
return &Logger{sl: zapLogger.Sugar()}
}

View File

@@ -3,6 +3,7 @@ package models
import (
"encoding/json"
"errors"
"fmt"
"strings"
"gorm.io/datatypes"
@@ -16,16 +17,21 @@ type Bus485Properties struct {
BusAddress uint8 `json:"bus_address"` // 485 总线地址
}
// AreaControllerProperties 定义了区域主控的特有属性
type AreaControllerProperties struct {
FirmwareVersion string `json:"firmware_version,omitempty"` // 主控程序版本
}
// AreaController 是一个LoRa转总线(如485)的通信网关
type AreaController struct {
Model
// Name 是主控的业务名称,例如 "1号猪舍主控"
Name string `gorm:"not null;unique" json:"name"`
Name string `gorm:"not null" json:"name"`
// NetworkID 是主控在通信网络中的唯一标识,例如 LoRaWAN 的 DevEUI。
// 这是 transport 层用来寻址的关键。
NetworkID string `gorm:"not null;unique;index" json:"network_id"`
NetworkID string `gorm:"not null;index" json:"network_id"`
// Location 描述了主控的物理安装位置。
Location string `gorm:"index" json:"location"`
@@ -45,6 +51,29 @@ func (ac *AreaController) SelfCheck() error {
return nil
}
// ParseProperties 解析 JSON 属性到一个具体的结构体中。
// 调用方需要传入一个指向目标结构体实例的指针。
func (ac *AreaController) ParseProperties(v interface{}) error {
if ac.Properties == nil {
return errors.New("区域主控属性为空,无法解析")
}
return json.Unmarshal(ac.Properties, v)
}
// SetProperties 将一个结构体编码为 JSON 并设置到 Properties 字段。
func (ac *AreaController) SetProperties(v interface{}) error {
if v == nil {
ac.Properties = nil
return nil
}
jsonBytes, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("无法编码区域主控的属性 (Properties): %w", err)
}
ac.Properties = jsonBytes
return nil
}
// TableName 自定义 GORM 使用的数据库表名
func (AreaController) TableName() string {
return "area_controllers"

View File

@@ -108,7 +108,7 @@ type DeviceTemplate struct {
Model
// Name 是此模板的唯一名称, 例如 "FanModel-XYZ-2000" 或 "TempSensor-T1"
Name string `gorm:"not null;unique" json:"name"`
Name string `gorm:"not null" json:"name"`
// Manufacturer 是设备的制造商。
Manufacturer string `json:"manufacturer"`

View File

@@ -153,6 +153,61 @@ func (PendingCollection) TableName() string {
return "pending_collections"
}
// --- OTA 升级任务 ---
// OTATaskStatus 定义 OTA 升级任务的状态
type OTATaskStatus string
const (
OTATaskStatusPending OTATaskStatus = "待开始" // 任务已创建,等待下发
OTATaskStatusInProgress OTATaskStatus = "进行中" // 任务已下发,设备正在处理
OTATaskStatusSuccess OTATaskStatus = "成功" // 设备报告升级成功,新固件已运行
OTATaskStatusAlreadyUpToDate OTATaskStatus = "版本已是最新" // 设备报告版本已是最新,未执行升级
OTATaskStatusFailedPreparation OTATaskStatus = "准备升级失败" // 平台在解压、生成清单等文件操作阶段发生错误
OTATaskStatusFailedPreCheck OTATaskStatus = "预检失败" // 设备报告升级前检查失败 (如拒绝降级、准备分区失败)
OTATaskStatusFailedDownload OTATaskStatus = "下载或校验失败" // 设备报告文件下载或校验失败 (包括清单文件和固件文件)
OTATaskStatusFailedRollback OTATaskStatus = "固件回滚" // 新固件启动失败,设备自动回滚
OTATaskStatusTimedOut OTATaskStatus = "超时" // 平台在超时后仍未收到最终报告
OTATaskStatusPlatformError OTATaskStatus = "平台内部错误" // 平台处理过程中发生的非设备报告错误
OTATaskStatusStopped OTATaskStatus = "手动停止" // 手动停止
)
// OTADir 是 OTA 升级相关的临时文件存储目录
const OTADir = "ota"
// OTATask 记录一次 OTA 升级任务的详细信息
type OTATask struct {
// ID 是数据库自增主键,将作为 task_id 在平台与设备间通信
ID uint32 `gorm:"primaryKey"`
// CreatedAt 是任务创建和开始的时间,作为联合主键方便只查询热点数据
CreatedAt time.Time `gorm:"primaryKey"`
AreaControllerID uint32 `gorm:"not null;index;comment:目标区域主控的ID"`
TargetVersion string `gorm:"type:varchar(32);not null;comment:目标固件版本号"`
Status OTATaskStatus `gorm:"type:varchar(32);not null;index;comment:任务状态"`
ErrorMessage string `gorm:"type:text;comment:错误信息,如果任务失败"`
FailedFilePath string `gorm:"type:text;comment:失败时关联的文件路径"`
CompletedAt *time.Time `gorm:"comment:任务完成(成功或失败)的时间"`
FinalReportedVersion string `gorm:"type:varchar(32);comment:任务结束后,设备上报的最终固件版本"`
}
func (o OTATask) IsOver() bool {
switch o.Status {
case OTATaskStatusPending, OTATaskStatusInProgress:
return false
default:
return true
}
}
// TableName 自定义 GORM 使用的数据库表名
func (OTATask) TableName() string {
return "ota_tasks"
}
// --- 用户审计日志 ---
// --- 审计日志状态常量 ---

View File

@@ -7,11 +7,15 @@ package models
// PigHouse 定义了猪舍,是猪栏的集合
type PigHouse struct {
Model
Name string `gorm:"size:100;not null;unique;comment:猪舍名称, 如 '育肥舍A栋'"`
Name string `gorm:"size:100;not null;comment:猪舍名称, 如 '育肥舍A栋'"`
Description string `gorm:"size:255;comment:描述信息"`
Pens []Pen `gorm:"foreignKey:HouseID"` // 一个猪舍包含多个猪栏
}
func (ph PigHouse) TableName() string {
return "pig_houses"
}
// PenStatus 定义了猪栏的当前状态
type PenStatus string
@@ -33,3 +37,7 @@ type Pen struct {
Capacity int `gorm:"not null;comment:设计容量 (头)"`
Status PenStatus `gorm:"not null;index;comment:猪栏当前状态"`
}
func (p Pen) TableName() string {
return "pens"
}

View File

@@ -1,111 +0,0 @@
package models
import (
"time"
)
/*
饲料和饲喂相关的模型
*/
// RawMaterial 代表饲料的原料。
// 建议:所有重量单位统一存储 (例如, 全部使用 'g'),便于计算和避免转换错误。
type RawMaterial struct {
Model
Name string `gorm:"size:100;unique;not null;comment:原料名称"`
Description string `gorm:"size:255;comment:描述"`
Quantity float32 `gorm:"not null;comment:库存总量, 单位: g"`
}
func (RawMaterial) TableName() string {
return "raw_materials"
}
// RawMaterialPurchase 记录了原料的每一次采购。
type RawMaterialPurchase struct {
Model
RawMaterialID uint32 `gorm:"not null;index;comment:关联的原料ID"`
RawMaterial RawMaterial `gorm:"foreignKey:RawMaterialID"`
Supplier string `gorm:"size:100;comment:供应商"`
Amount float32 `gorm:"not null;comment:采购数量, 单位: g"`
UnitPrice float32 `gorm:"comment:单价"`
TotalPrice float32 `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 {
Model
RawMaterialID uint32 `gorm:"not null;index;comment:关联的原料ID"`
ChangeAmount float32 `gorm:"not null;comment:变动数量, 正数为入库, 负数为出库"`
SourceType StockLogSourceType `gorm:"size:50;not null;index;comment:库存变动来源类型"`
SourceID uint32 `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 {
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 {
Model
FeedFormulaID uint32 `gorm:"not null;index;comment:外键到 FeedFormula"`
RawMaterialID uint32 `gorm:"not null;index;comment:外键到 RawMaterial"`
RawMaterial RawMaterial `gorm:"foreignKey:RawMaterialID"`
Percentage float32 `gorm:"not null;comment:该原料在配方中的百分比 (0-1.0)"`
}
func (FeedFormulaComponent) TableName() string {
return "feed_formula_components"
}
// FeedUsageRecord 代表饲料使用记录。
// 应用层逻辑:当一条使用记录被创建时,应根据其使用的 FeedFormula,
// 计算出每种 RawMaterial 的消耗量,并在 RawMaterialStockLog 中创建对应的出库记录。
type FeedUsageRecord struct {
Model
PenID uint32 `gorm:"not null;index;comment:关联的猪栏ID"`
Pen Pen `gorm:"foreignKey:PenID"`
FeedFormulaID uint32 `gorm:"not null;index;comment:使用的饲料配方ID"`
FeedFormula FeedFormula `gorm:"foreignKey:FeedFormulaID"`
Amount float32 `gorm:"not null;comment:使用数量, 单位: g"`
RecordedAt time.Time `gorm:"primaryKey;comment:记录时间"`
OperatorID uint32 `gorm:"not null;comment:操作员"`
Remarks string `gorm:"comment:备注, 如 '例行喂料, 弱猪补料' 等"`
}
func (FeedUsageRecord) TableName() string {
return "feed_usage_records"
}

View File

@@ -12,6 +12,13 @@ import (
"gorm.io/gorm"
)
type AIModel string
const (
AI_MODEL_NONE AIModel = "None"
AI_MODEL_GEMINI AIModel = "Gemini"
)
// Model 用于代替gorm.Model, 使用uint32以节约空间
type Model struct {
ID uint32 `gorm:"primarykey"`
@@ -56,6 +63,10 @@ func GetAllModels() []interface{} {
&WeighingRecord{},
&PigTransferLog{},
&PigSickLog{},
&PigBreed{},
&PigAgeStage{},
&PigType{},
&PigNutrientRequirement{},
// Pig Buy & Sell
&PigPurchase{},
@@ -63,11 +74,11 @@ func GetAllModels() []interface{} {
// Feed Models
&RawMaterial{},
&RawMaterialPurchase{},
&Nutrient{},
&RawMaterialNutrient{},
&RawMaterialStockLog{},
&FeedFormula{},
&FeedFormulaComponent{},
&FeedUsageRecord{},
&Recipe{},
&RecipeIngredient{},
// Medication Models
&Medication{},
@@ -79,6 +90,9 @@ func GetAllModels() []interface{} {
// Notification Models
&Notification{},
// OTA Upgrade Models
&OTATask{},
}
}
@@ -121,7 +135,7 @@ func (a *UintArray) Scan(src interface{}) error {
case string:
srcStr = v
default:
return errors.New("无法扫描非字符串或字节类型的源到 UintArray")
return errors.New("无法将值 %v (类型 %T) 扫描为 UintArray")
}
// 去掉花括号

View File

@@ -0,0 +1,48 @@
package models
// PigBreed 猪品种模型
type PigBreed struct {
Model
Name string `gorm:"size:50;not null;comment:品种名称"`
Description string `gorm:"type:text" json:"description"` // 其他描述
ParentInfo string `gorm:"type:text" json:"parent_info"` // 父母信息
AppearanceFeatures string `gorm:"type:text" json:"appearance_features"` // 外貌特征
BreedAdvantages string `gorm:"type:text" json:"breed_advantages"` // 品种优点
BreedDisadvantages string `gorm:"type:text" json:"breed_disadvantages"` // 品种缺点
}
func (PigBreed) TableName() string {
return "pig_breeds"
}
// PigAgeStage 猪年龄阶段模型
type PigAgeStage struct {
Model
Name string `gorm:"size:50;not null;comment:年龄阶段名称 (如: 仔猪, 生长猪, 育肥猪)"`
Description string `gorm:"size:255;comment:阶段描述"`
}
func (PigAgeStage) TableName() string {
return "pig_age_stages"
}
// PigType 猪类型模型,代表特定品种和年龄阶段的组合
type PigType struct {
Model
BreedID uint32 `gorm:"not null;index;comment:关联的猪品种ID"`
Breed PigBreed `gorm:"foreignKey:BreedID"`
AgeStageID uint32 `gorm:"not null;index;comment:关联的猪年龄阶段ID"`
AgeStage PigAgeStage `gorm:"foreignKey:AgeStageID"`
Description string `gorm:"size:255;comment:该猪类型的描述或特点"`
DailyFeedIntake float32 `gorm:"comment:理论日均食量 (g/天)"`
DailyGainWeight float32 `gorm:"comment:理论日增重 (g/天)"`
MinDays uint32 `gorm:"comment:该猪类型在该年龄阶段的最小日龄"`
MaxDays uint32 `gorm:"comment:该猪类型在该年龄阶段的最大日龄"`
MinWeight float32 `gorm:"comment:该猪类型在该年龄阶段的最小体重 (g)"`
MaxWeight float32 `gorm:"comment:该猪类型在该年龄阶段的最大体重 (g)"`
PigNutrientRequirements []PigNutrientRequirement `gorm:"foreignKey:PigTypeID"`
}
func (PigType) TableName() string {
return "pig_types"
}

View File

@@ -12,12 +12,10 @@ import (
type PigBatchStatus string
const (
BatchStatusWeaning PigBatchStatus = "保育" // 从断奶到保育结束
BatchStatusGrowing PigBatchStatus = "生长" // 生长育肥阶段
BatchStatusFinishing PigBatchStatus = "育肥" // 最后的育肥阶段
BatchStatusForSale PigBatchStatus = "待售" // 达到出栏标准
BatchStatusSold PigBatchStatus = "已出售"
BatchStatusArchived PigBatchStatus = "已归档" // 批次结束(如全群淘汰等)
BatchStatusActive PigBatchStatus = "生产中" // 饲养中
BatchStatusForSale PigBatchStatus = "待售" // 达到出栏标准
BatchStatusSold PigBatchStatus = "已出售"
BatchStatusArchived PigBatchStatus = "已归档" // 批次结束(如全群淘汰等)
)
// PigBatchOriginType 定义了猪批次的来源
@@ -31,7 +29,7 @@ const (
// PigBatch 是猪批次的核心模型,代表了一群被共同管理的猪
type PigBatch struct {
Model
BatchNumber string `gorm:"size:50;not null;uniqueIndex;comment:批次编号,如 2024-W25-A01"`
BatchNumber string `gorm:"size:50;not null;comment:批次编号,如 2024-W25-A01"`
OriginType PigBatchOriginType `gorm:"size:20;not null;comment:批次来源 (自繁, 外购)"`
StartDate time.Time `gorm:"not null;comment:批次开始日期 (如转入日或购买日)"`
EndDate time.Time `gorm:"not null;comment:批次结束日期 (全部淘汰或售出)"`

View File

@@ -0,0 +1,16 @@
package models
// PigNutrientRequirement 猪营养需求模型
type PigNutrientRequirement struct {
Model
PigTypeID uint32 `gorm:"not null;index;comment:关联的猪类型ID"`
PigType PigType `gorm:"foreignKey:PigTypeID"`
NutrientID uint32 `gorm:"not null;index;comment:关联的营养素ID"`
Nutrient Nutrient `gorm:"foreignKey:NutrientID"`
MinRequirement float32 `gorm:"not null;comment:最低营养需求量"`
MaxRequirement float32 `gorm:"not null;comment:最高营养需求量"`
}
func (PigNutrientRequirement) TableName() string {
return "pig_nutrient_requirements"
}

View File

@@ -16,8 +16,12 @@ type PlanName string
const (
// PlanNamePeriodicSystemHealthCheck 是周期性系统健康检查计划的名称
PlanNamePeriodicSystemHealthCheck PlanName = "周期性系统健康检查"
// PlanNamePeriodicHeartbeatCheck 是周期性心跳检测计划的名称
PlanNamePeriodicHeartbeatCheck PlanName = "周期性心跳检测"
// PlanNameAlarmNotification 是告警通知发送计划的名称
PlanNameAlarmNotification PlanName = "告警通知发送"
// PlanNameOtaCheck 是定时检查OTA升级任务的计划名称
PlanNameOtaCheck PlanName = "定时检查OTA任务"
)
// PlanExecutionType 定义了计划的执行类型
@@ -40,14 +44,16 @@ const (
type TaskType string
const (
TaskPlanAnalysis TaskType = "计划分析" // 解析Plan的Task列表并添加到待执行队列的特殊任务
TaskTypeWaiting TaskType = "等待" // 等待任务
TaskTypeReleaseFeedWeight TaskType = "下料" // 下料口释放指定重量任务
TaskTypeFullCollection TaskType = "全量采集" // 新增的全量采集任务
TaskTypeAlarmNotification TaskType = "告警通知" // 告警通知任务
TaskTypeNotificationRefresh TaskType = "通知刷新" // 通知刷新任务
TaskTypeDeviceThresholdCheck TaskType = "设备阈值检查" // 设备阈值检查任务
TaskTypeAreaCollectorThresholdCheck TaskType = "区域阈值检查" // 区域阈值检查任务
TaskPlanAnalysis TaskType = "计划分析" // 解析Plan的Task列表并添加到待执行队列的特殊任务
TaskTypeWaiting TaskType = "等待" // 等待任务
TaskTypeReleaseFeedWeight TaskType = "下料" // 下料口释放指定重量任务
TaskTypeFullCollection TaskType = "全量采集" // 新增的全量采集任务
TaskTypeHeartbeat TaskType = "心跳检测" // 区域主控心跳检测任务
TaskTypeAlarmNotification TaskType = "告警通知" // 告警通知任务
TaskTypeNotificationRefresh TaskType = "通知刷新" // 通知刷新任务
TaskTypeDeviceThresholdCheck TaskType = "设备阈值检查" // 设备阈值检查任务
TaskTypeAreaCollectorThresholdCheck TaskType = "区域阈值检查" // 区域阈值检查任务
TaskTypeOTACheck TaskType = "OTA升级检查任务" // OTA升级超时检查任务
)
// -- Task Parameters --

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