Compare commits

...

38 Commits

Author SHA1 Message Date
3743b5ddcd 增加创建和更新计划基本信息的界面 2025-09-10 20:04:38 +08:00
6fe73d8ffe 限制用户不能创建一个既有子计划又有步骤的计划 2025-09-10 19:36:34 +08:00
91f160b07e 优化计划展示界面 2025-09-10 19:27:42 +08:00
a1950872fc 1.修复参数解析bug
2. 增加查看计划详情的界面
2025-09-10 19:03:41 +08:00
c499571c11 修复参数解析bug 2025-09-10 18:16:07 +08:00
cc7ea94e41 1. 实现前端删除饲喂计划
2. 修复后端delete接口bug
2025-09-10 16:25:52 +08:00
40a19b831a 增加饲喂计划列表展示界面 2025-09-10 16:14:05 +08:00
9944340d17 1.注册饲喂计划相关路由
2. 实现create接口
2025-09-10 15:13:31 +08:00
4a70c1e839 修复更新计划时会用原计划ID创建新计划的问题 2025-09-10 15:00:29 +08:00
e75b3ee148 实现Delete接口 2025-09-10 14:46:47 +08:00
cbcba09d40 实现Update接口 2025-09-10 14:42:54 +08:00
4805e422f7 实现Detail接口 2025-09-10 14:35:54 +08:00
64c86de71e 实现UpdateFeedingPlan 2025-09-10 14:32:56 +08:00
3ecbf3b5af 实现DeleteFeedingPlan 2025-09-10 14:29:44 +08:00
1a14aec19b 1. 调整之前的Feed数据库查询方法
2. 实现CreateFeedingPlan
2025-09-10 14:15:03 +08:00
008677467b 1. 定义Detail接口
2. 实现ListPlans接口
2025-09-10 13:41:24 +08:00
2b4dd3e74d task增加任务完成后通知 2025-09-10 13:04:25 +08:00
8468a96398 model修改:
1. 增加子计划支持
2. 增加步骤和计划执行完后等待一段时间再执行下一个, 增加延迟执行和多次执行
2025-09-09 20:52:38 +08:00
e27aec0ca2 定义喂料计划model 2025-09-09 20:42:19 +08:00
52cf8c58ed 前端默认展开设备列表 2025-09-09 19:16:13 +08:00
d7c2ffb108 Merge pull request '单测' (#2) from 单测 into main
Reviewed-on: #2
2025-09-09 19:11:14 +08:00
43befdb71c 增加关闭hub 2025-09-09 19:11:08 +08:00
8d639d3b09 MockDeviceRepo 2025-09-09 18:45:53 +08:00
4f928dff9f MockDeviceRepo 2025-09-09 18:45:44 +08:00
946c02516c 修复时间戳校验失败的问题 2025-09-09 16:06:12 +08:00
0159626e1b Merge pull request '合并websocket逻辑' (#1) from 合并websocket逻辑 into main
Reviewed-on: #1
2025-09-09 15:29:43 +08:00
4c9f059af2 合并websocket逻辑 2025-09-09 15:27:53 +08:00
adb9a12a9d 合并websocket逻辑 2025-09-09 15:19:34 +08:00
3cc02d3a98 config增加心跳时间 2025-09-09 13:35:22 +08:00
d90698401d 调整心跳间隔方便调试 2025-09-09 13:16:50 +08:00
5e8a57d7e8 删掉没用的文件 2025-09-09 13:06:35 +08:00
00822427ca 实现在心跳中采集各设备信息 2025-09-09 11:51:13 +08:00
fc657d7448 增加心跳 2025-09-09 10:51:36 +08:00
8b22514aad 前端设备列表展示设备状态 2025-09-09 00:03:23 +08:00
75f9e07fcc 获取设备列表接口增加设备状态 2025-09-08 23:41:04 +08:00
bdbab31181 1. 增加状态池初始化
2. 把状态池注入需要的地方
2025-09-08 23:30:59 +08:00
15f500210f 定义设备状态池 2025-09-08 23:18:12 +08:00
f5893f5cde 1. 优化前端显示
2. 优化日志输出
2025-09-08 22:22:23 +08:00
38 changed files with 4116 additions and 799 deletions

View File

@@ -103,6 +103,29 @@ ws://[server_address]:[port]/ws/device?device_id=[device_id]
- `command`: 指令名称,固定为"query_all_device_status"
- `timestamp`: 指令发送时间
### 3.4 心跳包指令
平台向中继设备发送心跳包指令,用于检测设备连接状态并获取下级设备状态信息。
**请求格式**
```json
{
"type": "command",
"command": "heartbeat",
"data": {
"timestamp": 1672545600
},
"timestamp": "2023-01-01T12:00:00Z"
}
```
**参数说明**
- `type`: 消息类型,固定为"command"
- `command`: 指令名称,固定为"heartbeat"
- `data`: 指令数据
- `timestamp`: 时间戳Unix时间戳格式
- `timestamp`: 指令发送时间
## 4. 响应接口
### 4.1 设备控制响应
@@ -167,6 +190,48 @@ ws://[server_address]:[port]/ws/device?device_id=[device_id]
}
```
### 4.4 心跳包响应
中继设备响应心跳包指令,返回自身及下级设备的状态信息。
**响应格式**
```json
{
"type": "response",
"command": "heartbeat",
"data": {
"devices": [
{
"device_id": "relay-001",
"device_type": "relay",
"status": "running"
},
{
"device_id": "fan-001",
"device_type": "fan",
"status": "running"
},
{
"device_id": "curtain-001",
"device_type": "water_curtain",
"status": "stopped"
}
]
},
"timestamp": "2023-01-01T12:00:05Z"
}
```
**参数说明**
- `type`: 消息类型,固定为"response"
- `command`: 指令名称,固定为"heartbeat"
- `data`: 响应数据
- `devices`: 设备列表
- `device_id`: 设备唯一标识符
- `device_type`: 设备类型
- `status`: 设备状态(如: running, stopped, online, offline等
- `timestamp`: 平台发送的时间戳, 需要原封不动的返回
## 5. 请求-响应机制
平台在发送指令后会等待中继设备的响应超时时间由配置文件决定默认为5秒。
@@ -232,7 +297,31 @@ type CommandResponse struct {
}
```
### 7.2 响应处理规则
### 7.2 ParseData 方法
CommandResponse结构体提供了ParseData方法用于将响应数据解析到指定的结构体中
```go
func (cr *CommandResponse) ParseData(target interface{}) error
```
使用示例:
```go
// 定义目标结构体
type DeviceStatus struct {
DeviceID string `json:"device_id"`
Status string `json:"status"`
Message string `json:"message"`
}
// 解析响应数据
var status DeviceStatus
if err := response.ParseData(&status); err != nil {
// 处理错误
}
```
### 7.3 响应处理规则
1. `Status` 字段:表示操作的整体状态,如 "success"、"failed" 等
2. `Message` 字段:提供人类可读的操作结果描述
@@ -253,6 +342,7 @@ type CommandResponse struct {
|------|------|
| fan | 风机设备 |
| water_curtain | 水帘设备 |
| relay | 中继设备 |
## 10. 动作说明
@@ -268,4 +358,7 @@ type CommandResponse struct {
| success | 操作成功 |
| failed | 操作失败 |
| running | 设备运行中 |
| stopped | 设备已停止 |
| stopped | 设备已停止 |
| online | 设备在线 |
| offline | 设备离线 |
| active | 设备激活 |

View File

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

View File

@@ -25,4 +25,13 @@ database:
# WebSocket配置
websocket:
# WebSocket请求超时时间(秒)
timeout: 5
timeout: 5
# 心跳检测间隔(秒), 如果超过这个时间没有消息往来系统会自动发送一个心跳包维持长链接
heartbeat_interval: 54
# 心跳配置
heartbeat:
# 心跳间隔(秒)
interval: 30
# 请求并发数
concurrency: 5

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

21
frontend/dist/assets/index.cb9d3828.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>猪场管理系统</title>
<script type="module" crossorigin src="/assets/index.3d7f01fe.js"></script>
<link rel="stylesheet" href="/assets/index.fd8bdce3.css">
<script type="module" crossorigin src="/assets/index.cb9d3828.js"></script>
<link rel="stylesheet" href="/assets/index.bcc76856.css">
</head>
<body>
<div id="app"></div>

View File

@@ -0,0 +1,303 @@
<template>
<div class="feed-plan-form">
<div class="form-header">
<h2>{{ isEditMode ? '编辑饲喂计划' : '新建饲喂计划' }}</h2>
</div>
<form @submit.prevent="handleSubmit">
<div class="form-group">
<label for="name">计划名称 *</label>
<input
type="text"
id="name"
v-model="form.name"
required
:disabled="loading"
>
</div>
<div class="form-group">
<label for="description">计划描述</label>
<textarea
id="description"
v-model="form.description"
rows="3"
:disabled="loading"
></textarea>
</div>
<div class="form-group">
<label>计划类型 *</label>
<div class="radio-group">
<label class="radio-item">
<input
type="radio"
v-model="form.type"
value="manual"
:disabled="loading"
>
手动触发
</label>
<label class="radio-item">
<input
type="radio"
v-model="form.type"
value="auto"
:disabled="loading"
>
自动触发
</label>
</div>
</div>
<div class="form-group">
<label>
<input
type="checkbox"
v-model="form.enabled"
:disabled="loading"
>
启用计划
</label>
</div>
<div v-if="form.type === 'auto'" class="form-group">
<label for="schedule_cron">定时表达式</label>
<input
type="text"
id="schedule_cron"
v-model="form.schedule_cron"
placeholder="例如: 0 0 7 * * *"
:disabled="loading"
>
<div class="help-text">Cron表达式用于设置自动执行时间</div>
</div>
<div class="form-group">
<label for="execution_limit">执行次数限制</label>
<input
type="number"
id="execution_limit"
v-model.number="form.execution_limit"
min="0"
:disabled="loading"
>
<div class="help-text">0表示无限制</div>
</div>
<div class="form-actions">
<button
type="button"
class="btn btn-secondary"
@click="$emit('cancel')"
:disabled="loading"
>
取消
</button>
<button
type="submit"
class="btn btn-primary"
:disabled="loading || isSubmitting"
>
{{ loading || isSubmitting ? '处理中...' : (isEditMode ? '更新计划' : '创建计划') }}
</button>
</div>
</form>
</div>
</template>
<script>
export default {
name: 'FeedPlanForm',
props: {
// 编辑模式下的初始数据
initialData: {
type: Object,
default: () => ({
name: '',
description: '',
type: 'manual',
enabled: true,
schedule_cron: '',
execution_limit: 0
})
},
// 是否为编辑模式
isEditMode: {
type: Boolean,
default: false
},
// 提交时的加载状态
loading: {
type: Boolean,
default: false
}
},
data() {
return {
form: { ...this.initialData },
isSubmitting: false // 防止重复提交
}
},
watch: {
// 监听初始数据变化,更新表单
initialData: {
handler(newVal) {
this.form = { ...newVal }
},
deep: true
}
},
methods: {
async handleSubmit() {
// 防止重复提交
if (this.isSubmitting || this.loading) {
return
}
this.isSubmitting = true
try {
// 表单验证
if (!this.form.name.trim()) {
alert('请输入计划名称')
return
}
if (this.form.type === 'auto' && this.form.schedule_cron && !this.isValidCron(this.form.schedule_cron)) {
alert('请输入有效的Cron表达式')
return
}
// 触发提交事件
this.$emit('submit', { ...this.form })
} finally {
// 在下一个tick重置提交状态确保事件已经触发
this.$nextTick(() => {
this.isSubmitting = false
})
}
},
// 简单的Cron表达式验证
isValidCron(cron) {
// 这里可以添加更复杂的验证逻辑
// 现在只是简单检查格式
return typeof cron === 'string' && cron.trim().length > 0
}
}
}
</script>
<style scoped>
.feed-plan-form {
background: white;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.form-header h2 {
margin-top: 0;
color: #333;
text-align: center;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: #333;
}
.form-group input[type="text"],
.form-group input[type="number"],
.form-group textarea {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.form-group input[type="text"]:focus,
.form-group input[type="number"]:focus,
.form-group textarea:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.form-group input:disabled,
.form-group textarea:disabled {
background-color: #f8f9fa;
cursor: not-allowed;
}
.radio-group {
display: flex;
gap: 20px;
}
.radio-item {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
}
.help-text {
font-size: 12px;
color: #666;
margin-top: 5px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 15px;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #0069d9;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover:not(:disabled) {
background-color: #5a6268;
}
</style>

View File

@@ -12,6 +12,7 @@
<ul>
<li><router-link to="/dashboard" class="active">控制台</router-link></li>
<li><router-link to="/device">设备管理</router-link></li>
<li><router-link to="/feed/plan">饲喂计划</router-link></li>
</ul>
</div>

View File

@@ -8,6 +8,14 @@
</div>
</header>
<nav class="nav">
<ul>
<li><router-link to="/dashboard">控制台</router-link></li>
<li><router-link to="/device" class="active">设备管理</router-link></li>
<li><router-link to="/feed/plan">饲喂计划</router-link></li>
</ul>
</nav>
<main class="main-content">
<div class="toolbar">
<button class="btn btn-primary" @click="openAddDeviceModal">添加设备</button>
@@ -30,6 +38,7 @@
<span class="node-title">{{ relay.name }}</span>
<span class="node-type relay-type">{{ getDeviceTypeText(relay.type) }}</span>
<span v-if="relay.address" class="node-address">[{{ relay.address }}]</span>
<span class="node-status" :class="{ 'status-active': relay.active, 'status-inactive': !relay.active }">{{ relay.active ? '在线' : '离线' }}</span>
</div>
<div class="node-actions">
<button class="action-btn edit-btn" @click.stop="editDevice(relay)">编辑</button>
@@ -50,6 +59,7 @@
<span class="node-title">{{ controller.name }}</span>
<span class="node-type controller-type">{{ getDeviceTypeText(controller.type) }}</span>
<span v-if="controller.address" class="node-address">[{{ controller.address }}]</span>
<span class="node-status" :class="{ 'status-active': controller.active, 'status-inactive': !controller.active }">{{ controller.active ? '在线' : '离线' }}</span>
</div>
<div class="node-actions">
<button class="action-btn edit-btn" @click.stop="editDevice(controller)">编辑</button>
@@ -69,6 +79,7 @@
<span class="node-title">{{ leaf.name }}</span>
<span class="node-type device-type">{{ getDeviceTypeText(leaf.type) }}</span>
<span v-if="leaf.address" class="node-address">[{{ leaf.address }}]</span>
<span class="node-status" :class="{ 'status-active': leaf.active, 'status-inactive': !leaf.active }">{{ leaf.active ? '在线' : '离线' }}</span>
</div>
<div class="node-actions">
<button class="action-btn edit-btn" @click.stop="editDevice(leaf)">编辑</button>
@@ -131,11 +142,11 @@
<select id="parentId" v-model="deviceForm.parent_id">
<option value="">请选择上级设备</option>
<option
v-for="parent in getParentDevices(deviceForm.type)"
v-for="parent in getParentDevicesWithDisplayName(deviceForm.type)"
:key="parent.id"
:value="parent.id"
>
{{ parent.name }}
{{ parent.display_name }}
</option>
</select>
</div>
@@ -225,6 +236,11 @@ export default {
'fan': '风机',
'water_curtain': '水帘'
}
const statusMap = {
'online': '在线',
'offline': '离线',
'error': '故障'
}
return typeMap[type] || type
},
@@ -241,6 +257,33 @@ export default {
return []
},
// 获取带显示名称的上级设备选项
getParentDevicesWithDisplayName(currentType) {
const parents = this.getParentDevices(currentType);
if (currentType === 'pig_pen_controller' || currentType === 'feed_mill_controller') {
// 控制器的上级是中继设备,直接返回中继设备列表,显示设备名称
return parents.map(relay => ({
...relay,
display_name: relay.name
}));
} else if (currentType === 'fan' || currentType === 'water_curtain') {
// 设备的上级是控制器,需要构建"中继设备名 - 区域主控名"格式的显示名称
return parents.map(controller => {
// 查找控制器的上级中继设备
const relay = this.devices.find(device => device.id === controller.parent_id);
const relayName = relay ? relay.name : '未知中继';
return {
...controller,
display_name: `${relayName} - ${controller.name}`
};
});
}
return [];
},
// 加载设备列表
async loadDevices() {
try {
@@ -254,6 +297,8 @@ export default {
if (response.ok && data.code === 0) {
this.devices = data.data.devices
// 默认展开所有节点
this.expandAllNodes()
} else {
console.error('获取设备列表失败:', data.message)
}
@@ -262,6 +307,23 @@ export default {
}
},
// 展开所有节点
expandAllNodes() {
// 清空当前展开的节点
this.expandedNodes.clear()
// 展开所有中继设备节点
this.relayDevices.forEach(relay => {
this.expandedNodes.add(relay.id)
// 展开所有控制器设备节点
const controllers = this.getControllerDevices(relay.id)
controllers.forEach(controller => {
this.expandedNodes.add(controller.id)
})
})
},
// 打开添加设备模态框
openAddDeviceModal() {
this.editingDevice = null
@@ -427,6 +489,39 @@ export default {
background-color: #f5f7fa;
}
.nav {
background-color: #343a40;
padding: 0;
margin-bottom: 20px;
}
.nav ul {
list-style-type: none;
margin: 0;
padding: 0;
display: flex;
}
.nav li {
margin: 0;
}
.nav a {
display: block;
padding: 15px 20px;
color: #fff;
text-decoration: none;
transition: background-color 0.3s;
}
.nav a:hover {
background-color: #495057;
}
.nav a.active {
background-color: #007bff;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
@@ -594,6 +689,23 @@ export default {
text-transform: uppercase;
}
.node-status {
font-size: 0.8rem;
padding: 0.3rem 0.6rem;
border-radius: 12px;
font-weight: 500;
}
.status-active {
background: #4caf50;
color: white;
}
.status-inactive {
background: #9e9e9e;
color: white;
}
.relay-type {
background: #ff9800;
color: white;

View File

@@ -0,0 +1,514 @@
<template>
<div class="feed-plan-management">
<div class="header">
<h1>饲喂计划管理</h1>
<div class="user-info">
<span>欢迎, {{ username }}</span>
<button class="logout-btn" @click="logout">退出</button>
</div>
</div>
<nav class="nav">
<ul>
<li><router-link to="/dashboard">控制台</router-link></li>
<li><router-link to="/device">设备管理</router-link></li>
<li><router-link to="/feed/plan" class="active">饲喂计划</router-link></li>
</ul>
</nav>
<main class="main-content">
<div class="toolbar">
<button class="btn btn-primary" @click="createPlan">创建计划</button>
</div>
<div class="plan-list">
<div v-if="loading" class="loading">
加载中...
</div>
<div v-else-if="plans.length === 0" class="no-plans">
暂无饲喂计划
</div>
<div v-else class="plans-container">
<div
v-for="plan in plans"
:key="plan.id"
class="plan-card"
>
<div class="plan-header">
<h3>{{ plan.name }}</h3>
<span :class="['plan-status', { 'enabled': plan.enabled, 'disabled': !plan.enabled }]">
{{ plan.enabled ? '已启用' : '已禁用' }}
</span>
</div>
<div class="plan-details">
<p class="plan-description">{{ plan.description || '暂无描述' }}</p>
<div class="plan-meta">
<span class="plan-type">{{ plan.type === 'manual' ? '手动触发' : '自动触发' }}</span>
<span v-if="plan.schedule_cron" class="plan-cron">定时: {{ plan.schedule_cron }}</span>
</div>
</div>
<div class="plan-actions">
<button class="action-btn detail-btn" @click="viewDetail(plan.id)">详情</button>
<button class="action-btn edit-btn" @click="editPlan(plan)">编辑</button>
<button class="action-btn delete-btn" @click="deletePlan(plan.id)">删除</button>
</div>
</div>
</div>
</div>
</main>
<!-- 计划表单模态框 -->
<div v-if="showModal" class="modal-overlay" @click="closeModal">
<div class="modal-content" @click.stop>
<FeedPlanForm
:initial-data="currentPlan"
:is-edit-mode="modalType === 'edit'"
:loading="submitting"
@submit="submitForm"
@cancel="closeModal"
/>
</div>
</div>
</div>
</template>
<script>
import FeedPlanForm from '../components/FeedPlanForm.vue'
export default {
name: 'FeedPlan',
components: {
FeedPlanForm
},
data() {
return {
username: '',
plans: [],
loading: true,
// 控制模态框显示
showModal: false,
// 当前操作类型: 'create' 或 'edit'
modalType: 'create',
// 当前编辑的计划
currentPlan: null,
// 提交时的加载状态
submitting: false
}
},
mounted() {
this.username = localStorage.getItem('username') || '管理员'
this.loadPlans()
},
methods: {
// 加载饲喂计划列表
async loadPlans() {
this.loading = true
try {
const response = await fetch('/api/v1/feed/plan/list', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('authToken')
}
})
const data = await response.json()
if (response.ok && data.code === 0) {
this.plans = data.data.plans || []
} else {
console.error('获取饲喂计划列表失败:', data.message)
}
} catch (error) {
console.error('获取饲喂计划列表失败:', error)
} finally {
this.loading = false
}
},
// 查看详情
viewDetail(planId) {
this.$router.push(`/feed/plan/detail/${planId}`)
},
// 创建计划
createPlan() {
this.modalType = 'create'
this.currentPlan = {
name: '',
description: '',
type: 'manual',
enabled: true,
schedule_cron: '',
execution_limit: 0
}
this.showModal = true
},
// 编辑计划
editPlan(plan) {
this.modalType = 'edit'
// 深拷贝计划数据,避免直接修改原数据
this.currentPlan = JSON.parse(JSON.stringify(plan))
this.showModal = true
},
// 关闭模态框
closeModal() {
this.showModal = false
this.currentPlan = null
},
// 提交表单
async submitForm(formData) {
// 防止重复提交
if (this.submitting) {
return
}
this.submitting = true
try {
let url, method
if (this.modalType === 'create') {
url = '/api/v1/feed/plan/create'
method = 'POST'
} else {
url = '/api/v1/feed/plan/update'
method = 'POST'
}
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('authToken')
},
body: JSON.stringify(formData)
})
const data = await response.json()
if (response.ok && data.code === 0) {
// 提交成功,关闭模态框并重新加载列表
this.closeModal()
await this.loadPlans()
alert(this.modalType === 'create' ? '创建计划成功' : '更新计划成功')
} else {
alert((this.modalType === 'create' ? '创建计划失败: ' : '更新计划失败: ') + (data.message || '未知错误'))
}
} catch (error) {
console.error(this.modalType === 'create' ? '创建计划失败:' : '更新计划失败:', error)
alert((this.modalType === 'create' ? '创建计划失败: ' : '更新计划失败: ') + error.message)
} finally {
this.submitting = false
}
},
// 删除计划
async deletePlan(planId) {
if (!confirm('确定要删除这个饲喂计划吗?')) {
return
}
try {
const response = await fetch('/api/v1/feed/plan/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('authToken')
},
body: JSON.stringify({ id: planId })
})
const data = await response.json()
if (response.ok && data.code === 0) {
// 删除成功,重新加载列表
await this.loadPlans()
this.$message?.success('删除成功') || alert('删除成功')
} else {
this.$message?.error('删除失败: ' + data.message) || alert('删除失败: ' + data.message)
}
} catch (error) {
console.error('删除饲喂计划失败:', error)
this.$message?.error('删除饲喂计划失败: ' + error.message) || alert('删除饲喂计划失败: ' + error.message)
}
},
// 退出登录
logout() {
localStorage.removeItem('authToken')
localStorage.removeItem('username')
this.$router.push('/')
}
}
}
</script>
<style scoped>
.feed-plan-management {
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
}
.header h1 {
margin: 0;
color: #333;
}
.user-info {
display: flex;
align-items: center;
gap: 15px;
}
.logout-btn {
padding: 8px 16px;
background-color: #dc3545;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s;
}
.logout-btn:hover {
background-color: #c82333;
}
.nav {
background-color: #343a40;
padding: 0;
margin-bottom: 20px;
}
.nav ul {
list-style-type: none;
margin: 0;
padding: 0;
display: flex;
}
.nav li {
margin: 0;
}
.nav a {
display: block;
padding: 15px 20px;
color: #fff;
text-decoration: none;
transition: background-color 0.3s;
}
.nav a:hover {
background-color: #495057;
}
.nav a.active {
background-color: #007bff;
}
.toolbar {
margin-bottom: 20px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:hover {
background-color: #0069d9;
}
.plan-list {
min-height: 400px;
}
.loading, .no-plans {
text-align: center;
padding: 50px;
color: #666;
font-size: 16px;
}
.plans-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
}
.plan-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
background-color: white;
transition: box-shadow 0.3s, transform 0.3s;
}
.plan-card:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.plan-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.plan-header h3 {
margin: 0;
color: #333;
font-size: 18px;
}
.plan-status {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}
.plan-status.enabled {
background-color: #d4edda;
color: #155724;
}
.plan-status.disabled {
background-color: #f8d7da;
color: #721c24;
}
.plan-details {
margin-bottom: 20px;
}
.plan-description {
margin: 0 0 15px 0;
color: #666;
line-height: 1.5;
}
.plan-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.plan-type, .plan-cron {
padding: 4px 8px;
background-color: #e9ecef;
border-radius: 4px;
font-size: 12px;
color: #495057;
}
.plan-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.action-btn {
padding: 6px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: background-color 0.3s;
}
.detail-btn {
background-color: #17a2b8;
color: white;
}
.detail-btn:hover {
background-color: #138496;
}
.edit-btn {
background-color: #ffc107;
color: #212529;
}
.edit-btn:hover {
background-color: #e0a800;
}
.delete-btn {
background-color: #dc3545;
color: white;
}
.delete-btn:hover {
background-color: #c82333;
}
@media (max-width: 768px) {
.plans-container {
grid-template-columns: 1fr;
}
.header {
flex-direction: column;
gap: 15px;
align-items: flex-start;
}
.nav ul {
flex-direction: column;
}
}
/* 模态框样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
</style>

View File

@@ -0,0 +1,652 @@
<template>
<div class="feed-plan-detail">
<div class="header">
<h1>饲喂计划详情</h1>
<div class="user-info">
<span>欢迎, {{ username }}</span>
<button class="logout-btn" @click="logout">退出</button>
</div>
</div>
<nav class="nav">
<ul>
<li><router-link to="/dashboard">控制台</router-link></li>
<li><router-link to="/device">设备管理</router-link></li>
<li><router-link to="/feed/plan">饲喂计划</router-link></li>
<li><router-link to="/feed/plan/detail" class="active">计划详情</router-link></li>
</ul>
</nav>
<main class="main-content">
<div v-if="loading" class="loading">
加载中...
</div>
<div v-else-if="error" class="error">
{{ error }}
</div>
<div v-else-if="plan" class="plan-detail-container">
<div class="plan-header">
<h2>{{ plan.name }}</h2>
<span :class="['plan-status', { 'enabled': plan.enabled, 'disabled': !plan.enabled }]">
{{ plan.enabled ? '已启用' : '已禁用' }}
</span>
</div>
<div class="plan-info">
<div class="info-item">
<label>计划描述:</label>
<span>{{ plan.description || '无描述' }}</span>
</div>
<div class="info-item">
<label>计划类型:</label>
<span>{{ plan.type === 'manual' ? '手动触发' : '自动触发' }}</span>
</div>
<div v-if="plan.schedule_cron" class="info-item">
<label>定时表达式:</label>
<span>{{ plan.schedule_cron }}</span>
</div>
<div class="info-item">
<label>执行次数限制:</label>
<span>{{ plan.execution_limit > 0 ? plan.execution_limit : '无限制' }}</span>
</div>
<!-- 移除主计划中的父计划ID和顺序显示 -->
</div>
<div class="plan-steps">
<h3>计划步骤</h3>
<div v-if="plan.steps && plan.steps.length > 0" class="steps-list">
<div
v-for="(step, index) in plan.steps"
:key="step.id"
class="step-item"
>
<div class="step-header">
<span class="step-number">步骤 {{ index + 1 }}</span>
<span v-if="step.schedule_cron" class="step-cron">定时: {{ step.schedule_cron }}</span>
</div>
<div class="step-details">
<div class="detail-item">
<label>设备ID:</label>
<span>{{ step.device_id }}</span>
</div>
<div class="detail-item">
<label>目标值:</label>
<span>{{ step.target_value }}</span>
</div>
<div class="detail-item">
<label>动作:</label>
<span>{{ step.action }}</span>
</div>
<div class="detail-item">
<label>执行次数限制:</label>
<span>{{ step.execution_limit > 0 ? step.execution_limit : '无限制' }}</span>
</div>
</div>
</div>
</div>
<div v-else class="no-steps">
该计划暂无步骤
</div>
</div>
<div v-if="plan.sub_plans && plan.sub_plans.length > 0" class="sub-plans">
<h3>子计划</h3>
<div class="sub-plans-list">
<div
v-for="subPlan in plan.sub_plans"
:key="subPlan.id"
class="sub-plan-item"
>
<div class="sub-plan-header">
<h4>{{ subPlan.name }}</h4>
<span :class="['plan-status', { 'enabled': subPlan.enabled, 'disabled': !subPlan.enabled }]">
{{ subPlan.enabled ? '已启用' : '已禁用' }}
</span>
</div>
<div class="sub-plan-info">
<div class="info-item">
<label>描述:</label>
<span>{{ subPlan.description || '无描述' }}</span>
</div>
<div class="info-item">
<label>类型:</label>
<span>{{ subPlan.type === 'manual' ? '手动触发' : '自动触发' }}</span>
</div>
<div v-if="subPlan.schedule_cron" class="info-item">
<label>定时表达式:</label>
<span>{{ subPlan.schedule_cron }}</span>
</div>
<div class="info-item">
<label>顺序:</label>
<span>{{ (subPlan.order_in_parent || 0) + 1 }}</span>
</div>
<div v-if="subPlan.parent_id" class="info-item">
<label>父计划:</label>
<span>{{ getParentPlanName(subPlan.parent_id) }}(id:{{ subPlan.parent_id }})</span>
</div>
</div>
<div class="sub-plan-steps">
<h5>子计划步骤</h5>
<div v-if="subPlan.steps && subPlan.steps.length > 0" class="steps-list">
<div
v-for="(step, index) in subPlan.steps"
:key="step.id"
class="step-item"
>
<div class="step-header">
<span class="step-number">步骤 {{ index + 1 }}</span>
<span v-if="step.schedule_cron" class="step-cron">定时: {{ step.schedule_cron }}</span>
</div>
<div class="step-details">
<div class="detail-item">
<label>设备ID:</label>
<span>{{ step.device_id }}</span>
</div>
<div class="detail-item">
<label>目标值:</label>
<span>{{ step.target_value }}</span>
</div>
<div class="detail-item">
<label>动作:</label>
<span>{{ step.action }}</span>
</div>
<div class="detail-item">
<label>执行次数限制:</label>
<span>{{ step.execution_limit > 0 ? step.execution_limit : '无限制' }}</span>
</div>
</div>
</div>
</div>
<div v-else class="no-steps">
该子计划暂无步骤
</div>
</div>
</div>
</div>
</div>
<div class="actions">
<button class="btn btn-secondary" @click="goBack">返回列表</button>
<button class="btn btn-primary" @click="editPlan">编辑计划</button>
</div>
</div>
</main>
</div>
</template>
<script>
export default {
name: 'FeedPlanDetail',
data() {
return {
username: '',
plan: null,
loading: true,
error: null
}
},
mounted() {
this.username = localStorage.getItem('username') || '管理员'
this.loadPlanDetail()
},
methods: {
// 加载计划详情
async loadPlanDetail() {
this.loading = true
this.error = null
try {
const planId = this.$route.query.id || this.$route.params.id
if (!planId) {
this.error = '无效的计划ID'
return
}
const response = await fetch(`/api/v1/feed/plan/detail?id=${planId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('authToken')
}
})
const data = await response.json()
if (response.ok && data.code === 0) {
this.plan = data.data
// 加载子计划的详细信息(包括步骤)
await this.loadSubPlanDetails()
} else {
this.error = data.message || '获取计划详情失败'
}
} catch (error) {
console.error('获取计划详情失败:', error)
this.error = '获取计划详情失败: ' + error.message
} finally {
this.loading = false
}
},
// 加载子计划详情
async loadSubPlanDetails() {
if (!this.plan || !this.plan.sub_plans || this.plan.sub_plans.length === 0) {
return
}
// 遍历所有子计划
for (let i = 0; i < this.plan.sub_plans.length; i++) {
const subPlan = this.plan.sub_plans[i]
// 如果子计划没有步骤或步骤为空,则加载详细信息
if (!subPlan.steps || subPlan.steps.length === 0) {
try {
const response = await fetch(`/api/v1/feed/plan/detail?id=${subPlan.id}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('authToken')
}
})
const data = await response.json()
if (response.ok && data.code === 0) {
// 用详细信息替换原来的简略信息
this.plan.sub_plans[i] = data.data
}
} catch (error) {
console.error(`获取子计划 ${subPlan.id} 详情失败:`, error)
}
}
}
},
// 返回列表
goBack() {
this.$router.push('/feed/plan')
},
// 获取父计划名称
getParentPlanName(parentId) {
// 如果父计划就是当前主计划
if (this.plan && this.plan.id === parentId) {
return this.plan.name
}
// 检查是否在子计划中
if (this.plan && this.plan.sub_plans) {
const parentPlan = this.plan.sub_plans.find(plan => plan.id === parentId)
if (parentPlan) {
return parentPlan.name
}
}
// 默认返回"未知父计划"
return '未知父计划'
},
// 编辑计划
editPlan() {
// TODO: 实现编辑计划逻辑
alert('编辑计划功能待实现')
},
// 退出登录
logout() {
localStorage.removeItem('authToken')
localStorage.removeItem('username')
this.$router.push('/')
}
}
}
</script>
<style scoped>
.feed-plan-detail {
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
}
.header h1 {
margin: 0;
color: #333;
}
.user-info {
display: flex;
align-items: center;
gap: 15px;
}
.logout-btn {
padding: 8px 16px;
background-color: #dc3545;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s;
}
.logout-btn:hover {
background-color: #c82333;
}
.nav {
background-color: #343a40;
padding: 0;
margin-bottom: 20px;
}
.nav ul {
list-style-type: none;
margin: 0;
padding: 0;
display: flex;
}
.nav li {
margin: 0;
}
.nav a {
display: block;
padding: 15px 20px;
color: #fff;
text-decoration: none;
transition: background-color 0.3s;
}
.nav a:hover {
background-color: #495057;
}
.nav a.active {
background-color: #007bff;
}
.loading, .error {
text-align: center;
padding: 50px;
font-size: 16px;
}
.loading {
color: #666;
}
.error {
color: #dc3545;
}
.plan-detail-container {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
padding: 30px;
}
.plan-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
}
.plan-header h2 {
margin: 0;
color: #333;
}
.plan-status {
padding: 6px 12px;
border-radius: 4px;
font-size: 14px;
font-weight: bold;
}
.plan-status.enabled {
background-color: #d4edda;
color: #155724;
}
.plan-status.disabled {
background-color: #f8d7da;
color: #721c24;
}
.plan-info {
margin-bottom: 30px;
}
.info-item {
display: flex;
margin-bottom: 15px;
align-items: center;
}
.info-item label {
width: 150px;
font-weight: bold;
color: #333;
}
.info-item span {
flex: 1;
color: #666;
}
.plan-steps, .sub-plans {
margin-bottom: 30px;
}
.plan-steps h3, .sub-plans h3 {
margin-top: 0;
margin-bottom: 20px;
color: #333;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
.steps-list {
display: flex;
flex-direction: column;
gap: 20px;
}
.step-item {
border: 1px solid #ddd;
border-radius: 6px;
padding: 20px;
background-color: #f8f9fa;
}
.step-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
.step-number {
font-weight: bold;
color: #007bff;
}
.step-cron {
font-size: 12px;
color: #666;
background-color: #e9ecef;
padding: 2px 6px;
border-radius: 3px;
}
.step-details {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 15px;
}
.detail-item {
display: flex;
flex-direction: column;
}
.detail-item label {
font-size: 12px;
color: #666;
margin-bottom: 3px;
}
.detail-item span {
font-weight: 500;
color: #333;
}
.no-steps {
text-align: center;
padding: 30px;
color: #666;
font-style: italic;
}
.sub-plans-list {
display: flex;
flex-direction: column;
gap: 25px;
}
.sub-plan-item {
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
background-color: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.sub-plan-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
.sub-plan-header h4 {
margin: 0;
color: #333;
}
.sub-plan-info {
margin-bottom: 20px;
}
.sub-plan-info .info-item {
margin-bottom: 10px;
}
.sub-plan-steps h5 {
margin-top: 0;
margin-bottom: 15px;
color: #333;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 15px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:hover {
background-color: #0069d9;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #5a6268;
}
@media (max-width: 768px) {
.header {
flex-direction: column;
gap: 15px;
align-items: flex-start;
}
.nav ul {
flex-direction: column;
}
.info-item {
flex-direction: column;
align-items: flex-start;
gap: 5px;
}
.info-item label {
width: auto;
}
.step-details {
grid-template-columns: 1fr;
}
.actions {
flex-direction: column;
}
}
</style>

View File

@@ -2,6 +2,8 @@ import { createRouter, createWebHistory } from 'vue-router'
import Login from '../pages/Login.vue'
import Dashboard from '../pages/Dashboard.vue'
import Device from '../pages/Device.vue'
import FeedPlan from '../pages/FeedPlan.vue'
import FeedPlanDetail from '../pages/FeedPlanDetail.vue'
const routes = [
{
@@ -20,6 +22,19 @@ const routes = [
name: 'Device',
component: Device,
meta: { requiresAuth: true }
},
{
path: '/feed/plan',
name: 'FeedPlan',
component: FeedPlan,
meta: { requiresAuth: true }
},
{
path: '/feed/plan/detail/:id',
name: 'FeedPlanDetail',
component: FeedPlanDetail,
meta: { requiresAuth: true },
props: true
}
]

View File

@@ -1,9 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 3000,
proxy: {

7
go.mod
View File

@@ -6,6 +6,8 @@ require (
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.0.0
github.com/gorilla/websocket v1.5.0
github.com/panjf2000/ants/v2 v2.11.3
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.17.0
gopkg.in/yaml.v2 v2.4.0
gorm.io/driver/postgres v1.5.9
@@ -15,6 +17,7 @@ require (
require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
@@ -35,12 +38,14 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect

11
go.sum
View File

@@ -62,6 +62,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/panjf2000/ants/v2 v2.11.3 h1:AfI0ngBoXJmYOpDh9m516vjqoUu2sLrIVgppI9TZVpg=
github.com/panjf2000/ants/v2 v2.11.3/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -71,14 +73,17 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
@@ -90,8 +95,8 @@ golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=

View File

@@ -13,6 +13,7 @@ import (
"git.huangwc.com/pig/pig-farm-controller/internal/api/middleware"
"git.huangwc.com/pig/pig-farm-controller/internal/config"
"git.huangwc.com/pig/pig-farm-controller/internal/controller/device"
"git.huangwc.com/pig/pig-farm-controller/internal/controller/feed"
"git.huangwc.com/pig/pig-farm-controller/internal/controller/operation"
"git.huangwc.com/pig/pig-farm-controller/internal/controller/remote"
"git.huangwc.com/pig/pig-farm-controller/internal/controller/user"
@@ -44,6 +45,9 @@ type API struct {
// deviceController 设备控制控制器
deviceController *device.Controller
// feedController 饲喂管理控制器
feedController *feed.Controller
// remoteController 远程控制控制器
remoteController *remote.Controller
@@ -53,8 +57,11 @@ type API struct {
// websocketManager WebSocket管理器
websocketManager *websocket.Manager
// websocketService WebSocket服务
websocketService *service.WebSocketService
// heartbeatService 心跳服务
heartbeatService *service.HeartbeatService
// deviceStatusPool 设备状态池
deviceStatusPool *service.DeviceStatusPool
// logger 日志记录器
logger *logs.Logger
@@ -62,7 +69,16 @@ type API struct {
// NewAPI 创建并返回一个新的API实例
// 初始化Gin引擎和相关配置
func NewAPI(cfg *config.Config, userRepo repository.UserRepo, operationHistoryRepo repository.OperationHistoryRepo, deviceControlRepo repository.DeviceControlRepo, deviceRepo repository.DeviceRepo, websocketService *service.WebSocketService) *API {
func NewAPI(cfg *config.Config,
userRepo repository.UserRepo,
operationHistoryRepo repository.OperationHistoryRepo,
deviceControlRepo repository.DeviceControlRepo,
deviceRepo repository.DeviceRepo,
feedRepo repository.FeedPlanRepo,
websocketManager *websocket.Manager,
heartbeatService *service.HeartbeatService,
deviceStatusPool *service.DeviceStatusPool,
) *API {
// 设置Gin为发布模式
gin.SetMode(gin.DebugMode)
@@ -93,13 +109,13 @@ func NewAPI(cfg *config.Config, userRepo repository.UserRepo, operationHistoryRe
operationController := operation.NewController(operationHistoryRepo)
// 创建设备控制控制器
deviceController := device.NewController(deviceControlRepo, deviceRepo, websocketService)
deviceController := device.NewController(deviceControlRepo, deviceRepo, websocketManager, heartbeatService, deviceStatusPool)
// 创建WebSocket管理
websocketManager := websocket.NewManager(websocketService)
// 创建饲喂管理控制
feedController := feed.NewController(feedRepo)
// 创建远程控制控制器
remoteController := remote.NewController(websocketService)
remoteController := remote.NewController(websocketManager)
// 创建鉴权中间件
authMiddleware := middleware.NewAuthMiddleware(userRepo)
@@ -110,10 +126,12 @@ func NewAPI(cfg *config.Config, userRepo repository.UserRepo, operationHistoryRe
userController: userController,
operationController: operationController,
deviceController: deviceController,
feedController: feedController,
remoteController: remoteController,
authMiddleware: authMiddleware,
websocketManager: websocketManager,
websocketService: websocketService,
heartbeatService: heartbeatService,
deviceStatusPool: deviceStatusPool,
logger: logs.NewLogger(),
}
}
@@ -218,6 +236,17 @@ func (a *API) setupRoutes() {
deviceGroup.POST("/create", a.deviceController.Create)
deviceGroup.POST("/update", a.deviceController.Update)
deviceGroup.POST("/delete", a.deviceController.Delete)
deviceGroup.GET("/status", a.deviceController.GetDeviceStatus)
}
// 饲喂相关路由
feedGroup := protectedGroup.Group("/feed")
{
feedGroup.GET("/plan/list", a.feedController.ListPlans)
feedGroup.GET("/plan/detail", a.feedController.Detail)
feedGroup.POST("/plan/create", a.feedController.Create)
feedGroup.POST("/plan/update", a.feedController.Update)
feedGroup.POST("/plan/delete", a.feedController.Delete)
}
// 远程控制相关路由

View File

@@ -1,180 +0,0 @@
// Package api 提供统一的API接口层
// 负责处理所有外部请求包括HTTP和WebSocket接口
// 将请求路由到相应的服务层进行处理
package api
import (
"net/http"
"sync"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/service"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
// WebSocket消息类型常量
const (
// MessageTypeCommand 平台向设备发送的指令
MessageTypeCommand = "command"
// MessageTypeResponse 设备向平台发送的响应
MessageTypeResponse = "response"
// MessageTypeHeartbeat 心跳消息
MessageTypeHeartbeat = "heartbeat"
)
// WebSocketMessage WebSocket消息结构
type WebSocketMessage struct {
// Type 消息类型
Type string `json:"type"`
// DeviceID 设备ID
DeviceID string `json:"device_id,omitempty"`
// Command 指令内容
Command string `json:"command,omitempty"`
// Data 消息数据
Data interface{} `json:"data,omitempty"`
// Timestamp 时间戳
Timestamp time.Time `json:"timestamp"`
}
// WebSocketManager WebSocket管理器
type WebSocketManager struct {
// websocketService WebSocket服务
websocketService *service.WebSocketService
// logger 日志记录器
logger *logs.Logger
// upgrader WebSocket升级器
upgrader websocket.Upgrader
// mutex 互斥锁
mutex sync.RWMutex
// connections 设备连接映射
connections map[string]*websocket.Conn
}
// NewWebSocketManager 创建WebSocket管理器实例
func NewWebSocketManager(websocketService *service.WebSocketService) *WebSocketManager {
return &WebSocketManager{
websocketService: websocketService,
logger: logs.NewLogger(),
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
// 允许所有跨域请求
return true
},
},
connections: make(map[string]*websocket.Conn),
}
}
// HandleConnection 处理WebSocket连接
func (wm *WebSocketManager) HandleConnection(c *gin.Context) {
// 升级HTTP连接到WebSocket
conn, err := wm.upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
wm.logger.Error("WebSocket连接升级失败: " + err.Error())
return
}
// 获取设备ID
deviceID := c.Query("device_id")
if deviceID == "" {
wm.logger.Error("缺少设备ID参数")
conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "缺少设备ID参数"))
conn.Close()
return
}
// 添加连接到映射
wm.mutex.Lock()
wm.connections[deviceID] = conn
wm.mutex.Unlock()
wm.logger.Info("设备 " + deviceID + " 已连接")
// 发送连接成功消息
successMsg := service.WebSocketMessage{
Type: "system",
Command: "connected",
Timestamp: time.Now(),
}
conn.WriteJSON(successMsg)
// 处理消息循环
for {
// 读取消息
messageType, message, err := conn.ReadMessage()
if err != nil {
wm.logger.Error("读取设备 " + deviceID + " 消息失败: " + err.Error())
break
}
// 只处理文本消息
if messageType != websocket.TextMessage {
continue
}
// 处理设备消息
if err := wm.websocketService.HandleMessage(deviceID, message); err != nil {
wm.logger.Error("处理设备 " + deviceID + " 消息失败: " + err.Error())
continue
}
}
// 连接断开时清理
wm.mutex.Lock()
delete(wm.connections, deviceID)
wm.mutex.Unlock()
conn.Close()
wm.logger.Info("设备 " + deviceID + " 已断开连接")
}
// SendCommand 向指定设备发送指令
func (wm *WebSocketManager) SendCommand(deviceID, command string, data interface{}) error {
wm.mutex.RLock()
conn, exists := wm.connections[deviceID]
wm.mutex.RUnlock()
if !exists {
return wm.websocketService.SendCommand(deviceID, command, data)
}
// 构造消息
msg := service.WebSocketMessage{
Type: service.MessageTypeCommand,
Command: command,
Data: data,
Timestamp: time.Now(),
}
// 发送消息
if err := conn.WriteJSON(msg); err != nil {
return err
}
return nil
}
// GetConnectedDevices 获取已连接的设备列表
func (wm *WebSocketManager) GetConnectedDevices() []string {
wm.mutex.RLock()
defer wm.mutex.RUnlock()
devices := make([]string, 0, len(wm.connections))
for deviceID := range wm.connections {
devices = append(devices, deviceID)
}
return devices
}

View File

@@ -20,6 +20,9 @@ type Config struct {
// WebSocket WebSocket配置
WebSocket WebSocketConfig `yaml:"websocket"`
// Heartbeat 心跳配置
Heartbeat HeartbeatConfig `yaml:"heartbeat"`
}
// ServerConfig 代表服务器配置
@@ -74,6 +77,18 @@ type DatabaseConfig struct {
type WebSocketConfig struct {
// Timeout WebSocket请求超时时间(秒)
Timeout int `yaml:"timeout"`
// HeartbeatInterval 心跳检测间隔(秒), 如果超过这个时间没有消息往来系统会自动发送一个心跳包维持长链接
HeartbeatInterval int `yaml:"heartbeat_interval"`
}
// HeartbeatConfig 代表心跳配置
type HeartbeatConfig struct {
// Interval 心跳间隔(秒)
Interval int `yaml:"interval"`
// Concurrency 请求并发数
Concurrency int `yaml:"concurrency"`
}
// NewConfig 创建并返回一个新的配置实例
@@ -82,6 +97,9 @@ func NewConfig() *Config {
WebSocket: WebSocketConfig{
Timeout: 5, // 默认5秒超时
},
Heartbeat: HeartbeatConfig{
Interval: 30, // 默认30秒心跳间隔
},
}
}
@@ -115,10 +133,12 @@ func (c *Config) GetDatabaseConnectionString() string {
)
}
// GetWebSocketTimeout 获取WebSocket超时时间(秒)
func (c *Config) GetWebSocketTimeout() int {
if c.WebSocket.Timeout <= 0 {
return 5 // 默认5秒超时
}
return c.WebSocket.Timeout
// GetWebSocketConfig 获取WebSocket配置
func (c *Config) GetWebSocketConfig() WebSocketConfig {
return c.WebSocket
}
// GetHeartbeatConfig 获取心跳配置
func (c *Config) GetHeartbeatConfig() HeartbeatConfig {
return c.Heartbeat
}

View File

@@ -12,12 +12,19 @@ import (
"git.huangwc.com/pig/pig-farm-controller/internal/model"
"git.huangwc.com/pig/pig-farm-controller/internal/service"
"git.huangwc.com/pig/pig-farm-controller/internal/storage/repository"
"git.huangwc.com/pig/pig-farm-controller/internal/websocket"
"github.com/gin-gonic/gin"
)
// ListResponse 设备列表响应结构体
type ListResponse struct {
Devices []model.Device `json:"devices"`
Devices []DeviceListItem `json:"devices"`
}
// DeviceListItem 设备列表项结构体
type DeviceListItem struct {
model.Device
Active bool `json:"active"`
}
// DeviceRequest 设备创建/更新请求结构体
@@ -115,16 +122,20 @@ func (req *DeviceRequest) BindAndValidate(data []byte) error {
type Controller struct {
deviceControlRepo repository.DeviceControlRepo
deviceRepo repository.DeviceRepo
websocketService *service.WebSocketService
websocketManager *websocket.Manager
heartbeatService *service.HeartbeatService
deviceStatusPool *service.DeviceStatusPool
logger *logs.Logger
}
// NewController 创建设备控制控制器实例
func NewController(deviceControlRepo repository.DeviceControlRepo, deviceRepo repository.DeviceRepo, websocketService *service.WebSocketService) *Controller {
func NewController(deviceControlRepo repository.DeviceControlRepo, deviceRepo repository.DeviceRepo, websocketManager *websocket.Manager, heartbeatService *service.HeartbeatService, deviceStatusPool *service.DeviceStatusPool) *Controller {
return &Controller{
deviceControlRepo: deviceControlRepo,
deviceRepo: deviceRepo,
websocketService: websocketService,
websocketManager: websocketManager,
heartbeatService: heartbeatService,
deviceStatusPool: deviceStatusPool,
logger: logs.NewLogger(),
}
}
@@ -138,7 +149,22 @@ func (c *Controller) List(ctx *gin.Context) {
return
}
controller.SendSuccessResponse(ctx, "获取设备列表成功", ListResponse{Devices: devices})
// 构建设备列表项,包含设备状态信息
deviceList := make([]DeviceListItem, len(devices))
for i, device := range devices {
// 从设备状态池获取设备状态,默认为非激活状态
active := false
if status, exists := c.deviceStatusPool.GetStatus(strconv.FormatUint(uint64(device.ID), 10)); exists {
active = status.Active
}
deviceList[i] = DeviceListItem{
Device: device,
Active: active,
}
}
controller.SendSuccessResponse(ctx, "获取设备列表成功", ListResponse{Devices: deviceList})
}
// Create 创建设备
@@ -176,6 +202,9 @@ func (c *Controller) Create(ctx *gin.Context) {
return
}
// 刷新设备状态
c.heartbeatService.TriggerManualHeartbeatAsync()
controller.SendSuccessResponse(ctx, "创建设备成功", device)
}
@@ -230,9 +259,6 @@ func (c *Controller) Update(ctx *gin.Context) {
req.BusNumber != nil && req.DeviceAddress != nil {
device.Set485Address(*req.BusNumber, *req.DeviceAddress)
}
// TODO: 设备状态应该由系统自动获取,而不是由用户指定
// 这里保持设备原有状态,后续需要实现自动状态检测
// 设备状态现在只在内存中维护,不持久化到数据库
if err := c.deviceRepo.Update(device); err != nil {
c.logger.Error("更新设备失败: " + err.Error())
@@ -240,6 +266,9 @@ func (c *Controller) Update(ctx *gin.Context) {
return
}
// 刷新设备状态
c.heartbeatService.TriggerManualHeartbeatAsync()
controller.SendSuccessResponse(ctx, "更新设备成功", device)
}
@@ -268,6 +297,9 @@ func (c *Controller) Delete(ctx *gin.Context) {
return
}
// 刷新设备状态
c.heartbeatService.TriggerManualHeartbeatAsync()
controller.SendSuccessResponse(ctx, "删除设备成功", nil)
}
@@ -288,6 +320,12 @@ type SwitchResponseData struct {
Message string `json:"message"` // 添加消息字段
}
// DeviceStatusResponse 设备状态响应结构体
type DeviceStatusResponse struct {
DeviceID string `json:"device_id"`
Active bool `json:"active"`
}
// RelayControlData 发送给中继设备的控制数据结构体
type RelayControlData struct {
DeviceType string `json:"device_type"`
@@ -330,7 +368,7 @@ func (c *Controller) Switch(ctx *gin.Context) {
}
// 发送指令并等待响应
response, err := c.websocketService.SendCommandAndWait("relay-001", "control_device", controlData, 0)
response, err := c.websocketManager.SendCommandAndWait("relay-001", "control_device", controlData, 0)
if err != nil {
c.logger.Error("通过WebSocket发送设备控制指令失败: " + err.Error())
controller.SendErrorResponse(ctx, controller.InternalServerErrorCode, "设备控制失败: "+err.Error())
@@ -368,6 +406,9 @@ func (c *Controller) Switch(ctx *gin.Context) {
Message: message,
}
// 刷新设备状态
c.heartbeatService.TriggerManualHeartbeatAsync()
controller.SendSuccessResponse(ctx, "设备控制成功", data)
}
@@ -436,3 +477,28 @@ func (c *Controller) createDeviceControlRecord(userID uint, deviceID, deviceType
return c.deviceControlRepo.Create(control)
}
// GetDeviceStatus 获取设备当前状态
func (c *Controller) GetDeviceStatus(ctx *gin.Context) {
deviceID := ctx.Query("device_id")
if deviceID == "" {
controller.SendErrorResponse(ctx, controller.InvalidParameterCode, "设备ID不能为空")
return
}
// TODO 需要刷新设备状态吗? 刷新的话这个接口可能会很慢
// 从设备状态池中获取设备状态
status, exists := c.deviceStatusPool.GetStatus(deviceID)
if !exists {
controller.SendErrorResponse(ctx, controller.NotFoundCode, "设备状态不存在")
return
}
response := DeviceStatusResponse{
DeviceID: deviceID,
Active: status.Active,
}
controller.SendSuccessResponse(ctx, "获取设备状态成功", response)
}

View File

@@ -3,14 +3,471 @@
// 通过任务执行器执行具体控制任务
package feed
// FeedController 饲料控制器
import (
"fmt"
"strconv"
"git.huangwc.com/pig/pig-farm-controller/internal/controller"
"git.huangwc.com/pig/pig-farm-controller/internal/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/model"
"git.huangwc.com/pig/pig-farm-controller/internal/storage/repository"
"github.com/gin-gonic/gin"
)
// Controller 饲料控制器
// 管理饲料制备和分配设备的控制逻辑
type FeedController struct {
// TODO: 定义饲料控制器结构
type Controller struct {
feedPlanRepo repository.FeedPlanRepo
logger *logs.Logger
}
// NewFeedController 创建并返回一个新的饲料控制器实例
func NewFeedController() *FeedController {
// NewController 创建并返回一个新的饲料控制器实例
func NewController(feedPlanRepo repository.FeedPlanRepo) *Controller {
// TODO: 实现饲料控制器初始化
return &Controller{
feedPlanRepo: feedPlanRepo,
logger: logs.NewLogger(),
}
}
// CreateRequest 创建计划请求结构体
type CreateRequest struct {
// Name 计划名称
Name string `json:"name"`
// Description 计划描述
Description string `json:"description"`
// Type 计划类型(手动触发/自动触发)
Type model.FeedingPlanType `json:"type"`
// Enabled 是否启用
Enabled bool `json:"enabled"`
// ScheduleCron 定时任务表达式(仅当Type为auto时有效)
ScheduleCron *string `json:"schedule_cron,omitempty"`
// ExecutionLimit 执行次数限制(0表示无限制仅当Type为auto时有效)
ExecutionLimit int `json:"execution_limit"`
// ParentID 父计划ID用于支持子计划结构
ParentID *uint `json:"parent_id,omitempty"`
// OrderInParent 在父计划中的执行顺序
OrderInParent *int `json:"order_in_parent,omitempty"`
// IsMaster 是否为主计划(主计划可以包含子计划)
IsMaster bool `json:"is_master"`
// Steps 计划步骤列表
Steps []FeedingPlanStep `json:"steps"`
// SubPlans 子计划列表
SubPlans []CreateRequest `json:"sub_plans"`
}
// Create 创建饲料计划
func (c *Controller) Create(ctx *gin.Context) {
var req CreateRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
controller.SendErrorResponse(ctx, controller.InvalidParameterCode, "请求参数错误: "+err.Error())
return
}
// 校验计划结构
if err := c.validatePlanStructure(&req); err != nil {
controller.SendErrorResponse(ctx, controller.InvalidParameterCode, "计划结构错误: "+err.Error())
return
}
// 转换请求结构体为模型
plan := c.convertToCreateModel(&req)
// 调用仓库创建计划
if err := c.feedPlanRepo.CreateFeedingPlan(plan); err != nil {
c.logger.Error("创建计划失败: " + err.Error())
controller.SendErrorResponse(ctx, controller.InternalServerErrorCode, "创建计划失败")
return
}
controller.SendSuccessResponse(ctx, "创建计划成功", nil)
}
// validatePlanStructure 校验计划结构,不允许计划同时包含步骤和子计划
func (c *Controller) validatePlanStructure(req *CreateRequest) error {
// 检查当前计划是否同时包含步骤和子计划
if len(req.Steps) > 0 && len(req.SubPlans) > 0 {
return fmt.Errorf("计划不能同时包含步骤和子计划")
}
// 递归检查子计划
for _, subPlan := range req.SubPlans {
if err := c.validatePlanStructure(&subPlan); err != nil {
return err
}
}
return nil
}
// convertToCreateModel 将创建请求结构体转换为数据库模型
func (c *Controller) convertToCreateModel(req *CreateRequest) *model.FeedingPlan {
plan := &model.FeedingPlan{
Name: req.Name,
Description: req.Description,
Type: req.Type,
Enabled: req.Enabled,
ScheduleCron: req.ScheduleCron,
ExecutionLimit: req.ExecutionLimit,
ParentID: req.ParentID,
OrderInParent: req.OrderInParent,
// 不需要显式设置ID字段仓库层会处理
}
// 转换步骤
plan.Steps = make([]model.FeedingPlanStep, len(req.Steps))
for i, step := range req.Steps {
plan.Steps[i] = model.FeedingPlanStep{
// ID在创建时不需要设置
// PlanID会在创建过程中自动设置
StepOrder: step.StepOrder,
DeviceID: step.DeviceID,
TargetValue: step.TargetValue,
Action: step.Action,
ScheduleCron: step.ScheduleCron,
ExecutionLimit: step.ExecutionLimit,
}
}
// 转换子计划
plan.SubPlans = make([]model.FeedingPlan, len(req.SubPlans))
for i, subReq := range req.SubPlans {
plan.SubPlans[i] = *c.convertToCreateModel(&subReq)
}
return plan
}
// Delete 删除饲料计划
func (c *Controller) Delete(ctx *gin.Context) {
// 获取路径参数中的计划ID
var req struct {
ID uint `json:"id" binding:"required"`
}
if err := ctx.ShouldBindJSON(&req); err != nil {
controller.SendErrorResponse(ctx, controller.InvalidParameterCode, "请求参数错误: "+err.Error())
return
}
// 调用仓库删除计划
if err := c.feedPlanRepo.DeleteFeedingPlan(uint(req.ID)); err != nil {
c.logger.Error("删除计划失败: " + err.Error())
controller.SendErrorResponse(ctx, controller.InternalServerErrorCode, "删除计划失败")
return
}
controller.SendSuccessResponse(ctx, "删除计划成功", nil)
}
type ListPlansResponse struct {
Plans []ListPlanResponseItem `json:"plans"`
}
type ListPlanResponseItem struct {
// ID 计划ID
ID uint `json:"id"`
// Name 计划名称
Name string `json:"name"`
// Description 计划描述
Description string `json:"description"`
// Type 计划类型
Type model.FeedingPlanType `json:"type"`
// Enabled 是否启用
Enabled bool `json:"enabled"`
// ScheduleCron 定时任务表达式
ScheduleCron *string `json:"schedule_cron,omitempty"`
}
// ListPlans 获取饲料计划列表
func (c *Controller) ListPlans(ctx *gin.Context) {
introductions, err := c.feedPlanRepo.ListAllPlanIntroduction()
if err != nil {
c.logger.Error("获取设备列表失败: " + err.Error())
controller.SendErrorResponse(ctx, controller.InternalServerErrorCode, "获取计划列表失败")
}
resp := ListPlansResponse{
Plans: []ListPlanResponseItem{},
}
for _, introduction := range introductions {
resp.Plans = append(resp.Plans, ListPlanResponseItem{
ID: introduction.ID,
Name: introduction.Name,
Description: introduction.Description,
Enabled: introduction.Enabled,
Type: introduction.Type,
ScheduleCron: introduction.ScheduleCron,
})
}
controller.SendSuccessResponse(ctx, "success", resp)
}
// UpdateRequest 更新计划请求结构体
type UpdateRequest struct {
// ID 计划ID
ID uint `json:"id"`
// Name 计划名称
Name string `json:"name"`
// Description 计划描述
Description string `json:"description"`
// Type 计划类型(手动触发/自动触发)
Type model.FeedingPlanType `json:"type"`
// Enabled 是否启用
Enabled bool `json:"enabled"`
// ScheduleCron 定时任务表达式(仅当Type为auto时有效)
ScheduleCron *string `json:"schedule_cron,omitempty"`
// ExecutionLimit 执行次数限制(0表示无限制仅当Type为auto时有效)
ExecutionLimit int `json:"execution_limit"`
// ParentID 父计划ID用于支持子计划结构
ParentID *uint `json:"parent_id,omitempty"`
// OrderInParent 在父计划中的执行顺序
OrderInParent *int `json:"order_in_parent,omitempty"`
// IsMaster 是否为主计划(主计划可以包含子计划)
IsMaster bool `json:"is_master"`
// Steps 计划步骤列表
Steps []FeedingPlanStep `json:"steps"`
// SubPlans 子计划列表
SubPlans []UpdateRequest `json:"sub_plans"`
}
// DetailResponse 喂料计划主表
type DetailResponse struct {
// ID 计划ID
ID uint `json:"id"`
// Name 计划名称
Name string `json:"name"`
// Description 计划描述
Description string `json:"description"`
// Type 计划类型(手动触发/自动触发)
Type model.FeedingPlanType `json:"type"`
// Enabled 是否启用
Enabled bool `json:"enabled"`
// ScheduleCron 定时任务表达式(仅当Type为auto时有效)
ScheduleCron *string `json:"schedule_cron,omitempty"`
// ExecutionLimit 执行次数限制(0表示无限制仅当Type为auto时有效)
ExecutionLimit int `json:"execution_limit"`
// ParentID 父计划ID用于支持子计划结构
ParentID *uint `json:"parent_id,omitempty"`
// OrderInParent 在父计划中的执行顺序
OrderInParent *int `json:"order_in_parent,omitempty"`
// Steps 计划步骤列表
Steps []FeedingPlanStep `json:"steps"`
// SubPlans 子计划列表
SubPlans []DetailResponse `json:"sub_plans"`
}
// FeedingPlanStep 喂料计划步骤表,表示计划中的每个设备动作
type FeedingPlanStep struct {
// ID 步骤ID
ID uint `json:"id"`
// PlanID 关联的计划ID
PlanID uint `json:"plan_id"`
// StepOrder 步骤顺序
StepOrder int `json:"step_order"`
// DeviceID 关联的设备ID
DeviceID uint `json:"device_id"`
// TargetValue 目标值(达到该值后停止工作切换到下一个设备)
TargetValue float64 `json:"target_value"`
// Action 动作(如:打开设备)
Action string `json:"action"`
// ScheduleCron 步骤定时任务表达式(可选)
ScheduleCron *string `json:"schedule_cron,omitempty"`
// ExecutionLimit 步骤执行次数限制(0表示无限制)
ExecutionLimit int `json:"execution_limit"`
}
// Detail 获取饲料计划列细节
func (c *Controller) Detail(ctx *gin.Context) {
// 获取查询参数中的计划ID
planIDStr := ctx.Query("id")
if planIDStr == "" {
controller.SendErrorResponse(ctx, controller.InvalidParameterCode, "缺少计划ID参数")
return
}
planID, err := strconv.ParseUint(planIDStr, 10, 32)
if err != nil {
controller.SendErrorResponse(ctx, controller.InvalidParameterCode, "无效的计划ID")
return
}
// 从仓库中获取计划详情
plan, err := c.feedPlanRepo.FindFeedingPlanByID(uint(planID))
if err != nil {
c.logger.Error("获取计划详情失败: " + err.Error())
controller.SendErrorResponse(ctx, controller.InternalServerErrorCode, "获取计划详情失败")
return
}
// 转换为响应结构体
resp := c.convertToDetailResponse(plan)
controller.SendSuccessResponse(ctx, "success", resp)
}
// Update 更新饲料计划
func (c *Controller) Update(ctx *gin.Context) {
var req UpdateRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
controller.SendErrorResponse(ctx, controller.InvalidParameterCode, "请求参数错误: "+err.Error())
return
}
// 校验计划结构
if err := c.validateUpdatePlanStructure(&req); err != nil {
controller.SendErrorResponse(ctx, controller.InvalidParameterCode, "计划结构错误: "+err.Error())
return
}
// 转换请求结构体为模型
plan := c.convertToUpdateModel(&req)
// 调用仓库更新计划
if err := c.feedPlanRepo.UpdateFeedingPlan(plan); err != nil {
c.logger.Error("更新计划失败: " + err.Error())
controller.SendErrorResponse(ctx, controller.InternalServerErrorCode, "更新计划失败")
return
}
controller.SendSuccessResponse(ctx, "更新计划成功", nil)
}
// validateUpdatePlanStructure 校验更新计划结构,不允许计划同时包含步骤和子计划
func (c *Controller) validateUpdatePlanStructure(req *UpdateRequest) error {
// 检查当前计划是否同时包含步骤和子计划
if len(req.Steps) > 0 && len(req.SubPlans) > 0 {
return fmt.Errorf("计划不能同时包含步骤和子计划")
}
// 递归检查子计划
for _, subPlan := range req.SubPlans {
if err := c.validateUpdatePlanStructure(&subPlan); err != nil {
return err
}
}
return nil
}
// convertToUpdateModel 将更新请求结构体转换为数据库模型
func (c *Controller) convertToUpdateModel(req *UpdateRequest) *model.FeedingPlan {
plan := &model.FeedingPlan{
ID: req.ID,
Name: req.Name,
Description: req.Description,
Type: req.Type,
Enabled: req.Enabled,
ScheduleCron: req.ScheduleCron,
ExecutionLimit: req.ExecutionLimit,
ParentID: req.ParentID,
OrderInParent: req.OrderInParent,
Steps: make([]model.FeedingPlanStep, len(req.Steps)),
SubPlans: make([]model.FeedingPlan, len(req.SubPlans)),
}
// 转换步骤
for i, step := range req.Steps {
plan.Steps[i] = model.FeedingPlanStep{
ID: step.ID,
PlanID: step.PlanID,
StepOrder: step.StepOrder,
DeviceID: step.DeviceID,
TargetValue: step.TargetValue,
Action: step.Action,
ScheduleCron: step.ScheduleCron,
ExecutionLimit: step.ExecutionLimit,
}
}
// 转换子计划
for i, subReq := range req.SubPlans {
plan.SubPlans[i] = *c.convertToUpdateModel(&subReq)
}
return plan
}
// convertToDetailResponse 将数据库模型转换为响应结构体
func (c *Controller) convertToDetailResponse(plan *model.FeedingPlan) *DetailResponse {
resp := &DetailResponse{
ID: plan.ID,
Name: plan.Name,
Description: plan.Description,
Type: plan.Type,
Enabled: plan.Enabled,
ScheduleCron: plan.ScheduleCron,
ExecutionLimit: plan.ExecutionLimit,
ParentID: plan.ParentID,
OrderInParent: plan.OrderInParent,
Steps: make([]FeedingPlanStep, len(plan.Steps)),
SubPlans: make([]DetailResponse, len(plan.SubPlans)),
}
// 转换步骤
for i, step := range plan.Steps {
resp.Steps[i] = FeedingPlanStep{
ID: step.ID,
PlanID: step.PlanID,
StepOrder: step.StepOrder,
DeviceID: step.DeviceID,
TargetValue: step.TargetValue,
Action: step.Action,
ScheduleCron: step.ScheduleCron,
ExecutionLimit: step.ExecutionLimit,
}
}
// 转换子计划
for i, subPlan := range plan.SubPlans {
// 递归转换子计划
resp.SubPlans[i] = *c.convertToDetailResponse(&subPlan)
}
return resp
}

View File

@@ -5,20 +5,20 @@ package remote
import (
"git.huangwc.com/pig/pig-farm-controller/internal/controller"
"git.huangwc.com/pig/pig-farm-controller/internal/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/service"
"git.huangwc.com/pig/pig-farm-controller/internal/websocket"
"github.com/gin-gonic/gin"
)
// Controller 远程控制控制器
type Controller struct {
websocketService *service.WebSocketService
websocketManager *websocket.Manager
logger *logs.Logger
}
// NewController 创建远程控制控制器实例
func NewController(websocketService *service.WebSocketService) *Controller {
func NewController(websocketManager *websocket.Manager) *Controller {
return &Controller{
websocketService: websocketService,
websocketManager: websocketManager,
logger: logs.NewLogger(),
}
}
@@ -69,7 +69,7 @@ func (c *Controller) SendCommand(ctx *gin.Context) {
}
// 发送指令并等待响应
response, err := c.websocketService.SendCommandAndWait(req.DeviceID, req.Command, commandData, 0)
response, err := c.websocketManager.SendCommandAndWait(req.DeviceID, req.Command, commandData, 0)
if err != nil {
c.logger.Error("发送指令失败: " + err.Error())
controller.SendErrorResponse(ctx, controller.InternalServerErrorCode, "发送指令失败: "+err.Error())
@@ -100,7 +100,7 @@ type ListConnectedDevicesResponseData struct {
// @Router /api/v1/remote/devices [get]
func (c *Controller) ListConnectedDevices(ctx *gin.Context) {
// 获取已连接的设备列表
devices := c.websocketService.GetConnectedDevices()
devices := c.websocketManager.GetConnectedDevices()
data := ListConnectedDevicesResponseData{
Devices: devices,

View File

@@ -13,6 +13,7 @@ import (
"git.huangwc.com/pig/pig-farm-controller/internal/storage/db"
"git.huangwc.com/pig/pig-farm-controller/internal/storage/repository"
"git.huangwc.com/pig/pig-farm-controller/internal/task"
"git.huangwc.com/pig/pig-farm-controller/internal/websocket"
)
// Application 代表核心应用结构
@@ -39,8 +40,17 @@ type Application struct {
// DeviceRepo 设备仓库实例
DeviceRepo repository.DeviceRepo
// WebSocketService WebSocket服务实例
WebSocketService *service.WebSocketService
// FeedPlanRepo 投喂计划仓库实例
FeedPlanRepo repository.FeedPlanRepo
// WebsocketManager WebSocket管理器
WebsocketManager *websocket.Manager
// DeviceStatusPool 设备状态池实例
DeviceStatusPool *service.DeviceStatusPool
// HeartbeatService 心跳服务实例
HeartbeatService *service.HeartbeatService
// Config 应用配置
Config *config.Config
@@ -90,13 +100,30 @@ func (app *Application) Start() error {
// 初始化设备仓库
app.DeviceRepo = repository.NewDeviceRepo(app.Storage.GetDB())
app.FeedPlanRepo = repository.NewFeedPlanRepo(app.Storage.GetDB())
// 初始化设备状态池
app.DeviceStatusPool = service.NewDeviceStatusPool()
// 初始化WebSocket服务
app.WebSocketService = service.NewWebSocketService()
app.WebsocketManager = websocket.NewManager(app.DeviceRepo)
// 设置WebSocket超时时间
app.WebSocketService.SetDefaultTimeout(app.Config.GetWebSocketTimeout())
app.WebsocketManager.SetDefaultTimeout(app.Config.GetWebSocketConfig().Timeout)
// 初始化心跳服务
app.HeartbeatService = service.NewHeartbeatService(app.WebsocketManager, app.DeviceStatusPool, app.DeviceRepo, app.Config)
// 初始化API组件
app.API = api.NewAPI(app.Config, app.UserRepo, app.OperationHistoryRepo, app.DeviceControlRepo, app.DeviceRepo, app.WebSocketService)
app.API = api.NewAPI(app.Config,
app.UserRepo,
app.OperationHistoryRepo,
app.DeviceControlRepo,
app.DeviceRepo,
app.FeedPlanRepo,
app.WebsocketManager,
app.HeartbeatService,
app.DeviceStatusPool,
)
// 初始化任务执行器组件(使用5个工作协程)
app.TaskExecutor = task.NewExecutor(5)
@@ -111,6 +138,9 @@ func (app *Application) Start() error {
app.logger.Info("启动任务执行器")
app.TaskExecutor.Start()
// 启动心跳服务
app.logger.Info("启动心跳服务")
app.HeartbeatService.Start()
return nil
}
@@ -126,6 +156,10 @@ func (app *Application) Stop() error {
app.logger.Info("停止任务执行器")
app.TaskExecutor.Stop()
// 停止心跳服务
app.logger.Info("停止心跳服务")
app.HeartbeatService.Stop()
// 停止存储组件
if err := app.Storage.Disconnect(); err != nil {
return fmt.Errorf("存储断开连接失败: %v", err)

View File

@@ -1,174 +0,0 @@
// Package core 提供WebSocket服务功能
// 实现中继设备和平台之间的双向通信
package core
import (
"encoding/json"
"fmt"
"sync"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/model"
"github.com/gorilla/websocket"
)
// WebSocket消息类型常量
const (
// MessageTypeCommand 平台向设备发送的指令
MessageTypeCommand = "command"
// MessageTypeResponse 设备向平台发送的响应
MessageTypeResponse = "response"
// MessageTypeHeartbeat 心跳消息
MessageTypeHeartbeat = "heartbeat"
)
// WebSocketMessage WebSocket消息结构
type WebSocketMessage struct {
// Type 消息类型
Type string `json:"type"`
// DeviceID 设备ID
DeviceID string `json:"device_id,omitempty"`
// Command 指令内容
Command string `json:"command,omitempty"`
// Data 消息数据
Data interface{} `json:"data,omitempty"`
// Timestamp 时间戳
Timestamp time.Time `json:"timestamp"`
}
// DeviceConnection 设备连接信息
type DeviceConnection struct {
// DeviceID 设备ID
DeviceID string
// Connection WebSocket连接
Connection *websocket.Conn
// LastHeartbeat 最后心跳时间
LastHeartbeat time.Time
// DeviceInfo 设备信息
DeviceInfo *model.Device
}
// WebSocketService WebSocket服务
type WebSocketService struct {
// connections 设备连接映射
connections map[string]*DeviceConnection
// mutex 互斥锁
mutex sync.RWMutex
// logger 日志记录器
logger *logs.Logger
}
// NewWebSocketService 创建WebSocket服务实例
func NewWebSocketService() *WebSocketService {
return &WebSocketService{
connections: make(map[string]*DeviceConnection),
logger: logs.NewLogger(),
}
}
// AddConnection 添加设备连接
func (ws *WebSocketService) AddConnection(deviceID string, conn *websocket.Conn) {
ws.mutex.Lock()
defer ws.mutex.Unlock()
ws.connections[deviceID] = &DeviceConnection{
DeviceID: deviceID,
Connection: conn,
LastHeartbeat: time.Now(),
}
ws.logger.Info(fmt.Sprintf("设备 %s 已连接", deviceID))
}
// RemoveConnection 移除设备连接
func (ws *WebSocketService) RemoveConnection(deviceID string) {
ws.mutex.Lock()
defer ws.mutex.Unlock()
delete(ws.connections, deviceID)
ws.logger.Info(fmt.Sprintf("设备 %s 已断开连接", deviceID))
}
// SendCommand 向指定设备发送指令
func (ws *WebSocketService) SendCommand(deviceID, command string, data interface{}) error {
ws.mutex.RLock()
deviceConn, exists := ws.connections[deviceID]
ws.mutex.RUnlock()
if !exists {
return fmt.Errorf("设备 %s 未连接", deviceID)
}
// 构造消息
msg := WebSocketMessage{
Type: MessageTypeCommand,
Command: command,
Data: data,
Timestamp: time.Now(),
}
// 发送消息
if err := deviceConn.Connection.WriteJSON(msg); err != nil {
return fmt.Errorf("向设备 %s 发送指令失败: %v", deviceID, err)
}
return nil
}
// GetConnectedDevices 获取已连接的设备列表
func (ws *WebSocketService) GetConnectedDevices() []string {
ws.mutex.RLock()
defer ws.mutex.RUnlock()
devices := make([]string, 0, len(ws.connections))
for deviceID := range ws.connections {
devices = append(devices, deviceID)
}
return devices
}
// HandleMessage 处理来自设备的消息
func (ws *WebSocketService) HandleMessage(deviceID string, message []byte) error {
// 解析消息
var msg WebSocketMessage
if err := json.Unmarshal(message, &msg); err != nil {
return fmt.Errorf("解析设备 %s 消息失败: %v", deviceID, err)
}
// 更新心跳时间
if msg.Type == MessageTypeHeartbeat {
ws.mutex.Lock()
if deviceConn, exists := ws.connections[deviceID]; exists {
deviceConn.LastHeartbeat = time.Now()
}
ws.mutex.Unlock()
}
// 记录消息日志
ws.logger.Info(fmt.Sprintf("收到来自设备 %s 的消息: %v", deviceID, msg))
return nil
}
// GetDeviceConnection 获取设备连接信息
func (ws *WebSocketService) GetDeviceConnection(deviceID string) (*DeviceConnection, bool) {
ws.mutex.RLock()
defer ws.mutex.RUnlock()
deviceConn, exists := ws.connections[deviceID]
return deviceConn, exists
}

193
internal/model/feed.go Normal file
View File

@@ -0,0 +1,193 @@
package model
import (
"time"
"gorm.io/gorm"
)
// FeedingPlanType 喂料计划类型枚举
type FeedingPlanType string
const (
// FeedingPlanTypeManual 手动触发
FeedingPlanTypeManual FeedingPlanType = "manual"
// FeedingPlanTypeAuto 自动触发
FeedingPlanTypeAuto FeedingPlanType = "auto"
)
// FeedingPlan 喂料计划主表
type FeedingPlan struct {
// ID 计划ID
ID uint `gorm:"primaryKey;column:id" json:"id"`
// Name 计划名称
Name string `gorm:"not null;column:name" json:"name"`
// Description 计划描述
Description string `gorm:"column:description" json:"description"`
// Type 计划类型(手动触发/自动触发)
Type FeedingPlanType `gorm:"not null;column:type" json:"type"`
// Enabled 是否启用
Enabled bool `gorm:"not null;default:true;column:enabled" json:"enabled"`
// ScheduleCron 定时任务表达式(仅当Type为auto时有效)
ScheduleCron *string `gorm:"column:schedule_cron" json:"schedule_cron,omitempty"`
// ExecutionLimit 执行次数限制(0表示无限制仅当Type为auto时有效)
ExecutionLimit int `gorm:"not null;default:0;column:execution_limit" json:"execution_limit"`
// ParentID 父计划ID用于支持子计划结构
ParentID *uint `gorm:"column:parent_id;index" json:"parent_id,omitempty"`
// OrderInParent 在父计划中的执行顺序
OrderInParent *int `gorm:"column:order_in_parent" json:"order_in_parent,omitempty"`
// CreatedAt 创建时间
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
// UpdatedAt 更新时间
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
// DeletedAt 删除时间(用于软删除)
DeletedAt gorm.DeletedAt `gorm:"index;column:deleted_at" json:"-"`
// Steps 计划步骤列表
Steps []FeedingPlanStep `gorm:"foreignKey:PlanID" json:"-"`
// SubPlans 子计划列表
SubPlans []FeedingPlan `gorm:"foreignKey:ParentID" json:"-"`
}
// TableName 指定FeedingPlan模型对应的数据库表名
func (FeedingPlan) TableName() string {
return "feeding_plans"
}
// FeedingPlanStep 喂料计划步骤表,表示计划中的每个设备动作
type FeedingPlanStep struct {
// ID 步骤ID
ID uint `gorm:"primaryKey;column:id" json:"id"`
// PlanID 关联的计划ID
PlanID uint `gorm:"not null;column:plan_id;index" json:"plan_id"`
// StepOrder 步骤顺序
StepOrder int `gorm:"not null;column:step_order" json:"step_order"`
// DeviceID 关联的设备ID
DeviceID uint `gorm:"not null;column:device_id;index" json:"device_id"`
// TargetValue 目标值(达到该值后停止工作切换到下一个设备)
TargetValue float64 `gorm:"not null;column:target_value" json:"target_value"`
// Action 动作(如:打开设备)
Action string `gorm:"not null;column:action" json:"action"`
// ScheduleCron 步骤定时任务表达式(可选)
ScheduleCron *string `gorm:"column:schedule_cron" json:"schedule_cron,omitempty"`
// ExecutionLimit 步骤执行次数限制(0表示无限制)
ExecutionLimit int `gorm:"not null;default:0;column:execution_limit" json:"execution_limit"`
// CreatedAt 创建时间
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
// UpdatedAt 更新时间
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
// DeletedAt 删除时间(用于软删除)
DeletedAt gorm.DeletedAt `gorm:"index;column:deleted_at" json:"-"`
}
// TableName 指定FeedingPlanStep模型对应的数据库表名
func (FeedingPlanStep) TableName() string {
return "feeding_plan_steps"
}
// FeedingExecution 喂料执行记录表
type FeedingExecution struct {
// ID 执行记录ID
ID uint `gorm:"primaryKey;column:id" json:"id"`
// PlanID 关联的计划ID
PlanID uint `gorm:"not null;column:plan_id;index" json:"plan_id"`
// MasterPlanID 主计划ID如果是子计划执行记录主计划ID
MasterPlanID *uint `gorm:"column:master_plan_id;index" json:"master_plan_id,omitempty"`
// TriggerType 触发类型(手动/自动)
TriggerType FeedingPlanType `gorm:"not null;column:trigger_type" json:"trigger_type"`
// Status 执行状态(进行中/已完成/已取消/失败)
Status string `gorm:"not null;column:status" json:"status"`
// StartedAt 开始执行时间
StartedAt *time.Time `gorm:"column:started_at" json:"started_at,omitempty"`
// FinishedAt 完成时间
FinishedAt *time.Time `gorm:"column:finished_at" json:"finished_at,omitempty"`
// CreatedAt 创建时间
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
// UpdatedAt 更新时间
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
// DeletedAt 删除时间(用于软删除)
DeletedAt gorm.DeletedAt `gorm:"index;column:deleted_at" json:"-"`
// Steps 执行步骤详情
Steps []FeedingExecutionStep `gorm:"foreignKey:ExecutionID" json:"-"`
}
// TableName 指定FeedingExecution模型对应的数据库表名
func (FeedingExecution) TableName() string {
return "feeding_executions"
}
// FeedingExecutionStep 喂料执行步骤详情表
type FeedingExecutionStep struct {
// ID 执行步骤ID
ID uint `gorm:"primaryKey;column:id" json:"id"`
// ExecutionID 关联的执行记录ID
ExecutionID uint `gorm:"not null;column:execution_id;index" json:"execution_id"`
// StepID 关联的计划步骤ID
StepID uint `gorm:"not null;column:step_id;index" json:"step_id"`
// DeviceID 关联的设备ID
DeviceID uint `gorm:"not null;column:device_id;index" json:"device_id"`
// TargetValue 目标值
TargetValue float64 `gorm:"not null;column:target_value" json:"target_value"`
// ActualValue 实际值
ActualValue *float64 `gorm:"column:actual_value" json:"actual_value,omitempty"`
// Status 步骤状态(待执行/执行中/已完成/失败)
Status string `gorm:"not null;column:status" json:"status"`
// StartedAt 开始执行时间
StartedAt *time.Time `gorm:"column:started_at" json:"started_at,omitempty"`
// FinishedAt 完成时间
FinishedAt *time.Time `gorm:"column:finished_at" json:"finished_at,omitempty"`
// CreatedAt 创建时间
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
// UpdatedAt 更新时间
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
// DeletedAt 删除时间(用于软删除)
DeletedAt gorm.DeletedAt `gorm:"index;column:deleted_at" json:"-"`
}
// TableName 指定FeedingExecutionStep模型对应的数据库表名
func (FeedingExecutionStep) TableName() string {
return "feeding_execution_steps"
}

View File

@@ -0,0 +1,83 @@
// Package service 提供各种业务服务功能
package service
import (
"sync"
)
// DeviceStatus 设备状态信息
type DeviceStatus struct {
// Active 设备是否启动
Active bool
}
// DeviceStatusPool 设备状态池,用于管理所有设备的当前状态
type DeviceStatusPool struct {
// statuses 设备状态映射 设备ID:状态
statuses map[string]*DeviceStatus
// mutex 读写锁,保证并发安全
mutex sync.RWMutex
}
// NewDeviceStatusPool 创建设备状态池实例
func NewDeviceStatusPool() *DeviceStatusPool {
return &DeviceStatusPool{
statuses: make(map[string]*DeviceStatus),
}
}
// SetStatus 设置设备状态
func (dsp *DeviceStatusPool) SetStatus(deviceID string, status *DeviceStatus) {
dsp.mutex.Lock()
defer dsp.mutex.Unlock()
dsp.statuses[deviceID] = status
}
// GetStatus 获取设备状态
func (dsp *DeviceStatusPool) GetStatus(deviceID string) (*DeviceStatus, bool) {
dsp.mutex.RLock()
defer dsp.mutex.RUnlock()
status, exists := dsp.statuses[deviceID]
return status, exists
}
// DeleteStatus 删除设备状态
func (dsp *DeviceStatusPool) DeleteStatus(deviceID string) {
dsp.mutex.Lock()
defer dsp.mutex.Unlock()
delete(dsp.statuses, deviceID)
}
// GetAllStatuses 获取所有设备状态
func (dsp *DeviceStatusPool) GetAllStatuses() map[string]*DeviceStatus {
dsp.mutex.RLock()
defer dsp.mutex.RUnlock()
// 创建副本以避免外部修改
result := make(map[string]*DeviceStatus)
for id, status := range dsp.statuses {
result[id] = status
}
return result
}
// SetAllStatuses 全量更新设备状态池
func (dsp *DeviceStatusPool) SetAllStatuses(statuses map[string]*DeviceStatus) {
dsp.mutex.Lock()
defer dsp.mutex.Unlock()
// 清空现有状态
for id := range dsp.statuses {
delete(dsp.statuses, id)
}
// 添加新状态
for id, status := range statuses {
dsp.statuses[id] = status
}
}

View File

@@ -0,0 +1,297 @@
// Package service 提供各种业务服务功能
package service
import (
"context"
"errors"
"fmt"
"sync"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/config"
"git.huangwc.com/pig/pig-farm-controller/internal/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/model"
"git.huangwc.com/pig/pig-farm-controller/internal/storage/repository"
"git.huangwc.com/pig/pig-farm-controller/internal/websocket"
"github.com/panjf2000/ants/v2"
)
// HeartbeatService 心跳服务,负责管理设备的心跳检测
type HeartbeatService struct {
// websocketManager WebSocket管理器
websocketManager *websocket.Manager
// deviceStatusPool 设备状态池
deviceStatusPool *DeviceStatusPool
// deviceRepo 设备仓库
deviceRepo repository.DeviceRepo
// logger 日志记录器
logger *logs.Logger
// 心跳间隔
heartbeatInterval time.Duration
// 手动心跳触发器
triggerChan chan struct{}
// ticker 心跳定时器
ticker *time.Ticker
// poolSize 线程池大小
poolSize int
// pool 线程池
pool *ants.Pool
// ctx 上下文
ctx context.Context
// cancel 取消函数
cancel context.CancelFunc
}
// NewHeartbeatService 创建心跳服务实例
func NewHeartbeatService(websocketManager *websocket.Manager, deviceStatusPool *DeviceStatusPool, deviceRepo repository.DeviceRepo, config *config.Config) *HeartbeatService {
interval := config.GetHeartbeatConfig().Interval
if interval <= 0 {
interval = 30 // 默认30秒心跳间隔
}
concurrency := config.GetHeartbeatConfig().Concurrency
if concurrency <= 0 {
concurrency = 10 // 默认10个并发
}
return &HeartbeatService{
websocketManager: websocketManager,
deviceStatusPool: deviceStatusPool,
deviceRepo: deviceRepo,
logger: logs.NewLogger(),
heartbeatInterval: time.Duration(interval) * time.Second,
poolSize: concurrency,
triggerChan: make(chan struct{}),
}
}
// Start 启动心跳服务
func (hs *HeartbeatService) Start() {
// 创建上下文
ctx, cancel := context.WithCancel(context.Background())
hs.cancel = cancel
// 创建定时器
hs.logger.Info(fmt.Sprintf("设置心跳间隔为 %d 秒", int(hs.heartbeatInterval.Seconds())))
hs.ticker = time.NewTicker(hs.heartbeatInterval)
// 创建线程池
hs.pool, _ = ants.NewPool(hs.poolSize)
// 启动心跳goroutine
go func() {
for {
select {
case <-hs.ticker.C:
hs.handleHeartbeatAll()
case <-hs.triggerChan:
hs.handleHeartbeatAll()
case <-ctx.Done():
hs.logger.Info("心跳服务已停止")
return
}
}
}()
hs.logger.Info("心跳服务已启动")
}
// Stop 停止心跳服务
func (hs *HeartbeatService) Stop() {
if hs == nil {
return
}
if hs.ticker != nil {
hs.ticker.Stop()
}
if hs.cancel != nil {
hs.cancel()
}
if hs.pool != nil {
hs.pool.Release()
}
hs.logger.Info("[Heartbeat] 心跳任务停止指令已发送")
}
// TriggerManualHeartbeat 手动触发心跳检测
func (hs *HeartbeatService) TriggerManualHeartbeat() {
hs.logger.Info("收到手动触发心跳检测请求")
hs.triggerChan <- struct{}{}
hs.logger.Info("手动心跳检测完成")
}
// TriggerManualHeartbeatAsync 手动触发心跳检测且不等待检测结果
func (hs *HeartbeatService) TriggerManualHeartbeatAsync() {
hs.logger.Info("收到手动触发异步心跳检测请求")
go func() {
hs.triggerChan <- struct{}{}
hs.logger.Info("手动心跳检测完成")
}()
}
// sendHeartbeatAll 发送心跳包到所有中继设备
func (hs *HeartbeatService) handleHeartbeatAll() {
// 记录心跳开始日志
hs.logger.Debug("开始发送心跳包")
// 获取所有中继设备
relays, err := hs.deviceRepo.ListAll()
if err != nil {
hs.logger.Error("获取设备列表失败: " + err.Error())
return
}
// 创建线程安全的临时map用于保存所有设备状态
tempStatusMap := &TempStatusMap{
data: make(map[string]*DeviceStatus),
mu: sync.RWMutex{},
}
// 遍历所有连接的设备并发送心跳包
wg := sync.WaitGroup{}
for _, relay := range relays {
// 心跳包之发送给中继设备
if relay.Type != model.DeviceTypeRelay {
continue
}
id := fmt.Sprintf("%v", relay.ID)
name := relay.Name
wg.Add(1)
err := hs.pool.Submit(func() {
defer wg.Done()
err := hs.handleHeartbeatWithStatus(id, tempStatusMap)
if err != nil {
hs.logger.Error("[Heartbeat] 向设备 " + name + "(id:" + id + ") 发送心跳包失败: " + err.Error())
}
})
if err != nil {
hs.logger.Error("向设备 " + name + "(id:" + id + ") 发送心跳包失败(线程池异常): " + err.Error())
}
}
wg.Wait()
// 获取所有设备列表
allDevices, err := hs.deviceRepo.ListAll()
if err != nil {
hs.logger.Error("获取所有设备列表失败: " + err.Error())
return
}
// 补齐临时map中缺失的设备缺失的设备全部设为离线状态
tempStatusMap.mu.Lock()
for _, device := range allDevices {
id := fmt.Sprintf("%v", device.ID)
if _, exists := tempStatusMap.data[id]; !exists {
tempStatusMap.data[id] = &DeviceStatus{
Active: false,
}
}
}
tempStatusMap.mu.Unlock()
// 将临时状态更新到全局状态池
hs.deviceStatusPool.SetAllStatuses(tempStatusMap.data)
hs.logger.Debug("心跳包发送完成")
}
// TempStatusMap 线程安全的临时状态映射
type TempStatusMap struct {
data map[string]*DeviceStatus
mu sync.RWMutex
}
// SetStatus 设置设备状态
func (tsm *TempStatusMap) SetStatus(deviceID string, status *DeviceStatus) {
tsm.mu.Lock()
defer tsm.mu.Unlock()
tsm.data[deviceID] = status
}
// GetStatus 获取设备状态
func (tsm *TempStatusMap) GetStatus(deviceID string) (*DeviceStatus, bool) {
tsm.mu.RLock()
defer tsm.mu.RUnlock()
status, exists := tsm.data[deviceID]
return status, exists
}
// sendHeartbeat 发送心跳包到所有中继设备
func (hs *HeartbeatService) handleHeartbeatWithStatus(deviceID string, tempStatusMap *TempStatusMap) error {
// 构造带时间戳的心跳包数据
heartbeatData := map[string]interface{}{
"timestamp": time.Now().Unix(),
}
// 发送心跳包到设备
response, err := hs.websocketManager.SendCommandAndWait(deviceID, "heartbeat", heartbeatData, 0)
if err != nil {
hs.logger.Error(fmt.Sprintf("向设备 %s 发送心跳包失败: %v", deviceID, err))
// 更新设备状态为离线
tempStatusMap.SetStatus(deviceID, &DeviceStatus{
Active: false,
})
return err
}
// 记录收到心跳响应
hs.logger.Debug(fmt.Sprintf("收到来自设备 %s 的心跳响应: %+v", deviceID, response))
// 有响应中继设备就是在线
tempStatusMap.SetStatus(deviceID, &DeviceStatus{
Active: true,
})
// 时间戳校验
if response.Timestamp.Unix() != heartbeatData["timestamp"] {
hs.logger.Error(fmt.Sprintf("心跳响应时间戳校验失败: %v , 响应时间戳应当与发送的时间戳一致", response))
return errors.New("心跳响应时间戳校验失败")
}
// 解析响应中的下级设备状态
type DeviceStatusInfo struct {
DeviceID string `json:"device_id"`
DeviceType string `json:"device_type"`
Status string `json:"status"`
}
type HeartbeatResponseData struct {
Devices []DeviceStatusInfo `json:"devices"`
}
var responseData HeartbeatResponseData
if err := response.ParseData(&responseData); err != nil {
hs.logger.Error(fmt.Sprintf("解析设备 %s 的心跳响应数据失败: %v", deviceID, err))
return err
}
// 更新所有下级设备的状态
for _, device := range responseData.Devices {
// 根据设备状态确定Active值
isActive := device.Status == "running" || device.Status == "online" || device.Status == "active"
tempStatusMap.SetStatus(device.DeviceID, &DeviceStatus{
Active: isActive,
})
}
return nil
}

View File

@@ -1,302 +0,0 @@
// Package service 提供WebSocket服务功能
// 实现中继设备和平台之间的双向通信
package service
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/logs"
"github.com/gorilla/websocket"
)
// WebSocket消息类型常量
const (
// MessageTypeCommand 平台向设备发送的指令
MessageTypeCommand = "command"
// MessageTypeResponse 设备向平台发送的响应
MessageTypeResponse = "response"
// MessageTypeHeartbeat 心跳消息
MessageTypeHeartbeat = "heartbeat"
)
// WebSocketMessage WebSocket消息结构
type WebSocketMessage struct {
// Type 消息类型
Type string `json:"type"`
// DeviceID 设备ID
DeviceID string `json:"device_id,omitempty"`
// Command 指令内容
Command string `json:"command,omitempty"`
// Data 消息数据
Data interface{} `json:"data,omitempty"`
// Timestamp 时间戳
Timestamp time.Time `json:"timestamp"`
}
// DeviceConnection 设备连接信息
type DeviceConnection struct {
// DeviceID 设备ID
DeviceID string
// Connection WebSocket连接
Connection *websocket.Conn
// LastHeartbeat 最后心跳时间
LastHeartbeat time.Time
// ResponseChan 响应通道
ResponseChan chan *WebSocketMessage
}
// WebSocketService WebSocket服务
type WebSocketService struct {
// connections 设备连接映射
connections map[string]*DeviceConnection
// mutex 互斥锁
mutex sync.RWMutex
// logger 日志记录器
logger *logs.Logger
// defaultTimeout 默认超时时间(秒)
defaultTimeout int
}
// NewWebSocketService 创建WebSocket服务实例
func NewWebSocketService() *WebSocketService {
return &WebSocketService{
connections: make(map[string]*DeviceConnection),
logger: logs.NewLogger(),
defaultTimeout: 5, // 默认5秒超时
}
}
// SetDefaultTimeout 设置默认超时时间
func (ws *WebSocketService) SetDefaultTimeout(timeout int) {
ws.defaultTimeout = timeout
}
// AddConnection 添加设备连接
func (ws *WebSocketService) AddConnection(deviceID string, conn *websocket.Conn) {
ws.mutex.Lock()
defer ws.mutex.Unlock()
ws.connections[deviceID] = &DeviceConnection{
DeviceID: deviceID,
Connection: conn,
LastHeartbeat: time.Now(),
}
ws.logger.Info(fmt.Sprintf("设备 %s 已连接", deviceID))
}
// RemoveConnection 移除设备连接
func (ws *WebSocketService) RemoveConnection(deviceID string) {
ws.mutex.Lock()
defer ws.mutex.Unlock()
delete(ws.connections, deviceID)
ws.logger.Info(fmt.Sprintf("设备 %s 已断开连接", deviceID))
}
// SetResponseHandler 设置响应处理器
func (ws *WebSocketService) SetResponseHandler(deviceID string, responseChan chan *WebSocketMessage) {
ws.mutex.Lock()
defer ws.mutex.Unlock()
if deviceConn, exists := ws.connections[deviceID]; exists {
deviceConn.ResponseChan = responseChan
}
}
// SendCommand 向指定设备发送指令
func (ws *WebSocketService) SendCommand(deviceID, command string, data interface{}) error {
ws.mutex.RLock()
deviceConn, exists := ws.connections[deviceID]
ws.mutex.RUnlock()
if !exists {
return fmt.Errorf("设备 %s 未连接", deviceID)
}
// 构造消息
msg := WebSocketMessage{
Type: MessageTypeCommand,
Command: command,
Data: data,
Timestamp: time.Now(),
}
// 发送消息
if err := deviceConn.Connection.WriteJSON(msg); err != nil {
return fmt.Errorf("向设备 %s 发送指令失败: %v", deviceID, err)
}
return nil
}
// CommandResponse WebSocket命令响应结构体
type CommandResponse struct {
// DeviceID 设备ID
DeviceID string `json:"device_id,omitempty"`
// Command 命令名称
Command string `json:"command,omitempty"`
// Data 响应数据
Data interface{} `json:"data,omitempty"`
// Status 响应状态
Status string `json:"status,omitempty"`
// Message 响应消息
Message string `json:"message,omitempty"`
// Timestamp 时间戳
Timestamp time.Time `json:"timestamp"`
}
// ParseData 将响应数据解析到目标结构体
func (cr *CommandResponse) ParseData(target interface{}) error {
dataBytes, err := json.Marshal(cr.Data)
if err != nil {
return err
}
return json.Unmarshal(dataBytes, target)
}
// CommandResult WebSocket命令执行结果
type CommandResult struct {
// Response 响应消息
Response *CommandResponse
// Error 错误信息
Error error
}
// SendCommandAndWait 发送指令并等待响应
func (ws *WebSocketService) SendCommandAndWait(deviceID, command string, data interface{}, timeout int) (*CommandResponse, error) {
// 如果未指定超时时间,使用默认超时时间
if timeout <= 0 {
timeout = ws.defaultTimeout
}
// 创建用于接收响应的通道
responseChan := make(chan *WebSocketMessage, 1)
ws.SetResponseHandler(deviceID, responseChan)
// 发送指令
if err := ws.SendCommand(deviceID, command, data); err != nil {
return nil, fmt.Errorf("发送指令失败: %v", err)
}
// 等待设备响应,设置超时
var response *WebSocketMessage
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
defer cancel()
select {
case response = <-responseChan:
// 成功接收到响应
// 转换为CommandResponse结构体
commandResponse := &CommandResponse{
DeviceID: response.DeviceID,
Command: response.Command,
Data: response.Data,
Timestamp: response.Timestamp,
}
// 尝试提取状态和消息字段
if responseData, ok := response.Data.(map[string]interface{}); ok {
if status, exists := responseData["status"]; exists {
if statusStr, ok := status.(string); ok {
commandResponse.Status = statusStr
}
}
if message, exists := responseData["message"]; exists {
if messageStr, ok := message.(string); ok {
commandResponse.Message = messageStr
}
}
}
return commandResponse, nil
case <-ctx.Done():
// 超时处理
return nil, fmt.Errorf("等待设备响应超时")
}
}
// GetConnectedDevices 获取已连接的设备列表
func (ws *WebSocketService) GetConnectedDevices() []string {
ws.mutex.RLock()
defer ws.mutex.RUnlock()
devices := make([]string, 0, len(ws.connections))
for deviceID := range ws.connections {
devices = append(devices, deviceID)
}
return devices
}
// HandleMessage 处理来自设备的消息
func (ws *WebSocketService) HandleMessage(deviceID string, message []byte) error {
// 解析消息
var msg WebSocketMessage
if err := json.Unmarshal(message, &msg); err != nil {
return fmt.Errorf("解析设备 %s 消息失败: %v", deviceID, err)
}
// 更新心跳时间
if msg.Type == MessageTypeHeartbeat {
ws.mutex.Lock()
if deviceConn, exists := ws.connections[deviceID]; exists {
deviceConn.LastHeartbeat = time.Now()
}
ws.mutex.Unlock()
}
// 处理响应消息
if msg.Type == MessageTypeResponse {
ws.mutex.RLock()
if deviceConn, exists := ws.connections[deviceID]; exists && deviceConn.ResponseChan != nil {
// 发送响应到通道
select {
case deviceConn.ResponseChan <- &msg:
// 成功发送
default:
// 通道已满,丢弃消息
ws.logger.Warn(fmt.Sprintf("设备 %s 的响应通道已满,丢弃响应消息", deviceID))
}
}
ws.mutex.RUnlock()
}
// 记录消息日志
ws.logger.Info(fmt.Sprintf("收到来自设备 %s 的消息: %v", deviceID, msg))
return nil
}
// GetDeviceConnection 获取设备连接信息
func (ws *WebSocketService) GetDeviceConnection(deviceID string) (*DeviceConnection, bool) {
ws.mutex.RLock()
defer ws.mutex.RUnlock()
deviceConn, exists := ws.connections[deviceID]
return deviceConn, exists
}

View File

@@ -19,6 +19,10 @@ var migrateModels = []interface{}{
&model.OperationHistory{},
&model.Device{},
&model.DeviceControl{},
&model.FeedingPlan{},
&model.FeedingPlanStep{},
&model.FeedingExecution{},
&model.FeedingExecutionStep{},
}
// PostgresStorage 代表基于PostgreSQL的存储实现

View File

@@ -34,6 +34,9 @@ type DeviceRepo interface {
// ListAll 获取所有设备列表
ListAll() ([]model.Device, error)
// FindRelayDevices 获取所有中继设备
FindRelayDevices() ([]*model.Device, error)
}
// DeviceControlRepo 设备控制仓库接口
@@ -135,6 +138,16 @@ func (r *deviceRepo) FindByType(deviceType model.DeviceType) ([]*model.Device, e
return devices, nil
}
// FindRelayDevices 获取所有中继设备
func (r *deviceRepo) FindRelayDevices() ([]*model.Device, error) {
var devices []*model.Device
result := r.db.Where("type = ?", model.DeviceTypeRelay).Find(&devices)
if result.Error != nil {
return nil, result.Error
}
return devices, nil
}
// Update 更新设备信息
func (r *deviceRepo) Update(device *model.Device) error {
result := r.db.Save(device)

View File

@@ -0,0 +1,205 @@
package repository
import (
"sort"
"git.huangwc.com/pig/pig-farm-controller/internal/model"
"gorm.io/gorm"
)
// FeedPlanRepo 饲喂管理接口
type FeedPlanRepo interface {
// ListAllPlanIntroduction 获取所有计划简介
ListAllPlanIntroduction() ([]*model.FeedingPlan, error)
// FindFeedingPlanByID 根据ID获取计划详情
FindFeedingPlanByID(id uint) (*model.FeedingPlan, error)
// CreateFeedingPlan 创建饲料计划
CreateFeedingPlan(feedingPlan *model.FeedingPlan) error
// DeleteFeedingPlan 删除饲料计划及其所有子计划和步骤
DeleteFeedingPlan(id uint) error
// UpdateFeedingPlan 更新饲料计划,采用先删除再重新创建的方式
UpdateFeedingPlan(feedingPlan *model.FeedingPlan) error
}
type feedPlanRepo struct {
db *gorm.DB
}
func NewFeedPlanRepo(db *gorm.DB) FeedPlanRepo {
return &feedPlanRepo{
db: db,
}
}
// ListAllPlanIntroduction 获取所有计划简介
func (f *feedPlanRepo) ListAllPlanIntroduction() ([]*model.FeedingPlan, error) {
var plans []*model.FeedingPlan
err := f.db.Model(&model.FeedingPlan{}).
Select("id, name, description, type, enabled, schedule_cron").
Find(&plans).Error
return plans, err
}
// FindFeedingPlanByID 根据ID获取计划详情
func (f *feedPlanRepo) FindFeedingPlanByID(feedingPlanID uint) (*model.FeedingPlan, error) {
var plan model.FeedingPlan
err := f.db.Where("id = ?", feedingPlanID).
Preload("Steps").
Preload("SubPlans").
First(&plan).Error
if err != nil {
return nil, err
}
return &plan, nil
}
// CreateFeedingPlan 创建饲料计划,包括步骤和子计划
func (f *feedPlanRepo) CreateFeedingPlan(feedingPlan *model.FeedingPlan) error {
// 清空所有ID确保创建新记录
f.clearAllIDs(feedingPlan)
return f.db.Transaction(func(tx *gorm.DB) error {
return f.createFeedingPlanWithTx(tx, feedingPlan)
})
}
// UpdateFeedingPlan 更新饲料计划,采用先删除再重新创建的方式
func (f *feedPlanRepo) UpdateFeedingPlan(feedingPlan *model.FeedingPlan) error {
// 检查计划是否存在
_, err := f.FindFeedingPlanByID(feedingPlan.ID)
if err != nil {
return err
}
return f.db.Transaction(func(tx *gorm.DB) error {
// 先删除原有的计划
if err := f.deleteFeedingPlanWithTx(tx, feedingPlan.ID); err != nil {
return err
}
// 清空所有ID包括子计划和步骤的ID
f.clearAllIDs(feedingPlan)
// 再重新创建更新后的计划
if err := f.createFeedingPlanWithTx(tx, feedingPlan); err != nil {
return err
}
return nil
})
}
// DeleteFeedingPlan 删除饲料计划及其所有子计划和步骤
func (f *feedPlanRepo) DeleteFeedingPlan(id uint) error {
return f.db.Transaction(func(tx *gorm.DB) error {
// 递归删除计划及其所有子计划
if err := f.deleteFeedingPlanWithTx(tx, id); err != nil {
return err
}
return nil
})
}
// deleteFeedingPlanWithTx 在事务中递归删除饲料计划
func (f *feedPlanRepo) deleteFeedingPlanWithTx(tx *gorm.DB, id uint) error {
// 先查找计划及其子计划
var plan model.FeedingPlan
if err := tx.Where("id = ?", id).Preload("SubPlans").First(&plan).Error; err != nil {
return err
}
// 递归删除所有子计划
for _, subPlan := range plan.SubPlans {
if err := f.deleteFeedingPlanWithTx(tx, subPlan.ID); err != nil {
return err
}
}
// 删除该计划的所有步骤
if err := tx.Where("plan_id = ?", id).Delete(&model.FeedingPlanStep{}).Error; err != nil {
return err
}
// 删除计划本身
if err := tx.Delete(&model.FeedingPlan{}, id).Error; err != nil {
return err
}
return nil
}
// createFeedingPlanWithTx 在事务中递归创建饲料计划
func (f *feedPlanRepo) createFeedingPlanWithTx(tx *gorm.DB, feedingPlan *model.FeedingPlan) error {
// 先创建计划主体
if err := tx.Create(feedingPlan).Error; err != nil {
return err
}
// 处理步骤 - 先按现有顺序排序再重新分配从0开始的连续编号
sort.Slice(feedingPlan.Steps, func(i, j int) bool {
return feedingPlan.Steps[i].StepOrder < feedingPlan.Steps[j].StepOrder
})
// 重新填充步骤编号
for i := range feedingPlan.Steps {
feedingPlan.Steps[i].StepOrder = i
feedingPlan.Steps[i].PlanID = feedingPlan.ID
}
// 如果有步骤,批量创建步骤
if len(feedingPlan.Steps) > 0 {
if err := tx.Create(&feedingPlan.Steps).Error; err != nil {
return err
}
}
// 处理子计划 - 重新填充子计划编号和父ID
sort.Slice(feedingPlan.SubPlans, func(i, j int) bool {
// 如果OrderInParent为nil放在最后
if feedingPlan.SubPlans[i].OrderInParent == nil {
return false
}
if feedingPlan.SubPlans[j].OrderInParent == nil {
return true
}
return *feedingPlan.SubPlans[i].OrderInParent < *feedingPlan.SubPlans[j].OrderInParent
})
// 重新填充子计划编号和父ID
for i := range feedingPlan.SubPlans {
order := i
feedingPlan.SubPlans[i].OrderInParent = &order
feedingPlan.SubPlans[i].ParentID = &feedingPlan.ID
// 递归创建子计划
if err := f.createFeedingPlanWithTx(tx, &feedingPlan.SubPlans[i]); err != nil {
return err
}
}
return nil
}
// clearAllIDs 清空计划及其子计划和步骤的所有ID
func (f *feedPlanRepo) clearAllIDs(plan *model.FeedingPlan) {
// 清空计划ID
plan.ID = 0
// 清空所有步骤的ID和关联的计划ID
for i := range plan.Steps {
plan.Steps[i].ID = 0
plan.Steps[i].PlanID = 0
}
// 清空所有子计划的ID和关联的父计划ID并递归清空子计划的ID
for i := range plan.SubPlans {
plan.SubPlans[i].ID = 0
plan.SubPlans[i].ParentID = nil
f.clearAllIDs(&plan.SubPlans[i])
}
}

View File

@@ -23,6 +23,12 @@ type Task interface {
// GetPriority 获取任务优先级
GetPriority() int
// Done 返回一个channel当任务执行完毕时该channel会被关闭
Done() <-chan struct{}
// IsDone 检查任务是否已完成
IsDone() bool
}
// taskItem 任务队列中的元素

View File

@@ -0,0 +1,164 @@
package mocks
// Package mocks 模拟测试包
import (
"git.huangwc.com/pig/pig-farm-controller/internal/model"
"github.com/stretchr/testify/mock"
)
// MockDeviceRepo 模拟设备仓库实现DeviceRepo接口
type MockDeviceRepo struct {
mock.Mock
}
// Create 模拟创建设备方法
func (m *MockDeviceRepo) Create(device *model.Device) error {
args := m.Called(device)
return args.Error(0)
}
// FindByID 模拟根据ID查找设备方法
func (m *MockDeviceRepo) FindByID(id uint) (*model.Device, error) {
args := m.Called(id)
// 返回第一个参数作为设备,第二个参数作为错误
device, ok := args.Get(0).(*model.Device)
if !ok {
return nil, args.Error(1)
}
return device, args.Error(1)
}
// FindByIDString 模拟根据ID字符串查找设备方法
func (m *MockDeviceRepo) FindByIDString(id string) (*model.Device, error) {
args := m.Called(id)
// 返回第一个参数作为设备,第二个参数作为错误
device, ok := args.Get(0).(*model.Device)
if !ok {
return nil, args.Error(1)
}
return device, args.Error(1)
}
// FindByParentID 模拟根据上级设备ID查找设备方法
func (m *MockDeviceRepo) FindByParentID(parentID uint) ([]*model.Device, error) {
args := m.Called(parentID)
// 返回第一个参数作为设备列表,第二个参数作为错误
devices, ok := args.Get(0).([]*model.Device)
if !ok {
return nil, args.Error(1)
}
return devices, args.Error(1)
}
// FindByType 模拟根据设备类型查找设备方法
func (m *MockDeviceRepo) FindByType(deviceType model.DeviceType) ([]*model.Device, error) {
args := m.Called(deviceType)
// 返回第一个参数作为设备列表,第二个参数作为错误
devices, ok := args.Get(0).([]*model.Device)
if !ok {
return nil, args.Error(1)
}
return devices, args.Error(1)
}
// Update 模拟更新设备信息方法
func (m *MockDeviceRepo) Update(device *model.Device) error {
args := m.Called(device)
return args.Error(0)
}
// Delete 模拟删除设备方法
func (m *MockDeviceRepo) Delete(id uint) error {
args := m.Called(id)
return args.Error(0)
}
// ListAll 模拟获取所有设备列表方法
func (m *MockDeviceRepo) ListAll() ([]model.Device, error) {
args := m.Called()
// 返回第一个参数作为设备列表,第二个参数作为错误
devices, ok := args.Get(0).([]model.Device)
if !ok {
return nil, args.Error(1)
}
return devices, args.Error(1)
}
// FindRelayDevices 模拟获取所有中继设备方法
func (m *MockDeviceRepo) FindRelayDevices() ([]*model.Device, error) {
args := m.Called()
// 返回第一个参数作为设备列表,第二个参数作为错误
devices, ok := args.Get(0).([]*model.Device)
if !ok {
return nil, args.Error(1)
}
return devices, args.Error(1)
}
// FindByDeviceID 模拟根据设备ID查找设备方法额外方法
func (m *MockDeviceRepo) FindByDeviceID(deviceID string) (*model.Device, error) {
args := m.Called(deviceID)
// 返回第一个参数作为设备,第二个参数作为错误
device, ok := args.Get(0).(*model.Device)
if !ok {
return nil, args.Error(1)
}
return device, args.Error(1)
}
// FindControllers 模拟查找控制器方法(额外方法)
func (m *MockDeviceRepo) FindControllers() ([]*model.Device, error) {
args := m.Called()
// 返回第一个参数作为设备列表,第二个参数作为错误
devices, ok := args.Get(0).([]*model.Device)
if !ok {
return nil, args.Error(1)
}
return devices, args.Error(1)
}
// FindRelays 模拟查找中继设备方法(额外方法)
func (m *MockDeviceRepo) FindRelays() ([]*model.Device, error) {
args := m.Called()
// 返回第一个参数作为设备列表,第二个参数作为错误
devices, ok := args.Get(0).([]*model.Device)
if !ok {
return nil, args.Error(1)
}
return devices, args.Error(1)
}
// FindDevicesByType 模拟根据类型查找设备方法(额外方法)
func (m *MockDeviceRepo) FindDevicesByType(deviceType string) ([]*model.Device, error) {
args := m.Called(deviceType)
// 返回第一个参数作为设备列表,第二个参数作为错误
devices, ok := args.Get(0).([]*model.Device)
if !ok {
return nil, args.Error(1)
}
return devices, args.Error(1)
}
// FindRelayDevices 模拟根据中继ID查找设备方法额外方法
func (m *MockDeviceRepo) FindRelayDevicesByID(relayID uint) ([]*model.Device, error) {
args := m.Called(relayID)
// 返回第一个参数作为设备列表,第二个参数作为错误
devices, ok := args.Get(0).([]*model.Device)
if !ok {
return nil, args.Error(1)
}
return devices, args.Error(1)
}
// UpdateDeviceStatus 模拟更新设备状态方法(额外方法)
func (m *MockDeviceRepo) UpdateDeviceStatus(id uint, active bool) error {
args := m.Called(id, active)
return args.Error(0)
}
// GetDeviceStatus 模拟获取设备状态方法(额外方法)
func (m *MockDeviceRepo) GetDeviceStatus(id uint) (bool, error) {
args := m.Called(id)
return args.Bool(0), args.Error(1)
}

209
internal/websocket/hub.go Normal file
View File

@@ -0,0 +1,209 @@
// Package websocket 提供WebSocket通信功能
// 实现中继设备与平台之间的实时通信
package websocket
import (
"fmt"
"net/http"
"sync"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/storage/repository"
"github.com/gorilla/websocket"
)
// Message WebSocket消息结构
type Message struct {
DeviceID string `json:"device_id"`
Type string `json:"type"`
Data interface{} `json:"data"`
Timestamp time.Time `json:"timestamp"`
}
// Hub WebSocket中心管理所有客户端连接
type Hub struct {
// 注册客户端的通道
register chan *Client
// 注销客户端的通道
unregister chan *Client
// 当前活跃的客户端映射
clients map[*Client]bool
// 广播消息通道
broadcast chan Message
// 设备ID到客户端的映射
deviceClients map[string]*Client
// 日志记录器
logger *logs.Logger
// 互斥锁保护映射
mutex sync.RWMutex
// deviceRepo 设备仓库
deviceRepo repository.DeviceRepo
// 关闭消息
close chan struct{}
}
// Client WebSocket客户端结构
type Client struct {
hub *Hub
// WebSocket连接
conn *websocket.Conn
// 发送缓冲区
send chan Message
// 设备ID
DeviceID string
// HTTP请求
Request *http.Request
// 日志记录器
logger *logs.Logger
}
// NewHub 创建新的WebSocket中心实例
func NewHub(deviceRepo repository.DeviceRepo) *Hub {
return &Hub{
register: make(chan *Client),
unregister: make(chan *Client),
clients: make(map[*Client]bool),
broadcast: make(chan Message),
deviceClients: make(map[string]*Client),
logger: logs.NewLogger(),
deviceRepo: deviceRepo,
close: make(chan struct{}),
}
}
// getDeviceDisplayName 获取设备显示名称
func (h *Hub) getDeviceDisplayName(deviceID string) string {
if h.deviceRepo != nil {
if device, err := h.deviceRepo.FindByIDString(deviceID); err == nil && device != nil {
return fmt.Sprintf("%s(id:%s)", device.Name, deviceID)
}
}
return fmt.Sprintf("未知设备(id:%s)", deviceID)
}
// Run 启动WebSocket中心
func (h *Hub) Run() {
for {
select {
case client := <-h.register:
h.registerClient(client)
case client := <-h.unregister:
h.unregisterClient(client)
case message := <-h.broadcast:
h.broadcastMessage(message)
case <-h.close:
return
}
}
}
func (h *Hub) Close() {
// 关闭时清理所有资源
for client := range h.clients {
h.unregisterClient(client)
}
close(h.close)
}
// registerClient 注册客户端
func (h *Hub) registerClient(client *Client) {
h.mutex.Lock()
defer h.mutex.Unlock()
h.clients[client] = true
if client.DeviceID != "" {
h.deviceClients[client.DeviceID] = client
}
deviceName := h.getDeviceDisplayName(client.DeviceID)
h.logger.Info("[WebSocket] 客户端 " + deviceName + " 已注册,当前客户端数: " + fmt.Sprintf("%d", len(h.clients)))
}
// unregisterClient 注销客户端
func (h *Hub) unregisterClient(client *Client) {
h.mutex.Lock()
defer h.mutex.Unlock()
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
if client.DeviceID != "" {
delete(h.deviceClients, client.DeviceID)
}
close(client.send)
}
deviceName := h.getDeviceDisplayName(client.DeviceID)
h.logger.Info("[WebSocket] 客户端 " + deviceName + " 已注销,当前客户端数: " + fmt.Sprintf("%d", len(h.clients)))
}
// broadcastMessage 广播消息
func (h *Hub) broadcastMessage(message Message) {
h.mutex.RLock()
defer h.mutex.RUnlock()
if client, exists := h.deviceClients[message.DeviceID]; exists {
select {
case client.send <- message:
default:
close(client.send)
delete(h.clients, client)
delete(h.deviceClients, message.DeviceID)
}
}
}
// SendToDevice 向指定设备发送消息
func (h *Hub) SendToDevice(deviceID string, msgType string, data interface{}) error {
h.mutex.RLock()
defer h.mutex.RUnlock()
deviceName := h.getDeviceDisplayName(deviceID)
if client, exists := h.deviceClients[deviceID]; exists {
message := Message{
DeviceID: deviceID,
Type: msgType,
Data: data,
Timestamp: time.Now(),
}
select {
case client.send <- message:
h.logger.Info(fmt.Sprintf("[WebSocket] 向设备 %s 发送消息: %s", deviceName, msgType))
return nil
default:
h.logger.Error(fmt.Sprintf("[WebSocket] 设备 %s 消息通道已满", deviceName))
return fmt.Errorf("设备 %s 消息通道已满", deviceName)
}
}
h.logger.Warn(fmt.Sprintf("[WebSocket] 设备 %s 未连接", deviceName))
return fmt.Errorf("设备 %s 未连接", deviceName)
}
// GetConnectedDevices 获取已连接的设备列表
func (h *Hub) GetConnectedDevices() []string {
h.mutex.RLock()
defer h.mutex.RUnlock()
devices := make([]string, 0, len(h.deviceClients))
for deviceID := range h.deviceClients {
devices = append(devices, deviceID)
}
return devices
}

View File

@@ -3,20 +3,110 @@
package websocket
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/service"
"git.huangwc.com/pig/pig-farm-controller/internal/storage/repository"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
// WebSocket消息类型常量
const (
// MessageTypeCommand 平台向设备发送的指令
MessageTypeCommand = "command"
// MessageTypeResponse 设备向平台发送的响应
MessageTypeResponse = "response"
// MessageTypeHeartbeat 心跳消息
MessageTypeHeartbeat = "heartbeat"
)
// WebSocketMessage WebSocket消息结构
type WebSocketMessage struct {
// Type 消息类型
Type string `json:"type"`
// DeviceID 设备ID
DeviceID string `json:"device_id,omitempty"`
// Command 指令内容
Command string `json:"command,omitempty"`
// Data 消息数据
Data interface{} `json:"data,omitempty"`
// Timestamp 时间戳
Timestamp time.Time `json:"timestamp"`
}
// DeviceConnection 设备连接信息
type DeviceConnection struct {
// DeviceID 设备ID
DeviceID string
// Connection WebSocket连接
Connection *websocket.Conn
// LastHeartbeat 最后心跳时间
LastHeartbeat time.Time
// ResponseChan 响应通道
ResponseChan chan *WebSocketMessage
}
// CommandResponse WebSocket命令响应结构体
type CommandResponse struct {
// DeviceID 设备ID
DeviceID string `json:"device_id,omitempty"`
// Command 命令名称
Command string `json:"command,omitempty"`
// Data 响应数据
Data interface{} `json:"data,omitempty"`
// Status 响应状态
Status string `json:"status,omitempty"`
// Message 响应消息
Message string `json:"message,omitempty"`
// Timestamp 时间戳
Timestamp time.Time `json:"timestamp"`
}
// ParseData 将响应数据解析到目标结构体
func (cr *CommandResponse) ParseData(target interface{}) error {
dataBytes, err := json.Marshal(cr.Data)
if err != nil {
return err
}
return json.Unmarshal(dataBytes, target)
}
// CommandResult WebSocket命令执行结果
type CommandResult struct {
// Response 响应消息
Response *CommandResponse
// Error 错误信息
Error error
}
// Manager WebSocket管理器
type Manager struct {
// websocketService WebSocket服务
websocketService *service.WebSocketService
// connections 设备连接映射
connections map[string]*DeviceConnection
// mutex 互斥锁
mutex sync.RWMutex
// logger 日志记录器
logger *logs.Logger
@@ -24,117 +114,165 @@ type Manager struct {
// upgrader WebSocket升级器
upgrader websocket.Upgrader
// mutex 互斥锁
mutex sync.RWMutex
// defaultTimeout 默认超时时间(秒)
defaultTimeout int
// connections 设备连接映射
connections map[string]*websocket.Conn
// deviceRepo 设备仓库
deviceRepo repository.DeviceRepo
}
// NewManager 创建WebSocket管理器实例
func NewManager(websocketService *service.WebSocketService) *Manager {
func NewManager(deviceRepo repository.DeviceRepo) *Manager {
return &Manager{
websocketService: websocketService,
logger: logs.NewLogger(),
connections: make(map[string]*DeviceConnection),
logger: logs.NewLogger(),
defaultTimeout: 5, // 默认5秒超时
deviceRepo: deviceRepo,
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
// 允许所有跨域请求
return true
},
},
connections: make(map[string]*websocket.Conn),
}
}
// HandleConnection 处理WebSocket连接
func (wm *Manager) HandleConnection(c *gin.Context) {
// 升级HTTP连接到WebSocket
conn, err := wm.upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
wm.logger.Error("WebSocket连接升级失败: " + err.Error())
return
}
// SetDefaultTimeout 设置默认超时时间
func (wm *Manager) SetDefaultTimeout(timeout int) {
wm.defaultTimeout = timeout
}
// 获取设备ID
deviceID := c.Query("device_id")
if deviceID == "" {
wm.logger.Error("缺少设备ID参数")
conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "缺少设备ID参数"))
conn.Close()
return
// getDeviceDisplayName 获取设备显示名称
func (wm *Manager) getDeviceDisplayName(deviceID string) string {
if wm.deviceRepo != nil {
if device, err := wm.deviceRepo.FindByIDString(deviceID); err == nil && device != nil {
return fmt.Sprintf("%s(id:%s)", device.Name, deviceID)
}
}
return fmt.Sprintf("未知设备(id:%s)", deviceID)
}
// 添加连接到映射
// AddConnection 添加设备连接
func (wm *Manager) AddConnection(deviceID string, conn *websocket.Conn) {
wm.mutex.Lock()
wm.connections[deviceID] = conn
wm.mutex.Unlock()
defer wm.mutex.Unlock()
wm.logger.Info("设备 " + deviceID + " 已连接")
// 发送连接成功消息
successMsg := service.WebSocketMessage{
Type: "system",
Command: "connected",
Timestamp: time.Now(),
}
conn.WriteJSON(successMsg)
// 处理消息循环
for {
// 读取消息
messageType, message, err := conn.ReadMessage()
if err != nil {
wm.logger.Error("读取设备 " + deviceID + " 消息失败: " + err.Error())
break
}
// 只处理文本消息
if messageType != websocket.TextMessage {
continue
}
// 处理设备消息
if err := wm.websocketService.HandleMessage(deviceID, message); err != nil {
wm.logger.Error("处理设备 " + deviceID + " 消息失败: " + err.Error())
continue
}
wm.connections[deviceID] = &DeviceConnection{
DeviceID: deviceID,
Connection: conn,
LastHeartbeat: time.Now(),
}
// 连接断开时清理
deviceName := wm.getDeviceDisplayName(deviceID)
wm.logger.Info(fmt.Sprintf("设备 %s 已连接", deviceName))
}
// RemoveConnection 移除设备连接
func (wm *Manager) RemoveConnection(deviceID string) {
wm.mutex.Lock()
defer wm.mutex.Unlock()
deviceName := wm.getDeviceDisplayName(deviceID)
delete(wm.connections, deviceID)
wm.mutex.Unlock()
conn.Close()
wm.logger.Info("设备 " + deviceID + " 已断开连接")
wm.logger.Info(fmt.Sprintf("设备 %s 已断开连接", deviceName))
}
// SetResponseHandler 设置响应处理器
func (wm *Manager) SetResponseHandler(deviceID string, responseChan chan *WebSocketMessage) {
wm.mutex.Lock()
defer wm.mutex.Unlock()
if deviceConn, exists := wm.connections[deviceID]; exists {
deviceConn.ResponseChan = responseChan
}
}
// SendCommand 向指定设备发送指令
func (wm *Manager) SendCommand(deviceID, command string, data interface{}) error {
wm.mutex.RLock()
conn, exists := wm.connections[deviceID]
deviceConn, exists := wm.connections[deviceID]
wm.mutex.RUnlock()
deviceName := wm.getDeviceDisplayName(deviceID)
if !exists {
return wm.websocketService.SendCommand(deviceID, command, data)
return fmt.Errorf("设备 %s 未连接", deviceName)
}
// 构造消息
msg := service.WebSocketMessage{
Type: service.MessageTypeCommand,
msg := WebSocketMessage{
Type: MessageTypeCommand,
Command: command,
Data: data,
Timestamp: time.Now(),
}
// 发送消息
if err := conn.WriteJSON(msg); err != nil {
return err
if err := deviceConn.Connection.WriteJSON(msg); err != nil {
return fmt.Errorf("向设备 %s 发送指令失败: %v", deviceName, err)
}
return nil
}
// SendCommandAndWait 发送指令并等待响应
func (wm *Manager) SendCommandAndWait(deviceID, command string, data interface{}, timeout int) (*CommandResponse, error) {
deviceName := wm.getDeviceDisplayName(deviceID)
// 如果未指定超时时间,使用默认超时时间
if timeout <= 0 {
timeout = wm.defaultTimeout
}
// 创建用于接收响应的通道
responseChan := make(chan *WebSocketMessage, 1)
wm.SetResponseHandler(deviceID, responseChan)
// 发送指令
if err := wm.SendCommand(deviceID, command, data); err != nil {
return nil, fmt.Errorf("发送指令失败: %v", err)
}
// 等待设备响应,设置超时
var response *WebSocketMessage
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
defer cancel()
select {
case response = <-responseChan:
// 成功接收到响应
// 转换为CommandResponse结构体
commandResponse := &CommandResponse{
DeviceID: response.DeviceID,
Command: response.Command,
Data: response.Data,
Timestamp: response.Timestamp,
}
// 尝试提取状态和消息字段
if responseData, ok := response.Data.(map[string]interface{}); ok {
if status, exists := responseData["status"]; exists {
if statusStr, ok := status.(string); ok {
commandResponse.Status = statusStr
}
}
if message, exists := responseData["message"]; exists {
if messageStr, ok := message.(string); ok {
commandResponse.Message = messageStr
}
}
}
return commandResponse, nil
case <-ctx.Done():
// 超时处理
return nil, fmt.Errorf("等待设备 %s 响应超时", deviceName)
}
}
// GetConnectedDevices 获取已连接的设备列表
func (wm *Manager) GetConnectedDevices() []string {
wm.mutex.RLock()
@@ -147,3 +285,110 @@ func (wm *Manager) GetConnectedDevices() []string {
return devices
}
// HandleMessage 处理来自设备的消息
func (wm *Manager) HandleMessage(deviceID string, message []byte) error {
// 解析消息
var msg WebSocketMessage
if err := json.Unmarshal(message, &msg); err != nil {
return fmt.Errorf("解析设备 %s 消息失败: %v", wm.getDeviceDisplayName(deviceID), err)
}
// 更新心跳时间
if msg.Type == MessageTypeHeartbeat {
wm.mutex.Lock()
if deviceConn, exists := wm.connections[deviceID]; exists {
deviceConn.LastHeartbeat = time.Now()
}
wm.mutex.Unlock()
}
// 处理响应消息
if msg.Type == MessageTypeResponse {
wm.mutex.RLock()
if deviceConn, exists := wm.connections[deviceID]; exists && deviceConn.ResponseChan != nil {
// 发送响应到通道
select {
case deviceConn.ResponseChan <- &msg:
// 成功发送
default:
// 通道已满,丢弃消息
wm.logger.Warn(fmt.Sprintf("设备 %s 的响应通道已满,丢弃响应消息", wm.getDeviceDisplayName(deviceID)))
}
}
wm.mutex.RUnlock()
}
// 记录消息日志
wm.logger.Info(fmt.Sprintf("收到来自设备 %s 的消息: %v", wm.getDeviceDisplayName(deviceID), msg))
return nil
}
// GetDeviceConnection 获取设备连接信息
func (wm *Manager) GetDeviceConnection(deviceID string) (*DeviceConnection, bool) {
wm.mutex.RLock()
defer wm.mutex.RUnlock()
deviceConn, exists := wm.connections[deviceID]
return deviceConn, exists
}
// HandleConnection 处理WebSocket连接
func (wm *Manager) HandleConnection(c *gin.Context) {
// 升级HTTP连接到WebSocket
conn, err := wm.upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
wm.logger.Error(fmt.Sprintf("WebSocket连接升级失败: %v", err))
return
}
// 获取设备ID
deviceID := c.Query("device_id")
if deviceID == "" {
wm.logger.Error("缺少设备ID参数")
conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "缺少设备ID参数"))
conn.Close()
return
}
// 添加连接
wm.AddConnection(deviceID, conn)
deviceName := wm.getDeviceDisplayName(deviceID)
wm.logger.Info("设备 " + deviceName + " 已连接")
// 发送连接成功消息
successMsg := WebSocketMessage{
Type: "system",
Command: "connected",
Timestamp: time.Now(),
}
conn.WriteJSON(successMsg)
// 处理消息循环
for {
// 读取消息
messageType, message, err := conn.ReadMessage()
if err != nil {
wm.logger.Error(fmt.Sprintf("读取设备 %s 消息失败: %v", deviceName, err))
break
}
// 只处理文本消息
if messageType != websocket.TextMessage {
continue
}
// 处理设备消息
if err := wm.HandleMessage(deviceID, message); err != nil {
wm.logger.Error(fmt.Sprintf("处理设备 %s 消息失败: %v", deviceName, err))
continue
}
}
// 连接断开时清理
wm.RemoveConnection(deviceID)
conn.Close()
wm.logger.Info("设备 " + deviceName + " 已断开连接")
}

View File

@@ -0,0 +1,214 @@
// Package websocket 提供WebSocket通信功能
// 实现中继设备与平台之间的实时通信
package websocket
import (
"encoding/json"
"fmt"
"net/http"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/storage/repository"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
// Server WebSocket服务器结构
type Server struct {
hub *Hub
logger *logs.Logger
deviceRepo repository.DeviceRepo
}
const (
// 允许写入的最长时间
writeWait = 10 * time.Second
// 允许读取的最长时间
pongWait = 60 * time.Second
// 发送ping消息的周期
pingPeriod = (pongWait * 9) / 10
// 发送队列的最大容量
maxMessageSize = 512
)
var (
newline = []byte{'\n'}
space = []byte{' '}
)
// Upgrader WebSocket升级器
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
// 允许所有来源的连接(在生产环境中应该更严格)
return true
},
}
// NewServer 创建新的WebSocket服务器实例
func NewServer(deviceRepo repository.DeviceRepo) *Server {
return &Server{
hub: NewHub(deviceRepo),
logger: logs.NewLogger(),
deviceRepo: deviceRepo,
}
}
// getDeviceDisplayName 获取设备显示名称
func (s *Server) getDeviceDisplayName(deviceID string) string {
if s.deviceRepo != nil {
if device, err := s.deviceRepo.FindByIDString(deviceID); err == nil && device != nil {
return fmt.Sprintf("%s(id:%s)", device.Name, deviceID)
}
}
return fmt.Sprintf("未知设备(id:%s)", deviceID)
}
// Start 启动WebSocket服务器
func (s *Server) Start() {
// 启动hub
go s.hub.Run()
}
func (s *Server) Stop() {
s.hub.Close()
}
// readPump 从WebSocket连接读取消息
func (c *Client) readPump() {
defer func() {
c.hub.unregister <- c
c.conn.Close()
}()
c.conn.SetReadLimit(maxMessageSize)
c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
var msg Message
err := c.conn.ReadJSON(&msg)
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
c.logger.Error("[WebSocket] 读取错误: " + err.Error())
}
break
}
// 处理收到的消息
c.hub.broadcast <- msg
}
}
// writePump 向WebSocket连接写入消息
func (c *Client) writePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.conn.Close()
}()
for {
select {
case message, ok := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
// hub关闭了send通道
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
w, err := c.conn.NextWriter(websocket.TextMessage)
if err != nil {
return
}
// 将消息序列化为JSON
data, err := json.Marshal(message)
if err != nil {
c.logger.Error("[WebSocket] 消息序列化失败: " + err.Error())
continue
}
w.Write(data)
// 添加队列中的其他消息
n := len(c.send)
for i := 0; i < n; i++ {
msg := <-c.send
data, err := json.Marshal(msg)
if err != nil {
c.logger.Error("[WebSocket] 消息序列化失败: " + err.Error())
continue
}
w.Write(newline)
w.Write(data)
}
if err := w.Close(); err != nil {
return
}
case <-ticker.C:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
// HandleConnection 处理WebSocket连接请求
func (s *Server) HandleConnection(c *gin.Context) {
// 升级HTTP连接为WebSocket连接
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
s.logger.Error("[WebSocket] 连接升级失败: " + err.Error())
return
}
// 从查询参数获取设备ID
deviceID := c.Query("device_id")
if deviceID == "" {
s.logger.Warn("[WebSocket] 缺少设备ID参数")
conn.Close()
return
}
// 创建客户端
client := &Client{
hub: s.hub,
conn: conn,
send: make(chan Message, 256),
DeviceID: deviceID,
Request: c.Request,
logger: s.logger,
}
// 注册客户端
client.hub.register <- client
// 启动读写goroutine
go client.writePump()
go client.readPump()
deviceName := s.getDeviceDisplayName(deviceID)
s.logger.Info("[WebSocket] 设备 " + deviceName + " 连接成功")
}
// SendToDevice 向指定设备发送消息
func (s *Server) SendToDevice(deviceID string, msgType string, data interface{}) error {
return s.hub.SendToDevice(deviceID, msgType, data)
}
// GetConnectedDevices 获取已连接的设备列表
func (s *Server) GetConnectedDevices() []string {
return s.hub.GetConnectedDevices()
}

23
vendor/modules.txt vendored
View File

@@ -24,6 +24,9 @@ github.com/bytedance/sonic/utf8
# github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311
## explicit; go 1.15
github.com/chenzhuoyu/base64x
# github.com/davecgh/go-spew v1.1.1
## explicit
github.com/davecgh/go-spew/spew
# github.com/gabriel-vasile/mimetype v1.4.2
## explicit; go 1.20
github.com/gabriel-vasile/mimetype
@@ -118,6 +121,10 @@ github.com/modern-go/concurrent
# github.com/modern-go/reflect2 v1.0.2
## explicit; go 1.12
github.com/modern-go/reflect2
# github.com/panjf2000/ants/v2 v2.11.3
## explicit; go 1.18
github.com/panjf2000/ants/v2
github.com/panjf2000/ants/v2/pkg/sync
# github.com/pelletier/go-toml/v2 v2.0.8
## explicit; go 1.16
github.com/pelletier/go-toml/v2
@@ -125,8 +132,19 @@ github.com/pelletier/go-toml/v2/internal/characters
github.com/pelletier/go-toml/v2/internal/danger
github.com/pelletier/go-toml/v2/internal/tracker
github.com/pelletier/go-toml/v2/unstable
# github.com/pmezard/go-difflib v1.0.0
## explicit
github.com/pmezard/go-difflib/difflib
# github.com/rogpeppe/go-internal v1.14.1
## explicit; go 1.23
# github.com/stretchr/objx v0.5.2
## explicit; go 1.20
github.com/stretchr/objx
# github.com/stretchr/testify v1.10.0
## explicit; go 1.17
github.com/stretchr/testify/assert
github.com/stretchr/testify/assert/yaml
github.com/stretchr/testify/mock
# github.com/twitchyliquid64/golang-asm v0.15.1
## explicit; go 1.13
github.com/twitchyliquid64/golang-asm/asm/arch
@@ -167,8 +185,9 @@ golang.org/x/net/http2
golang.org/x/net/http2/h2c
golang.org/x/net/http2/hpack
golang.org/x/net/idna
# golang.org/x/sync v0.1.0
## explicit
# golang.org/x/sync v0.11.0
## explicit; go 1.18
golang.org/x/sync/errgroup
golang.org/x/sync/semaphore
# golang.org/x/sys v0.26.0
## explicit; go 1.18