Compare commits

...

38 Commits

Author SHA1 Message Date
e5b75e3879 优化代码 2025-10-29 19:42:22 +08:00
67575c17bc 修bug 2025-10-29 19:15:52 +08:00
7ac9e49212 调整日志等级 2025-10-29 19:14:26 +08:00
ff45c59946 修bug 2025-10-29 19:07:00 +08:00
8d48576305 修bug 2025-10-29 18:56:05 +08:00
af8689d627 计划监控增加计划名 2025-10-29 17:52:07 +08:00
2910c9186a Merge pull request 'issue_42' (#46) from issue_42 into main
Reviewed-on: #46
2025-10-29 17:21:25 +08:00
b09d32b1d7 修改config.yml 2025-10-29 17:21:23 +08:00
403d46b777 删掉原来的定时采集线程 2025-10-29 17:13:03 +08:00
85bd5254c1 实现全量采集系统计划 2025-10-29 17:10:48 +08:00
5050f76066 增加全量采集任务 2025-10-29 16:37:05 +08:00
1ee3e638f7 controller调整, 增加计划类型 2025-10-29 16:25:39 +08:00
94e8768424 plan增加一个类型字段 2025-10-29 15:48:49 +08:00
675711cdcf 拆分task包 2025-10-29 15:30:16 +08:00
e66ee67cf7 Merge pull request 'issue_42' (#44) from issue_42 into main
Reviewed-on: #44
2025-10-26 15:57:04 +08:00
40eb57ee47 重构core包 2025-10-26 15:48:38 +08:00
6a8e8f1f7d 实现定时采集 2025-10-26 15:10:38 +08:00
5c83c19bce Merge pull request 'issue_22' (#41) from issue_22 into main
Reviewed-on: #41
2025-10-25 15:42:19 +08:00
86c9073da8 修bug 2025-10-25 15:41:49 +08:00
43c1839345 实现controller 2025-10-25 15:04:47 +08:00
f62cc1c4a9 实现service层 2025-10-25 14:36:24 +08:00
f6d2069e1a 发送通知时写入数据库 2025-10-25 14:17:17 +08:00
f33e14f60f 发送通知时写入数据库 2025-10-25 14:15:17 +08:00
d6f275b2d1 定义仓库方法 2025-10-25 13:35:43 +08:00
d8de5a68eb 定义通知model 2025-10-25 13:28:19 +08:00
bd8729d473 中文枚举 2025-10-24 21:38:52 +08:00
3fd97aa43f 日志发送逻辑及测试消息发送接口 2025-10-24 21:24:48 +08:00
9d6876684b 实现飞书/微信/邮件发送通知 2025-10-24 20:33:15 +08:00
47ed819b9d Merge pull request 'issue_39' (#40) from issue_39 into main
Reviewed-on: #40
2025-10-23 16:07:20 +08:00
b1dce77e51 修bug 2025-10-23 16:06:15 +08:00
21607559c4 修bug 2025-10-23 12:00:57 +08:00
af6a00ee47 优化报错 2025-10-23 11:52:08 +08:00
324a533c94 猪群相关接口增加当前总量和当前总存栏量 2025-10-23 11:29:48 +08:00
c1f71050e9 猪栏信息接口增加猪栏当前存栏量 2025-10-23 10:52:40 +08:00
db32c37318 修bug 2025-10-22 17:58:24 +08:00
3d5741f5fd 修bug 2025-10-20 20:58:24 +08:00
c4c9723b7b make dev 2025-10-20 20:50:03 +08:00
a32749cef8 lora mesh 发送即收到 2025-10-20 19:31:19 +08:00
63 changed files with 3967 additions and 668 deletions

3
.gitignore vendored
View File

@@ -23,4 +23,5 @@ vendor/
.env .env
bin/ bin/
app_logs/ app_logs/
tmp/

View File

@@ -50,4 +50,9 @@ proto:
# 运行代码检查 # 运行代码检查
.PHONY: lint .PHONY: lint
lint: lint:
golangci-lint run ./... golangci-lint run ./...
# 测试模式(改动文件自动重编译重启)
.PHONY: dev
dev:
air

115
config.example.yml Normal file
View File

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

View File

@@ -12,7 +12,7 @@ server:
# 日志配置 # 日志配置
log: log:
level: "debug" # 日志级别: "debug", "info", "warn", "error", "dpanic", "panic", "fatal" level: "info" # 日志级别: "debug", "info", "warn", "error", "dpanic", "panic", "fatal"
format: "console" # 日志格式: "console" 或 "json" format: "console" # 日志格式: "console" 或 "json"
enable_file: true # 是否启用文件日志 enable_file: true # 是否启用文件日志
file_path: "./app_logs/app.log" # 日志文件路径 file_path: "./app_logs/app.log" # 日志文件路径
@@ -86,4 +86,8 @@ lora_mesh:
max_chunk_size: 238 max_chunk_size: 238
#分片重组超时时间(秒)。如果在一个分片到达后,超过这个时间 #分片重组超时时间(秒)。如果在一个分片到达后,超过这个时间
# 还没收到完整的包,则认为接收失败。 # 还没收到完整的包,则认为接收失败。
reassembly_timeout: 30 reassembly_timeout: 30
# 定时采集配置
collection:
interval: 1 # 采集间隔 (分钟)

View File

@@ -975,6 +975,149 @@ const docTemplate = `{
} }
} }
}, },
"/api/v1/monitor/notifications": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "根据提供的过滤条件,分页获取通知列表",
"produces": [
"application/json"
],
"tags": [
"数据监控"
],
"summary": "批量查询通知",
"parameters": [
{
"type": "string",
"name": "end_time",
"in": "query"
},
{
"enum": [
7,
-1,
0,
1,
2,
3,
4,
5,
-1,
5,
6
],
"type": "integer",
"format": "int32",
"x-enum-varnames": [
"_numLevels",
"DebugLevel",
"InfoLevel",
"WarnLevel",
"ErrorLevel",
"DPanicLevel",
"PanicLevel",
"FatalLevel",
"_minLevel",
"_maxLevel",
"InvalidLevel"
],
"name": "level",
"in": "query"
},
{
"enum": [
"邮件",
"企业微信",
"飞书",
"日志"
],
"type": "string",
"x-enum-varnames": [
"NotifierTypeSMTP",
"NotifierTypeWeChat",
"NotifierTypeLark",
"NotifierTypeLog"
],
"name": "notifier_type",
"in": "query"
},
{
"type": "string",
"name": "order_by",
"in": "query"
},
{
"type": "integer",
"name": "page",
"in": "query"
},
{
"type": "integer",
"name": "pageSize",
"in": "query"
},
{
"type": "string",
"name": "start_time",
"in": "query"
},
{
"enum": [
"发送成功",
"发送失败",
"已跳过"
],
"type": "string",
"x-enum-comments": {
"NotificationStatusFailed": "通知发送失败",
"NotificationStatusSkipped": "通知因某些原因被跳过(例如:用户未配置联系方式)",
"NotificationStatusSuccess": "通知已成功发送"
},
"x-enum-descriptions": [
"通知已成功发送",
"通知发送失败",
"通知因某些原因被跳过(例如:用户未配置联系方式)"
],
"x-enum-varnames": [
"NotificationStatusSuccess",
"NotificationStatusFailed",
"NotificationStatusSkipped"
],
"name": "status",
"in": "query"
},
{
"type": "integer",
"name": "user_id",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/controller.Response"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/dto.ListNotificationResponse"
}
}
}
]
}
}
}
}
},
"/api/v1/monitor/pending-collections": { "/api/v1/monitor/pending-collections": {
"get": { "get": {
"security": [ "security": [
@@ -3457,7 +3600,7 @@ const docTemplate = `{
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "获取所有计划的列表", "description": "获取所有计划的列表,支持按类型过滤和分页",
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -3465,6 +3608,36 @@ const docTemplate = `{
"计划管理" "计划管理"
], ],
"summary": "获取计划列表", "summary": "获取计划列表",
"parameters": [
{
"type": "integer",
"description": "页码",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "每页大小",
"name": "pageSize",
"in": "query"
},
{
"enum": [
"所有任务",
"自定义任务",
"系统任务"
],
"type": "string",
"x-enum-varnames": [
"PlanTypeFilterAll",
"PlanTypeFilterCustom",
"PlanTypeFilterSystem"
],
"description": "计划类型",
"name": "planType",
"in": "query"
}
],
"responses": { "responses": {
"200": { "200": {
"description": "业务码为200代表成功获取列表", "description": "业务码为200代表成功获取列表",
@@ -3477,10 +3650,7 @@ const docTemplate = `{
"type": "object", "type": "object",
"properties": { "properties": {
"data": { "data": {
"type": "array", "$ref": "#/definitions/dto.ListPlansResponse"
"items": {
"$ref": "#/definitions/dto.PlanResponse"
}
} }
} }
} }
@@ -3590,7 +3760,7 @@ const docTemplate = `{
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "根据计划ID更新计划的详细信息。", "description": "根据计划ID更新计划的详细信息。系统计划不允许修改。",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -3646,7 +3816,7 @@ const docTemplate = `{
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "根据计划ID删除计划。软删除", "description": "根据计划ID删除计划。软删除系统计划不允许删除。",
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -3680,7 +3850,7 @@ const docTemplate = `{
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "根据计划ID启动一个计划的执行。", "description": "根据计划ID启动一个计划的执行。系统计划不允许手动启动。",
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -3714,7 +3884,7 @@ const docTemplate = `{
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "根据计划ID停止一个正在执行的计划。", "description": "根据计划ID停止一个正在执行的计划。系统计划不能被停止。",
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -3923,6 +4093,64 @@ const docTemplate = `{
} }
} }
} }
},
"/api/v1/users/{id}/notifications/test": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "为指定用户发送一条特定渠道的测试消息,以验证其配置是否正确。",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"用户管理"
],
"summary": "发送测试通知",
"parameters": [
{
"type": "integer",
"description": "用户ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "请求体",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.SendTestNotificationRequest"
}
}
],
"responses": {
"200": {
"description": "成功响应",
"schema": {
"allOf": [
{
"$ref": "#/definitions/controller.Response"
},
{
"type": "object",
"properties": {
"data": {
"type": "string"
}
}
}
]
}
}
}
}
} }
}, },
"definitions": { "definitions": {
@@ -3953,6 +4181,7 @@ const docTemplate = `{
2001, 2001,
4000, 4000,
4001, 4001,
4003,
4004, 4004,
4009, 4009,
5000, 5000,
@@ -3962,6 +4191,7 @@ const docTemplate = `{
"CodeBadRequest": "请求参数错误", "CodeBadRequest": "请求参数错误",
"CodeConflict": "资源冲突", "CodeConflict": "资源冲突",
"CodeCreated": "创建成功", "CodeCreated": "创建成功",
"CodeForbidden": "禁止访问",
"CodeInternalError": "服务器内部错误", "CodeInternalError": "服务器内部错误",
"CodeNotFound": "资源未找到", "CodeNotFound": "资源未找到",
"CodeServiceUnavailable": "服务不可用", "CodeServiceUnavailable": "服务不可用",
@@ -3973,6 +4203,7 @@ const docTemplate = `{
"创建成功", "创建成功",
"请求参数错误", "请求参数错误",
"未授权", "未授权",
"禁止访问",
"资源未找到", "资源未找到",
"资源冲突", "资源冲突",
"服务器内部错误", "服务器内部错误",
@@ -3983,6 +4214,7 @@ const docTemplate = `{
"CodeCreated", "CodeCreated",
"CodeBadRequest", "CodeBadRequest",
"CodeUnauthorized", "CodeUnauthorized",
"CodeForbidden",
"CodeNotFound", "CodeNotFound",
"CodeConflict", "CodeConflict",
"CodeInternalError", "CodeInternalError",
@@ -4431,6 +4663,20 @@ const docTemplate = `{
} }
} }
}, },
"dto.ListNotificationResponse": {
"type": "object",
"properties": {
"list": {
"type": "array",
"items": {
"$ref": "#/definitions/dto.NotificationDTO"
}
},
"pagination": {
"$ref": "#/definitions/dto.PaginationDTO"
}
}
},
"dto.ListPendingCollectionResponse": { "dto.ListPendingCollectionResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -4529,6 +4775,21 @@ const docTemplate = `{
} }
} }
}, },
"dto.ListPlansResponse": {
"type": "object",
"properties": {
"plans": {
"type": "array",
"items": {
"$ref": "#/definitions/dto.PlanResponse"
}
},
"total": {
"type": "integer",
"example": 100
}
}
},
"dto.ListRawMaterialPurchaseResponse": { "dto.ListRawMaterialPurchaseResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -4739,6 +5000,47 @@ const docTemplate = `{
} }
} }
}, },
"dto.NotificationDTO": {
"type": "object",
"properties": {
"alarm_timestamp": {
"type": "string"
},
"created_at": {
"type": "string"
},
"error_message": {
"type": "string"
},
"id": {
"type": "integer"
},
"level": {
"$ref": "#/definitions/zapcore.Level"
},
"message": {
"type": "string"
},
"notifier_type": {
"$ref": "#/definitions/notify.NotifierType"
},
"status": {
"$ref": "#/definitions/models.NotificationStatus"
},
"title": {
"type": "string"
},
"to_address": {
"type": "string"
},
"updated_at": {
"type": "string"
},
"user_id": {
"type": "integer"
}
}
},
"dto.PaginationDTO": { "dto.PaginationDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -4770,6 +5072,9 @@ const docTemplate = `{
"capacity": { "capacity": {
"type": "integer" "type": "integer"
}, },
"current_pig_count": {
"type": "integer"
},
"house_id": { "house_id": {
"type": "integer" "type": "integer"
}, },
@@ -4903,6 +5208,14 @@ const docTemplate = `{
"description": "创建时间", "description": "创建时间",
"type": "string" "type": "string"
}, },
"currentTotalPigsInPens": {
"description": "当前存栏总数",
"type": "integer"
},
"currentTotalQuantity": {
"description": "当前总数",
"type": "integer"
},
"end_date": { "end_date": {
"description": "批次结束日期", "description": "批次结束日期",
"type": "string" "type": "string"
@@ -5172,6 +5485,9 @@ const docTemplate = `{
"plan_id": { "plan_id": {
"type": "integer" "type": "integer"
}, },
"plan_name": {
"type": "string"
},
"started_at": { "started_at": {
"type": "string" "type": "string"
}, },
@@ -5226,6 +5542,14 @@ const docTemplate = `{
"type": "string", "type": "string",
"example": "猪舍温度控制计划" "example": "猪舍温度控制计划"
}, },
"plan_type": {
"allOf": [
{
"$ref": "#/definitions/models.PlanType"
}
],
"example": "自定义任务"
},
"status": { "status": {
"allOf": [ "allOf": [
{ {
@@ -5580,6 +5904,22 @@ const docTemplate = `{
} }
} }
}, },
"dto.SendTestNotificationRequest": {
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"description": "Type 指定要测试的通知渠道",
"allOf": [
{
"$ref": "#/definitions/notify.NotifierType"
}
]
}
}
},
"dto.SensorDataDTO": { "dto.SensorDataDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -6190,6 +6530,29 @@ const docTemplate = `{
"ReasonTypeHealthCare" "ReasonTypeHealthCare"
] ]
}, },
"models.NotificationStatus": {
"type": "string",
"enum": [
"发送成功",
"发送失败",
"已跳过"
],
"x-enum-comments": {
"NotificationStatusFailed": "通知发送失败",
"NotificationStatusSkipped": "通知因某些原因被跳过(例如:用户未配置联系方式)",
"NotificationStatusSuccess": "通知已成功发送"
},
"x-enum-descriptions": [
"通知已成功发送",
"通知发送失败",
"通知因某些原因被跳过(例如:用户未配置联系方式)"
],
"x-enum-varnames": [
"NotificationStatusSuccess",
"NotificationStatusFailed",
"NotificationStatusSkipped"
]
},
"models.PenStatus": { "models.PenStatus": {
"type": "string", "type": "string",
"enum": [ "enum": [
@@ -6431,6 +6794,17 @@ const docTemplate = `{
"PlanStatusFailed" "PlanStatusFailed"
] ]
}, },
"models.PlanType": {
"type": "string",
"enum": [
"自定义任务",
"系统任务"
],
"x-enum-varnames": [
"PlanTypeCustom",
"PlanTypeSystem"
]
},
"models.SensorType": { "models.SensorType": {
"type": "string", "type": "string",
"enum": [ "enum": [
@@ -6486,22 +6860,26 @@ const docTemplate = `{
"enum": [ "enum": [
"计划分析", "计划分析",
"等待", "等待",
"下料" "下料",
"全量采集"
], ],
"x-enum-comments": { "x-enum-comments": {
"TaskPlanAnalysis": "解析Plan的Task列表并添加到待执行队列的特殊任务", "TaskPlanAnalysis": "解析Plan的Task列表并添加到待执行队列的特殊任务",
"TaskTypeFullCollection": "新增的全量采集任务",
"TaskTypeReleaseFeedWeight": "下料口释放指定重量任务", "TaskTypeReleaseFeedWeight": "下料口释放指定重量任务",
"TaskTypeWaiting": "等待任务" "TaskTypeWaiting": "等待任务"
}, },
"x-enum-descriptions": [ "x-enum-descriptions": [
"解析Plan的Task列表并添加到待执行队列的特殊任务", "解析Plan的Task列表并添加到待执行队列的特殊任务",
"等待任务", "等待任务",
"下料口释放指定重量任务" "下料口释放指定重量任务",
"新增的全量采集任务"
], ],
"x-enum-varnames": [ "x-enum-varnames": [
"TaskPlanAnalysis", "TaskPlanAnalysis",
"TaskTypeWaiting", "TaskTypeWaiting",
"TaskTypeReleaseFeedWeight" "TaskTypeReleaseFeedWeight",
"TaskTypeFullCollection"
] ]
}, },
"models.ValueDescriptor": { "models.ValueDescriptor": {
@@ -6519,6 +6897,64 @@ const docTemplate = `{
"$ref": "#/definitions/models.SensorType" "$ref": "#/definitions/models.SensorType"
} }
} }
},
"notify.NotifierType": {
"type": "string",
"enum": [
"邮件",
"企业微信",
"飞书",
"日志"
],
"x-enum-varnames": [
"NotifierTypeSMTP",
"NotifierTypeWeChat",
"NotifierTypeLark",
"NotifierTypeLog"
]
},
"repository.PlanTypeFilter": {
"type": "string",
"enum": [
"所有任务",
"自定义任务",
"系统任务"
],
"x-enum-varnames": [
"PlanTypeFilterAll",
"PlanTypeFilterCustom",
"PlanTypeFilterSystem"
]
},
"zapcore.Level": {
"type": "integer",
"format": "int32",
"enum": [
7,
-1,
0,
1,
2,
3,
4,
5,
-1,
5,
6
],
"x-enum-varnames": [
"_numLevels",
"DebugLevel",
"InfoLevel",
"WarnLevel",
"ErrorLevel",
"DPanicLevel",
"PanicLevel",
"FatalLevel",
"_minLevel",
"_maxLevel",
"InvalidLevel"
]
} }
}, },
"securityDefinitions": { "securityDefinitions": {

View File

@@ -967,6 +967,149 @@
} }
} }
}, },
"/api/v1/monitor/notifications": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "根据提供的过滤条件,分页获取通知列表",
"produces": [
"application/json"
],
"tags": [
"数据监控"
],
"summary": "批量查询通知",
"parameters": [
{
"type": "string",
"name": "end_time",
"in": "query"
},
{
"enum": [
7,
-1,
0,
1,
2,
3,
4,
5,
-1,
5,
6
],
"type": "integer",
"format": "int32",
"x-enum-varnames": [
"_numLevels",
"DebugLevel",
"InfoLevel",
"WarnLevel",
"ErrorLevel",
"DPanicLevel",
"PanicLevel",
"FatalLevel",
"_minLevel",
"_maxLevel",
"InvalidLevel"
],
"name": "level",
"in": "query"
},
{
"enum": [
"邮件",
"企业微信",
"飞书",
"日志"
],
"type": "string",
"x-enum-varnames": [
"NotifierTypeSMTP",
"NotifierTypeWeChat",
"NotifierTypeLark",
"NotifierTypeLog"
],
"name": "notifier_type",
"in": "query"
},
{
"type": "string",
"name": "order_by",
"in": "query"
},
{
"type": "integer",
"name": "page",
"in": "query"
},
{
"type": "integer",
"name": "pageSize",
"in": "query"
},
{
"type": "string",
"name": "start_time",
"in": "query"
},
{
"enum": [
"发送成功",
"发送失败",
"已跳过"
],
"type": "string",
"x-enum-comments": {
"NotificationStatusFailed": "通知发送失败",
"NotificationStatusSkipped": "通知因某些原因被跳过(例如:用户未配置联系方式)",
"NotificationStatusSuccess": "通知已成功发送"
},
"x-enum-descriptions": [
"通知已成功发送",
"通知发送失败",
"通知因某些原因被跳过(例如:用户未配置联系方式)"
],
"x-enum-varnames": [
"NotificationStatusSuccess",
"NotificationStatusFailed",
"NotificationStatusSkipped"
],
"name": "status",
"in": "query"
},
{
"type": "integer",
"name": "user_id",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/controller.Response"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/dto.ListNotificationResponse"
}
}
}
]
}
}
}
}
},
"/api/v1/monitor/pending-collections": { "/api/v1/monitor/pending-collections": {
"get": { "get": {
"security": [ "security": [
@@ -3449,7 +3592,7 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "获取所有计划的列表", "description": "获取所有计划的列表,支持按类型过滤和分页",
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -3457,6 +3600,36 @@
"计划管理" "计划管理"
], ],
"summary": "获取计划列表", "summary": "获取计划列表",
"parameters": [
{
"type": "integer",
"description": "页码",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "每页大小",
"name": "pageSize",
"in": "query"
},
{
"enum": [
"所有任务",
"自定义任务",
"系统任务"
],
"type": "string",
"x-enum-varnames": [
"PlanTypeFilterAll",
"PlanTypeFilterCustom",
"PlanTypeFilterSystem"
],
"description": "计划类型",
"name": "planType",
"in": "query"
}
],
"responses": { "responses": {
"200": { "200": {
"description": "业务码为200代表成功获取列表", "description": "业务码为200代表成功获取列表",
@@ -3469,10 +3642,7 @@
"type": "object", "type": "object",
"properties": { "properties": {
"data": { "data": {
"type": "array", "$ref": "#/definitions/dto.ListPlansResponse"
"items": {
"$ref": "#/definitions/dto.PlanResponse"
}
} }
} }
} }
@@ -3582,7 +3752,7 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "根据计划ID更新计划的详细信息。", "description": "根据计划ID更新计划的详细信息。系统计划不允许修改。",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -3638,7 +3808,7 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "根据计划ID删除计划。软删除", "description": "根据计划ID删除计划。软删除系统计划不允许删除。",
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -3672,7 +3842,7 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "根据计划ID启动一个计划的执行。", "description": "根据计划ID启动一个计划的执行。系统计划不允许手动启动。",
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -3706,7 +3876,7 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "根据计划ID停止一个正在执行的计划。", "description": "根据计划ID停止一个正在执行的计划。系统计划不能被停止。",
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -3915,6 +4085,64 @@
} }
} }
} }
},
"/api/v1/users/{id}/notifications/test": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "为指定用户发送一条特定渠道的测试消息,以验证其配置是否正确。",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"用户管理"
],
"summary": "发送测试通知",
"parameters": [
{
"type": "integer",
"description": "用户ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "请求体",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.SendTestNotificationRequest"
}
}
],
"responses": {
"200": {
"description": "成功响应",
"schema": {
"allOf": [
{
"$ref": "#/definitions/controller.Response"
},
{
"type": "object",
"properties": {
"data": {
"type": "string"
}
}
}
]
}
}
}
}
} }
}, },
"definitions": { "definitions": {
@@ -3945,6 +4173,7 @@
2001, 2001,
4000, 4000,
4001, 4001,
4003,
4004, 4004,
4009, 4009,
5000, 5000,
@@ -3954,6 +4183,7 @@
"CodeBadRequest": "请求参数错误", "CodeBadRequest": "请求参数错误",
"CodeConflict": "资源冲突", "CodeConflict": "资源冲突",
"CodeCreated": "创建成功", "CodeCreated": "创建成功",
"CodeForbidden": "禁止访问",
"CodeInternalError": "服务器内部错误", "CodeInternalError": "服务器内部错误",
"CodeNotFound": "资源未找到", "CodeNotFound": "资源未找到",
"CodeServiceUnavailable": "服务不可用", "CodeServiceUnavailable": "服务不可用",
@@ -3965,6 +4195,7 @@
"创建成功", "创建成功",
"请求参数错误", "请求参数错误",
"未授权", "未授权",
"禁止访问",
"资源未找到", "资源未找到",
"资源冲突", "资源冲突",
"服务器内部错误", "服务器内部错误",
@@ -3975,6 +4206,7 @@
"CodeCreated", "CodeCreated",
"CodeBadRequest", "CodeBadRequest",
"CodeUnauthorized", "CodeUnauthorized",
"CodeForbidden",
"CodeNotFound", "CodeNotFound",
"CodeConflict", "CodeConflict",
"CodeInternalError", "CodeInternalError",
@@ -4423,6 +4655,20 @@
} }
} }
}, },
"dto.ListNotificationResponse": {
"type": "object",
"properties": {
"list": {
"type": "array",
"items": {
"$ref": "#/definitions/dto.NotificationDTO"
}
},
"pagination": {
"$ref": "#/definitions/dto.PaginationDTO"
}
}
},
"dto.ListPendingCollectionResponse": { "dto.ListPendingCollectionResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -4521,6 +4767,21 @@
} }
} }
}, },
"dto.ListPlansResponse": {
"type": "object",
"properties": {
"plans": {
"type": "array",
"items": {
"$ref": "#/definitions/dto.PlanResponse"
}
},
"total": {
"type": "integer",
"example": 100
}
}
},
"dto.ListRawMaterialPurchaseResponse": { "dto.ListRawMaterialPurchaseResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -4731,6 +4992,47 @@
} }
} }
}, },
"dto.NotificationDTO": {
"type": "object",
"properties": {
"alarm_timestamp": {
"type": "string"
},
"created_at": {
"type": "string"
},
"error_message": {
"type": "string"
},
"id": {
"type": "integer"
},
"level": {
"$ref": "#/definitions/zapcore.Level"
},
"message": {
"type": "string"
},
"notifier_type": {
"$ref": "#/definitions/notify.NotifierType"
},
"status": {
"$ref": "#/definitions/models.NotificationStatus"
},
"title": {
"type": "string"
},
"to_address": {
"type": "string"
},
"updated_at": {
"type": "string"
},
"user_id": {
"type": "integer"
}
}
},
"dto.PaginationDTO": { "dto.PaginationDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -4762,6 +5064,9 @@
"capacity": { "capacity": {
"type": "integer" "type": "integer"
}, },
"current_pig_count": {
"type": "integer"
},
"house_id": { "house_id": {
"type": "integer" "type": "integer"
}, },
@@ -4895,6 +5200,14 @@
"description": "创建时间", "description": "创建时间",
"type": "string" "type": "string"
}, },
"currentTotalPigsInPens": {
"description": "当前存栏总数",
"type": "integer"
},
"currentTotalQuantity": {
"description": "当前总数",
"type": "integer"
},
"end_date": { "end_date": {
"description": "批次结束日期", "description": "批次结束日期",
"type": "string" "type": "string"
@@ -5164,6 +5477,9 @@
"plan_id": { "plan_id": {
"type": "integer" "type": "integer"
}, },
"plan_name": {
"type": "string"
},
"started_at": { "started_at": {
"type": "string" "type": "string"
}, },
@@ -5218,6 +5534,14 @@
"type": "string", "type": "string",
"example": "猪舍温度控制计划" "example": "猪舍温度控制计划"
}, },
"plan_type": {
"allOf": [
{
"$ref": "#/definitions/models.PlanType"
}
],
"example": "自定义任务"
},
"status": { "status": {
"allOf": [ "allOf": [
{ {
@@ -5572,6 +5896,22 @@
} }
} }
}, },
"dto.SendTestNotificationRequest": {
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"description": "Type 指定要测试的通知渠道",
"allOf": [
{
"$ref": "#/definitions/notify.NotifierType"
}
]
}
}
},
"dto.SensorDataDTO": { "dto.SensorDataDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -6182,6 +6522,29 @@
"ReasonTypeHealthCare" "ReasonTypeHealthCare"
] ]
}, },
"models.NotificationStatus": {
"type": "string",
"enum": [
"发送成功",
"发送失败",
"已跳过"
],
"x-enum-comments": {
"NotificationStatusFailed": "通知发送失败",
"NotificationStatusSkipped": "通知因某些原因被跳过(例如:用户未配置联系方式)",
"NotificationStatusSuccess": "通知已成功发送"
},
"x-enum-descriptions": [
"通知已成功发送",
"通知发送失败",
"通知因某些原因被跳过(例如:用户未配置联系方式)"
],
"x-enum-varnames": [
"NotificationStatusSuccess",
"NotificationStatusFailed",
"NotificationStatusSkipped"
]
},
"models.PenStatus": { "models.PenStatus": {
"type": "string", "type": "string",
"enum": [ "enum": [
@@ -6423,6 +6786,17 @@
"PlanStatusFailed" "PlanStatusFailed"
] ]
}, },
"models.PlanType": {
"type": "string",
"enum": [
"自定义任务",
"系统任务"
],
"x-enum-varnames": [
"PlanTypeCustom",
"PlanTypeSystem"
]
},
"models.SensorType": { "models.SensorType": {
"type": "string", "type": "string",
"enum": [ "enum": [
@@ -6478,22 +6852,26 @@
"enum": [ "enum": [
"计划分析", "计划分析",
"等待", "等待",
"下料" "下料",
"全量采集"
], ],
"x-enum-comments": { "x-enum-comments": {
"TaskPlanAnalysis": "解析Plan的Task列表并添加到待执行队列的特殊任务", "TaskPlanAnalysis": "解析Plan的Task列表并添加到待执行队列的特殊任务",
"TaskTypeFullCollection": "新增的全量采集任务",
"TaskTypeReleaseFeedWeight": "下料口释放指定重量任务", "TaskTypeReleaseFeedWeight": "下料口释放指定重量任务",
"TaskTypeWaiting": "等待任务" "TaskTypeWaiting": "等待任务"
}, },
"x-enum-descriptions": [ "x-enum-descriptions": [
"解析Plan的Task列表并添加到待执行队列的特殊任务", "解析Plan的Task列表并添加到待执行队列的特殊任务",
"等待任务", "等待任务",
"下料口释放指定重量任务" "下料口释放指定重量任务",
"新增的全量采集任务"
], ],
"x-enum-varnames": [ "x-enum-varnames": [
"TaskPlanAnalysis", "TaskPlanAnalysis",
"TaskTypeWaiting", "TaskTypeWaiting",
"TaskTypeReleaseFeedWeight" "TaskTypeReleaseFeedWeight",
"TaskTypeFullCollection"
] ]
}, },
"models.ValueDescriptor": { "models.ValueDescriptor": {
@@ -6511,6 +6889,64 @@
"$ref": "#/definitions/models.SensorType" "$ref": "#/definitions/models.SensorType"
} }
} }
},
"notify.NotifierType": {
"type": "string",
"enum": [
"邮件",
"企业微信",
"飞书",
"日志"
],
"x-enum-varnames": [
"NotifierTypeSMTP",
"NotifierTypeWeChat",
"NotifierTypeLark",
"NotifierTypeLog"
]
},
"repository.PlanTypeFilter": {
"type": "string",
"enum": [
"所有任务",
"自定义任务",
"系统任务"
],
"x-enum-varnames": [
"PlanTypeFilterAll",
"PlanTypeFilterCustom",
"PlanTypeFilterSystem"
]
},
"zapcore.Level": {
"type": "integer",
"format": "int32",
"enum": [
7,
-1,
0,
1,
2,
3,
4,
5,
-1,
5,
6
],
"x-enum-varnames": [
"_numLevels",
"DebugLevel",
"InfoLevel",
"WarnLevel",
"ErrorLevel",
"DPanicLevel",
"PanicLevel",
"FatalLevel",
"_minLevel",
"_maxLevel",
"InvalidLevel"
]
} }
}, },
"securityDefinitions": { "securityDefinitions": {

View File

@@ -17,6 +17,7 @@ definitions:
- 2001 - 2001
- 4000 - 4000
- 4001 - 4001
- 4003
- 4004 - 4004
- 4009 - 4009
- 5000 - 5000
@@ -26,6 +27,7 @@ definitions:
CodeBadRequest: 请求参数错误 CodeBadRequest: 请求参数错误
CodeConflict: 资源冲突 CodeConflict: 资源冲突
CodeCreated: 创建成功 CodeCreated: 创建成功
CodeForbidden: 禁止访问
CodeInternalError: 服务器内部错误 CodeInternalError: 服务器内部错误
CodeNotFound: 资源未找到 CodeNotFound: 资源未找到
CodeServiceUnavailable: 服务不可用 CodeServiceUnavailable: 服务不可用
@@ -36,6 +38,7 @@ definitions:
- 创建成功 - 创建成功
- 请求参数错误 - 请求参数错误
- 未授权 - 未授权
- 禁止访问
- 资源未找到 - 资源未找到
- 资源冲突 - 资源冲突
- 服务器内部错误 - 服务器内部错误
@@ -45,6 +48,7 @@ definitions:
- CodeCreated - CodeCreated
- CodeBadRequest - CodeBadRequest
- CodeUnauthorized - CodeUnauthorized
- CodeForbidden
- CodeNotFound - CodeNotFound
- CodeConflict - CodeConflict
- CodeInternalError - CodeInternalError
@@ -349,6 +353,15 @@ definitions:
pagination: pagination:
$ref: '#/definitions/dto.PaginationDTO' $ref: '#/definitions/dto.PaginationDTO'
type: object type: object
dto.ListNotificationResponse:
properties:
list:
items:
$ref: '#/definitions/dto.NotificationDTO'
type: array
pagination:
$ref: '#/definitions/dto.PaginationDTO'
type: object
dto.ListPendingCollectionResponse: dto.ListPendingCollectionResponse:
properties: properties:
list: list:
@@ -412,6 +425,16 @@ definitions:
pagination: pagination:
$ref: '#/definitions/dto.PaginationDTO' $ref: '#/definitions/dto.PaginationDTO'
type: object type: object
dto.ListPlansResponse:
properties:
plans:
items:
$ref: '#/definitions/dto.PlanResponse'
type: array
total:
example: 100
type: integer
type: object
dto.ListRawMaterialPurchaseResponse: dto.ListRawMaterialPurchaseResponse:
properties: properties:
list: list:
@@ -552,6 +575,33 @@ definitions:
- quantity - quantity
- toPenID - toPenID
type: object type: object
dto.NotificationDTO:
properties:
alarm_timestamp:
type: string
created_at:
type: string
error_message:
type: string
id:
type: integer
level:
$ref: '#/definitions/zapcore.Level'
message:
type: string
notifier_type:
$ref: '#/definitions/notify.NotifierType'
status:
$ref: '#/definitions/models.NotificationStatus'
title:
type: string
to_address:
type: string
updated_at:
type: string
user_id:
type: integer
type: object
dto.PaginationDTO: dto.PaginationDTO:
properties: properties:
page: page:
@@ -572,6 +622,8 @@ definitions:
properties: properties:
capacity: capacity:
type: integer type: integer
current_pig_count:
type: integer
house_id: house_id:
type: integer type: integer
id: id:
@@ -660,6 +712,12 @@ definitions:
create_time: create_time:
description: 创建时间 description: 创建时间
type: string type: string
currentTotalPigsInPens:
description: 当前存栏总数
type: integer
currentTotalQuantity:
description: 当前总数
type: integer
end_date: end_date:
description: 批次结束日期 description: 批次结束日期
type: string type: string
@@ -835,6 +893,8 @@ definitions:
type: integer type: integer
plan_id: plan_id:
type: integer type: integer
plan_name:
type: string
started_at: started_at:
type: string type: string
status: status:
@@ -870,6 +930,10 @@ definitions:
name: name:
example: 猪舍温度控制计划 example: 猪舍温度控制计划
type: string type: string
plan_type:
allOf:
- $ref: '#/definitions/models.PlanType'
example: 自定义任务
status: status:
allOf: allOf:
- $ref: '#/definitions/models.PlanStatus' - $ref: '#/definitions/models.PlanStatus'
@@ -1117,6 +1181,15 @@ definitions:
- traderName - traderName
- unitPrice - unitPrice
type: object type: object
dto.SendTestNotificationRequest:
properties:
type:
allOf:
- $ref: '#/definitions/notify.NotifierType'
description: Type 指定要测试的通知渠道
required:
- type
type: object
dto.SensorDataDTO: dto.SensorDataDTO:
properties: properties:
data: data:
@@ -1540,6 +1613,24 @@ definitions:
- ReasonTypePreventive - ReasonTypePreventive
- ReasonTypeTreatment - ReasonTypeTreatment
- ReasonTypeHealthCare - ReasonTypeHealthCare
models.NotificationStatus:
enum:
- 发送成功
- 发送失败
- 已跳过
type: string
x-enum-comments:
NotificationStatusFailed: 通知发送失败
NotificationStatusSkipped: 通知因某些原因被跳过(例如:用户未配置联系方式)
NotificationStatusSuccess: 通知已成功发送
x-enum-descriptions:
- 通知已成功发送
- 通知发送失败
- 通知因某些原因被跳过(例如:用户未配置联系方式)
x-enum-varnames:
- NotificationStatusSuccess
- NotificationStatusFailed
- NotificationStatusSkipped
models.PenStatus: models.PenStatus:
enum: enum:
- 空闲 - 空闲
@@ -1737,6 +1828,14 @@ definitions:
- PlanStatusEnabled - PlanStatusEnabled
- PlanStatusStopped - PlanStatusStopped
- PlanStatusFailed - PlanStatusFailed
models.PlanType:
enum:
- 自定义任务
- 系统任务
type: string
x-enum-varnames:
- PlanTypeCustom
- PlanTypeSystem
models.SensorType: models.SensorType:
enum: enum:
- 信号强度 - 信号强度
@@ -1784,19 +1883,23 @@ definitions:
- 计划分析 - 计划分析
- 等待 - 等待
- 下料 - 下料
- 全量采集
type: string type: string
x-enum-comments: x-enum-comments:
TaskPlanAnalysis: 解析Plan的Task列表并添加到待执行队列的特殊任务 TaskPlanAnalysis: 解析Plan的Task列表并添加到待执行队列的特殊任务
TaskTypeFullCollection: 新增的全量采集任务
TaskTypeReleaseFeedWeight: 下料口释放指定重量任务 TaskTypeReleaseFeedWeight: 下料口释放指定重量任务
TaskTypeWaiting: 等待任务 TaskTypeWaiting: 等待任务
x-enum-descriptions: x-enum-descriptions:
- 解析Plan的Task列表并添加到待执行队列的特殊任务 - 解析Plan的Task列表并添加到待执行队列的特殊任务
- 等待任务 - 等待任务
- 下料口释放指定重量任务 - 下料口释放指定重量任务
- 新增的全量采集任务
x-enum-varnames: x-enum-varnames:
- TaskPlanAnalysis - TaskPlanAnalysis
- TaskTypeWaiting - TaskTypeWaiting
- TaskTypeReleaseFeedWeight - TaskTypeReleaseFeedWeight
- TaskTypeFullCollection
models.ValueDescriptor: models.ValueDescriptor:
properties: properties:
multiplier: multiplier:
@@ -1808,6 +1911,55 @@ definitions:
type: type:
$ref: '#/definitions/models.SensorType' $ref: '#/definitions/models.SensorType'
type: object type: object
notify.NotifierType:
enum:
- 邮件
- 企业微信
- 飞书
- 日志
type: string
x-enum-varnames:
- NotifierTypeSMTP
- NotifierTypeWeChat
- NotifierTypeLark
- NotifierTypeLog
repository.PlanTypeFilter:
enum:
- 所有任务
- 自定义任务
- 系统任务
type: string
x-enum-varnames:
- PlanTypeFilterAll
- PlanTypeFilterCustom
- PlanTypeFilterSystem
zapcore.Level:
enum:
- 7
- -1
- 0
- 1
- 2
- 3
- 4
- 5
- -1
- 5
- 6
format: int32
type: integer
x-enum-varnames:
- _numLevels
- DebugLevel
- InfoLevel
- WarnLevel
- ErrorLevel
- DPanicLevel
- PanicLevel
- FatalLevel
- _minLevel
- _maxLevel
- InvalidLevel
info: info:
contact: contact:
email: divano@example.com email: divano@example.com
@@ -2371,6 +2523,105 @@ paths:
summary: 获取用药记录列表 summary: 获取用药记录列表
tags: tags:
- 数据监控 - 数据监控
/api/v1/monitor/notifications:
get:
description: 根据提供的过滤条件,分页获取通知列表
parameters:
- in: query
name: end_time
type: string
- enum:
- 7
- -1
- 0
- 1
- 2
- 3
- 4
- 5
- -1
- 5
- 6
format: int32
in: query
name: level
type: integer
x-enum-varnames:
- _numLevels
- DebugLevel
- InfoLevel
- WarnLevel
- ErrorLevel
- DPanicLevel
- PanicLevel
- FatalLevel
- _minLevel
- _maxLevel
- InvalidLevel
- enum:
- 邮件
- 企业微信
- 飞书
- 日志
in: query
name: notifier_type
type: string
x-enum-varnames:
- NotifierTypeSMTP
- NotifierTypeWeChat
- NotifierTypeLark
- NotifierTypeLog
- in: query
name: order_by
type: string
- in: query
name: page
type: integer
- in: query
name: pageSize
type: integer
- in: query
name: start_time
type: string
- enum:
- 发送成功
- 发送失败
- 已跳过
in: query
name: status
type: string
x-enum-comments:
NotificationStatusFailed: 通知发送失败
NotificationStatusSkipped: 通知因某些原因被跳过(例如:用户未配置联系方式)
NotificationStatusSuccess: 通知已成功发送
x-enum-descriptions:
- 通知已成功发送
- 通知发送失败
- 通知因某些原因被跳过(例如:用户未配置联系方式)
x-enum-varnames:
- NotificationStatusSuccess
- NotificationStatusFailed
- NotificationStatusSkipped
- in: query
name: user_id
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/controller.Response'
- properties:
data:
$ref: '#/definitions/dto.ListNotificationResponse'
type: object
security:
- BearerAuth: []
summary: 批量查询通知
tags:
- 数据监控
/api/v1/monitor/pending-collections: /api/v1/monitor/pending-collections:
get: get:
description: 根据提供的过滤条件,分页获取待采集请求 description: 根据提供的过滤条件,分页获取待采集请求
@@ -3829,7 +4080,28 @@ paths:
- 猪场管理 - 猪场管理
/api/v1/plans: /api/v1/plans:
get: get:
description: 获取所有计划的列表 description: 获取所有计划的列表,支持按类型过滤和分页
parameters:
- description: 页码
in: query
name: page
type: integer
- description: 每页大小
in: query
name: pageSize
type: integer
- description: 计划类型
enum:
- 所有任务
- 自定义任务
- 系统任务
in: query
name: planType
type: string
x-enum-varnames:
- PlanTypeFilterAll
- PlanTypeFilterCustom
- PlanTypeFilterSystem
produces: produces:
- application/json - application/json
responses: responses:
@@ -3840,9 +4112,7 @@ paths:
- $ref: '#/definitions/controller.Response' - $ref: '#/definitions/controller.Response'
- properties: - properties:
data: data:
items: $ref: '#/definitions/dto.ListPlansResponse'
$ref: '#/definitions/dto.PlanResponse'
type: array
type: object type: object
security: security:
- BearerAuth: [] - BearerAuth: []
@@ -3879,7 +4149,7 @@ paths:
- 计划管理 - 计划管理
/api/v1/plans/{id}: /api/v1/plans/{id}:
delete: delete:
description: 根据计划ID删除计划。软删除 description: 根据计划ID删除计划。软删除系统计划不允许删除。
parameters: parameters:
- description: 计划ID - description: 计划ID
in: path in: path
@@ -3926,7 +4196,7 @@ paths:
put: put:
consumes: consumes:
- application/json - application/json
description: 根据计划ID更新计划的详细信息。 description: 根据计划ID更新计划的详细信息。系统计划不允许修改。
parameters: parameters:
- description: 计划ID - description: 计划ID
in: path in: path
@@ -3958,7 +4228,7 @@ paths:
- 计划管理 - 计划管理
/api/v1/plans/{id}/start: /api/v1/plans/{id}/start:
post: post:
description: 根据计划ID启动一个计划的执行。 description: 根据计划ID启动一个计划的执行。系统计划不允许手动启动。
parameters: parameters:
- description: 计划ID - description: 计划ID
in: path in: path
@@ -3979,7 +4249,7 @@ paths:
- 计划管理 - 计划管理
/api/v1/plans/{id}/stop: /api/v1/plans/{id}/stop:
post: post:
description: 根据计划ID停止一个正在执行的计划。 description: 根据计划ID停止一个正在执行的计划。系统计划不能被停止。
parameters: parameters:
- description: 计划ID - description: 计划ID
in: path in: path
@@ -4078,6 +4348,40 @@ paths:
summary: 获取指定用户的操作历史 summary: 获取指定用户的操作历史
tags: tags:
- 用户管理 - 用户管理
/api/v1/users/{id}/notifications/test:
post:
consumes:
- application/json
description: 为指定用户发送一条特定渠道的测试消息,以验证其配置是否正确。
parameters:
- description: 用户ID
in: path
name: id
required: true
type: integer
- description: 请求体
in: body
name: body
required: true
schema:
$ref: '#/definitions/dto.SendTestNotificationRequest'
produces:
- application/json
responses:
"200":
description: 成功响应
schema:
allOf:
- $ref: '#/definitions/controller.Response'
- properties:
data:
type: string
type: object
security:
- BearerAuth: []
summary: 发送测试通知
tags:
- 用户管理
/api/v1/users/login: /api/v1/users/login:
post: post:
consumes: consumes:

View File

@@ -28,7 +28,8 @@ import (
"git.huangwc.com/pig/pig-farm-controller/internal/app/webhook" "git.huangwc.com/pig/pig-farm-controller/internal/app/webhook"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/audit" "git.huangwc.com/pig/pig-farm-controller/internal/domain/audit"
domain_device "git.huangwc.com/pig/pig-farm-controller/internal/domain/device" domain_device "git.huangwc.com/pig/pig-farm-controller/internal/domain/device"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/task" domain_notify "git.huangwc.com/pig/pig-farm-controller/internal/domain/notify"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/scheduler"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/token" "git.huangwc.com/pig/pig-farm-controller/internal/domain/token"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/config" "git.huangwc.com/pig/pig-farm-controller/internal/infra/config"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
@@ -39,21 +40,21 @@ import (
// API 结构体定义了 HTTP 服务器及其依赖 // API 结构体定义了 HTTP 服务器及其依赖
type API struct { type API struct {
engine *gin.Engine // Gin 引擎实例,用于处理 HTTP 请求 engine *gin.Engine // Gin 引擎实例,用于处理 HTTP 请求
logger *logs.Logger // 日志记录器,用于输出日志信息 logger *logs.Logger // 日志记录器,用于输出日志信息
userRepo repository.UserRepository // 用户数据仓库接口,用于用户数据操作 userRepo repository.UserRepository // 用户数据仓库接口,用于用户数据操作
tokenService token.TokenService // Token 服务接口,用于 JWT token 的生成和解析 tokenService token.Service // Token 服务接口,用于 JWT token 的生成和解析
auditService audit.Service // 审计服务,用于记录用户操作 auditService audit.Service // 审计服务,用于记录用户操作
httpServer *http.Server // 标准库的 HTTP 服务器实例,用于启动和停止服务 httpServer *http.Server // 标准库的 HTTP 服务器实例,用于启动和停止服务
config config.ServerConfig // API 服务器的配置,使用 infra/config 包中的 ServerConfig config config.ServerConfig // API 服务器的配置,使用 infra/config 包中的 ServerConfig
userController *user.Controller // 用户控制器实例 userController *user.Controller // 用户控制器实例
deviceController *device.Controller // 设备控制器实例 deviceController *device.Controller // 设备控制器实例
planController *plan.Controller // 计划控制器实例 planController *plan.Controller // 计划控制器实例
pigFarmController *management.PigFarmController // 猪场管理控制器实例 pigFarmController *management.PigFarmController // 猪场管理控制器实例
pigBatchController *management.PigBatchController // 猪群控制器实例 pigBatchController *management.PigBatchController // 猪群控制器实例
monitorController *monitor.Controller // 数据监控控制器实例 monitorController *monitor.Controller // 数据监控控制器实例
listenHandler webhook.ListenHandler // 设备上行事件监听器 listenHandler webhook.ListenHandler // 设备上行事件监听器
analysisTaskManager *task.AnalysisPlanTaskManager // 计划触发器管理器实例 analysisTaskManager *scheduler.AnalysisPlanTaskManager // 计划触发器管理器实例
} }
// NewAPI 创建并返回一个新的 API 实例 // NewAPI 创建并返回一个新的 API 实例
@@ -68,12 +69,12 @@ func NewAPI(cfg config.ServerConfig,
pigFarmService service.PigFarmService, pigFarmService service.PigFarmService,
pigBatchService service.PigBatchService, pigBatchService service.PigBatchService,
monitorService service.MonitorService, monitorService service.MonitorService,
userActionLogRepository repository.UserActionLogRepository, tokenService token.Service,
tokenService token.TokenService,
auditService audit.Service, auditService audit.Service,
notifyService domain_notify.Service,
deviceService domain_device.Service, deviceService domain_device.Service,
listenHandler webhook.ListenHandler, listenHandler webhook.ListenHandler,
analysisTaskManager *task.AnalysisPlanTaskManager) *API { analysisTaskManager *scheduler.AnalysisPlanTaskManager) *API {
// 设置 Gin 模式,例如 gin.ReleaseMode (生产模式) 或 gin.DebugMode (开发模式) // 设置 Gin 模式,例如 gin.ReleaseMode (生产模式) 或 gin.DebugMode (开发模式)
// 从配置中获取 Gin 模式 // 从配置中获取 Gin 模式
gin.SetMode(cfg.Mode) gin.SetMode(cfg.Mode)
@@ -96,7 +97,7 @@ func NewAPI(cfg config.ServerConfig,
config: cfg, config: cfg,
listenHandler: listenHandler, listenHandler: listenHandler,
// 在 NewAPI 中初始化用户控制器,并将其作为 API 结构体的成员 // 在 NewAPI 中初始化用户控制器,并将其作为 API 结构体的成员
userController: user.NewController(userRepo, monitorService, logger, tokenService), userController: user.NewController(userRepo, monitorService, logger, tokenService, notifyService),
// 在 NewAPI 中初始化设备控制器,并将其作为 API 结构体的成员 // 在 NewAPI 中初始化设备控制器,并将其作为 API 结构体的成员
deviceController: device.NewController(deviceRepository, areaControllerRepository, deviceTemplateRepository, deviceService, logger), deviceController: device.NewController(deviceRepository, areaControllerRepository, deviceTemplateRepository, deviceService, logger),
// 在 NewAPI 中初始化计划控制器,并将其作为 API 结构体的成员 // 在 NewAPI 中初始化计划控制器,并将其作为 API 结构体的成员

View File

@@ -12,6 +12,7 @@ import (
// setupRoutes 设置所有 API 路由 // setupRoutes 设置所有 API 路由
// 在此方法中,使用已初始化的控制器实例将其路由注册到 Gin 引擎中。 // 在此方法中,使用已初始化的控制器实例将其路由注册到 Gin 引擎中。
func (a *API) setupRoutes() { func (a *API) setupRoutes() {
a.logger.Info("开始初始化所有 API 路由")
// --- Public Routes --- // --- Public Routes ---
// 这些路由不需要身份验证 // 这些路由不需要身份验证
@@ -19,7 +20,7 @@ func (a *API) setupRoutes() {
// 用户注册和登录 // 用户注册和登录
a.engine.POST("/api/v1/users", a.userController.CreateUser) // 注册新用户 a.engine.POST("/api/v1/users", a.userController.CreateUser) // 注册新用户
a.engine.POST("/api/v1/users/login", a.userController.Login) // 用户登录 a.engine.POST("/api/v1/users/login", a.userController.Login) // 用户登录
a.logger.Info("公开接口注册成功:用户注册、登录") a.logger.Debug("公开接口注册成功:用户注册、登录")
// 注册 pprof 路由 // 注册 pprof 路由
pprofGroup := a.engine.Group("/debug/pprof") pprofGroup := a.engine.Group("/debug/pprof")
@@ -37,15 +38,15 @@ func (a *API) setupRoutes() {
pprofGroup.GET("/mutex", gin.WrapH(pprof.Handler("mutex"))) // pprof 互斥锁 pprofGroup.GET("/mutex", gin.WrapH(pprof.Handler("mutex"))) // pprof 互斥锁
pprofGroup.GET("/threadcreate", gin.WrapH(pprof.Handler("threadcreate"))) pprofGroup.GET("/threadcreate", gin.WrapH(pprof.Handler("threadcreate")))
} }
a.logger.Info("pprof 接口注册成功") a.logger.Debug("pprof 接口注册成功")
// 上行事件监听路由 // 上行事件监听路由
a.engine.POST("/upstream", gin.WrapH(a.listenHandler.Handler())) // 处理设备上行事件 a.engine.POST("/upstream", gin.WrapH(a.listenHandler.Handler())) // 处理设备上行事件
a.logger.Info("上行事件监听接口注册成功") a.logger.Debug("上行事件监听接口注册成功")
// 添加 Swagger UI 路由, Swagger UI可在 /swagger/index.html 上找到 // 添加 Swagger UI 路由, Swagger UI可在 /swagger/index.html 上找到
a.engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) // Swagger UI 接口 a.engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) // Swagger UI 接口
a.logger.Info("Swagger UI 接口注册成功") a.logger.Debug("Swagger UI 接口注册成功")
// --- Authenticated Routes --- // --- Authenticated Routes ---
// 所有在此注册的路由都需要通过 JWT 身份验证 // 所有在此注册的路由都需要通过 JWT 身份验证
@@ -57,8 +58,9 @@ func (a *API) setupRoutes() {
userGroup := authGroup.Group("/users") userGroup := authGroup.Group("/users")
{ {
userGroup.GET("/:id/history", a.userController.ListUserHistory) // 获取用户操作历史 userGroup.GET("/:id/history", a.userController.ListUserHistory) // 获取用户操作历史
userGroup.POST("/:id/notifications/test", a.userController.SendTestNotification)
} }
a.logger.Info("用户相关接口注册成功 (需要认证和审计)") a.logger.Debug("用户相关接口注册成功 (需要认证和审计)")
// 设备相关路由组 // 设备相关路由组
deviceGroup := authGroup.Group("/devices") deviceGroup := authGroup.Group("/devices")
@@ -70,7 +72,7 @@ func (a *API) setupRoutes() {
deviceGroup.DELETE("/:id", a.deviceController.DeleteDevice) // 删除设备 deviceGroup.DELETE("/:id", a.deviceController.DeleteDevice) // 删除设备
deviceGroup.POST("/manual-control/:id", a.deviceController.ManualControl) // 手动控制设备 deviceGroup.POST("/manual-control/:id", a.deviceController.ManualControl) // 手动控制设备
} }
a.logger.Info("设备相关接口注册成功 (需要认证和审计)") a.logger.Debug("设备相关接口注册成功 (需要认证和审计)")
// 区域主控相关路由组 // 区域主控相关路由组
areaControllerGroup := authGroup.Group("/area-controllers") areaControllerGroup := authGroup.Group("/area-controllers")
@@ -81,7 +83,7 @@ func (a *API) setupRoutes() {
areaControllerGroup.PUT("/:id", a.deviceController.UpdateAreaController) // 更新区域主控 areaControllerGroup.PUT("/:id", a.deviceController.UpdateAreaController) // 更新区域主控
areaControllerGroup.DELETE("/:id", a.deviceController.DeleteAreaController) // 删除区域主控 areaControllerGroup.DELETE("/:id", a.deviceController.DeleteAreaController) // 删除区域主控
} }
a.logger.Info("区域主控相关接口注册成功 (需要认证和审计)") a.logger.Debug("区域主控相关接口注册成功 (需要认证和审计)")
// 设备模板相关路由组 // 设备模板相关路由组
deviceTemplateGroup := authGroup.Group("/device-templates") deviceTemplateGroup := authGroup.Group("/device-templates")
@@ -92,7 +94,7 @@ func (a *API) setupRoutes() {
deviceTemplateGroup.PUT("/:id", a.deviceController.UpdateDeviceTemplate) // 更新设备模板 deviceTemplateGroup.PUT("/:id", a.deviceController.UpdateDeviceTemplate) // 更新设备模板
deviceTemplateGroup.DELETE("/:id", a.deviceController.DeleteDeviceTemplate) // 删除设备模板 deviceTemplateGroup.DELETE("/:id", a.deviceController.DeleteDeviceTemplate) // 删除设备模板
} }
a.logger.Info("设备模板相关接口注册成功 (需要认证和审计)") a.logger.Debug("设备模板相关接口注册成功 (需要认证和审计)")
// 计划相关路由组 // 计划相关路由组
planGroup := authGroup.Group("/plans") planGroup := authGroup.Group("/plans")
@@ -105,7 +107,7 @@ func (a *API) setupRoutes() {
planGroup.POST("/:id/start", a.planController.StartPlan) // 启动计划 planGroup.POST("/:id/start", a.planController.StartPlan) // 启动计划
planGroup.POST("/:id/stop", a.planController.StopPlan) // 停止计划 planGroup.POST("/:id/stop", a.planController.StopPlan) // 停止计划
} }
a.logger.Info("计划相关接口注册成功 (需要认证和审计)") a.logger.Debug("计划相关接口注册成功 (需要认证和审计)")
// 猪舍相关路由组 // 猪舍相关路由组
pigHouseGroup := authGroup.Group("/pig-houses") pigHouseGroup := authGroup.Group("/pig-houses")
@@ -116,7 +118,7 @@ func (a *API) setupRoutes() {
pigHouseGroup.PUT("/:id", a.pigFarmController.UpdatePigHouse) // 更新猪舍 pigHouseGroup.PUT("/:id", a.pigFarmController.UpdatePigHouse) // 更新猪舍
pigHouseGroup.DELETE("/:id", a.pigFarmController.DeletePigHouse) // 删除猪舍 pigHouseGroup.DELETE("/:id", a.pigFarmController.DeletePigHouse) // 删除猪舍
} }
a.logger.Info("猪舍相关接口注册成功 (需要认证和审计)") a.logger.Debug("猪舍相关接口注册成功 (需要认证和审计)")
// 猪圈相关路由组 // 猪圈相关路由组
penGroup := authGroup.Group("/pens") penGroup := authGroup.Group("/pens")
@@ -128,7 +130,7 @@ func (a *API) setupRoutes() {
penGroup.DELETE("/:id", a.pigFarmController.DeletePen) // 删除猪圈 penGroup.DELETE("/:id", a.pigFarmController.DeletePen) // 删除猪圈
penGroup.PUT("/:id/status", a.pigFarmController.UpdatePenStatus) // 更新猪圈状态 penGroup.PUT("/:id/status", a.pigFarmController.UpdatePenStatus) // 更新猪圈状态
} }
a.logger.Info("猪圈相关接口注册成功 (需要认证和审计)") a.logger.Debug("猪圈相关接口注册成功 (需要认证和审计)")
// 猪群相关路由组 // 猪群相关路由组
pigBatchGroup := authGroup.Group("/pig-batches") pigBatchGroup := authGroup.Group("/pig-batches")
@@ -140,7 +142,7 @@ func (a *API) setupRoutes() {
pigBatchGroup.DELETE("/:id", a.pigBatchController.DeletePigBatch) // 删除猪群 pigBatchGroup.DELETE("/:id", a.pigBatchController.DeletePigBatch) // 删除猪群
pigBatchGroup.POST("/assign-pens/:id", a.pigBatchController.AssignEmptyPensToBatch) // 为猪群分配空栏 pigBatchGroup.POST("/assign-pens/:id", a.pigBatchController.AssignEmptyPensToBatch) // 为猪群分配空栏
pigBatchGroup.POST("/reclassify-pen/:fromBatchID", a.pigBatchController.ReclassifyPenToNewBatch) // 将猪栏划拨到新群 pigBatchGroup.POST("/reclassify-pen/:fromBatchID", a.pigBatchController.ReclassifyPenToNewBatch) // 将猪栏划拨到新群
penGroup.DELETE("/remove-pen/:penID/:batchID", a.pigBatchController.RemoveEmptyPenFromBatch) // 从猪群移除空栏 pigBatchGroup.DELETE("/remove-pen/:penID/:batchID", a.pigBatchController.RemoveEmptyPenFromBatch) // 从猪群移除空栏
pigBatchGroup.POST("/move-pigs-into-pen/:id", a.pigBatchController.MovePigsIntoPen) // 将猪只从“虚拟库存”移入指定猪栏 pigBatchGroup.POST("/move-pigs-into-pen/:id", a.pigBatchController.MovePigsIntoPen) // 将猪只从“虚拟库存”移入指定猪栏
pigBatchGroup.POST("/sell-pigs/:id", a.pigBatchController.SellPigs) // 处理卖猪业务 pigBatchGroup.POST("/sell-pigs/:id", a.pigBatchController.SellPigs) // 处理卖猪业务
pigBatchGroup.POST("/buy-pigs/:id", a.pigBatchController.BuyPigs) // 处理买猪业务 pigBatchGroup.POST("/buy-pigs/:id", a.pigBatchController.BuyPigs) // 处理买猪业务
@@ -153,7 +155,7 @@ func (a *API) setupRoutes() {
pigBatchGroup.POST("/record-death/:id", a.pigBatchController.RecordDeath) // 记录正常猪只死亡事件 pigBatchGroup.POST("/record-death/:id", a.pigBatchController.RecordDeath) // 记录正常猪只死亡事件
pigBatchGroup.POST("/record-cull/:id", a.pigBatchController.RecordCull) // 记录正常猪只淘汰事件 pigBatchGroup.POST("/record-cull/:id", a.pigBatchController.RecordCull) // 记录正常猪只淘汰事件
} }
a.logger.Info("猪群相关接口注册成功 (需要认证和审计)") a.logger.Debug("猪群相关接口注册成功 (需要认证和审计)")
// 数据监控相关路由组 // 数据监控相关路由组
monitorGroup := authGroup.Group("/monitor") monitorGroup := authGroup.Group("/monitor")
@@ -175,7 +177,10 @@ func (a *API) setupRoutes() {
monitorGroup.GET("/pig-sick-logs", a.monitorController.ListPigSickLogs) monitorGroup.GET("/pig-sick-logs", a.monitorController.ListPigSickLogs)
monitorGroup.GET("/pig-purchases", a.monitorController.ListPigPurchases) monitorGroup.GET("/pig-purchases", a.monitorController.ListPigPurchases)
monitorGroup.GET("/pig-sales", a.monitorController.ListPigSales) monitorGroup.GET("/pig-sales", a.monitorController.ListPigSales)
monitorGroup.GET("/notifications", a.monitorController.ListNotifications)
} }
a.logger.Info("数据监控相关接口注册成功 (需要认证和审计)") a.logger.Debug("数据监控相关接口注册成功 (需要认证和审计)")
} }
a.logger.Debug("所有接口注册成功")
} }

View File

@@ -2,6 +2,7 @@ package management
import ( import (
"errors" "errors"
"fmt"
"strconv" "strconv"
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller" "git.huangwc.com/pig/pig-farm-controller/internal/app/controller"
@@ -25,7 +26,7 @@ func mapAndSendError(c *PigBatchController, ctx *gin.Context, action string, err
controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), action, err.Error(), id) controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), action, err.Error(), id)
} else { } else {
c.logger.Errorf("操作[%s]业务逻辑失败: %v", action, err) c.logger.Errorf("操作[%s]业务逻辑失败: %v", action, err)
controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "操作失败", action, err.Error(), id) controller.SendErrorWithAudit(ctx, controller.CodeInternalError, fmt.Sprintf("操作失败: %v", err), action, err.Error(), id)
} }
} }
@@ -156,7 +157,7 @@ func handleAPIRequestWithResponse[Req any, Resp any](
) { ) {
// 1. 绑定请求体 // 1. 绑定请求体
if err := ctx.ShouldBindJSON(&reqDTO); err != nil { if err := ctx.ShouldBindJSON(&reqDTO); err != nil {
controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", reqDTO) controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, fmt.Sprintf("无效的请求体: %v", err), action, fmt.Sprintf("请求体绑定失败: %v", err), reqDTO)
return return
} }

View File

@@ -1 +0,0 @@
package management

View File

@@ -1 +0,0 @@
package management

View File

@@ -1 +0,0 @@
package management

View File

@@ -239,12 +239,11 @@ func (c *PigFarmController) CreatePen(ctx *gin.Context) {
} }
resp := dto.PenResponse{ resp := dto.PenResponse{
ID: pen.ID, ID: pen.ID,
PenNumber: pen.PenNumber, PenNumber: pen.PenNumber,
HouseID: pen.HouseID, HouseID: pen.HouseID,
Capacity: pen.Capacity, Capacity: pen.Capacity,
Status: pen.Status, Status: pen.Status,
PigBatchID: *pen.PigBatchID,
} }
controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "创建成功", resp, action, "创建成功", resp) controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "创建成功", resp, action, "创建成功", resp)
} }
@@ -277,15 +276,7 @@ func (c *PigFarmController) GetPen(ctx *gin.Context) {
return return
} }
resp := dto.PenResponse{ controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取成功", pen, action, "获取成功", pen)
ID: pen.ID,
PenNumber: pen.PenNumber,
HouseID: pen.HouseID,
Capacity: pen.Capacity,
Status: pen.Status,
PigBatchID: *pen.PigBatchID,
}
controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取成功", resp, action, "获取成功", resp)
} }
// ListPens godoc // ListPens godoc
@@ -305,19 +296,7 @@ func (c *PigFarmController) ListPens(ctx *gin.Context) {
return return
} }
var resp []dto.PenResponse controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取成功", pens, action, "获取成功", pens)
for _, pen := range pens {
resp = append(resp, dto.PenResponse{
ID: pen.ID,
PenNumber: pen.PenNumber,
HouseID: pen.HouseID,
Capacity: pen.Capacity,
Status: pen.Status,
PigBatchID: *pen.PigBatchID,
})
}
controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取成功", resp, action, "获取成功", resp)
} }
// UpdatePen godoc // UpdatePen godoc
@@ -363,7 +342,7 @@ func (c *PigFarmController) UpdatePen(ctx *gin.Context) {
HouseID: pen.HouseID, HouseID: pen.HouseID,
Capacity: pen.Capacity, Capacity: pen.Capacity,
Status: pen.Status, Status: pen.Status,
PigBatchID: *pen.PigBatchID, PigBatchID: pen.PigBatchID,
} }
controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "更新成功", resp, action, "更新成功", resp) controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "更新成功", resp, action, "更新成功", resp)
} }
@@ -448,7 +427,7 @@ func (c *PigFarmController) UpdatePenStatus(ctx *gin.Context) {
HouseID: pen.HouseID, HouseID: pen.HouseID,
Capacity: pen.Capacity, Capacity: pen.Capacity,
Status: pen.Status, Status: pen.Status,
PigBatchID: *pen.PigBatchID, PigBatchID: pen.PigBatchID,
} }
controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "更新成功", resp, action, "更新成功", resp) controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "更新成功", resp, action, "更新成功", resp)
} }

View File

@@ -149,7 +149,7 @@ func (c *Controller) ListPlanExecutionLogs(ctx *gin.Context) {
opts.Status = &status opts.Status = &status
} }
data, total, err := c.monitorService.ListPlanExecutionLogs(opts, req.Page, req.PageSize) planLogs, plans, total, err := c.monitorService.ListPlanExecutionLogs(opts, req.Page, req.PageSize)
if err != nil { if err != nil {
if errors.Is(err, repository.ErrInvalidPagination) { if errors.Is(err, repository.ErrInvalidPagination) {
c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err)
@@ -162,8 +162,8 @@ func (c *Controller) ListPlanExecutionLogs(ctx *gin.Context) {
return return
} }
resp := dto.NewListPlanExecutionLogResponse(data, total, req.Page, req.PageSize) resp := dto.NewListPlanExecutionLogResponse(planLogs, plans, total, req.Page, req.PageSize)
c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(planLogs), total)
controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取计划执行日志成功", resp, actionType, "获取计划执行日志成功", req) controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取计划执行日志成功", resp, actionType, "获取计划执行日志成功", req)
} }
@@ -839,3 +839,50 @@ func (c *Controller) ListPigSales(ctx *gin.Context) {
c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total)
controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取猪只售卖记录成功", resp, actionType, "获取猪只售卖记录成功", req) controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取猪只售卖记录成功", resp, actionType, "获取猪只售卖记录成功", req)
} }
// ListNotifications godoc
// @Summary 批量查询通知
// @Description 根据提供的过滤条件,分页获取通知列表
// @Tags 数据监控
// @Security BearerAuth
// @Produce json
// @Param query query dto.ListNotificationRequest true "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListNotificationResponse}
// @Router /api/v1/monitor/notifications [get]
func (c *Controller) ListNotifications(ctx *gin.Context) {
const actionType = "批量查询通知"
var req dto.ListNotificationRequest
if err := ctx.ShouldBindQuery(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req)
return
}
opts := repository.NotificationListOptions{
UserID: req.UserID,
NotifierType: req.NotifierType,
Level: req.Level,
StartTime: req.StartTime,
EndTime: req.EndTime,
OrderBy: req.OrderBy,
Status: req.Status,
}
data, total, err := c.monitorService.ListNotifications(opts, req.Page, req.PageSize)
if err != nil {
if errors.Is(err, repository.ErrInvalidPagination) {
c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err)
controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req)
return
}
c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err)
controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "批量查询通知失败: "+err.Error(), actionType, "服务层查询失败", req)
return
}
resp := dto.NewListNotificationResponse(data, total, req.Page, req.PageSize)
c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total)
controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "批量查询通知成功", resp, actionType, "批量查询通知成功", req)
}

View File

@@ -6,7 +6,7 @@ import (
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller" "git.huangwc.com/pig/pig-farm-controller/internal/app/controller"
"git.huangwc.com/pig/pig-farm-controller/internal/app/dto" "git.huangwc.com/pig/pig-farm-controller/internal/app/dto"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/task" "git.huangwc.com/pig/pig-farm-controller/internal/domain/scheduler"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" "git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" "git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
@@ -20,11 +20,11 @@ import (
type Controller struct { type Controller struct {
logger *logs.Logger logger *logs.Logger
planRepo repository.PlanRepository planRepo repository.PlanRepository
analysisPlanTaskManager *task.AnalysisPlanTaskManager analysisPlanTaskManager *scheduler.AnalysisPlanTaskManager
} }
// NewController 创建一个新的 Controller 实例 // NewController 创建一个新的 Controller 实例
func NewController(logger *logs.Logger, planRepo repository.PlanRepository, analysisPlanTaskManager *task.AnalysisPlanTaskManager) *Controller { func NewController(logger *logs.Logger, planRepo repository.PlanRepository, analysisPlanTaskManager *scheduler.AnalysisPlanTaskManager) *Controller {
return &Controller{ return &Controller{
logger: logger, logger: logger,
planRepo: planRepo, planRepo: planRepo,
@@ -61,7 +61,11 @@ func (c *Controller) CreatePlan(ctx *gin.Context) {
return return
} }
// --- 自动判断 ContentType --- // --- 业务规则处理 ---
// 1. 设置计划类型:用户创建的计划永远是自定义计划
planToCreate.PlanType = models.PlanTypeCustom
// 2. 自动判断 ContentType
if len(req.SubPlanIDs) > 0 { if len(req.SubPlanIDs) > 0 {
planToCreate.ContentType = models.PlanContentTypeSubPlans planToCreate.ContentType = models.PlanContentTypeSubPlans
} else { } else {
@@ -145,16 +149,25 @@ func (c *Controller) GetPlan(ctx *gin.Context) {
// ListPlans godoc // ListPlans godoc
// @Summary 获取计划列表 // @Summary 获取计划列表
// @Description 获取所有计划的列表 // @Description 获取所有计划的列表,支持按类型过滤和分页
// @Tags 计划管理 // @Tags 计划管理
// @Security BearerAuth // @Security BearerAuth
// @Produce json // @Produce json
// @Success 200 {object} controller.Response{data=[]dto.PlanResponse} "业务码为200代表成功获取列表" // @Param query query dto.ListPlansQuery false "查询参数"
// @Success 200 {object} controller.Response{data=dto.ListPlansResponse} "业务码为200代表成功获取列表"
// @Router /api/v1/plans [get] // @Router /api/v1/plans [get]
func (c *Controller) ListPlans(ctx *gin.Context) { func (c *Controller) ListPlans(ctx *gin.Context) {
const actionType = "获取计划列表" const actionType = "获取计划列表"
var query dto.ListPlansQuery
if err := ctx.ShouldBindQuery(&query); err != nil {
c.logger.Errorf("%s: 查询参数绑定失败: %v", actionType, err)
controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "查询参数绑定失败", query)
return
}
// 1. 调用仓库层获取所有计划 // 1. 调用仓库层获取所有计划
plans, err := c.planRepo.ListBasicPlans() opts := repository.ListPlansOptions{PlanType: query.PlanType}
plans, total, err := c.planRepo.ListPlans(opts, query.Page, query.PageSize)
if err != nil { if err != nil {
c.logger.Errorf("%s: 数据库查询失败: %v", actionType, err) c.logger.Errorf("%s: 数据库查询失败: %v", actionType, err)
controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划列表时发生内部错误", actionType, "数据库查询失败", nil) controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划列表时发生内部错误", actionType, "数据库查询失败", nil)
@@ -176,7 +189,7 @@ func (c *Controller) ListPlans(ctx *gin.Context) {
// 3. 构造并发送成功响应 // 3. 构造并发送成功响应
resp := dto.ListPlansResponse{ resp := dto.ListPlansResponse{
Plans: planResponses, Plans: planResponses,
Total: len(planResponses), Total: total,
} }
c.logger.Infof("%s: 获取计划列表成功, 数量: %d", actionType, len(planResponses)) c.logger.Infof("%s: 获取计划列表成功, 数量: %d", actionType, len(planResponses))
controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取计划列表成功", resp, actionType, "获取计划列表成功", resp) controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取计划列表成功", resp, actionType, "获取计划列表成功", resp)
@@ -184,7 +197,7 @@ func (c *Controller) ListPlans(ctx *gin.Context) {
// UpdatePlan godoc // UpdatePlan godoc
// @Summary 更新计划 // @Summary 更新计划
// @Description 根据计划ID更新计划的详细信息。 // @Description 根据计划ID更新计划的详细信息。系统计划不允许修改。
// @Tags 计划管理 // @Tags 计划管理
// @Security BearerAuth // @Security BearerAuth
// @Accept json // @Accept json
@@ -212,7 +225,27 @@ func (c *Controller) UpdatePlan(ctx *gin.Context) {
return return
} }
// 3. 将请求转换为模型(转换函数带校验) // 3. 检查计划是否存在
existingPlan, err := c.planRepo.GetBasicPlanByID(uint(id))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.logger.Warnf("%s: 计划不存在, ID: %d", actionType, id)
controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "计划不存在", actionType, "计划不存在", id)
return
}
c.logger.Errorf("%s: 获取计划信息失败: %v, ID: %d", actionType, err, id)
controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划信息时发生内部错误", actionType, "数据库查询失败", id)
return
}
// 4. 业务规则:系统计划不允许修改
if existingPlan.PlanType == models.PlanTypeSystem {
c.logger.Warnf("%s: 尝试修改系统计划, ID: %d", actionType, id)
controller.SendErrorWithAudit(ctx, controller.CodeForbidden, "系统计划不允许修改", actionType, "尝试修改系统计划", id)
return
}
// 5. 将请求转换为模型(转换函数带校验)
planToUpdate, err := dto.NewPlanFromUpdateRequest(&req) planToUpdate, err := dto.NewPlanFromUpdateRequest(&req)
if err != nil { if err != nil {
c.logger.Errorf("%s: 计划数据校验失败: %v", actionType, err) c.logger.Errorf("%s: 计划数据校验失败: %v", actionType, err)
@@ -229,20 +262,7 @@ func (c *Controller) UpdatePlan(ctx *gin.Context) {
planToUpdate.ContentType = models.PlanContentTypeTasks planToUpdate.ContentType = models.PlanContentTypeTasks
} }
// 4. 检查计划是否存在 // 6. 调用仓库方法更新计划
_, err = c.planRepo.GetBasicPlanByID(uint(id))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.logger.Warnf("%s: 计划不存在, ID: %d", actionType, id)
controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "计划不存在", actionType, "计划不存在", id)
return
}
c.logger.Errorf("%s: 获取计划信息失败: %v, ID: %d", actionType, err, id)
controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划信息时发生内部错误", actionType, "数据库查询失败", id)
return
}
// 5. 调用仓库方法更新计划
// 只要是更新任务,就重置执行计数器 // 只要是更新任务,就重置执行计数器
planToUpdate.ExecuteCount = 0 // 重置计数器 planToUpdate.ExecuteCount = 0 // 重置计数器
c.logger.Infof("计划 #%d 被更新,执行计数器已重置为 0。", planToUpdate.ID) c.logger.Infof("计划 #%d 被更新,执行计数器已重置为 0。", planToUpdate.ID)
@@ -259,7 +279,7 @@ func (c *Controller) UpdatePlan(ctx *gin.Context) {
c.logger.Errorf("为更新后的计划 %d 确保触发器任务定义失败: %v", planToUpdate.ID, err) c.logger.Errorf("为更新后的计划 %d 确保触发器任务定义失败: %v", planToUpdate.ID, err)
} }
// 6. 获取更新后的完整计划用于响应 // 7. 获取更新后的完整计划用于响应
updatedPlan, err := c.planRepo.GetPlanByID(uint(id)) updatedPlan, err := c.planRepo.GetPlanByID(uint(id))
if err != nil { if err != nil {
c.logger.Errorf("%s: 获取更新后计划详情失败: %v, ID: %d", actionType, err, id) c.logger.Errorf("%s: 获取更新后计划详情失败: %v, ID: %d", actionType, err, id)
@@ -267,7 +287,7 @@ func (c *Controller) UpdatePlan(ctx *gin.Context) {
return return
} }
// 7. 将模型转换为响应 DTO // 8. 将模型转换为响应 DTO
resp, err := dto.NewPlanToResponse(updatedPlan) resp, err := dto.NewPlanToResponse(updatedPlan)
if err != nil { if err != nil {
c.logger.Errorf("%s: 序列化响应失败: %v, Updated Plan: %+v", actionType, err, updatedPlan) c.logger.Errorf("%s: 序列化响应失败: %v, Updated Plan: %+v", actionType, err, updatedPlan)
@@ -275,14 +295,14 @@ func (c *Controller) UpdatePlan(ctx *gin.Context) {
return return
} }
// 8. 发送成功响应 // 9. 发送成功响应
c.logger.Infof("%s: 计划更新成功, ID: %d", actionType, updatedPlan.ID) c.logger.Infof("%s: 计划更新成功, ID: %d", actionType, updatedPlan.ID)
controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "计划更新成功", resp, actionType, "计划更新成功", resp) controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "计划更新成功", resp, actionType, "计划更新成功", resp)
} }
// DeletePlan godoc // DeletePlan godoc
// @Summary 删除计划 // @Summary 删除计划
// @Description 根据计划ID删除计划。软删除 // @Description 根据计划ID删除计划。软删除系统计划不允许删除。
// @Tags 计划管理 // @Tags 计划管理
// @Security BearerAuth // @Security BearerAuth
// @Produce json // @Produce json
@@ -313,7 +333,14 @@ func (c *Controller) DeletePlan(ctx *gin.Context) {
return return
} }
// 3. 停止这个计划 // 3. 业务规则:系统计划不允许删除
if plan.PlanType == models.PlanTypeSystem {
c.logger.Warnf("%s: 尝试删除系统计划, ID: %d", actionType, id)
controller.SendErrorWithAudit(ctx, controller.CodeForbidden, "系统计划不允许删除", actionType, "尝试删除系统计划", id)
return
}
// 4. 停止这个计划
if plan.Status == models.PlanStatusEnabled { if plan.Status == models.PlanStatusEnabled {
if err := c.planRepo.StopPlanTransactionally(uint(id)); err != nil { if err := c.planRepo.StopPlanTransactionally(uint(id)); err != nil {
c.logger.Errorf("%s: 停止计划失败: %v, ID: %d", actionType, err, id) c.logger.Errorf("%s: 停止计划失败: %v, ID: %d", actionType, err, id)
@@ -322,21 +349,21 @@ func (c *Controller) DeletePlan(ctx *gin.Context) {
} }
} }
// 4. 调用仓库层删除计划 // 5. 调用仓库层删除计划
if err := c.planRepo.DeletePlan(uint(id)); err != nil { if err := c.planRepo.DeletePlan(uint(id)); err != nil {
c.logger.Errorf("%s: 数据库删除失败: %v, ID: %d", actionType, err, id) c.logger.Errorf("%s: 数据库删除失败: %v, ID: %d", actionType, err, id)
controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除计划时发生内部错误", actionType, "数据库删除失败", id) controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除计划时发生内部错误", actionType, "数据库删除失败", id)
return return
} }
// 5. 发送成功响应 // 6. 发送成功响应
c.logger.Infof("%s: 计划删除成功, ID: %d", actionType, id) c.logger.Infof("%s: 计划删除成功, ID: %d", actionType, id)
controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "计划删除成功", nil, actionType, "计划删除成功", id) controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "计划删除成功", nil, actionType, "计划删除成功", id)
} }
// StartPlan godoc // StartPlan godoc
// @Summary 启动计划 // @Summary 启动计划
// @Description 根据计划ID启动一个计划的执行。 // @Description 根据计划ID启动一个计划的执行。系统计划不允许手动启动。
// @Tags 计划管理 // @Tags 计划管理
// @Security BearerAuth // @Security BearerAuth
// @Produce json // @Produce json
@@ -367,7 +394,12 @@ func (c *Controller) StartPlan(ctx *gin.Context) {
return return
} }
// 3. 检查计划当前状态 // 3. 业务规则检查
if plan.PlanType == models.PlanTypeSystem {
c.logger.Warnf("%s: 尝试手动启动系统计划, ID: %d", actionType, id)
controller.SendErrorWithAudit(ctx, controller.CodeForbidden, "系统计划不允许手动启动", actionType, "尝试手动启动系统计划", id)
return
}
if plan.Status == models.PlanStatusEnabled { if plan.Status == models.PlanStatusEnabled {
c.logger.Warnf("%s: 计划已处于启动状态,无需重复操作, ID: %d", actionType, id) c.logger.Warnf("%s: 计划已处于启动状态,无需重复操作, ID: %d", actionType, id)
controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "计划已处于启动状态,无需重复操作", actionType, "计划已处于启动状态", id) controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "计划已处于启动状态,无需重复操作", actionType, "计划已处于启动状态", id)
@@ -416,7 +448,7 @@ func (c *Controller) StartPlan(ctx *gin.Context) {
// StopPlan godoc // StopPlan godoc
// @Summary 停止计划 // @Summary 停止计划
// @Description 根据计划ID停止一个正在执行的计划。 // @Description 根据计划ID停止一个正在执行的计划。系统计划不能被停止。
// @Tags 计划管理 // @Tags 计划管理
// @Security BearerAuth // @Security BearerAuth
// @Produce json // @Produce json
@@ -447,21 +479,28 @@ func (c *Controller) StopPlan(ctx *gin.Context) {
return return
} }
// 3. 检查计划当前状态 // 3. 业务规则:系统计划不允许停止
if plan.PlanType == models.PlanTypeSystem {
c.logger.Warnf("%s: 尝试停止系统计划, ID: %d", actionType, id)
controller.SendErrorWithAudit(ctx, controller.CodeForbidden, "系统计划不允许停止", actionType, "尝试停止系统计划", id)
return
}
// 4. 检查计划当前状态
if plan.Status != models.PlanStatusEnabled { if plan.Status != models.PlanStatusEnabled {
c.logger.Warnf("%s: 计划当前不是启用状态, ID: %d, Status: %s", actionType, id, plan.Status) c.logger.Warnf("%s: 计划当前不是启用状态, ID: %d, Status: %s", actionType, id, plan.Status)
controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "计划当前不是启用状态", actionType, "计划未启用", id) controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "计划当前不是启用状态", actionType, "计划未启用", id)
return return
} }
// 4. 调用仓库层方法,该方法内部处理事务 // 5. 调用仓库层方法,该方法内部处理事务
if err := c.planRepo.StopPlanTransactionally(uint(id)); err != nil { if err := c.planRepo.StopPlanTransactionally(uint(id)); err != nil {
c.logger.Errorf("%s: 停止计划失败: %v, ID: %d", actionType, err, id) c.logger.Errorf("%s: 停止计划失败: %v, ID: %d", actionType, err, id)
controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "停止计划时发生内部错误: "+err.Error(), actionType, "停止计划失败", id) controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "停止计划时发生内部错误: "+err.Error(), actionType, "停止计划失败", id)
return return
} }
// 5. 发送成功响应 // 6. 发送成功响应
c.logger.Infof("%s: 计划已成功停止, ID: %d", actionType, id) c.logger.Infof("%s: 计划已成功停止, ID: %d", actionType, id)
controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "计划已成功停止", nil, actionType, "计划已成功停止", id) controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "计划已成功停止", nil, actionType, "计划已成功停止", id)
} }

View File

@@ -18,6 +18,7 @@ const (
// 客户端错误状态码 (4000-4999) // 客户端错误状态码 (4000-4999)
CodeBadRequest ResponseCode = 4000 // 请求参数错误 CodeBadRequest ResponseCode = 4000 // 请求参数错误
CodeUnauthorized ResponseCode = 4001 // 未授权 CodeUnauthorized ResponseCode = 4001 // 未授权
CodeForbidden ResponseCode = 4003 // 禁止访问
CodeNotFound ResponseCode = 4004 // 资源未找到 CodeNotFound ResponseCode = 4004 // 资源未找到
CodeConflict ResponseCode = 4009 // 资源冲突 CodeConflict ResponseCode = 4009 // 资源冲突

View File

@@ -7,6 +7,7 @@ import (
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller" "git.huangwc.com/pig/pig-farm-controller/internal/app/controller"
"git.huangwc.com/pig/pig-farm-controller/internal/app/dto" "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/app/service"
domain_notify "git.huangwc.com/pig/pig-farm-controller/internal/domain/notify"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/token" "git.huangwc.com/pig/pig-farm-controller/internal/domain/token"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" "git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
@@ -19,16 +20,24 @@ import (
type Controller struct { type Controller struct {
userRepo repository.UserRepository userRepo repository.UserRepository
monitorService service.MonitorService monitorService service.MonitorService
tokenService token.TokenService // 注入 token 服务 tokenService token.Service
notifyService domain_notify.Service
logger *logs.Logger logger *logs.Logger
} }
// NewController 创建用户控制器实例 // NewController 创建用户控制器实例
func NewController(userRepo repository.UserRepository, monitorService service.MonitorService, logger *logs.Logger, tokenService token.TokenService) *Controller { func NewController(
userRepo repository.UserRepository,
monitorService service.MonitorService,
logger *logs.Logger,
tokenService token.Service,
notifyService domain_notify.Service,
) *Controller {
return &Controller{ return &Controller{
userRepo: userRepo, userRepo: userRepo,
monitorService: monitorService, monitorService: monitorService,
tokenService: tokenService, tokenService: tokenService,
notifyService: notifyService,
logger: logger, logger: logger,
} }
} }
@@ -192,3 +201,46 @@ func (c *Controller) ListUserHistory(ctx *gin.Context) {
c.logger.Infof("%s: 成功获取用户 %d 的操作历史, 数量: %d", actionType, userID, len(data)) c.logger.Infof("%s: 成功获取用户 %d 的操作历史, 数量: %d", actionType, userID, len(data))
controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取用户操作历史成功", resp, actionType, "获取用户操作历史成功", opts) controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取用户操作历史成功", resp, actionType, "获取用户操作历史成功", opts)
} }
// SendTestNotification godoc
// @Summary 发送测试通知
// @Description 为指定用户发送一条特定渠道的测试消息,以验证其配置是否正确。
// @Tags 用户管理
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path int true "用户ID"
// @Param body body dto.SendTestNotificationRequest true "请求体"
// @Success 200 {object} controller.Response{data=string} "成功响应"
// @Router /api/v1/users/{id}/notifications/test [post]
func (c *Controller) SendTestNotification(ctx *gin.Context) {
const actionType = "发送测试通知"
// 1. 从 URL 中获取用户 ID
userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
c.logger.Errorf("%s: 无效的用户ID格式: %v", actionType, err)
controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的用户ID格式", actionType, "无效的用户ID格式", ctx.Param("id"))
return
}
// 2. 从请求体 (JSON Body) 中获取要测试的通知类型
var req dto.SendTestNotificationRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err)
controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "请求体格式错误或缺少 'type' 字段: "+err.Error(), actionType, "请求体绑定失败", req)
return
}
// 3. 调用领域服务
err = c.notifyService.SendTestMessage(uint(userID), req.Type)
if err != nil {
c.logger.Errorf("%s: 服务层调用失败: %v", actionType, err)
controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "发送测试消息失败: "+err.Error(), actionType, "服务层调用失败", gin.H{"userID": userID, "type": req.Type})
return
}
// 4. 返回成功响应
c.logger.Infof("%s: 成功为用户 %d 发送类型为 %s 的测试消息", actionType, userID, req.Type)
controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "测试消息已发送,请检查您的接收端。", nil, actionType, "测试消息发送成功", gin.H{"userID": userID, "type": req.Type})
}

View File

@@ -53,14 +53,20 @@ func NewListDeviceCommandLogResponse(data []models.DeviceCommandLog, total int64
} }
// NewListPlanExecutionLogResponse 从模型数据创建列表响应 DTO // NewListPlanExecutionLogResponse 从模型数据创建列表响应 DTO
func NewListPlanExecutionLogResponse(data []models.PlanExecutionLog, total int64, page, pageSize int) *ListPlanExecutionLogResponse { func NewListPlanExecutionLogResponse(planLogs []models.PlanExecutionLog, plans []models.Plan, total int64, page, pageSize int) *ListPlanExecutionLogResponse {
dtos := make([]PlanExecutionLogDTO, len(data)) planId2Name := make(map[uint]string)
for i, item := range data { for _, plan := range plans {
planId2Name[plan.ID] = plan.Name
}
dtos := make([]PlanExecutionLogDTO, len(planLogs))
for i, item := range planLogs {
dtos[i] = PlanExecutionLogDTO{ dtos[i] = PlanExecutionLogDTO{
ID: item.ID, ID: item.ID,
CreatedAt: item.CreatedAt, CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt, UpdatedAt: item.UpdatedAt,
PlanID: item.PlanID, PlanID: item.PlanID,
PlanName: planId2Name[item.PlanID],
Status: item.Status, Status: item.Status,
StartedAt: item.StartedAt, StartedAt: item.StartedAt,
EndedAt: item.EndedAt, EndedAt: item.EndedAt,

View File

@@ -24,8 +24,8 @@ type ListSensorDataRequest struct {
PageSize int `form:"pageSize,default=10"` PageSize int `form:"pageSize,default=10"`
DeviceID *uint `form:"device_id"` DeviceID *uint `form:"device_id"`
SensorType *string `form:"sensor_type"` SensorType *string `form:"sensor_type"`
StartTime *time.Time `form:"start_time" time_format:"rfc3339"` StartTime *time.Time `form:"start_time"`
EndTime *time.Time `form:"end_time" time_format:"rfc3339"` EndTime *time.Time `form:"end_time"`
OrderBy string `form:"order_by"` OrderBy string `form:"order_by"`
} }
@@ -52,8 +52,8 @@ type ListDeviceCommandLogRequest struct {
PageSize int `form:"pageSize,default=10"` PageSize int `form:"pageSize,default=10"`
DeviceID *uint `form:"device_id"` DeviceID *uint `form:"device_id"`
ReceivedSuccess *bool `form:"received_success"` ReceivedSuccess *bool `form:"received_success"`
StartTime *time.Time `form:"start_time" time_format:"rfc3339"` StartTime *time.Time `form:"start_time"`
EndTime *time.Time `form:"end_time" time_format:"rfc3339"` EndTime *time.Time `form:"end_time"`
OrderBy string `form:"order_by"` OrderBy string `form:"order_by"`
} }
@@ -80,8 +80,8 @@ type ListPlanExecutionLogRequest struct {
PageSize int `form:"pageSize,default=10"` PageSize int `form:"pageSize,default=10"`
PlanID *uint `form:"plan_id"` PlanID *uint `form:"plan_id"`
Status *string `form:"status"` Status *string `form:"status"`
StartTime *time.Time `form:"start_time" time_format:"rfc3339"` StartTime *time.Time `form:"start_time"`
EndTime *time.Time `form:"end_time" time_format:"rfc3339"` EndTime *time.Time `form:"end_time"`
OrderBy string `form:"order_by"` OrderBy string `form:"order_by"`
} }
@@ -91,6 +91,7 @@ type PlanExecutionLogDTO struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
PlanID uint `json:"plan_id"` PlanID uint `json:"plan_id"`
PlanName string `json:"plan_name"`
Status models.ExecutionStatus `json:"status"` Status models.ExecutionStatus `json:"status"`
StartedAt time.Time `json:"started_at"` StartedAt time.Time `json:"started_at"`
EndedAt time.Time `json:"ended_at"` EndedAt time.Time `json:"ended_at"`
@@ -112,8 +113,8 @@ type ListTaskExecutionLogRequest struct {
PlanExecutionLogID *uint `form:"plan_execution_log_id"` PlanExecutionLogID *uint `form:"plan_execution_log_id"`
TaskID *int `form:"task_id"` TaskID *int `form:"task_id"`
Status *string `form:"status"` Status *string `form:"status"`
StartTime *time.Time `form:"start_time" time_format:"rfc3339"` StartTime *time.Time `form:"start_time"`
EndTime *time.Time `form:"end_time" time_format:"rfc3339"` EndTime *time.Time `form:"end_time"`
OrderBy string `form:"order_by"` OrderBy string `form:"order_by"`
} }
@@ -152,8 +153,8 @@ type ListPendingCollectionRequest struct {
PageSize int `form:"pageSize,default=10"` PageSize int `form:"pageSize,default=10"`
DeviceID *uint `form:"device_id"` DeviceID *uint `form:"device_id"`
Status *string `form:"status"` Status *string `form:"status"`
StartTime *time.Time `form:"start_time" time_format:"rfc3339"` StartTime *time.Time `form:"start_time"`
EndTime *time.Time `form:"end_time" time_format:"rfc3339"` EndTime *time.Time `form:"end_time"`
OrderBy string `form:"order_by"` OrderBy string `form:"order_by"`
} }
@@ -183,8 +184,8 @@ type ListUserActionLogRequest struct {
Username *string `form:"username"` Username *string `form:"username"`
ActionType *string `form:"action_type"` ActionType *string `form:"action_type"`
Status *string `form:"status"` Status *string `form:"status"`
StartTime *time.Time `form:"start_time" time_format:"rfc3339"` StartTime *time.Time `form:"start_time"`
EndTime *time.Time `form:"end_time" time_format:"rfc3339"` EndTime *time.Time `form:"end_time"`
OrderBy string `form:"order_by"` OrderBy string `form:"order_by"`
} }
@@ -218,8 +219,8 @@ type ListRawMaterialPurchaseRequest struct {
PageSize int `form:"pageSize,default=10"` PageSize int `form:"pageSize,default=10"`
RawMaterialID *uint `form:"raw_material_id"` RawMaterialID *uint `form:"raw_material_id"`
Supplier *string `form:"supplier"` Supplier *string `form:"supplier"`
StartTime *time.Time `form:"start_time" time_format:"rfc3339"` StartTime *time.Time `form:"start_time"`
EndTime *time.Time `form:"end_time" time_format:"rfc3339"` EndTime *time.Time `form:"end_time"`
OrderBy string `form:"order_by"` OrderBy string `form:"order_by"`
} }
@@ -257,8 +258,8 @@ type ListRawMaterialStockLogRequest struct {
RawMaterialID *uint `form:"raw_material_id"` RawMaterialID *uint `form:"raw_material_id"`
SourceType *string `form:"source_type"` SourceType *string `form:"source_type"`
SourceID *uint `form:"source_id"` SourceID *uint `form:"source_id"`
StartTime *time.Time `form:"start_time" time_format:"rfc3339"` StartTime *time.Time `form:"start_time"`
EndTime *time.Time `form:"end_time" time_format:"rfc3339"` EndTime *time.Time `form:"end_time"`
OrderBy string `form:"order_by"` OrderBy string `form:"order_by"`
} }
@@ -288,8 +289,8 @@ type ListFeedUsageRecordRequest struct {
PenID *uint `form:"pen_id"` PenID *uint `form:"pen_id"`
FeedFormulaID *uint `form:"feed_formula_id"` FeedFormulaID *uint `form:"feed_formula_id"`
OperatorID *uint `form:"operator_id"` OperatorID *uint `form:"operator_id"`
StartTime *time.Time `form:"start_time" time_format:"rfc3339"` StartTime *time.Time `form:"start_time"`
EndTime *time.Time `form:"end_time" time_format:"rfc3339"` EndTime *time.Time `form:"end_time"`
OrderBy string `form:"order_by"` OrderBy string `form:"order_by"`
} }
@@ -334,8 +335,8 @@ type ListMedicationLogRequest struct {
MedicationID *uint `form:"medication_id"` MedicationID *uint `form:"medication_id"`
Reason *string `form:"reason"` Reason *string `form:"reason"`
OperatorID *uint `form:"operator_id"` OperatorID *uint `form:"operator_id"`
StartTime *time.Time `form:"start_time" time_format:"rfc3339"` StartTime *time.Time `form:"start_time"`
EndTime *time.Time `form:"end_time" time_format:"rfc3339"` EndTime *time.Time `form:"end_time"`
OrderBy string `form:"order_by"` OrderBy string `form:"order_by"`
} }
@@ -374,8 +375,8 @@ type ListPigBatchLogRequest struct {
PigBatchID *uint `form:"pig_batch_id"` PigBatchID *uint `form:"pig_batch_id"`
ChangeType *string `form:"change_type"` ChangeType *string `form:"change_type"`
OperatorID *uint `form:"operator_id"` OperatorID *uint `form:"operator_id"`
StartTime *time.Time `form:"start_time" time_format:"rfc3339"` StartTime *time.Time `form:"start_time"`
EndTime *time.Time `form:"end_time" time_format:"rfc3339"` EndTime *time.Time `form:"end_time"`
OrderBy string `form:"order_by"` OrderBy string `form:"order_by"`
} }
@@ -407,8 +408,8 @@ type ListWeighingBatchRequest struct {
Page int `form:"page,default=1"` Page int `form:"page,default=1"`
PageSize int `form:"pageSize,default=10"` PageSize int `form:"pageSize,default=10"`
PigBatchID *uint `form:"pig_batch_id"` PigBatchID *uint `form:"pig_batch_id"`
StartTime *time.Time `form:"start_time" time_format:"rfc3339"` StartTime *time.Time `form:"start_time"`
EndTime *time.Time `form:"end_time" time_format:"rfc3339"` EndTime *time.Time `form:"end_time"`
OrderBy string `form:"order_by"` OrderBy string `form:"order_by"`
} }
@@ -437,8 +438,8 @@ type ListWeighingRecordRequest struct {
WeighingBatchID *uint `form:"weighing_batch_id"` WeighingBatchID *uint `form:"weighing_batch_id"`
PenID *uint `form:"pen_id"` PenID *uint `form:"pen_id"`
OperatorID *uint `form:"operator_id"` OperatorID *uint `form:"operator_id"`
StartTime *time.Time `form:"start_time" time_format:"rfc3339"` StartTime *time.Time `form:"start_time"`
EndTime *time.Time `form:"end_time" time_format:"rfc3339"` EndTime *time.Time `form:"end_time"`
OrderBy string `form:"order_by"` OrderBy string `form:"order_by"`
} }
@@ -472,8 +473,8 @@ type ListPigTransferLogRequest struct {
TransferType *string `form:"transfer_type"` TransferType *string `form:"transfer_type"`
OperatorID *uint `form:"operator_id"` OperatorID *uint `form:"operator_id"`
CorrelationID *string `form:"correlation_id"` CorrelationID *string `form:"correlation_id"`
StartTime *time.Time `form:"start_time" time_format:"rfc3339"` StartTime *time.Time `form:"start_time"`
EndTime *time.Time `form:"end_time" time_format:"rfc3339"` EndTime *time.Time `form:"end_time"`
OrderBy string `form:"order_by"` OrderBy string `form:"order_by"`
} }
@@ -509,8 +510,8 @@ type ListPigSickLogRequest struct {
Reason *string `form:"reason"` Reason *string `form:"reason"`
TreatmentLocation *string `form:"treatment_location"` TreatmentLocation *string `form:"treatment_location"`
OperatorID *uint `form:"operator_id"` OperatorID *uint `form:"operator_id"`
StartTime *time.Time `form:"start_time" time_format:"rfc3339"` StartTime *time.Time `form:"start_time"`
EndTime *time.Time `form:"end_time" time_format:"rfc3339"` EndTime *time.Time `form:"end_time"`
OrderBy string `form:"order_by"` OrderBy string `form:"order_by"`
} }
@@ -546,8 +547,8 @@ type ListPigPurchaseRequest struct {
PigBatchID *uint `form:"pig_batch_id"` PigBatchID *uint `form:"pig_batch_id"`
Supplier *string `form:"supplier"` Supplier *string `form:"supplier"`
OperatorID *uint `form:"operator_id"` OperatorID *uint `form:"operator_id"`
StartTime *time.Time `form:"start_time" time_format:"rfc3339"` StartTime *time.Time `form:"start_time"`
EndTime *time.Time `form:"end_time" time_format:"rfc3339"` EndTime *time.Time `form:"end_time"`
OrderBy string `form:"order_by"` OrderBy string `form:"order_by"`
} }
@@ -581,8 +582,8 @@ type ListPigSaleRequest struct {
PigBatchID *uint `form:"pig_batch_id"` PigBatchID *uint `form:"pig_batch_id"`
Buyer *string `form:"buyer"` Buyer *string `form:"buyer"`
OperatorID *uint `form:"operator_id"` OperatorID *uint `form:"operator_id"`
StartTime *time.Time `form:"start_time" time_format:"rfc3339"` StartTime *time.Time `form:"start_time"`
EndTime *time.Time `form:"end_time" time_format:"rfc3339"` EndTime *time.Time `form:"end_time"`
OrderBy string `form:"order_by"` OrderBy string `form:"order_by"`
} }

View File

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

View File

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

View File

@@ -32,16 +32,18 @@ type PigBatchQueryDTO struct {
// PigBatchResponseDTO 定义了猪批次信息的响应结构 // PigBatchResponseDTO 定义了猪批次信息的响应结构
type PigBatchResponseDTO struct { type PigBatchResponseDTO struct {
ID uint `json:"id"` // 批次ID ID uint `json:"id"` // 批次ID
BatchNumber string `json:"batch_number"` // 批次编号 BatchNumber string `json:"batch_number"` // 批次编号
OriginType models.PigBatchOriginType `json:"origin_type"` // 批次来源 OriginType models.PigBatchOriginType `json:"origin_type"` // 批次来源
StartDate time.Time `json:"start_date"` // 批次开始日期 StartDate time.Time `json:"start_date"` // 批次开始日期
EndDate time.Time `json:"end_date"` // 批次结束日期 EndDate time.Time `json:"end_date"` // 批次结束日期
InitialCount int `json:"initial_count"` // 初始数量 InitialCount int `json:"initial_count"` // 初始数量
Status models.PigBatchStatus `json:"status"` // 批次状态 Status models.PigBatchStatus `json:"status"` // 批次状态
IsActive bool `json:"is_active"` // 是否活跃 IsActive bool `json:"is_active"` // 是否活跃
CreateTime time.Time `json:"create_time"` // 创建时间 CurrentTotalQuantity int `json:"currentTotalQuantity"` // 当前总数
UpdateTime time.Time `json:"update_time"` // 更新时间 CurrentTotalPigsInPens int `json:"currentTotalPigsInPens"` // 当前存栏总数
CreateTime time.Time `json:"create_time"` // 创建时间
UpdateTime time.Time `json:"update_time"` // 更新时间
} }
// AssignEmptyPensToBatchRequest 用于为猪批次分配空栏的请求体 // AssignEmptyPensToBatchRequest 用于为猪批次分配空栏的请求体

View File

@@ -11,12 +11,13 @@ type PigHouseResponse struct {
// PenResponse 定义了猪栏信息的响应结构 // PenResponse 定义了猪栏信息的响应结构
type PenResponse struct { type PenResponse struct {
ID uint `json:"id"` ID uint `json:"id"`
PenNumber string `json:"pen_number"` PenNumber string `json:"pen_number"`
HouseID uint `json:"house_id"` HouseID uint `json:"house_id"`
Capacity int `json:"capacity"` Capacity int `json:"capacity"`
Status models.PenStatus `json:"status"` Status models.PenStatus `json:"status"`
PigBatchID uint `json:"pig_batch_id"` PigBatchID *uint `json:"pig_batch_id,omitempty"`
CurrentPigCount int `json:"current_pig_count"`
} }
// CreatePigHouseRequest 定义了创建猪舍的请求结构 // CreatePigHouseRequest 定义了创建猪舍的请求结构

View File

@@ -17,6 +17,7 @@ func NewPlanToResponse(plan *models.Plan) (*PlanResponse, error) {
ID: plan.ID, ID: plan.ID,
Name: plan.Name, Name: plan.Name,
Description: plan.Description, Description: plan.Description,
PlanType: plan.PlanType,
ExecutionType: plan.ExecutionType, ExecutionType: plan.ExecutionType,
Status: plan.Status, Status: plan.Status,
ExecuteNum: plan.ExecuteNum, ExecuteNum: plan.ExecuteNum,
@@ -64,7 +65,7 @@ func NewPlanFromCreateRequest(req *CreatePlanRequest) (*models.Plan, error) {
ExecutionType: req.ExecutionType, ExecutionType: req.ExecutionType,
ExecuteNum: req.ExecuteNum, ExecuteNum: req.ExecuteNum,
CronExpression: req.CronExpression, CronExpression: req.CronExpression,
// ContentType 在控制器中设置,此处不再处理 // ContentType 和 PlanType 在控制器中设置,此处不再处理
} }
// 处理子计划 (通过ID引用) // 处理子计划 (通过ID引用)
@@ -116,7 +117,7 @@ func NewPlanFromUpdateRequest(req *UpdatePlanRequest) (*models.Plan, error) {
ExecutionType: req.ExecutionType, ExecutionType: req.ExecutionType,
ExecuteNum: req.ExecuteNum, ExecuteNum: req.ExecuteNum,
CronExpression: req.CronExpression, CronExpression: req.CronExpression,
// ContentType 在控制器中设置,此处不再处理 // ContentType 和 PlanType 在控制器中设置,此处不再处理
} }
// 处理子计划 (通过ID引用) // 处理子计划 (通过ID引用)

View File

@@ -1,6 +1,16 @@
package dto package dto
import "git.huangwc.com/pig/pig-farm-controller/internal/infra/models" import (
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
)
// ListPlansQuery 定义了获取计划列表时的查询参数
type ListPlansQuery struct {
PlanType repository.PlanTypeFilter `form:"planType,default=自定义任务"` // 计划类型
Page int `form:"page,default=1"` // 页码
PageSize int `form:"pageSize,default=10"` // 每页大小
}
// CreatePlanRequest 定义创建计划请求的结构体 // CreatePlanRequest 定义创建计划请求的结构体
type CreatePlanRequest struct { type CreatePlanRequest struct {
@@ -18,6 +28,7 @@ type PlanResponse struct {
ID uint `json:"id" example:"1"` ID uint `json:"id" example:"1"`
Name string `json:"name" example:"猪舍温度控制计划"` Name string `json:"name" example:"猪舍温度控制计划"`
Description string `json:"description" example:"根据温度自动调节风扇和加热器"` Description string `json:"description" example:"根据温度自动调节风扇和加热器"`
PlanType models.PlanType `json:"plan_type" example:"自定义任务"`
ExecutionType models.PlanExecutionType `json:"execution_type" example:"自动"` ExecutionType models.PlanExecutionType `json:"execution_type" example:"自动"`
Status models.PlanStatus `json:"status" example:"已启用"` Status models.PlanStatus `json:"status" example:"已启用"`
ExecuteNum uint `json:"execute_num" example:"10"` ExecuteNum uint `json:"execute_num" example:"10"`
@@ -31,7 +42,7 @@ type PlanResponse struct {
// ListPlansResponse 定义获取计划列表响应的结构体 // ListPlansResponse 定义获取计划列表响应的结构体
type ListPlansResponse struct { type ListPlansResponse struct {
Plans []PlanResponse `json:"plans"` Plans []PlanResponse `json:"plans"`
Total int `json:"total" example:"100"` Total int64 `json:"total" example:"100"`
} }
// UpdatePlanRequest 定义更新计划请求的结构体 // UpdatePlanRequest 定义更新计划请求的结构体

View File

@@ -14,7 +14,7 @@ import (
// AuthMiddleware 创建一个Gin中间件用于JWT身份验证 // AuthMiddleware 创建一个Gin中间件用于JWT身份验证
// 它依赖于 TokenService 来解析和验证 token并使用 UserRepository 来获取完整的用户信息 // 它依赖于 TokenService 来解析和验证 token并使用 UserRepository 来获取完整的用户信息
func AuthMiddleware(tokenService token.TokenService, userRepo repository.UserRepository) gin.HandlerFunc { func AuthMiddleware(tokenService token.Service, userRepo repository.UserRepository) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
// 从 Authorization header 获取 token // 从 Authorization header 获取 token
authHeader := c.GetHeader("Authorization") authHeader := c.GetHeader("Authorization")

View File

@@ -9,7 +9,7 @@ import (
type MonitorService interface { type MonitorService interface {
ListSensorData(opts repository.SensorDataListOptions, page, pageSize int) ([]models.SensorData, int64, error) ListSensorData(opts repository.SensorDataListOptions, page, pageSize int) ([]models.SensorData, int64, error)
ListDeviceCommandLogs(opts repository.DeviceCommandLogListOptions, page, pageSize int) ([]models.DeviceCommandLog, int64, error) ListDeviceCommandLogs(opts repository.DeviceCommandLogListOptions, page, pageSize int) ([]models.DeviceCommandLog, int64, error)
ListPlanExecutionLogs(opts repository.PlanExecutionLogListOptions, page, pageSize int) ([]models.PlanExecutionLog, int64, error) ListPlanExecutionLogs(opts repository.PlanExecutionLogListOptions, page, pageSize int) ([]models.PlanExecutionLog, []models.Plan, int64, error)
ListTaskExecutionLogs(opts repository.TaskExecutionLogListOptions, page, pageSize int) ([]models.TaskExecutionLog, int64, error) ListTaskExecutionLogs(opts repository.TaskExecutionLogListOptions, page, pageSize int) ([]models.TaskExecutionLog, int64, error)
ListPendingCollections(opts repository.PendingCollectionListOptions, page, pageSize int) ([]models.PendingCollection, int64, error) ListPendingCollections(opts repository.PendingCollectionListOptions, page, pageSize int) ([]models.PendingCollection, int64, error)
ListUserActionLogs(opts repository.UserActionLogListOptions, page, pageSize int) ([]models.UserActionLog, int64, error) ListUserActionLogs(opts repository.UserActionLogListOptions, page, pageSize int) ([]models.UserActionLog, int64, error)
@@ -24,6 +24,7 @@ type MonitorService interface {
ListPigSickLogs(opts repository.PigSickLogListOptions, page, pageSize int) ([]models.PigSickLog, int64, error) ListPigSickLogs(opts repository.PigSickLogListOptions, page, pageSize int) ([]models.PigSickLog, int64, error)
ListPigPurchases(opts repository.PigPurchaseListOptions, page, pageSize int) ([]models.PigPurchase, int64, error) ListPigPurchases(opts repository.PigPurchaseListOptions, page, pageSize int) ([]models.PigPurchase, int64, error)
ListPigSales(opts repository.PigSaleListOptions, page, pageSize int) ([]models.PigSale, int64, error) ListPigSales(opts repository.PigSaleListOptions, page, pageSize int) ([]models.PigSale, int64, error)
ListNotifications(opts repository.NotificationListOptions, page, pageSize int) ([]models.Notification, int64, error)
} }
// monitorService 是 MonitorService 接口的具体实现 // monitorService 是 MonitorService 接口的具体实现
@@ -31,6 +32,7 @@ type monitorService struct {
sensorDataRepo repository.SensorDataRepository sensorDataRepo repository.SensorDataRepository
deviceCommandLogRepo repository.DeviceCommandLogRepository deviceCommandLogRepo repository.DeviceCommandLogRepository
executionLogRepo repository.ExecutionLogRepository executionLogRepo repository.ExecutionLogRepository
planRepository repository.PlanRepository
pendingCollectionRepo repository.PendingCollectionRepository pendingCollectionRepo repository.PendingCollectionRepository
userActionLogRepo repository.UserActionLogRepository userActionLogRepo repository.UserActionLogRepository
rawMaterialRepo repository.RawMaterialRepository rawMaterialRepo repository.RawMaterialRepository
@@ -40,6 +42,7 @@ type monitorService struct {
pigTransferLogRepo repository.PigTransferLogRepository pigTransferLogRepo repository.PigTransferLogRepository
pigSickLogRepo repository.PigSickLogRepository pigSickLogRepo repository.PigSickLogRepository
pigTradeRepo repository.PigTradeRepository pigTradeRepo repository.PigTradeRepository
notificationRepo repository.NotificationRepository
} }
// NewMonitorService 创建一个新的 MonitorService 实例 // NewMonitorService 创建一个新的 MonitorService 实例
@@ -47,6 +50,7 @@ func NewMonitorService(
sensorDataRepo repository.SensorDataRepository, sensorDataRepo repository.SensorDataRepository,
deviceCommandLogRepo repository.DeviceCommandLogRepository, deviceCommandLogRepo repository.DeviceCommandLogRepository,
executionLogRepo repository.ExecutionLogRepository, executionLogRepo repository.ExecutionLogRepository,
planRepository repository.PlanRepository,
pendingCollectionRepo repository.PendingCollectionRepository, pendingCollectionRepo repository.PendingCollectionRepository,
userActionLogRepo repository.UserActionLogRepository, userActionLogRepo repository.UserActionLogRepository,
rawMaterialRepo repository.RawMaterialRepository, rawMaterialRepo repository.RawMaterialRepository,
@@ -56,11 +60,13 @@ func NewMonitorService(
pigTransferLogRepo repository.PigTransferLogRepository, pigTransferLogRepo repository.PigTransferLogRepository,
pigSickLogRepo repository.PigSickLogRepository, pigSickLogRepo repository.PigSickLogRepository,
pigTradeRepo repository.PigTradeRepository, pigTradeRepo repository.PigTradeRepository,
notificationRepo repository.NotificationRepository,
) MonitorService { ) MonitorService {
return &monitorService{ return &monitorService{
sensorDataRepo: sensorDataRepo, sensorDataRepo: sensorDataRepo,
deviceCommandLogRepo: deviceCommandLogRepo, deviceCommandLogRepo: deviceCommandLogRepo,
executionLogRepo: executionLogRepo, executionLogRepo: executionLogRepo,
planRepository: planRepository,
pendingCollectionRepo: pendingCollectionRepo, pendingCollectionRepo: pendingCollectionRepo,
userActionLogRepo: userActionLogRepo, userActionLogRepo: userActionLogRepo,
rawMaterialRepo: rawMaterialRepo, rawMaterialRepo: rawMaterialRepo,
@@ -70,6 +76,7 @@ func NewMonitorService(
pigTransferLogRepo: pigTransferLogRepo, pigTransferLogRepo: pigTransferLogRepo,
pigSickLogRepo: pigSickLogRepo, pigSickLogRepo: pigSickLogRepo,
pigTradeRepo: pigTradeRepo, pigTradeRepo: pigTradeRepo,
notificationRepo: notificationRepo,
} }
} }
@@ -84,8 +91,29 @@ func (s *monitorService) ListDeviceCommandLogs(opts repository.DeviceCommandLogL
} }
// ListPlanExecutionLogs 负责处理查询计划执行日志列表的业务逻辑 // ListPlanExecutionLogs 负责处理查询计划执行日志列表的业务逻辑
func (s *monitorService) ListPlanExecutionLogs(opts repository.PlanExecutionLogListOptions, page, pageSize int) ([]models.PlanExecutionLog, int64, error) { func (s *monitorService) ListPlanExecutionLogs(opts repository.PlanExecutionLogListOptions, page, pageSize int) ([]models.PlanExecutionLog, []models.Plan, int64, error) {
return s.executionLogRepo.ListPlanExecutionLogs(opts, page, pageSize) planLogs, total, err := s.executionLogRepo.ListPlanExecutionLogs(opts, page, pageSize)
if err != nil {
return nil, nil, 0, err
}
planIds := []uint{}
for _, datum := range planLogs {
has := false
for _, id := range planIds {
if id == datum.PlanID {
has = true
break
}
}
if !has {
planIds = append(planIds, datum.PlanID)
}
}
plans, err := s.planRepository.GetPlansByIDs(planIds)
if err != nil {
return nil, nil, 0, err
}
return planLogs, plans, total, nil
} }
// ListTaskExecutionLogs 负责处理查询任务执行日志列表的业务逻辑 // ListTaskExecutionLogs 负责处理查询任务执行日志列表的业务逻辑
@@ -157,3 +185,8 @@ func (s *monitorService) ListPigPurchases(opts repository.PigPurchaseListOptions
func (s *monitorService) ListPigSales(opts repository.PigSaleListOptions, page, pageSize int) ([]models.PigSale, int64, error) { func (s *monitorService) ListPigSales(opts repository.PigSaleListOptions, page, pageSize int) ([]models.PigSale, int64, error) {
return s.pigTradeRepo.ListPigSales(opts, page, pageSize) return s.pigTradeRepo.ListPigSales(opts, page, pageSize)
} }
// ListNotifications 负责处理查询通知列表的业务逻辑
func (s *monitorService) ListNotifications(opts repository.NotificationListOptions, page, pageSize int) ([]models.Notification, int64, error) {
return s.notificationRepo.List(opts, page, pageSize)
}

View File

@@ -57,21 +57,23 @@ func NewPigBatchService(domainService domain_pig.PigBatchService, logger *logs.L
} }
// toPigBatchResponseDTO 负责将领域模型转换为应用层DTO这个职责保留在应用层。 // toPigBatchResponseDTO 负责将领域模型转换为应用层DTO这个职责保留在应用层。
func (s *pigBatchService) toPigBatchResponseDTO(batch *models.PigBatch) *dto.PigBatchResponseDTO { func (s *pigBatchService) toPigBatchResponseDTO(batch *models.PigBatch, currentTotalQuantity, currentTotalPigsInPens int) *dto.PigBatchResponseDTO {
if batch == nil { if batch == nil {
return nil return nil
} }
return &dto.PigBatchResponseDTO{ return &dto.PigBatchResponseDTO{
ID: batch.ID, ID: batch.ID,
BatchNumber: batch.BatchNumber, BatchNumber: batch.BatchNumber,
OriginType: batch.OriginType, OriginType: batch.OriginType,
StartDate: batch.StartDate, StartDate: batch.StartDate,
EndDate: batch.EndDate, EndDate: batch.EndDate,
InitialCount: batch.InitialCount, InitialCount: batch.InitialCount,
Status: batch.Status, Status: batch.Status,
IsActive: batch.IsActive(), IsActive: batch.IsActive(),
CreateTime: batch.CreatedAt, CurrentTotalQuantity: currentTotalQuantity,
UpdateTime: batch.UpdatedAt, CurrentTotalPigsInPens: currentTotalPigsInPens,
CreateTime: batch.CreatedAt,
UpdateTime: batch.UpdatedAt,
} }
} }
@@ -94,7 +96,7 @@ func (s *pigBatchService) CreatePigBatch(operatorID uint, dto *dto.PigBatchCreat
} }
// 3. 领域模型 -> DTO // 3. 领域模型 -> DTO
return s.toPigBatchResponseDTO(createdBatch), nil return s.toPigBatchResponseDTO(createdBatch, dto.InitialCount, 0), nil
} }
// GetPigBatch 从领域服务获取数据并转换为DTO同时处理错误转换。 // GetPigBatch 从领域服务获取数据并转换为DTO同时处理错误转换。
@@ -104,8 +106,17 @@ func (s *pigBatchService) GetPigBatch(id uint) (*dto.PigBatchResponseDTO, error)
s.logger.Warnf("应用层: 获取猪批次失败, ID: %d, 错误: %v", id, err) s.logger.Warnf("应用层: 获取猪批次失败, ID: %d, 错误: %v", id, err)
return nil, MapDomainError(err) return nil, MapDomainError(err)
} }
currentTotalQuantity, err := s.domainService.GetCurrentPigQuantity(id)
return s.toPigBatchResponseDTO(batch), nil if err != nil {
s.logger.Warnf("应用层: 获取猪批次总数失败, ID: %d, 错误: %v", id, err)
return nil, MapDomainError(err)
}
currentTotalPigsInPens, err := s.domainService.GetTotalPigsInPensForBatch(id)
if err != nil {
s.logger.Warnf("应用层: 获取猪批次存栏总数失败, ID: %d, 错误: %v", id, err)
return nil, MapDomainError(err)
}
return s.toPigBatchResponseDTO(batch, currentTotalQuantity, currentTotalPigsInPens), nil
} }
// UpdatePigBatch 协调获取、更新和保存的流程,并处理错误转换。 // UpdatePigBatch 协调获取、更新和保存的流程,并处理错误转换。
@@ -144,8 +155,20 @@ func (s *pigBatchService) UpdatePigBatch(id uint, dto *dto.PigBatchUpdateDTO) (*
return nil, MapDomainError(err) return nil, MapDomainError(err)
} }
// 4. 转换并返回结果 // 4. 填充猪群信息
return s.toPigBatchResponseDTO(updatedBatch), nil currentTotalQuantity, err := s.domainService.GetCurrentPigQuantity(id)
if err != nil {
s.logger.Warnf("应用层: 获取猪批次总数失败, ID: %d, 错误: %v", id, err)
return nil, MapDomainError(err)
}
currentTotalPigsInPens, err := s.domainService.GetTotalPigsInPensForBatch(id)
if err != nil {
s.logger.Warnf("应用层: 获取猪批次存栏总数失败, ID: %d, 错误: %v", id, err)
return nil, MapDomainError(err)
}
// 5. 转换并返回结果
return s.toPigBatchResponseDTO(updatedBatch, currentTotalQuantity, currentTotalPigsInPens), nil
} }
// DeletePigBatch 将删除操作委托给领域服务,并转换领域错误为应用层错误。 // DeletePigBatch 将删除操作委托给领域服务,并转换领域错误为应用层错误。
@@ -168,7 +191,17 @@ func (s *pigBatchService) ListPigBatches(isActive *bool) ([]*dto.PigBatchRespons
var responseDTOs []*dto.PigBatchResponseDTO var responseDTOs []*dto.PigBatchResponseDTO
for _, batch := range batches { for _, batch := range batches {
responseDTOs = append(responseDTOs, s.toPigBatchResponseDTO(batch)) currentTotalQuantity, err := s.domainService.GetCurrentPigQuantity(batch.ID)
if err != nil {
s.logger.Warnf("应用层: 获取猪批次总数失败, ID: %d, 错误: %v", batch.ID, err)
return nil, MapDomainError(err)
}
currentTotalPigsInPens, err := s.domainService.GetTotalPigsInPensForBatch(batch.ID)
if err != nil {
s.logger.Warnf("应用层: 获取猪批次存栏总数失败, ID: %d, 错误: %v", batch.ID, err)
return nil, MapDomainError(err)
}
responseDTOs = append(responseDTOs, s.toPigBatchResponseDTO(batch, currentTotalQuantity, currentTotalPigsInPens))
} }
return responseDTOs, nil return responseDTOs, nil

View File

@@ -4,6 +4,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"git.huangwc.com/pig/pig-farm-controller/internal/app/dto"
domain_pig "git.huangwc.com/pig/pig-farm-controller/internal/domain/pig"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" "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/repository"
@@ -22,8 +24,8 @@ type PigFarmService interface {
// Pen methods // Pen methods
CreatePen(penNumber string, houseID uint, capacity int) (*models.Pen, error) CreatePen(penNumber string, houseID uint, capacity int) (*models.Pen, error)
GetPenByID(id uint) (*models.Pen, error) GetPenByID(id uint) (*dto.PenResponse, error)
ListPens() ([]models.Pen, error) ListPens() ([]*dto.PenResponse, error)
UpdatePen(id uint, penNumber string, houseID uint, capacity int, status models.PenStatus) (*models.Pen, error) UpdatePen(id uint, penNumber string, houseID uint, capacity int, status models.PenStatus) (*models.Pen, error)
DeletePen(id uint) error DeletePen(id uint) error
// UpdatePenStatus 更新猪栏状态 // UpdatePenStatus 更新猪栏状态
@@ -35,13 +37,15 @@ type pigFarmService struct {
farmRepository repository.PigFarmRepository farmRepository repository.PigFarmRepository
penRepository repository.PigPenRepository penRepository repository.PigPenRepository
batchRepository repository.PigBatchRepository batchRepository repository.PigBatchRepository
uow repository.UnitOfWork // 工作单元,用于事务管理 pigBatchService domain_pig.PigBatchService // Add domain PigBatchService dependency
uow repository.UnitOfWork // 工作单元,用于事务管理
} }
// NewPigFarmService 创建一个新的 PigFarmService 实例 // NewPigFarmService 创建一个新的 PigFarmService 实例
func NewPigFarmService(farmRepository repository.PigFarmRepository, func NewPigFarmService(farmRepository repository.PigFarmRepository,
penRepository repository.PigPenRepository, penRepository repository.PigPenRepository,
batchRepository repository.PigBatchRepository, batchRepository repository.PigBatchRepository,
pigBatchService domain_pig.PigBatchService,
uow repository.UnitOfWork, uow repository.UnitOfWork,
logger *logs.Logger) PigFarmService { logger *logs.Logger) PigFarmService {
return &pigFarmService{ return &pigFarmService{
@@ -49,6 +53,7 @@ func NewPigFarmService(farmRepository repository.PigFarmRepository,
farmRepository: farmRepository, farmRepository: farmRepository,
penRepository: penRepository, penRepository: penRepository,
batchRepository: batchRepository, batchRepository: batchRepository,
pigBatchService: pigBatchService,
uow: uow, uow: uow,
} }
} }
@@ -132,12 +137,64 @@ func (s *pigFarmService) CreatePen(penNumber string, houseID uint, capacity int)
return pen, err return pen, err
} }
func (s *pigFarmService) GetPenByID(id uint) (*models.Pen, error) { func (s *pigFarmService) GetPenByID(id uint) (*dto.PenResponse, error) {
return s.penRepository.GetPenByID(id) pen, err := s.penRepository.GetPenByID(id)
if err != nil {
return nil, err
}
currentPigCount, err := s.pigBatchService.GetCurrentPigsInPen(id)
if err != nil {
s.logger.Errorf("获取猪栏 %d 存栏量失败: %v", id, err)
currentPigCount = 0 // 如果获取计数时出错则默认为0
}
response := &dto.PenResponse{
ID: pen.ID,
PenNumber: pen.PenNumber,
HouseID: pen.HouseID,
Capacity: pen.Capacity,
Status: pen.Status,
CurrentPigCount: currentPigCount,
}
if pen.PigBatchID != nil {
response.PigBatchID = pen.PigBatchID
}
return response, nil
} }
func (s *pigFarmService) ListPens() ([]models.Pen, error) { func (s *pigFarmService) ListPens() ([]*dto.PenResponse, error) {
return s.penRepository.ListPens() pens, err := s.penRepository.ListPens()
if err != nil {
return nil, err
}
var response []*dto.PenResponse
for _, pen := range pens {
currentPigCount, err := s.pigBatchService.GetCurrentPigsInPen(pen.ID)
if err != nil {
s.logger.Errorf("获取猪栏 %d 存栏量失败: %v", pen.ID, err)
currentPigCount = 0 // 如果获取计数时出错则默认为0
}
penResponse := &dto.PenResponse{
ID: pen.ID,
PenNumber: pen.PenNumber,
HouseID: pen.HouseID,
Capacity: pen.Capacity,
Status: pen.Status,
CurrentPigCount: currentPigCount,
}
if pen.PigBatchID != nil {
penResponse.PigBatchID = pen.PigBatchID
}
response = append(response, penResponse)
}
return response, nil
} }
func (s *pigFarmService) UpdatePen(id uint, penNumber string, houseID uint, capacity int, status models.PenStatus) (*models.Pen, error) { func (s *pigFarmService) UpdatePen(id uint, penNumber string, houseID uint, capacity int, status models.PenStatus) (*models.Pen, error) {

View File

@@ -5,198 +5,69 @@ import (
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/app/api" "git.huangwc.com/pig/pig-farm-controller/internal/app/api"
"git.huangwc.com/pig/pig-farm-controller/internal/app/service"
"git.huangwc.com/pig/pig-farm-controller/internal/app/webhook"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/audit"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/device"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/pig"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/task"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/token"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/config" "git.huangwc.com/pig/pig-farm-controller/internal/infra/config"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/database"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport/lora"
) )
// Application 是整个应用的核心,封装了所有组件和生命周期。 // Application 是整个应用的核心,封装了所有组件和生命周期。
type Application struct { type Application struct {
Config *config.Config Config *config.Config
Logger *logs.Logger Logger *logs.Logger
Storage database.Storage API *api.API
Executor *task.Scheduler
API *api.API // 添加 API 对象
// 新增的仓库和管理器字段,以便在 initializePendingTasks 中访问 Infra *Infrastructure
planRepo repository.PlanRepository Domain *DomainServices
pendingTaskRepo repository.PendingTaskRepository App *AppServices
executionLogRepo repository.ExecutionLogRepository
pendingCollectionRepo repository.PendingCollectionRepository
analysisPlanTaskManager *task.AnalysisPlanTaskManager
// Lora Mesh 监听器
loraMeshCommunicator transport.Listener
} }
// NewApplication 创建并初始化一个新的 Application 实例。 // NewApplication 创建并初始化一个新的 Application 实例。
// 这是应用的“组合根”,所有依赖都在这里被创建和注入。 // 这是应用的“组合根”,所有依赖都在这里被创建和注入。
func NewApplication(configPath string) (*Application, error) { func NewApplication(configPath string) (*Application, error) {
// 加载配置 // 1. 初始化基本组件: 配置和日志
cfg := config.NewConfig() cfg := config.NewConfig()
if err := cfg.Load(configPath); err != nil { if err := cfg.Load(configPath); err != nil {
return nil, fmt.Errorf("无法加载配置: %w", err) return nil, fmt.Errorf("无法加载配置: %w", err)
} }
// 初始化日志记录器
logger := logs.NewLogger(cfg.Log) logger := logs.NewLogger(cfg.Log)
// 初始化数据库存储 // 2. 初始化所有分层服务
storage, err := initStorage(cfg.Database, logger) infra, err := initInfrastructure(cfg, logger)
if err != nil { if err != nil {
return nil, err // 错误已在 initStorage 中被包装 return nil, fmt.Errorf("初始化基础设施失败: %w", err)
} }
domain := initDomainServices(cfg, infra, logger)
appServices := initAppServices(infra, domain, logger)
// 初始化 Token 服务 // 3. 初始化 API 入口点
tokenService := token.NewTokenService([]byte(cfg.App.JWTSecret))
// --- 仓库对象初始化 ---
userRepo := repository.NewGormUserRepository(storage.GetDB())
deviceRepo := repository.NewGormDeviceRepository(storage.GetDB())
areaControllerRepo := repository.NewGormAreaControllerRepository(storage.GetDB())
deviceTemplateRepo := repository.NewGormDeviceTemplateRepository(storage.GetDB())
planRepo := repository.NewGormPlanRepository(storage.GetDB())
pendingTaskRepo := repository.NewGormPendingTaskRepository(storage.GetDB())
executionLogRepo := repository.NewGormExecutionLogRepository(storage.GetDB())
sensorDataRepo := repository.NewGormSensorDataRepository(storage.GetDB())
deviceCommandLogRepo := repository.NewGormDeviceCommandLogRepository(storage.GetDB())
pendingCollectionRepo := repository.NewGormPendingCollectionRepository(storage.GetDB())
userActionLogRepo := repository.NewGormUserActionLogRepository(storage.GetDB())
pigBatchRepo := repository.NewGormPigBatchRepository(storage.GetDB())
pigBatchLogRepo := repository.NewGormPigBatchLogRepository(storage.GetDB())
pigFarmRepo := repository.NewGormPigFarmRepository(storage.GetDB())
pigPenRepo := repository.NewGormPigPenRepository(storage.GetDB())
pigTransferLogRepo := repository.NewGormPigTransferLogRepository(storage.GetDB())
pigTradeRepo := repository.NewGormPigTradeRepository(storage.GetDB())
pigSickPigLogRepo := repository.NewGormPigSickLogRepository(storage.GetDB())
medicationLogRepo := repository.NewGormMedicationLogRepository(storage.GetDB())
rawMaterialRepo := repository.NewGormRawMaterialRepository(storage.GetDB())
// 初始化事务管理器
unitOfWork := repository.NewGormUnitOfWork(storage.GetDB(), logger)
// 初始化猪群管理领域
pigPenTransferManager := pig.NewPigPenTransferManager(pigPenRepo, pigTransferLogRepo, pigBatchRepo)
pigTradeManager := pig.NewPigTradeManager(pigTradeRepo)
pigSickManager := pig.NewSickPigManager(pigSickPigLogRepo, medicationLogRepo)
pigBatchDomain := pig.NewPigBatchService(pigBatchRepo, pigBatchLogRepo, unitOfWork,
pigPenTransferManager, pigTradeManager, pigSickManager)
// --- 业务逻辑处理器初始化 ---
pigFarmService := service.NewPigFarmService(pigFarmRepo, pigPenRepo, pigBatchRepo, unitOfWork, logger)
pigBatchService := service.NewPigBatchService(pigBatchDomain, logger)
monitorService := service.NewMonitorService(
sensorDataRepo,
deviceCommandLogRepo,
executionLogRepo,
pendingCollectionRepo,
userActionLogRepo,
rawMaterialRepo,
medicationLogRepo,
pigBatchRepo,
pigBatchLogRepo,
pigTransferLogRepo,
pigSickPigLogRepo,
pigTradeRepo,
)
// 初始化审计服务
auditService := audit.NewService(userActionLogRepo, logger)
// --- 初始化 LoRa 相关组件 ---
var listenHandler webhook.ListenHandler
var comm transport.Communicator
var loraListener transport.Listener
if cfg.Lora.Mode == config.LoraMode_LoRaWAN {
logger.Info("当前运行模式: lora_wan。初始化 ChirpStack 监听器和传输层。")
listenHandler = webhook.NewChirpStackListener(logger, sensorDataRepo, deviceRepo, areaControllerRepo, deviceCommandLogRepo, pendingCollectionRepo)
comm = lora.NewChirpStackTransport(cfg.ChirpStack, logger)
loraListener = lora.NewPlaceholderTransport(logger)
} else {
logger.Info("当前运行模式: lora_mesh。初始化 LoRa Mesh 传输层和占位符监听器。")
listenHandler = webhook.NewPlaceholderListener(logger)
tp, err := lora.NewLoRaMeshUartPassthroughTransport(cfg.LoraMesh, logger, areaControllerRepo, pendingCollectionRepo, deviceRepo, sensorDataRepo)
loraListener = tp
comm = tp
if err != nil {
return nil, fmt.Errorf("无法初始化 LoRa Mesh 模块: %w", err)
}
}
// 初始化计划触发器管理器
analysisPlanTaskManager := task.NewAnalysisPlanTaskManager(planRepo, pendingTaskRepo, executionLogRepo, logger)
// 初始化通用设备服务
generalDeviceService := device.NewGeneralDeviceService(
deviceRepo,
deviceCommandLogRepo,
pendingCollectionRepo,
logger,
comm,
)
// 初始化任务执行器
executor := task.NewScheduler(
pendingTaskRepo,
executionLogRepo,
deviceRepo,
sensorDataRepo,
planRepo,
analysisPlanTaskManager,
logger,
generalDeviceService,
time.Duration(cfg.Task.Interval)*time.Second,
cfg.Task.NumWorkers,
)
// 初始化 API 服务器
apiServer := api.NewAPI( apiServer := api.NewAPI(
cfg.Server, cfg.Server,
logger, logger,
userRepo, infra.Repos.UserRepo,
deviceRepo, infra.Repos.DeviceRepo,
areaControllerRepo, infra.Repos.AreaControllerRepo,
deviceTemplateRepo, infra.Repos.DeviceTemplateRepo,
planRepo, infra.Repos.PlanRepo,
pigFarmService, appServices.PigFarmService,
pigBatchService, appServices.PigBatchService,
monitorService, appServices.MonitorService,
userActionLogRepo, infra.TokenService,
tokenService, appServices.AuditService,
auditService, infra.NotifyService,
generalDeviceService, domain.GeneralDeviceService,
listenHandler, infra.Lora.ListenHandler,
analysisPlanTaskManager, domain.AnalysisPlanTaskManager,
) )
// 组装 Application 对象 // 4. 组装 Application 对象
app := &Application{ app := &Application{
Config: cfg, Config: cfg,
Logger: logger, Logger: logger,
Storage: storage, API: apiServer,
Executor: executor, Infra: infra,
API: apiServer, Domain: domain,
planRepo: planRepo, App: appServices,
pendingTaskRepo: pendingTaskRepo,
executionLogRepo: executionLogRepo,
pendingCollectionRepo: pendingCollectionRepo,
analysisPlanTaskManager: analysisPlanTaskManager,
loraMeshCommunicator: loraListener,
} }
return app, nil return app, nil
@@ -206,35 +77,23 @@ func NewApplication(configPath string) (*Application, error) {
func (app *Application) Start() error { func (app *Application) Start() error {
app.Logger.Info("应用启动中...") app.Logger.Info("应用启动中...")
// -- 启动 LoRa Mesh 监听器 // 1. 启动底层监听器
if err := app.loraMeshCommunicator.Listen(); err != nil { if err := app.Infra.Lora.LoraListener.Listen(); err != nil {
return fmt.Errorf("启动 LoRa Mesh 监听器失败: %w", err) return fmt.Errorf("启动 LoRa Mesh 监听器失败: %w", err)
} }
// --- 清理待采集任务 --- // 2. 初始化应用状态 (清理、刷新任务等)
if err := app.initializePendingCollections(); err != nil { if err := app.initializeState(); err != nil {
// 这是一个非致命错误,记录它,但应用应继续启动 return fmt.Errorf("初始化应用状态失败: %w", err)
app.Logger.Error(err)
} }
// --- 初始化待执行任务列表 --- // 3. 启动后台工作协程
if err := app.initializePendingTasks( app.Domain.Scheduler.Start()
app.planRepo, // 传入 planRepo
app.pendingTaskRepo, // 传入 pendingTaskRepo
app.executionLogRepo, // 传入 executionLogRepo
app.analysisPlanTaskManager, // 传入 analysisPlanTaskManager
app.Logger, // 传入 logger
); err != nil {
return fmt.Errorf("初始化待执行任务列表失败: %w", err)
}
// 启动任务执行 // 4. 启动 API 服务
app.Executor.Start()
// 启动 API 服务器
app.API.Start() app.API.Start()
// 等待关闭信号 // 5. 等待关闭信号
quit := make(chan os.Signal, 1) quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit <-quit
@@ -251,15 +110,15 @@ func (app *Application) Stop() error {
app.API.Stop() app.API.Stop()
// 关闭任务执行器 // 关闭任务执行器
app.Executor.Stop() app.Domain.Scheduler.Stop()
// 断开数据库连接 // 断开数据库连接
if err := app.Storage.Disconnect(); err != nil { if err := app.Infra.Storage.Disconnect(); err != nil {
app.Logger.Errorw("数据库连接断开失败", "error", err) app.Logger.Errorw("数据库连接断开失败", "error", err)
} }
// 关闭 LoRa Mesh 监听器 // 关闭 LoRa Mesh 监听器
if err := app.loraMeshCommunicator.Stop(); err != nil { if err := app.Infra.Lora.LoraListener.Stop(); err != nil {
app.Logger.Errorw("LoRa Mesh 监听器关闭失败", "error", err) app.Logger.Errorw("LoRa Mesh 监听器关闭失败", "error", err)
} }
@@ -269,135 +128,3 @@ func (app *Application) Stop() error {
app.Logger.Info("应用已成功关闭") app.Logger.Info("应用已成功关闭")
return nil return nil
} }
// initializePendingCollections 在应用启动时处理所有未完成的采集请求。
// 我们的策略是:任何在程序重启前仍处于“待处理”状态的请求,都应被视为已失败。
// 这保证了系统在每次启动时都处于一个干净、确定的状态。
func (app *Application) initializePendingCollections() error {
app.Logger.Info("开始清理所有未完成的采集请求...")
// 直接将所有 'pending' 状态的请求更新为 'timed_out'。
count, err := app.pendingCollectionRepo.MarkAllPendingAsTimedOut()
if err != nil {
return fmt.Errorf("清理未完成的采集请求失败: %v", err)
} else if count > 0 {
app.Logger.Infof("成功将 %d 个未完成的采集请求标记为超时。", count)
} else {
app.Logger.Info("没有需要清理的采集请求。")
}
return nil
}
// initializePendingTasks 在应用启动时清理并刷新待执行任务列表。
func (app *Application) initializePendingTasks(
planRepo repository.PlanRepository,
pendingTaskRepo repository.PendingTaskRepository,
executionLogRepo repository.ExecutionLogRepository,
analysisPlanTaskManager *task.AnalysisPlanTaskManager,
logger *logs.Logger,
) error {
logger.Info("开始初始化待执行任务列表...")
// 阶段一:修正因崩溃导致状态不一致的固定次数计划
logger.Info("阶段一:开始修正因崩溃导致状态不一致的固定次数计划...")
plansToCorrect, err := planRepo.FindPlansWithPendingTasks()
if err != nil {
return fmt.Errorf("查找需要修正的计划失败: %w", err)
}
for _, plan := range plansToCorrect {
logger.Infof("发现需要修正的计划 #%d (名称: %s)。", plan.ID, plan.Name)
// 更新计划的执行计数
plan.ExecuteCount++
logger.Infof("计划 #%d 执行计数已从 %d 更新为 %d。", plan.ID, plan.ExecuteCount-1, plan.ExecuteCount)
if plan.ExecutionType == models.PlanExecutionTypeManual ||
(plan.ExecutionType == models.PlanExecutionTypeAutomatic && plan.ExecuteCount >= plan.ExecuteNum) {
// 更新计划状态为已停止
plan.Status = models.PlanStatusStopped
logger.Infof("计划 #%d 状态已更新为 '执行完毕'。", plan.ID)
}
// 保存更新后的计划
if err := planRepo.UpdatePlan(plan); err != nil {
logger.Errorf("修正计划 #%d 状态失败: %v", plan.ID, err)
// 这是一个非阻塞性错误,继续处理其他计划
}
}
logger.Info("阶段一:固定次数计划修正完成。")
// 阶段二:清理所有待执行任务和相关日志
logger.Info("阶段二:开始清理所有待执行任务和相关日志...")
// --- 新增逻辑:处理因崩溃导致状态不一致的计划主表状态 ---
// 1. 查找所有未完成的计划执行日志 (状态为 Started 或 Waiting)
incompletePlanLogs, err := executionLogRepo.FindIncompletePlanExecutionLogs()
if err != nil {
return fmt.Errorf("查找未完成的计划执行日志失败: %w", err)
}
// 2. 收集所有受影响的唯一 PlanID
affectedPlanIDs := make(map[uint]struct{})
for _, log := range incompletePlanLogs {
affectedPlanIDs[log.PlanID] = struct{}{}
}
// 3. 对于每个受影响的 PlanID重置其 execute_count 并将其状态设置为 Failed
for planID := range affectedPlanIDs {
logger.Warnf("检测到计划 #%d 在应用崩溃前处于未完成状态,将重置其计数并标记为失败。", planID)
// 使用 UpdatePlanStateAfterExecution 来更新主表状态,避免影响关联数据
if err := planRepo.UpdatePlanStateAfterExecution(planID, 0, models.PlanStatusFailed); err != nil {
logger.Errorf("重置计划 #%d 计数并标记为失败时出错: %v", planID, err)
// 这是一个非阻塞性错误,继续处理其他计划
}
}
logger.Info("阶段二:计划主表状态修正完成。")
// 直接调用新的方法来更新计划执行日志状态为失败
if err := executionLogRepo.FailAllIncompletePlanExecutionLogs(); err != nil {
logger.Errorf("更新所有未完成计划执行日志状态为失败失败: %v", err)
// 这是一个非阻塞性错误,继续执行
}
// 直接调用新的方法来更新任务执行日志状态为取消
if err := executionLogRepo.CancelAllIncompleteTaskExecutionLogs(); err != nil {
logger.Errorf("更新所有未完成任务执行日志状态为取消失败: %v", err)
// 这是一个非阻塞性错误,继续执行
}
// 清空待执行列表
if err := pendingTaskRepo.ClearAllPendingTasks(); err != nil {
return fmt.Errorf("清空待执行任务列表失败: %w", err)
}
logger.Info("阶段二:待执行任务和相关日志清理完成。")
// 阶段三:初始刷新
logger.Info("阶段三:开始刷新待执行列表...")
if err := analysisPlanTaskManager.Refresh(); err != nil {
return fmt.Errorf("刷新待执行任务列表失败: %w", err)
}
logger.Info("阶段三:待执行任务列表初始化完成。")
logger.Info("待执行任务列表初始化完成。")
return nil
}
// initStorage 封装了数据库的初始化、连接和迁移逻辑。
func initStorage(cfg config.DatabaseConfig, logger *logs.Logger) (database.Storage, error) {
// 创建存储实例
storage := database.NewStorage(cfg, logger)
if err := storage.Connect(); err != nil {
// 错误已在 Connect 内部被记录,这里只需包装并返回
return nil, fmt.Errorf("数据库连接失败: %w", err)
}
// 执行数据库迁移
if err := storage.Migrate(models.GetAllModels()...); err != nil {
return nil, fmt.Errorf("数据库迁移失败: %w", err)
}
logger.Info("数据库初始化完成。")
return storage, nil
}

View File

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

View File

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

View File

@@ -113,6 +113,13 @@ func (g *GeneralDeviceService) Switch(device *models.Device, action DeviceAction
DeviceID: areaController.ID, DeviceID: areaController.ID,
SentAt: time.Now(), SentAt: time.Now(),
} }
if sendResult.AcknowledgedAt != nil {
logRecord.AcknowledgedAt = sendResult.AcknowledgedAt
}
if sendResult.ReceivedSuccess != nil {
logRecord.ReceivedSuccess = *sendResult.ReceivedSuccess
}
if err := g.deviceCommandLogRepo.Create(logRecord); err != nil { if err := g.deviceCommandLogRepo.Create(logRecord); err != nil {
// 记录日志失败是一个需要关注的问题,但可能不应该中断主流程。 // 记录日志失败是一个需要关注的问题,但可能不应该中断主流程。
// 我们记录一个错误日志,然后成功返回。 // 我们记录一个错误日志,然后成功返回。

View File

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

View File

@@ -6,7 +6,6 @@ import (
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" "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/repository"
"gorm.io/gorm"
) )
// --- 业务错误定义 --- // --- 业务错误定义 ---
@@ -58,6 +57,10 @@ type PigBatchService interface {
// GetCurrentPigQuantity 获取指定猪批次的当前猪只数量。 // GetCurrentPigQuantity 获取指定猪批次的当前猪只数量。
GetCurrentPigQuantity(batchID uint) (int, error) GetCurrentPigQuantity(batchID uint) (int, error)
// GetCurrentPigsInPen 获取指定猪栏的当前存栏量。
GetCurrentPigsInPen(penID uint) (int, error)
// GetTotalPigsInPensForBatch 获取指定猪群下所有猪栏的当前总存栏数
GetTotalPigsInPensForBatch(batchID uint) (int, error)
UpdatePigBatchQuantity(operatorID uint, batchID uint, changeType models.LogChangeType, changeAmount int, changeReason string, happenedAt time.Time) error UpdatePigBatchQuantity(operatorID uint, batchID uint, changeType models.LogChangeType, changeAmount int, changeReason string, happenedAt time.Time) error
@@ -118,48 +121,3 @@ func NewPigBatchService(
sickSvc: sickSvc, sickSvc: sickSvc,
} }
} }
func (s *pigBatchService) RemoveEmptyPenFromBatch(batchID uint, penID uint) error {
return s.uow.ExecuteInTransaction(func(tx *gorm.DB) error {
// 1. 检查猪批次是否存在且活跃
batch, err := s.pigBatchRepo.GetPigBatchByIDTx(tx, batchID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPigBatchNotFound
}
return err
}
if !batch.IsActive() {
return ErrPigBatchNotActive
}
// 2. 检查猪栏是否存在
pen, err := s.transferSvc.GetPenByID(tx, penID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPenNotFound
}
return err
}
// 3. 检查猪栏是否与当前批次关联
if pen.PigBatchID == nil || *pen.PigBatchID != batchID {
return ErrPenNotAssociatedWithBatch
}
// 4. 检查猪栏是否为空
pigsInPen, err := s.transferSvc.GetCurrentPigsInPen(tx, penID)
if err != nil {
return err
}
if pigsInPen > 0 {
return ErrPenNotEmpty
}
// 5. 释放猪栏 (将 pig_batch_id 设置为 nil状态设置为空闲)
if err := s.transferSvc.ReleasePen(tx, penID); err != nil {
return err
}
return nil
})
}

View File

@@ -381,3 +381,75 @@ func (s *pigBatchService) ReclassifyPenToNewBatch(fromBatchID uint, toBatchID ui
return nil return nil
}) })
} }
func (s *pigBatchService) RemoveEmptyPenFromBatch(batchID uint, penID uint) error {
return s.uow.ExecuteInTransaction(func(tx *gorm.DB) error {
// 1. 检查猪批次是否存在且活跃
batch, err := s.pigBatchRepo.GetPigBatchByIDTx(tx, batchID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPigBatchNotFound
}
return err
}
if !batch.IsActive() {
return ErrPigBatchNotActive
}
// 2. 检查猪栏是否存在
pen, err := s.transferSvc.GetPenByID(tx, penID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPenNotFound
}
return err
}
// 3. 检查猪栏是否与当前批次关联
if pen.PigBatchID == nil || *pen.PigBatchID != batchID {
return ErrPenNotAssociatedWithBatch
}
// 4. 检查猪栏是否为空
pigsInPen, err := s.transferSvc.GetCurrentPigsInPen(tx, penID)
if err != nil {
return err
}
if pigsInPen > 0 {
return ErrPenNotEmpty
}
// 5. 释放猪栏 (将 pig_batch_id 设置为 nil状态设置为空闲)
if err := s.transferSvc.ReleasePen(tx, penID); err != nil {
return err
}
return nil
})
}
func (s *pigBatchService) GetCurrentPigsInPen(penID uint) (int, error) {
var currentPigs int
err := s.uow.ExecuteInTransaction(func(tx *gorm.DB) error {
pigs, err := s.transferSvc.GetCurrentPigsInPen(tx, penID)
if err != nil {
return err
}
currentPigs = pigs
return nil
})
return currentPigs, err
}
// GetTotalPigsInPensForBatch 实现了获取指定猪群下所有猪栏的当前总存栏数的逻辑。
func (s *pigBatchService) GetTotalPigsInPensForBatch(batchID uint) (int, error) {
var totalPigs int
err := s.uow.ExecuteInTransaction(func(tx *gorm.DB) error {
pigs, err := s.transferSvc.GetTotalPigsInPensForBatchTx(tx, batchID)
if err != nil {
return err
}
totalPigs = pigs
return nil
})
return totalPigs, err
}

View File

@@ -1,4 +1,4 @@
package task package scheduler
import ( import (
"fmt" "fmt"

View File

@@ -1,4 +1,4 @@
package task package scheduler
import ( import (
"errors" "errors"
@@ -83,6 +83,7 @@ type Scheduler struct {
deviceRepo repository.DeviceRepository deviceRepo repository.DeviceRepository
sensorDataRepo repository.SensorDataRepository sensorDataRepo repository.SensorDataRepository
planRepo repository.PlanRepository planRepo repository.PlanRepository
taskFactory TaskFactory
analysisPlanTaskManager *AnalysisPlanTaskManager analysisPlanTaskManager *AnalysisPlanTaskManager
progressTracker *ProgressTracker progressTracker *ProgressTracker
deviceService device.Service deviceService device.Service
@@ -100,6 +101,7 @@ func NewScheduler(
sensorDataRepo repository.SensorDataRepository, sensorDataRepo repository.SensorDataRepository,
planRepo repository.PlanRepository, planRepo repository.PlanRepository,
analysisPlanTaskManager *AnalysisPlanTaskManager, analysisPlanTaskManager *AnalysisPlanTaskManager,
taskFactory TaskFactory,
logger *logs.Logger, logger *logs.Logger,
deviceService device.Service, deviceService device.Service,
interval time.Duration, interval time.Duration,
@@ -112,6 +114,7 @@ func NewScheduler(
sensorDataRepo: sensorDataRepo, sensorDataRepo: sensorDataRepo,
planRepo: planRepo, planRepo: planRepo,
analysisPlanTaskManager: analysisPlanTaskManager, analysisPlanTaskManager: analysisPlanTaskManager,
taskFactory: taskFactory,
logger: logger, logger: logger,
deviceService: deviceService, deviceService: deviceService,
pollingInterval: interval, pollingInterval: interval,
@@ -271,7 +274,7 @@ func (s *Scheduler) runTask(claimedLog *models.TaskExecutionLog) error {
} else { } else {
// 执行普通任务 // 执行普通任务
task := s.taskFactory(claimedLog) task := s.taskFactory.Production(claimedLog)
if err := task.Execute(); err != nil { if err := task.Execute(); err != nil {
s.logger.Errorf("[严重] 任务执行失败, 日志ID: %d, 错误: %v", claimedLog.ID, err) s.logger.Errorf("[严重] 任务执行失败, 日志ID: %d, 错误: %v", claimedLog.ID, err)
@@ -283,20 +286,6 @@ func (s *Scheduler) runTask(claimedLog *models.TaskExecutionLog) error {
return nil return nil
} }
// taskFactory 会根据任务类型初始化对应任务
func (s *Scheduler) taskFactory(claimedLog *models.TaskExecutionLog) Task {
switch claimedLog.Task.Type {
case models.TaskTypeWaiting:
return NewDelayTask(s.logger, claimedLog)
case models.TaskTypeReleaseFeedWeight:
return NewReleaseFeedWeightTask(claimedLog, s.sensorDataRepo, s.deviceRepo, s.deviceService, s.logger)
default:
// TODO 这里直接panic合适吗? 不过这个场景确实不该出现任何异常的任务类型
panic("不支持的任务类型")
}
}
// analysisPlan 解析Plan并将解析出的Task列表插入待执行队列中 // analysisPlan 解析Plan并将解析出的Task列表插入待执行队列中
func (s *Scheduler) analysisPlan(claimedLog *models.TaskExecutionLog) error { func (s *Scheduler) analysisPlan(claimedLog *models.TaskExecutionLog) error {
// 创建Plan执行记录 // 创建Plan执行记录
@@ -399,12 +388,27 @@ func (s *Scheduler) handlePlanTermination(planLogID uint, reason string) {
s.logger.Errorf("取消计划 %d 的后续任务日志时出错: %v", planLogID, err) s.logger.Errorf("取消计划 %d 的后续任务日志时出错: %v", planLogID, err)
} }
// 4. 将计划本身的状态更新为失败 // 4. 获取计划执行日志以获取顶层 PlanID
planLog, err := s.executionLogRepo.FindPlanExecutionLogByID(planLogID) planLog, err := s.executionLogRepo.FindPlanExecutionLogByID(planLogID)
if err != nil { if err != nil {
s.logger.Errorf("无法找到计划执行日志 %d 以更新父计划状态: %v", planLogID, err) s.logger.Errorf("无法找到计划执行日志 %d 以更新父计划状态: %v", planLogID, err)
return return
} }
// 5. 获取顶层计划的详细信息,以检查其类型
topLevelPlan, err := s.planRepo.GetBasicPlanByID(planLog.PlanID)
if err != nil {
s.logger.Errorf("获取顶层计划 %d 的基本信息失败: %v", planLog.PlanID, err)
return
}
// 6. 如果是系统任务,则不修改计划状态
if topLevelPlan.PlanType == models.PlanTypeSystem {
s.logger.Warnf("系统任务 %d (日志ID: %d) 执行失败,但根据策略不修改其计划状态。", topLevelPlan.ID, planLogID)
return
}
// 7. 将计划本身的状态更新为失败 (仅对非系统任务执行)
if err := s.planRepo.UpdatePlanStatus(planLog.PlanID, models.PlanStatusFailed); err != nil { if err := s.planRepo.UpdatePlanStatus(planLog.PlanID, models.PlanStatusFailed); err != nil {
s.logger.Errorf("更新计划 %d 状态为 '失败' 时出错: %v", planLog.PlanID, err) s.logger.Errorf("更新计划 %d 状态为 '失败' 时出错: %v", planLog.PlanID, err)
} }

View File

@@ -0,0 +1,23 @@
package scheduler
import "git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
// Task 定义了所有可被调度器执行的任务必须实现的接口。
type Task interface {
// Execute 是任务的核心执行逻辑。
// ctx: 用于控制任务的超时或取消。
// log: 包含了当前任务执行的完整上下文信息,包括从数据库中加载的任务参数等。
// 返回的 error 表示任务是否执行成功。调度器会根据返回的 error 是否为 nil 来决定任务状态。
Execute() error
// OnFailure 定义了当 Execute 方法返回错误时,需要执行的回滚或清理逻辑。
// log: 任务执行的上下文。
// executeErr: 从 Execute 方法返回的原始错误。
OnFailure(executeErr error)
}
// TaskFactory 是一个工厂接口,用于根据任务执行日志创建任务实例。
type TaskFactory interface {
// Production 根据指定的任务执行日志创建一个任务实例。
Production(claimedLog *models.TaskExecutionLog) Task
}

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"time" "time"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/scheduler"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" "git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
) )
@@ -19,7 +20,7 @@ type DelayTask struct {
logger *logs.Logger logger *logs.Logger
} }
func NewDelayTask(logger *logs.Logger, executionTask *models.TaskExecutionLog) Task { func NewDelayTask(logger *logs.Logger, executionTask *models.TaskExecutionLog) scheduler.Task {
return &DelayTask{ return &DelayTask{
executionTask: executionTask, executionTask: executionTask,
logger: logger, logger: logger,

View File

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

View File

@@ -6,6 +6,7 @@ import (
"time" "time"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/device" "git.huangwc.com/pig/pig-farm-controller/internal/domain/device"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/scheduler"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" "git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" "git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
@@ -40,12 +41,12 @@ func NewReleaseFeedWeightTask(
deviceRepo repository.DeviceRepository, deviceRepo repository.DeviceRepository,
deviceService device.Service, deviceService device.Service,
logger *logs.Logger, logger *logs.Logger,
) Task { ) scheduler.Task {
return &ReleaseFeedWeightTask{ return &ReleaseFeedWeightTask{
claimedLog: claimedLog, claimedLog: claimedLog,
deviceRepo: deviceRepo, deviceRepo: deviceRepo,
sensorDataRepo: sensorDataRepo, sensorDataRepo: sensorDataRepo,
feedPort: deviceService, // 直接注入 feedPort: deviceService,
logger: logger, logger: logger,
} }
} }

View File

@@ -1,30 +1,45 @@
package task package task
import ( import (
"git.huangwc.com/pig/pig-farm-controller/internal/domain/device"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/scheduler"
"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/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
) )
// Task 定义了所有可被调度器执行的任务必须实现的接口。 type taskFactory struct {
type Task interface { logger *logs.Logger
// Execute 是任务的核心执行逻辑。 sensorDataRepo repository.SensorDataRepository
// ctx: 用于控制任务的超时或取消。 deviceRepo repository.DeviceRepository
// log: 包含了当前任务执行的完整上下文信息,包括从数据库中加载的任务参数等。 deviceService device.Service
// 返回的 error 表示任务是否执行成功。调度器会根据返回的 error 是否为 nil 来决定任务状态。
Execute() error
// OnFailure 定义了当 Execute 方法返回错误时,需要执行的回滚或清理逻辑。
// log: 任务执行的上下文。
// executeErr: 从 Execute 方法返回的原始错误。
OnFailure(executeErr error)
} }
// TaskFactory 是一个任务组装工厂, 可以根据Task类型获取到对应的初始化函数 func NewTaskFactory(
var TaskFactory = func(tt models.TaskType) Task { logger *logs.Logger,
switch tt { sensorDataRepo repository.SensorDataRepository,
case models.TaskTypeWaiting: deviceRepo repository.DeviceRepository,
return &DelayTask{} deviceService device.Service,
default: ) scheduler.TaskFactory {
// 出现位置任务类型说明业务逻辑出现重大问题, 一个异常任务被创建了出来 return &taskFactory{
panic("发现未知任务类型") logger: logger,
sensorDataRepo: sensorDataRepo,
deviceRepo: deviceRepo,
deviceService: deviceService,
}
}
func (t *taskFactory) Production(claimedLog *models.TaskExecutionLog) scheduler.Task {
switch claimedLog.Task.Type {
case models.TaskTypeWaiting:
return NewDelayTask(t.logger, claimedLog)
case models.TaskTypeReleaseFeedWeight:
return NewReleaseFeedWeightTask(claimedLog, t.sensorDataRepo, t.deviceRepo, t.deviceService, t.logger)
case models.TaskTypeFullCollection:
return NewFullCollectionTask(claimedLog, t.deviceRepo, t.deviceService, t.logger)
default:
// TODO 这里直接panic合适吗? 不过这个场景确实不该出现任何异常的任务类型
t.logger.Panicf("不支持的任务类型: %s", claimedLog.Task.Type)
panic("不支持的任务类型") // 显式panic防编译器报错
} }
} }

View File

@@ -13,19 +13,19 @@ type Claims struct {
jwt.RegisteredClaims jwt.RegisteredClaims
} }
// TokenService 定义了 token 操作的接口 // Service 定义了 token 操作的接口
type TokenService interface { type Service interface {
GenerateToken(userID uint) (string, error) GenerateToken(userID uint) (string, error)
ParseToken(tokenString string) (*Claims, error) ParseToken(tokenString string) (*Claims, error)
} }
// tokenService 是 TokenService 接口的实现 // tokenService 是 Service 接口的实现
type tokenService struct { type tokenService struct {
secret []byte secret []byte
} }
// NewTokenService 创建并返回一个新的 TokenService 实例 // NewTokenService 创建并返回一个新的 Service 实例
func NewTokenService(secret []byte) TokenService { func NewTokenService(secret []byte) Service {
return &tokenService{secret: secret} return &tokenService{secret: secret}
} }

View File

@@ -41,6 +41,12 @@ type Config struct {
// LoraMesh LoraMesh配置 // LoraMesh LoraMesh配置
LoraMesh LoraMeshConfig `yaml:"lora_mesh"` LoraMesh LoraMeshConfig `yaml:"lora_mesh"`
// Notify 通知服务配置
Notify NotifyConfig `yaml:"notify"`
// Collection 定时采集配置
Collection CollectionConfig `yaml:"collection"`
} }
// AppConfig 代表应用基础配置 // AppConfig 代表应用基础配置
@@ -158,10 +164,54 @@ type LoraMeshConfig struct {
ReassemblyTimeout int `yaml:"reassembly_timeout"` ReassemblyTimeout int `yaml:"reassembly_timeout"`
} }
// NotifyConfig 包含了所有与通知服务相关的配置
type NotifyConfig struct {
Primary string `yaml:"primary"` // 首选通知渠道 (e.g., "邮件", "企业微信", "飞书", "日志")
FailureThreshold int `yaml:"failureThreshold"` // 连续失败多少次后触发广播模式
SMTP SMTPConfig `yaml:"smtp"`
WeChat WeChatConfig `yaml:"wechat"`
Lark LarkConfig `yaml:"lark"`
}
// SMTPConfig SMTP邮件配置
type SMTPConfig struct {
Enabled bool `yaml:"enabled"`
Host string `yaml:"host"`
Port int `yaml:"port"`
Username string `yaml:"username"`
Password string `yaml:"password"`
Sender string `yaml:"sender"`
}
// WeChatConfig 企业微信应用配置
type WeChatConfig struct {
Enabled bool `yaml:"enabled"`
CorpID string `yaml:"corpID"`
AgentID string `yaml:"agentID"`
Secret string `yaml:"secret"`
}
// LarkConfig 飞书应用配置
type LarkConfig struct {
Enabled bool `yaml:"enabled"`
AppID string `yaml:"appID"`
AppSecret string `yaml:"appSecret"`
}
// CollectionConfig 代表定时采集配置
type CollectionConfig struct {
// Interval 采集间隔(分钟), 默认 1
Interval int `yaml:"interval"`
}
// NewConfig 创建并返回一个新的配置实例 // NewConfig 创建并返回一个新的配置实例
func NewConfig() *Config { func NewConfig() *Config {
// 默认值可以在这里设置,但我们优先使用配置文件中的值 // 默认值可以在这里设置,但我们优先使用配置文件中的值
return &Config{} return &Config{
Collection: CollectionConfig{
Interval: 1, // 默认为1分钟
},
}
} }
// Load 从指定路径加载配置文件 // Load 从指定路径加载配置文件

View File

@@ -171,18 +171,19 @@ func (ps *PostgresStorage) creatingHyperTable() error {
{models.PigSickLog{}, "happened_at"}, {models.PigSickLog{}, "happened_at"},
{models.PigPurchase{}, "purchase_date"}, {models.PigPurchase{}, "purchase_date"},
{models.PigSale{}, "sale_date"}, {models.PigSale{}, "sale_date"},
{models.Notification{}, "alarm_timestamp"},
} }
for _, table := range tablesToConvert { for _, table := range tablesToConvert {
tableName := table.model.TableName() tableName := table.model.TableName()
chunkInterval := "1 days" // 统一设置为1天 chunkInterval := "1 days" // 统一设置为1天
ps.logger.Infow("准备将表转换为超表", "table", tableName, "chunk_interval", chunkInterval) ps.logger.Debugw("准备将表转换为超表", "table", tableName, "chunk_interval", chunkInterval)
sql := fmt.Sprintf("SELECT create_hypertable('%s', '%s', chunk_time_interval => INTERVAL '%s', if_not_exists => TRUE);", tableName, table.timeColumn, chunkInterval) sql := fmt.Sprintf("SELECT create_hypertable('%s', '%s', chunk_time_interval => INTERVAL '%s', if_not_exists => TRUE);", tableName, table.timeColumn, chunkInterval)
if err := ps.db.Exec(sql).Error; err != nil { if err := ps.db.Exec(sql).Error; err != nil {
ps.logger.Errorw("转换为超表失败", "table", tableName, "error", err) ps.logger.Errorw("转换为超表失败", "table", tableName, "error", err)
return fmt.Errorf("将 %s 转换为超表失败: %w", tableName, err) return fmt.Errorf("将 %s 转换为超表失败: %w", tableName, err)
} }
ps.logger.Infow("成功将表转换为超表 (或已转换)", "table", tableName) ps.logger.Debugw("成功将表转换为超表 (或已转换)", "table", tableName)
} }
return nil return nil
@@ -211,6 +212,7 @@ func (ps *PostgresStorage) applyCompressionPolicies() error {
{models.PigSickLog{}, "pig_batch_id"}, {models.PigSickLog{}, "pig_batch_id"},
{models.PigPurchase{}, "pig_batch_id"}, {models.PigPurchase{}, "pig_batch_id"},
{models.PigSale{}, "pig_batch_id"}, {models.PigSale{}, "pig_batch_id"},
{models.Notification{}, "user_id"},
} }
for _, policy := range policies { for _, policy := range policies {
@@ -218,22 +220,23 @@ func (ps *PostgresStorage) applyCompressionPolicies() error {
compressAfter := "3 days" // 统一设置为2天后即进入第3天开始压缩 compressAfter := "3 days" // 统一设置为2天后即进入第3天开始压缩
// 1. 开启表的压缩设置,并指定分段列 // 1. 开启表的压缩设置,并指定分段列
ps.logger.Infow("为表启用压缩设置", "table", tableName, "segment_by", policy.segmentColumn) ps.logger.Debugw("为表启用压缩设置", "table", tableName, "segment_by", policy.segmentColumn)
// 使用 + 而非Sprintf以规避goland静态检查报错 // 使用 + 而非Sprintf以规避goland静态检查报错
alterSQL := "ALTER TABLE" + " " + tableName + " SET (timescaledb.compress, timescaledb.compress_segmentby = '" + policy.segmentColumn + "');" alterSQL := "ALTER TABLE" + " " + tableName + " SET (timescaledb.compress, timescaledb.compress_segmentby = '" + policy.segmentColumn + "');"
if err := ps.db.Exec(alterSQL).Error; err != nil { if err := ps.db.Exec(alterSQL).Error; err != nil {
// 忽略错误,因为这个设置可能是不可变的,重复执行会报错 // 忽略错误,因为这个设置可能是不可变的,重复执行会报错
ps.logger.Warnw("启用压缩设置时遇到问题 (可能已设置,可忽略)", "table", tableName, "error", err) ps.logger.Warnw("启用压缩设置时遇到问题 (可能已设置,可忽略)", "table", tableName, "error", err)
} }
ps.logger.Debugw("成功为表启用压缩设置 (或已启用)", "table", tableName)
// 2. 添加压缩策略 // 2. 添加压缩策略
ps.logger.Infow("为表添加压缩策略", "table", tableName, "compress_after", compressAfter) ps.logger.Debugw("为表添加压缩策略", "table", tableName, "compress_after", compressAfter)
policySQL := fmt.Sprintf("SELECT add_compression_policy('%s', INTERVAL '%s', if_not_exists => TRUE);", tableName, compressAfter) policySQL := fmt.Sprintf("SELECT add_compression_policy('%s', INTERVAL '%s', if_not_exists => TRUE);", tableName, compressAfter)
if err := ps.db.Exec(policySQL).Error; err != nil { if err := ps.db.Exec(policySQL).Error; err != nil {
ps.logger.Errorw("添加压缩策略失败", "table", tableName, "error", err) ps.logger.Errorw("添加压缩策略失败", "table", tableName, "error", err)
return fmt.Errorf("为 %s 添加压缩策略失败: %w", tableName, err) return fmt.Errorf("为 %s 添加压缩策略失败: %w", tableName, err)
} }
ps.logger.Infow("成功为表添加压缩策略 (或已存在)", "table", tableName) ps.logger.Debugw("成功为表添加压缩策略 (或已存在)", "table", tableName)
} }
return nil return nil
@@ -245,22 +248,22 @@ func (ps *PostgresStorage) creatingIndex() error {
// 如果索引已存在,此命令不会报错 // 如果索引已存在,此命令不会报错
// 为 sensor_data 表的 data 字段创建 GIN 索引 // 为 sensor_data 表的 data 字段创建 GIN 索引
ps.logger.Info("正在为 sensor_data 表的 data 字段创建 GIN 索引") ps.logger.Debug("正在为 sensor_data 表的 data 字段创建 GIN 索引")
ginSensorDataIndexSQL := "CREATE INDEX IF NOT EXISTS idx_sensor_data_data_gin ON sensor_data USING GIN (data);" ginSensorDataIndexSQL := "CREATE INDEX IF NOT EXISTS idx_sensor_data_data_gin ON sensor_data USING GIN (data);"
if err := ps.db.Exec(ginSensorDataIndexSQL).Error; err != nil { if err := ps.db.Exec(ginSensorDataIndexSQL).Error; err != nil {
ps.logger.Errorw("为 sensor_data 的 data 字段创建 GIN 索引失败", "error", err) ps.logger.Errorw("为 sensor_data 的 data 字段创建 GIN 索引失败", "error", err)
return fmt.Errorf("为 sensor_data 的 data 字段创建 GIN 索引失败: %w", err) return fmt.Errorf("为 sensor_data 的 data 字段创建 GIN 索引失败: %w", err)
} }
ps.logger.Info("成功为 sensor_data 的 data 字段创建 GIN 索引 (或已存在)") ps.logger.Debug("成功为 sensor_data 的 data 字段创建 GIN 索引 (或已存在)")
// 为 tasks.parameters 创建 GIN 索引 // 为 tasks.parameters 创建 GIN 索引
ps.logger.Info("正在为 tasks 表的 parameters 字段创建 GIN 索引") ps.logger.Debug("正在为 tasks 表的 parameters 字段创建 GIN 索引")
taskGinIndexSQL := "CREATE INDEX IF NOT EXISTS idx_tasks_parameters_gin ON tasks USING GIN (parameters);" taskGinIndexSQL := "CREATE INDEX IF NOT EXISTS idx_tasks_parameters_gin ON tasks USING GIN (parameters);"
if err := ps.db.Exec(taskGinIndexSQL).Error; err != nil { if err := ps.db.Exec(taskGinIndexSQL).Error; err != nil {
ps.logger.Errorw("为 tasks 的 parameters 字段创建 GIN 索引失败", "error", err) ps.logger.Errorw("为 tasks 的 parameters 字段创建 GIN 索引失败", "error", err)
return fmt.Errorf("为 tasks 的 parameters 字段创建 GIN 索引失败: %w", err) return fmt.Errorf("为 tasks 的 parameters 字段创建 GIN 索引失败: %w", err)
} }
ps.logger.Info("成功为 tasks 的 parameters 字段创建 GIN 索引 (或已存在)") ps.logger.Debug("成功为 tasks 的 parameters 字段创建 GIN 索引 (或已存在)")
return nil return nil
} }

View File

@@ -59,6 +59,9 @@ func GetAllModels() []interface{} {
// Medication Models // Medication Models
&Medication{}, &Medication{},
&MedicationLog{}, &MedicationLog{},
// Notification Models
&Notification{},
} }
} }

View File

@@ -0,0 +1,77 @@
package models
import (
"database/sql/driver"
"errors"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/notify"
"go.uber.org/zap/zapcore"
"gorm.io/gorm"
)
// NotificationStatus 定义了通知发送尝试的状态枚举。
type NotificationStatus string
const (
NotificationStatusSuccess NotificationStatus = "发送成功" // 通知已成功发送
NotificationStatusFailed NotificationStatus = "发送失败" // 通知发送失败
NotificationStatusSkipped NotificationStatus = "已跳过" // 通知因某些原因被跳过(例如:用户未配置联系方式)
)
// LogLevel is a custom type for zapcore.Level to handle database scanning and valuing.
type LogLevel zapcore.Level
// Scan implements the sql.Scanner interface.
func (l *LogLevel) Scan(value interface{}) error {
var s string
switch v := value.(type) {
case []byte:
s = string(v)
case string:
s = v
default:
return errors.New("LogLevel的类型无效")
}
var zl zapcore.Level
if err := zl.UnmarshalText([]byte(s)); err != nil {
return err
}
*l = LogLevel(zl)
return nil
}
// Value implements the driver.Valuer interface.
func (l LogLevel) Value() (driver.Value, error) {
return (zapcore.Level)(l).String(), nil
}
// Notification 表示已发送或尝试发送的通知记录。
type Notification struct {
gorm.Model
// NotifierType 通知器类型 (例如:"邮件", "企业微信", "飞书", "日志")
NotifierType notify.NotifierType `gorm:"type:varchar(20);not null;index" json:"notifier_type"`
// UserID 接收通知的用户ID用于追溯通知记录到特定用户
UserID uint `gorm:"index" json:"user_id"` // 增加 UserID 字段,并添加索引
// Title 通知标题
Title string `gorm:"type:varchar(255);not null" json:"title"`
// Message 通知内容
Message string `gorm:"type:text;not null" json:"message"`
// Level 通知级别 (例如INFO, WARN, ERROR)
Level LogLevel `gorm:"type:varchar(10);not null" json:"level"`
// AlarmTimestamp 通知内容生成时的时间戳,与 ID 构成复合主键
AlarmTimestamp time.Time `gorm:"primaryKey;not null" json:"alarm_timestamp"`
// ToAddress 接收地址 (例如:邮箱地址, 企业微信ID, 日志标识符)
ToAddress string `gorm:"type:varchar(255);not null" json:"to_address"`
// Status 通知发送尝试的状态 (例如:"待发送", "发送成功", "发送失败", "已跳过")
Status NotificationStatus `gorm:"type:varchar(20);not null;default:'待发送'" json:"status"`
// ErrorMessage 如果通知发送失败,此字段存储错误信息
ErrorMessage string `gorm:"type:text" json:"error_message"`
}
// TableName 指定 Notification 模型的表名。
func (Notification) TableName() string {
return "notifications"
}

View File

@@ -34,6 +34,7 @@ const (
TaskPlanAnalysis TaskType = "计划分析" // 解析Plan的Task列表并添加到待执行队列的特殊任务 TaskPlanAnalysis TaskType = "计划分析" // 解析Plan的Task列表并添加到待执行队列的特殊任务
TaskTypeWaiting TaskType = "等待" // 等待任务 TaskTypeWaiting TaskType = "等待" // 等待任务
TaskTypeReleaseFeedWeight TaskType = "下料" // 下料口释放指定重量任务 TaskTypeReleaseFeedWeight TaskType = "下料" // 下料口释放指定重量任务
TaskTypeFullCollection TaskType = "全量采集" // 新增的全量采集任务
) )
// -- Task Parameters -- // -- Task Parameters --
@@ -52,12 +53,20 @@ const (
PlanStatusFailed PlanStatus = "执行失败" // 执行失败 PlanStatusFailed PlanStatus = "执行失败" // 执行失败
) )
type PlanType string
const (
PlanTypeCustom PlanType = "自定义任务"
PlanTypeSystem PlanType = "系统任务"
)
// Plan 代表系统中的一个计划,可以包含子计划或任务 // Plan 代表系统中的一个计划,可以包含子计划或任务
type Plan struct { type Plan struct {
gorm.Model gorm.Model
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
Description string `json:"description"` Description string `json:"description"`
PlanType PlanType `gorm:"not null;index" json:"plan_type"` // 任务类型, 包括系统任务和用户自定义任务
ExecutionType PlanExecutionType `gorm:"not null;index" json:"execution_type"` ExecutionType PlanExecutionType `gorm:"not null;index" json:"execution_type"`
Status PlanStatus `gorm:"default:'已禁用';index" json:"status"` // 计划是否被启动 Status PlanStatus `gorm:"default:'已禁用';index" json:"status"` // 计划是否被启动
ExecuteNum uint `gorm:"default:0" json:"execute_num"` // 计划预期执行次数 ExecuteNum uint `gorm:"default:0" json:"execute_num"` // 计划预期执行次数

View File

@@ -0,0 +1,193 @@
package notify
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
)
const (
// 飞书获取 tenant_access_token 的 API 地址
larkGetTokenURL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
// 飞书发送消息的 API 地址
larkSendMessageURL = "https://open.feishu.cn/open-apis/im/v1/messages"
)
// larkNotifier 实现了 Notifier 接口,用于通过飞书自建应用发送私聊消息。
type larkNotifier struct {
appID string // 应用 ID
appSecret string // 应用密钥
// 用于线程安全地管理 tenant_access_token
mu sync.Mutex
accessToken string
tokenExpiresAt time.Time
}
// NewLarkNotifier 创建一个新的 larkNotifier 实例。
// 调用者需要注入飞书应用的 AppID 和 AppSecret。
func NewLarkNotifier(appID, appSecret string) Notifier {
return &larkNotifier{
appID: appID,
appSecret: appSecret,
}
}
// Send 向指定用户发送一条飞书消息卡片。
// toAddr 参数是接收者的邮箱地址。
func (l *larkNotifier) Send(content AlarmContent, toAddr string) error {
// 1. 获取有效的 tenant_access_token
token, err := l.getAccessToken()
if err != nil {
return err
}
// 2. 构建消息卡片 JSON
// 飞书消息卡片结构复杂,这里构建一个简单的 Markdown 文本卡片
cardContent := map[string]interface{}{
"config": map[string]bool{
"wide_screen_mode": true,
},
"elements": []map[string]interface{}{
{
"tag": "div",
"text": map[string]string{
"tag": "lark_md",
"content": fmt.Sprintf("## %s\n**级别**: %s\n**时间**: %s\n\n%s",
content.Title,
content.Level.String(),
content.Timestamp.Format(DefaultTimeFormat),
content.Message,
),
},
},
},
}
cardJSON, err := json.Marshal(cardContent)
if err != nil {
return fmt.Errorf("序列化飞书卡片内容失败: %w", err)
}
// 3. 构建请求的 JSON Body
payload := larkMessagePayload{
ReceiveID: toAddr,
ReceiveIDType: "email", // 指定接收者类型为邮箱
MsgType: "interactive", // 消息卡片类型
Content: string(cardJSON),
}
jsonBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("序列化飞书消息失败: %w", err)
}
// 4. 发送 HTTP POST 请求
url := fmt.Sprintf("%s?receive_id_type=email", larkSendMessageURL) // 在 URL 中指定 receive_id_type
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBytes))
if err != nil {
return fmt.Errorf("创建飞书请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token) // 携带 access_token
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("发送飞书通知失败: %w", err)
}
defer resp.Body.Close()
// 5. 检查响应
var response larkResponse
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return fmt.Errorf("解析飞书响应失败: %w", err)
}
if response.Code != 0 {
return fmt.Errorf("飞书API返回错误: code=%d, msg=%s", response.Code, response.Msg)
}
return nil
}
// getAccessToken 获取并缓存 tenant_access_token处理了线程安全和自动刷新。
func (l *larkNotifier) getAccessToken() (string, error) {
l.mu.Lock()
defer l.mu.Unlock()
// 如果 token 存在且有效期还有5分钟以上则直接返回缓存的 token
if l.accessToken != "" && time.Now().Before(l.tokenExpiresAt.Add(-5*time.Minute)) {
return l.accessToken, nil
}
// 否则,重新获取 token
payload := map[string]string{
"app_id": l.appID,
"app_secret": l.appSecret,
}
jsonBytes, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("序列化获取 token 请求体失败: %w", err)
}
req, err := http.NewRequest("POST", larkGetTokenURL, bytes.NewBuffer(jsonBytes))
if err != nil {
return "", fmt.Errorf("创建获取 token 请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("获取 tenant_access_token 请求失败: %w", err)
}
defer resp.Body.Close()
var tokenResp larkTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return "", fmt.Errorf("解析 tenant_access_token 响应失败: %w", err)
}
if tokenResp.Code != 0 {
return "", fmt.Errorf("获取 tenant_access_token API 返回错误: code=%d, msg=%s", tokenResp.Code, tokenResp.Msg)
}
// 缓存新的 token 和过期时间
l.accessToken = tokenResp.TenantAccessToken
l.tokenExpiresAt = time.Now().Add(time.Duration(tokenResp.Expire) * time.Second)
return l.accessToken, nil
}
// Type 返回通知器的类型
func (l *larkNotifier) Type() NotifierType {
return NotifierTypeLark
}
// --- API 数据结构 ---
// larkTokenResponse 是获取 tenant_access_token API 的响应结构体
type larkTokenResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
TenantAccessToken string `json:"tenant_access_token"`
Expire int `json:"expire"` // 有效期,单位秒
}
// larkMessagePayload 是发送消息 API 的请求体结构
type larkMessagePayload struct {
ReceiveID string `json:"receive_id"`
ReceiveIDType string `json:"receive_id_type"`
MsgType string `json:"msg_type"`
Content string `json:"content"` // 对于 interactive 消息,这里是卡片的 JSON 字符串
}
// larkResponse 是飞书 API 的通用响应结构体
type larkResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
}

View File

@@ -0,0 +1,37 @@
package notify
import (
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
)
// logNotifier 实现了 Notifier 接口,用于将告警信息记录到日志中。
type logNotifier struct {
logger *logs.Logger
}
// NewLogNotifier 创建一个新的 logNotifier 实例。
// 它接收一个日志记录器,用于实际的日志输出。
func NewLogNotifier(logger *logs.Logger) Notifier {
return &logNotifier{
logger: logger,
}
}
// Send 将告警内容以结构化的方式记录到日志中。
// toAddr 参数在这里表示告警的预期接收者地址,也会被记录。
func (l *logNotifier) Send(content AlarmContent, toAddr string) error {
l.logger.Infow("告警已记录到日志",
"notifierType", NotifierTypeLog,
"title", content.Title,
"message", content.Message,
"level", content.Level.String(),
"timestamp", content.Timestamp.Format(DefaultTimeFormat),
"toAddr", toAddr,
)
return nil // 记录日志操作本身不应失败
}
// Type 返回通知器的类型。
func (l *logNotifier) Type() NotifierType {
return NotifierTypeLog
}

View File

@@ -0,0 +1,44 @@
package notify
import (
"time"
"go.uber.org/zap/zapcore"
)
// DefaultTimeFormat 定义了所有通知中统一使用的时间格式。
const DefaultTimeFormat = "2006-01-02 15:04:05"
// NotifierType 定义了通知器的类型。
type NotifierType string
const (
// NotifierTypeSMTP 表示 SMTP 邮件通知器。
NotifierTypeSMTP NotifierType = "邮件"
// NotifierTypeWeChat 表示企业微信通知器。
NotifierTypeWeChat NotifierType = "企业微信"
// NotifierTypeLark 表示飞书通知器。
NotifierTypeLark NotifierType = "飞书"
// NotifierTypeLog 表示日志通知器,作为最终的告警记录渠道。
NotifierTypeLog NotifierType = "日志"
)
// AlarmContent 定义了通知的内容
type AlarmContent struct {
// 通知标题
Title string
// 通知信息
Message string
// 通知级别
Level zapcore.Level
// 通知时间
Timestamp time.Time
}
// Notifier 定义了通知发送器的接口
type Notifier interface {
// Send 发送通知
Send(content AlarmContent, toAddr string) error
// Type 返回通知器的类型
Type() NotifierType
}

View File

@@ -0,0 +1,73 @@
package notify
import (
"fmt"
"net/smtp"
"strings"
)
// smtpNotifier 实现了 Notifier 接口,用于通过 SMTP 发送邮件通知。
type smtpNotifier struct {
host string // SMTP 服务器地址
port int // SMTP 服务器端口
username string // 发件人邮箱地址
password string // 发件人邮箱授权码
sender string // 发件人名称或地址,显示在邮件的 \"From\" 字段
}
// NewSMTPNotifier 创建一个新的 smtpNotifier 实例。
// 调用者需要注入 SMTP 相关的配置。
func NewSMTPNotifier(host string, port int, username, password, sender string) Notifier {
return &smtpNotifier{
host: host,
port: port,
username: username,
password: password,
sender: sender,
}
}
// Send 使用 net/smtp 包发送一封邮件。
func (s *smtpNotifier) Send(content AlarmContent, toAddr string) error {
// 1. 设置认证信息
auth := smtp.PlainAuth("", s.username, s.password, s.host)
// 2. 构建邮件内容
// 邮件头
subject := content.Title
contentType := "Content-Type: text/plain; charset=UTF-8"
fromHeader := fmt.Sprintf("From: %s", s.sender)
toHeader := fmt.Sprintf("To: %s", toAddr)
subjectHeader := fmt.Sprintf("Subject: %s", subject)
// 邮件正文
body := fmt.Sprintf("级别: %s\n时间: %s\n\n%s",
content.Level.String(),
content.Timestamp.Format(DefaultTimeFormat),
content.Message,
)
// 拼接完整的邮件报文
msg := strings.Join([]string{
fromHeader,
toHeader,
subjectHeader,
contentType,
"", // 邮件头和正文之间的空行
body,
}, "\r\n")
// 3. 发送邮件
addr := fmt.Sprintf("%s:%d", s.host, s.port)
err := smtp.SendMail(addr, auth, s.username, []string{toAddr}, []byte(msg))
if err != nil {
return fmt.Errorf("发送邮件失败: %w", err)
}
return nil
}
// Type 返回通知器的类型
func (s *smtpNotifier) Type() NotifierType {
return NotifierTypeSMTP
}

View File

@@ -0,0 +1,169 @@
package notify
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
)
const (
// 获取 access_token 的 API 地址
getTokenURL = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
// 发送应用消息的 API 地址
sendMessageURL = "https://qyapi.weixin.qq.com/cgi-bin/message/send"
)
// wechatNotifier 实现了 Notifier 接口,用于通过企业微信自建应用发送私聊消息。
type wechatNotifier struct {
corpID string // 企业ID (CorpID)
agentID string // 应用ID (AgentID)
secret string // 应用密钥 (Secret)
// 用于线程安全地管理 access_token
mu sync.Mutex
accessToken string
tokenExpiresAt time.Time
}
// NewWechatNotifier 创建一个新的 wechatNotifier 实例。
// 调用者需要注入企业微信应用的 CorpID, AgentID 和 Secret。
func NewWechatNotifier(corpID, agentID, secret string) Notifier {
return &wechatNotifier{
corpID: corpID,
agentID: agentID,
secret: secret,
}
}
// Send 向指定用户发送一条 markdown 格式的私聊消息。
// toAddr 参数是接收者的 UserID 列表,用逗号或竖线分隔。
func (w *wechatNotifier) Send(content AlarmContent, toAddr string) error {
// 1. 获取有效的 access_token
token, err := w.getAccessToken()
if err != nil {
return err
}
// 2. 构建 markdown 内容
markdownContent := fmt.Sprintf("## %s\n> 级别: <font color=\"warning\">%s</font>\n> 时间: %s\n\n%s",
content.Title,
content.Level.String(),
content.Timestamp.Format(DefaultTimeFormat),
content.Message,
)
// 3. 构建请求的 JSON Body
// 将逗号分隔的 toAddr 转换为竖线分隔,以符合 API 要求
userList := strings.ReplaceAll(toAddr, ",", "|")
payload := wechatMessagePayload{
ToUser: userList,
MsgType: "markdown",
AgentID: w.agentID,
Markdown: struct {
Content string `json:"content"`
}{
Content: markdownContent,
},
}
jsonBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("序列化企业微信消息失败: %w", err)
}
// 4. 发送 HTTP POST 请求
url := fmt.Sprintf("%s?access_token=%s", sendMessageURL, token)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBytes))
if err != nil {
return fmt.Errorf("创建企业微信请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("发送企业微信通知失败: %w", err)
}
defer resp.Body.Close()
// 5. 检查响应
var response wechatResponse
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return fmt.Errorf("解析企业微信响应失败: %w", err)
}
if response.ErrCode != 0 {
return fmt.Errorf("企业微信API返回错误: code=%d, msg=%s", response.ErrCode, response.ErrMsg)
}
return nil
}
// getAccessToken 获取并缓存 access_token处理了线程安全和自动刷新。
func (w *wechatNotifier) getAccessToken() (string, error) {
w.mu.Lock()
defer w.mu.Unlock()
// 如果 token 存在且有效期还有5分钟以上则直接返回缓存的 token
if w.accessToken != "" && time.Now().Before(w.tokenExpiresAt.Add(-5*time.Minute)) {
return w.accessToken, nil
}
// 否则,重新获取 token
url := fmt.Sprintf("%s?corpid=%s&corpsecret=%s", getTokenURL, w.corpID, w.secret)
resp, err := http.Get(url)
if err != nil {
return "", fmt.Errorf("获取 access_token 请求失败: %w", err)
}
defer resp.Body.Close()
var tokenResp tokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return "", fmt.Errorf("解析 access_token 响应失败: %w", err)
}
if tokenResp.ErrCode != 0 {
return "", fmt.Errorf("获取 access_token API 返回错误: code=%d, msg=%s", tokenResp.ErrCode, tokenResp.ErrMsg)
}
// 缓存新的 token 和过期时间
w.accessToken = tokenResp.AccessToken
w.tokenExpiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
return w.accessToken, nil
}
// Type 返回通知器的类型
func (w *wechatNotifier) Type() NotifierType {
return NotifierTypeWeChat
}
// --- API 数据结构 ---
// tokenResponse 是获取 access_token API 的响应结构体
type tokenResponse struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
// wechatMessagePayload 是发送应用消息 API 的请求体结构
type wechatMessagePayload struct {
ToUser string `json:"touser"`
MsgType string `json:"msgtype"`
AgentID string `json:"agentid"`
Markdown struct {
Content string `json:"content"`
} `json:"markdown"`
}
// wechatResponse 是企业微信 API 的通用响应结构体
type wechatResponse struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}

View File

@@ -23,6 +23,9 @@ type DeviceRepository interface {
// ListAll 获取所有设备的列表 // ListAll 获取所有设备的列表
ListAll() ([]*models.Device, error) ListAll() ([]*models.Device, error)
// ListAllSensors 获取所有传感器类型的设备列表
ListAllSensors() ([]*models.Device, error)
// ListByAreaControllerID 根据区域主控 ID 列出所有子设备。 // ListByAreaControllerID 根据区域主控 ID 列出所有子设备。
ListByAreaControllerID(areaControllerID uint) ([]*models.Device, error) ListByAreaControllerID(areaControllerID uint) ([]*models.Device, error)
@@ -84,6 +87,19 @@ func (r *gormDeviceRepository) ListAll() ([]*models.Device, error) {
return devices, nil return devices, nil
} }
// ListAllSensors 检索归类为传感器的所有设备
func (r *gormDeviceRepository) ListAllSensors() ([]*models.Device, error) {
var sensors []*models.Device
err := r.db.Preload("AreaController").Preload("DeviceTemplate").
Joins("JOIN device_templates ON device_templates.id = devices.device_template_id").
Where("device_templates.category = ?", models.CategorySensor).
Find(&sensors).Error
if err != nil {
return nil, fmt.Errorf("查询所有传感器失败: %w", err)
}
return sensors, nil
}
// ListByAreaControllerID 根据区域主控 ID 列出所有子设备 // ListByAreaControllerID 根据区域主控 ID 列出所有子设备
func (r *gormDeviceRepository) ListByAreaControllerID(areaControllerID uint) ([]*models.Device, error) { func (r *gormDeviceRepository) ListByAreaControllerID(areaControllerID uint) ([]*models.Device, error) {
var devices []*models.Device var devices []*models.Device

View File

@@ -0,0 +1,111 @@
package repository
import (
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/notify"
"go.uber.org/zap/zapcore"
"gorm.io/gorm"
)
// NotificationListOptions 定义了查询通知列表时的可选参数
type NotificationListOptions struct {
UserID *uint // 按用户ID过滤
NotifierType *notify.NotifierType // 按通知器类型过滤
Status *models.NotificationStatus // 按通知状态过滤 (例如:"success", "failed")
Level *zapcore.Level // 按通知等级过滤 (例如:"info", "warning", "error")
StartTime *time.Time // 通知内容生成时间范围 - 开始时间 (对应 AlarmTimestamp)
EndTime *time.Time // 通知内容生成时间范围 - 结束时间 (对应 AlarmTimestamp)
OrderBy string // 排序字段,例如 "alarm_timestamp DESC"
}
// NotificationRepository 定义了与通知记录相关的数据库操作接口。
type NotificationRepository interface {
// Create 将一条新的通知记录插入数据库。
Create(notification *models.Notification) error
// CreateInTx 在给定的事务中插入一条新的通知记录。
CreateInTx(tx *gorm.DB, notification *models.Notification) error
// BatchCreate 批量插入多条通知记录。
BatchCreate(notifications []*models.Notification) error
// List 支持分页和过滤的通知列表查询。
// 返回通知列表、总记录数和错误。
List(opts NotificationListOptions, page, pageSize int) ([]models.Notification, int64, error)
}
// gormNotificationRepository 是 NotificationRepository 的 GORM 实现。
type gormNotificationRepository struct {
db *gorm.DB
}
// NewGormNotificationRepository 创建一个新的 NotificationRepository GORM 实现实例。
func NewGormNotificationRepository(db *gorm.DB) NotificationRepository {
return &gormNotificationRepository{db: db}
}
// Create 将一条新的通知记录插入数据库。
func (r *gormNotificationRepository) Create(notification *models.Notification) error {
return r.db.Create(notification).Error
}
// CreateInTx 在给定的事务中插入一条新的通知记录。
func (r *gormNotificationRepository) CreateInTx(tx *gorm.DB, notification *models.Notification) error {
return tx.Create(notification).Error
}
// BatchCreate 批量插入多条通知记录。
func (r *gormNotificationRepository) BatchCreate(notifications []*models.Notification) error {
// GORM 的 Create 方法在传入切片时会自动进行批量插入
return r.db.Create(&notifications).Error
}
// List 实现了分页和过滤查询通知记录的功能
func (r *gormNotificationRepository) List(opts NotificationListOptions, page, pageSize int) ([]models.Notification, int64, error) {
// --- 校验分页参数 ---
if page <= 0 || pageSize <= 0 {
return nil, 0, ErrInvalidPagination // 复用已定义的错误
}
var results []models.Notification
var total int64
query := r.db.Model(&models.Notification{})
// --- 应用过滤条件 ---
if opts.UserID != nil {
query = query.Where("user_id = ?", *opts.UserID)
}
if opts.NotifierType != nil {
query = query.Where("notifier_type = ?", *opts.NotifierType)
}
if opts.Status != nil {
query = query.Where("status = ?", *opts.Status)
}
if opts.Level != nil {
query = query.Where("level = ?", opts.Level.String())
}
if opts.StartTime != nil {
query = query.Where("alarm_timestamp >= ?", *opts.StartTime)
}
if opts.EndTime != nil {
query = query.Where("alarm_timestamp <= ?", *opts.EndTime)
}
// --- 计算总数 ---
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// --- 应用排序条件 ---
orderBy := "alarm_timestamp DESC" // 默认按时间倒序
if opts.OrderBy != "" {
orderBy = opts.OrderBy
}
query = query.Order(orderBy)
// --- 分页 ---
offset := (page - 1) * pageSize
err := query.Limit(pageSize).Offset(offset).Find(&results).Error
return results, total, err
}

View File

@@ -21,15 +21,31 @@ var (
ErrDeleteWithReferencedPlan = errors.New("禁止删除正在被引用的计划") ErrDeleteWithReferencedPlan = errors.New("禁止删除正在被引用的计划")
) )
// PlanTypeFilter 定义计划类型的过滤器
type PlanTypeFilter string
const (
PlanTypeFilterAll PlanTypeFilter = "所有任务"
PlanTypeFilterCustom PlanTypeFilter = "自定义任务"
PlanTypeFilterSystem PlanTypeFilter = "系统任务"
)
// ListPlansOptions 定义了查询计划时的可选参数
type ListPlansOptions struct {
PlanType PlanTypeFilter
}
// PlanRepository 定义了与计划模型相关的数据库操作接口 // PlanRepository 定义了与计划模型相关的数据库操作接口
// 这是为了让业务逻辑层依赖于抽象,而不是具体的数据库实现 // 这是为了让业务逻辑层依赖于抽象,而不是具体的数据库实现
type PlanRepository interface { type PlanRepository interface {
// ListBasicPlans 获取所有计划的基本信息,不包含子计划和任务详情 // ListPlans 获取计划列表,支持过滤和分页
ListBasicPlans() ([]models.Plan, error) ListPlans(opts ListPlansOptions, page, pageSize int) ([]models.Plan, int64, error)
// GetBasicPlanByID 根据ID获取计划的基本信息不包含子计划和任务详情 // GetBasicPlanByID 根据ID获取计划的基本信息不包含子计划和任务详情
GetBasicPlanByID(id uint) (*models.Plan, error) GetBasicPlanByID(id uint) (*models.Plan, error)
// GetPlanByID 根据ID获取计划包含子计划和任务详情 // GetPlanByID 根据ID获取计划包含子计划和任务详情
GetPlanByID(id uint) (*models.Plan, error) GetPlanByID(id uint) (*models.Plan, error)
// GetPlansByIDs 根据ID列表获取计划不包含子计划和任务详情
GetPlansByIDs(ids []uint) ([]models.Plan, error)
// CreatePlan 创建一个新的计划 // CreatePlan 创建一个新的计划
CreatePlan(plan *models.Plan) error CreatePlan(plan *models.Plan) error
// UpdatePlan 更新计划,包括子计划和任务 // UpdatePlan 更新计划,包括子计划和任务
@@ -81,15 +97,37 @@ func NewGormPlanRepository(db *gorm.DB) PlanRepository {
} }
} }
// ListBasicPlans 获取所有计划的基本信息,不包含子计划和任务详情 // ListPlans 获取计划列表,支持过滤和分页
func (r *gormPlanRepository) ListBasicPlans() ([]models.Plan, error) { func (r *gormPlanRepository) ListPlans(opts ListPlansOptions, page, pageSize int) ([]models.Plan, int64, error) {
var plans []models.Plan if page <= 0 || pageSize <= 0 {
// GORM 默认不会加载关联,除非使用 Preload所以直接 Find 即可满足要求 return nil, 0, ErrInvalidPagination
result := r.db.Find(&plans)
if result.Error != nil {
return nil, result.Error
} }
return plans, nil
var plans []models.Plan
var total int64
query := r.db.Model(&models.Plan{})
switch opts.PlanType {
case PlanTypeFilterCustom:
query = query.Where("plan_type = ?", models.PlanTypeCustom)
case PlanTypeFilterSystem:
query = query.Where("plan_type = ?", models.PlanTypeSystem)
case PlanTypeFilterAll:
// 不添加 plan_type 的过滤条件
default:
// 默认查询自定义
query = query.Where("plan_type = ?", models.PlanTypeCustom)
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
err := query.Limit(pageSize).Offset(offset).Order("id DESC").Find(&plans).Error
return plans, total, err
} }
// GetBasicPlanByID 根据ID获取计划的基本信息不包含子计划和任务详情 // GetBasicPlanByID 根据ID获取计划的基本信息不包含子计划和任务详情
@@ -103,6 +141,19 @@ func (r *gormPlanRepository) GetBasicPlanByID(id uint) (*models.Plan, error) {
return &plan, nil return &plan, nil
} }
// GetPlansByIDs 根据ID列表获取计划不包含子计划和任务详情
func (r *gormPlanRepository) GetPlansByIDs(ids []uint) ([]models.Plan, error) {
var plans []models.Plan
if len(ids) == 0 {
return plans, nil
}
err := r.db.Where("id IN ?", ids).Find(&plans).Error
if err != nil {
return nil, err
}
return plans, nil
}
// GetPlanByID 根据ID获取计划包含子计划和任务详情 // GetPlanByID 根据ID获取计划包含子计划和任务详情
func (r *gormPlanRepository) GetPlanByID(id uint) (*models.Plan, error) { func (r *gormPlanRepository) GetPlanByID(id uint) (*models.Plan, error) {
var plan models.Plan var plan models.Plan

View File

@@ -0,0 +1,6 @@
package repository
import "errors"
// ErrInvalidPagination 表示分页参数无效
var ErrInvalidPagination = errors.New("无效的分页参数page和pageSize必须为大于0")

View File

@@ -1,16 +1,12 @@
package repository package repository
import ( import (
"errors"
"time" "time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" "git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"gorm.io/gorm" "gorm.io/gorm"
) )
// ErrInvalidPagination 表示分页参数无效
var ErrInvalidPagination = errors.New("无效的分页参数page和pageSize必须为大于0")
// SensorDataListOptions 定义了查询传感器数据列表时的可选参数 // SensorDataListOptions 定义了查询传感器数据列表时的可选参数
type SensorDataListOptions struct { type SensorDataListOptions struct {
DeviceID *uint DeviceID *uint

View File

@@ -13,6 +13,7 @@ type UserRepository interface {
FindByUsername(username string) (*models.User, error) FindByUsername(username string) (*models.User, error)
FindByID(id uint) (*models.User, error) FindByID(id uint) (*models.User, error)
FindUserForLogin(identifier string) (*models.User, error) FindUserForLogin(identifier string) (*models.User, error)
FindAll() ([]*models.User, error)
} }
// gormUserRepository 是 UserRepository 的 GORM 实现 // gormUserRepository 是 UserRepository 的 GORM 实现
@@ -64,3 +65,12 @@ func (r *gormUserRepository) FindByID(id uint) (*models.User, error) {
} }
return &user, nil return &user, nil
} }
// FindAll 返回数据库中的所有用户
func (r *gormUserRepository) FindAll() ([]*models.User, error) {
var users []*models.User
if err := r.db.Where("1 = 1").Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}

View File

@@ -265,7 +265,10 @@ func (t *LoRaMeshUartPassthroughTransport) executeSend(req *sendRequest) (*trans
} }
msgID := uuid.New().String() msgID := uuid.New().String()
return &transport.SendResult{MessageID: msgID}, nil // LoRa mesh 是单向通信, 发送方不知道也不关心接收方是否收到, 所以发送成功就当作接收成功
acknowledgedAt := time.Now()
receivedSuccess := true
return &transport.SendResult{MessageID: msgID, AcknowledgedAt: &acknowledgedAt, ReceivedSuccess: &receivedSuccess}, nil
} }
// handleFrame 处理一个从串口解析出的完整物理帧 // handleFrame 处理一个从串口解析出的完整物理帧

View File

@@ -1,5 +1,7 @@
package transport package transport
import "time"
// Communicator 用于其他设备通信 // Communicator 用于其他设备通信
type Communicator interface { type Communicator interface {
// Send 用于发送一条单向数据(不等待回信) // Send 用于发送一条单向数据(不等待回信)
@@ -12,6 +14,14 @@ type SendResult struct {
// MessageID 是通信服务为此次发送分配的唯一标识符。 // MessageID 是通信服务为此次发送分配的唯一标识符。
// 调用方需要保存此 ID以便后续关联 ACK 等事件。 // 调用方需要保存此 ID以便后续关联 ACK 等事件。
MessageID string MessageID string
// AcknowledgedAt 记录设备确认收到下行消息的时间。
// 并非所有发送实现都会同步返回收到时间
AcknowledgedAt *time.Time
// ReceivedSuccess 表示设备是否成功接收到下行消息。
// 并非所有发送实现都会同步返回是否送达
ReceivedSuccess *bool
} }
// Listener 用于监听其他设备发送过来的数据 // Listener 用于监听其他设备发送过来的数据