chore: 同步本地 v9 整改与运营能力
This commit is contained in:
@@ -28,7 +28,7 @@
|
|||||||
3. `app/android/app/build/outputs/apk/release/app-release.apk` — 由 `cd android && gradlew assembleRelease` 生成。
|
3. `app/android/app/build/outputs/apk/release/app-release.apk` — 由 `cd android && gradlew assembleRelease` 生成。
|
||||||
4. `server-go/server-go-linux` — 由 `go build -o server-go-linux ./cmd/server` 交叉编译生成。
|
4. `server-go/server-go-linux` — 由 `go build -o server-go-linux ./cmd/server` 交叉编译生成。
|
||||||
|
|
||||||
## 已完成功能(截至 2026-08-14)
|
## 已完成功能(截至 2026-08-17)
|
||||||
|
|
||||||
按《后续工作计划》推进(YOLO 训练相关 #1-4 因物理机问题挂起)。详细设计、验证与回滚点见根目录 `变更记录.md`、`开发交接记录.md` 及服务器 `/home/pan/backups/rollback-20260812/README.md`。
|
按《后续工作计划》推进(YOLO 训练相关 #1-4 因物理机问题挂起)。详细设计、验证与回滚点见根目录 `变更记录.md`、`开发交接记录.md` 及服务器 `/home/pan/backups/rollback-20260812/README.md`。
|
||||||
|
|
||||||
@@ -70,9 +70,16 @@
|
|||||||
- 已完成安全与正确性整改:AI 风险语义、Mock 隔离、WebSocket 越权、AI 流 SSRF、视频访问与密钥输出、用户级巡检幂等;
|
- 已完成安全与正确性整改:AI 风险语义、Mock 隔离、WebSocket 越权、AI 流 SSRF、视频访问与密钥输出、用户级巡检幂等;
|
||||||
- 已完成工程可靠性与业务闭环:版本化迁移、可靠通知/Outbox、统一检测任务/样本/发病事件、生物安全、离线巡检、会诊治理、知识审核、效果评估、可观测性;
|
- 已完成工程可靠性与业务闭环:版本化迁移、可靠通知/Outbox、统一检测任务/样本/发病事件、生物安全、离线巡检、会诊治理、知识审核、效果评估、可观测性;
|
||||||
- 已完成规格追踪、核心 E2E 用例和发布门禁文档,见 `docs/acceptance/`;
|
- 已完成规格追踪、核心 E2E 用例和发布门禁文档,见 `docs/acceptance/`;
|
||||||
- 整改基线已合并到 `dev_wjs` 并部署开发服务器;当前 schema 版本为 8;
|
- 整改基线已合并到 `dev_wjs` 并部署开发服务器;开发服务器当前 schema v8,本地新增 v9 尚未部署;
|
||||||
- 开发服务器已验证:Go/Web/AI 健康检查通过,E2E/登录态/负载/恢复演练结果见 `开发交接记录.md`、`变更记录.md` 和 `docs/operations/`。
|
- 开发服务器已验证:Go/Web/AI 健康检查通过,E2E/登录态/负载/恢复演练结果见 `开发交接记录.md`、`变更记录.md` 和 `docs/operations/`。
|
||||||
|
|
||||||
|
### 本地 v9 补充(2026-08-15~17)
|
||||||
|
|
||||||
|
- 对象授权:`organizations`/`organization_members` + `rooms.org_id`,房间/设备/传感器/阈值/告警/遥测/控制/录像/巡检/检测/会诊/溯源/生物安全按组织隔离,HTTP 越权返回 403,WS 订阅双重校验;
|
||||||
|
- 运营能力:设备维护、产量损失统计、脱敏案例库、实验室结构化结果,Web「运营能力」页;
|
||||||
|
- 组织管理:`GET /organizations/:id/members` + Web「组织管理」页,支持组织新增/编辑、成员添加/移除;
|
||||||
|
- 当前本地 `CurrentSchemaVersion=9`,开发服务器仍为 v8;v9 未提交、未部署。
|
||||||
|
|
||||||
### 待办与外部依赖
|
### 待办与外部依赖
|
||||||
|
|
||||||
- #23 摄像头流 AI 巡检:拉流抽帧骨架已就绪(`/stream-detect`);正式巡检任务编排需摄像头视角数据,仍按计划二期。
|
- #23 摄像头流 AI 巡检:拉流抽帧骨架已就绪(`/stream-detect`);正式巡检任务编排需摄像头视角数据,仍按计划二期。
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
系统提供 **Web 后台**、**React Native 移动端 APP**、**微信小程序** 三端界面,后端统一由 **Go 服务**提供 API。
|
系统提供 **Web 后台**、**React Native 移动端 APP**、**微信小程序** 三端界面,后端统一由 **Go 服务**提供 API。
|
||||||
|
|
||||||
> **最新进度**:AI 巡检闭环、知识库、分子检测、专家会诊、疫病溯源、天气预警、健康画像等已完成;整改 Task 0~14 代码与文档已完成并部署到开发服务器,详见根目录 `AGENTS.md`「已完成功能(截至 2026-08-14)」与 `开发交接记录.md`。
|
> **最新进度**:AI 巡检闭环、知识库、分子检测、专家会诊、疫病溯源、天气预警、健康画像等已完成;整改 Task 0~14 代码与文档已完成并部署到开发服务器。本地新增 v9 组织/房间对象授权、运营能力和组织管理页面,尚未部署,详见 `开发交接记录.md`。
|
||||||
|
|
||||||
## 1. 核心能力
|
## 1. 核心能力
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
### 1.5 RBAC 权限管理
|
### 1.5 RBAC 权限管理
|
||||||
|
|
||||||
- 4 个角色:`admin`(管理员)、`operator`(操作员)、`viewer`(查看者)、`farmer`(养殖员)
|
- 4 个角色:`admin`(管理员)、`operator`(操作员)、`viewer`(查看者)、`farmer`(养殖员)
|
||||||
- 36 个权限码:基础看板/蚕房/设备/阈值/告警/视频/能耗/日志/用户/审计,并扩展知识库、巡检、蚕匾批次、分子检测、耗材、会诊、溯源、天气、订阅等
|
- 46 个权限码:基础看板/蚕房/设备/阈值/告警/视频/能耗/日志/用户/审计,并扩展知识库、巡检、蚕匾批次、分子检测、耗材、会诊、溯源、天气、订阅、组织、设备维护、产量损失、案例库、实验室等
|
||||||
- 后端 `RequirePermission` 中间件校验权限码,admin 自动放行
|
- 后端 `RequirePermission` 中间件校验权限码,admin 自动放行
|
||||||
- 前端路由守卫 + 菜单动态过滤 + `<HasPermission>` 按钮级控制
|
- 前端路由守卫 + 菜单动态过滤 + `<HasPermission>` 按钮级控制
|
||||||
- 新用户注册默认角色为 `viewer`
|
- 新用户注册默认角色为 `viewer`
|
||||||
@@ -65,6 +65,7 @@
|
|||||||
- 蚕匾(`trays`)、蚕种批次(`batches`,含品种/蚕种来源/批次号/检疫证明/龄期/入房/上蔟)、饲养记录(`rearing_records`)三表 + CRUD
|
- 蚕匾(`trays`)、蚕种批次(`batches`,含品种/蚕种来源/批次号/检疫证明/龄期/入房/上蔟)、饲养记录(`rearing_records`)三表 + CRUD
|
||||||
- Web「批次管理」页(批次 + 蚕匾 + 饲养记录);小程序蚕房详情展示蚕匾与当前批次
|
- Web「批次管理」页(批次 + 蚕匾 + 饲养记录);小程序蚕房详情展示蚕匾与当前批次
|
||||||
- 生物安全:种源与检疫链、消毒计划/执行、批次/蚕匾/样本二维码身份;小程序可扫码并现场提交消毒执行
|
- 生物安全:种源与检疫链、消毒计划/执行、批次/蚕匾/样本二维码身份;小程序可扫码并现场提交消毒执行
|
||||||
|
- 运营能力:组织/房间级对象授权、设备校准/故障/维护/固件、产量损失统计、脱敏案例库、实验室结构化结果
|
||||||
|
|
||||||
### 1.9 AI 拍照巡检闭环(计划 #5/#6/#8/#9)
|
### 1.9 AI 拍照巡检闭环(计划 #5/#6/#8/#9)
|
||||||
|
|
||||||
@@ -120,6 +121,13 @@
|
|||||||
- 用户 openid 绑定与订阅授权(`/wechat/bind|subscribe`);巡检风险非绿异步触发
|
- 用户 openid 绑定与订阅授权(`/wechat/bind|subscribe`);巡检风险非绿异步触发
|
||||||
- 真实推送需配置 `WECHAT_APPID/SECRET/TEMPLATE_*` 并申请订阅消息模板
|
- 真实推送需配置 `WECHAT_APPID/SECRET/TEMPLATE_*` 并申请订阅消息模板
|
||||||
|
|
||||||
|
### 1.18 组织与对象级授权(IAM-003)
|
||||||
|
|
||||||
|
- `organizations` / `organization_members` + `rooms.org_id` 组成“组织 → 房间 → 业务记录”授权链
|
||||||
|
- 房间、设备、传感器、阈值、告警、遥测、控制、录像、巡检、检测、会诊、溯源、生物安全等核心对象按组织/房间过滤,越权返回 403
|
||||||
|
- WebSocket 设备订阅同时校验 RBAC 与组织成员关系
|
||||||
|
- Web「组织管理」页支持组织新增/编辑、成员添加/移除;默认组织兼容历史数据,新注册用户自动加入
|
||||||
|
|
||||||
## 2. 技术架构
|
## 2. 技术架构
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -355,6 +363,11 @@ Authorization: Bearer <accessToken>
|
|||||||
| `GET/POST /wechat/binding`、`/wechat/bind`、`/wechat/subscribe` | 微信订阅绑定/授权 |
|
| `GET/POST /wechat/binding`、`/wechat/bind`、`/wechat/subscribe` | 微信订阅绑定/授权 |
|
||||||
| `GET/POST/PATCH /consultations(/:id)`、`/:id/resolve`、`/:id/archive` | 专家会诊 |
|
| `GET/POST/PATCH /consultations(/:id)`、`/:id/resolve`、`/:id/archive` | 专家会诊 |
|
||||||
| `GET/POST/PATCH/DELETE /trace-records(/:id)`、`/:id/auto`、`/:id/checklist`、`/region-stats`、`/monthly-stats` | 疫病溯源与区域/月度统计 |
|
| `GET/POST/PATCH/DELETE /trace-records(/:id)`、`/:id/auto`、`/:id/checklist`、`/region-stats`、`/monthly-stats` | 疫病溯源与区域/月度统计 |
|
||||||
|
| `GET/POST/PATCH /organizations(/:id)`、`GET/POST/DELETE /organizations/:id/members(/:userId)` | 组织与成员数据范围 |
|
||||||
|
| `GET/POST/PATCH/DELETE /device-maintenance(/:id)` | 设备校准/故障/维护/固件 |
|
||||||
|
| `GET/POST/PATCH/DELETE /production-loss-records(/:id)`、`/stats` | 产量/死亡/淘汰/损失/成本 |
|
||||||
|
| `GET/POST/PATCH /case-studies(/:id)`、`/from-consultation/:id`、`/:id/review` | 脱敏案例沉淀与审核发布 |
|
||||||
|
| `GET/POST/PATCH/DELETE /laboratory-results(/:id)` | 实验室结构化结果/分子分型 |
|
||||||
| `GET /health-profiles/:roomId` | 蚕房健康画像 |
|
| `GET /health-profiles/:roomId` | 蚕房健康画像 |
|
||||||
| `GET /detection-methods/recommend` | 多检测方式推荐 |
|
| `GET /detection-methods/recommend` | 多检测方式推荐 |
|
||||||
| `GET/POST/PATCH/DELETE /consumables`、`/consumables/alerts`、`/consumables/purchase-suggestions` | 耗材管理 |
|
| `GET/POST/PATCH/DELETE /consumables`、`/consumables/alerts`、`/consumables/purchase-suggestions` | 耗材管理 |
|
||||||
@@ -431,7 +444,7 @@ Payload 示例:
|
|||||||
|
|
||||||
### 10.1 PostgreSQL(业务数据)
|
### 10.1 PostgreSQL(业务数据)
|
||||||
|
|
||||||
保存用户、蚕房、设备、传感器、阈值、告警、摄像头、录像片段、审计日志、权限等业务数据。系统启动时先执行 `server-go/migrations/` 下的版本化 SQL 迁移,再初始化权限种子数据;生产环境禁用 AutoMigrate。
|
保存用户、蚕房、设备、传感器、阈值、告警、摄像头、录像片段、审计日志、权限等业务数据。系统启动时先执行 `server-go/migrations/` 下的版本化 SQL 迁移,再初始化权限种子数据;生产环境禁用 AutoMigrate。本地代码当前为 schema v9(对象授权与运营能力),开发服务器仍为已部署的 schema v8。
|
||||||
|
|
||||||
```env
|
```env
|
||||||
PG=postgresql://postgres:pan@localhost:5432/silk
|
PG=postgresql://postgres:pan@localhost:5432/silk
|
||||||
@@ -473,7 +486,7 @@ S3_BUCKET=silk-video-events
|
|||||||
├── Go 后端 :3000 API 后端 (Gin)
|
├── Go 后端 :3000 API 后端 (Gin)
|
||||||
├── 前端静态服务 :5174 Node.js server.cjs
|
├── 前端静态服务 :5174 Node.js server.cjs
|
||||||
├── Go 录制服务 :9090 recorder-go
|
├── Go 录制服务 :9090 recorder-go
|
||||||
├── PostgreSQL 16 :5432 业务数据库
|
├── PostgreSQL 14 :5432 业务数据库(实际版本 14)
|
||||||
├── IoTDB 2.0.8 :18081 时序数据库
|
├── IoTDB 2.0.8 :18081 时序数据库
|
||||||
├── Valkey :6379 缓存
|
├── Valkey :6379 缓存
|
||||||
├── VerneMQ :1883 MQTT broker
|
├── VerneMQ :1883 MQTT broker
|
||||||
@@ -489,13 +502,13 @@ S3_BUCKET=silk-video-events
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# 交叉编译
|
# 交叉编译
|
||||||
cd e:\silk\server-go
|
cd D:\silk-master-84e4ddc\server-go
|
||||||
$env:GOOS="linux"; $env:GOARCH="amd64"
|
$env:GOOS="linux"; $env:GOARCH="amd64"
|
||||||
go build -o server-go-linux ./cmd/server
|
go build -o server-go-linux ./cmd/server
|
||||||
$env:GOOS=""; $env:GOARCH=""
|
$env:GOOS=""; $env:GOARCH=""
|
||||||
|
|
||||||
# 上传 + 启动(使用 sshpass + ConnectTimeout 避免卡死)
|
# 上传 + 启动(使用 sshpass + ConnectTimeout 避免卡死)
|
||||||
C:\msys64\usr\bin\sshpass.exe -p "pan" C:\msys64\usr\bin\scp.exe -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=10 e:\silk\server-go\server-go-linux pan@100.83.103.1:/home/pan/silk/server-go/
|
C:\msys64\usr\bin\sshpass.exe -p "pan" C:\msys64\usr\bin\scp.exe -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=10 D:\silk-master-84e4ddc\server-go\server-go-linux pan@100.83.103.1:/home/pan/silk/server-go/
|
||||||
C:\msys64\usr\bin\sshpass.exe -p "pan" C:\msys64\usr\bin\ssh.exe -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=10 pan@100.83.103.1 "fuser -n tcp -k 3000 2>/dev/null; sleep 1; chmod +x /home/pan/silk/server-go/server-go-linux; setsid bash /home/pan/silk/server-go/start.sh </dev/null >/home/pan/silk/server-go/server-go.log 2>&1 &"
|
C:\msys64\usr\bin\sshpass.exe -p "pan" C:\msys64\usr\bin\ssh.exe -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=10 pan@100.83.103.1 "fuser -n tcp -k 3000 2>/dev/null; sleep 1; chmod +x /home/pan/silk/server-go/server-go-linux; setsid bash /home/pan/silk/server-go/start.sh </dev/null >/home/pan/silk/server-go/server-go.log 2>&1 &"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -535,7 +548,7 @@ C:\msys64\usr\bin\sshpass.exe -p "pan" C:\msys64\usr\bin\ssh.exe -o StrictHostKe
|
|||||||
### 12.1 Web 前端
|
### 12.1 Web 前端
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd e:\silk\web
|
cd D:\silk-master-84e4ddc\web
|
||||||
npm install
|
npm install
|
||||||
npm run dev # 开发模式,端口 5173,代理 /api 到 :3000
|
npm run dev # 开发模式,端口 5173,代理 /api 到 :3000
|
||||||
npm run build # 生产构建
|
npm run build # 生产构建
|
||||||
@@ -545,7 +558,7 @@ npm test # Vitest 单元测试
|
|||||||
### 12.2 React Native APP
|
### 12.2 React Native APP
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd e:\silk\app
|
cd D:\silk-master-84e4ddc\app
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# 配置后端地址(.env 文件)
|
# 配置后端地址(.env 文件)
|
||||||
@@ -566,7 +579,7 @@ cd android
|
|||||||
### 12.3 微信小程序
|
### 12.3 微信小程序
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd e:\silk\miniapp
|
cd D:\silk-master-84e4ddc\miniapp
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
npm run dev:weapp # 微信小程序开发模式
|
npm run dev:weapp # 微信小程序开发模式
|
||||||
@@ -578,7 +591,7 @@ npm run build:h5 # H5 生产构建
|
|||||||
### 12.4 设备模拟器
|
### 12.4 设备模拟器
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd e:\silk\tools\simulator
|
cd D:\silk-master-84e4ddc\tools\simulator
|
||||||
npm install
|
npm install
|
||||||
npm run start
|
npm run start
|
||||||
```
|
```
|
||||||
@@ -593,7 +606,7 @@ npm run start
|
|||||||
### 12.6 ai-service(AI 推理服务)
|
### 12.6 ai-service(AI 推理服务)
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd e:\silk\ai-service
|
cd D:\silk-master-84e4ddc\ai-service
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
.venv\Scripts\pip install -r requirements.txt # Linux: .venv/bin/pip install -r requirements.txt
|
.venv\Scripts\pip install -r requirements.txt # Linux: .venv/bin/pip install -r requirements.txt
|
||||||
.venv\Scripts\python -m pytest tests -q # 单元测试
|
.venv\Scripts\python -m pytest tests -q # 单元测试
|
||||||
|
|||||||
@@ -61,6 +61,16 @@
|
|||||||
2. 核对巡检、检测、会诊、溯源、图片/光谱/录像引用。
|
2. 核对巡检、检测、会诊、溯源、图片/光谱/录像引用。
|
||||||
3. 记录 RPO/RTO 和失败项。
|
3. 记录 RPO/RTO 和失败项。
|
||||||
|
|
||||||
|
## E2E-11 跨组织对象访问返回 403
|
||||||
|
|
||||||
|
1. 创建组织 A/B,并将不同蚕房分别归属到 A/B。
|
||||||
|
2. 创建仅属于组织 A 的用户和仅属于组织 B 的用户。
|
||||||
|
3. 使用 B 用户访问 A 的蚕房、设备、遥测、控制、巡检、检测、会诊、溯源、录像等对象。
|
||||||
|
4. 期望详情/写操作返回 403,列表不返回 A 组织数据,WebSocket 订阅被拒绝。
|
||||||
|
5. 记录接口清单和证据。
|
||||||
|
|
||||||
|
当前状态:本地 Go/WS 单测已覆盖对象授权,待 v9 部署后执行真实账号联调。
|
||||||
|
|
||||||
## 2026-08-14 开发服务器执行结果
|
## 2026-08-14 开发服务器执行结果
|
||||||
|
|
||||||
环境:`100.83.103.1`,Go schema v8,Web :5174,AI :8000(mock)。
|
环境:`100.83.103.1`,Go schema v8,Web :5174,AI :8000(mock)。
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
- [ ] 执行 `powershell -ExecutionPolicy Bypass -File scripts/verify.ps1`,exit 0。
|
- [ ] 执行 `powershell -ExecutionPolicy Bypass -File scripts/verify.ps1`,exit 0。
|
||||||
- [ ] 执行 `git diff --check`,exit 0。
|
- [ ] 执行 `git diff --check`,exit 0。
|
||||||
- [ ] 涉及 schema 时完成 `pg_dump` 备份并记录恢复命令。
|
- [ ] 涉及 schema 时完成 `pg_dump` 备份并记录恢复命令。
|
||||||
|
- [ ] v9 发布前确认 `000009_object_authority_and_business_capabilities.up.sql`、`.down.sql` 路径和回滚顺序。
|
||||||
- [ ] 检查磁盘、端口、服务进程和依赖健康。
|
- [ ] 检查磁盘、端口、服务进程和依赖健康。
|
||||||
- [ ] 核对需求追踪矩阵:本次涉及的需求 ID 有实现、测试和限制说明。
|
- [ ] 核对需求追踪矩阵:本次涉及的需求 ID 有实现、测试和限制说明。
|
||||||
|
|
||||||
@@ -16,12 +17,14 @@
|
|||||||
- [ ] 执行数据库迁移。
|
- [ ] 执行数据库迁移。
|
||||||
- [ ] 重启服务。
|
- [ ] 重启服务。
|
||||||
- [ ] 健康检查和核心接口冒烟。
|
- [ ] 健康检查和核心接口冒烟。
|
||||||
|
- [ ] 登录管理员并冒烟 `/organizations`、`/organizations/:id/members`、Web `/organizations` 页面。
|
||||||
|
|
||||||
## 发布后
|
## 发布后
|
||||||
|
|
||||||
- [ ] 记录部署 commit、迁移版本、备份路径、冒烟结果。
|
- [ ] 记录部署 commit、迁移版本、备份路径、冒烟结果。
|
||||||
- [ ] 更新 `变更记录.md` 和 `开发交接记录.md`。
|
- [ ] 更新 `变更记录.md` 和 `开发交接记录.md`。
|
||||||
- [ ] 执行核心端到端用例中受影响的 E2E。
|
- [ ] 执行核心端到端用例中受影响的 E2E。
|
||||||
|
- [ ] 对象授权改动时执行 E2E-11 跨组织越权用例。
|
||||||
- [ ] 如失败,按回滚预案恢复并记录原因。
|
- [ ] 如失败,按回滚预案恢复并记录原因。
|
||||||
|
|
||||||
## 回滚
|
## 回滚
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
| 需求 ID | 实现 | 自动测试 | 手工用例 | 环境 | 证据 | 状态 | 限制 | 负责人 |
|
| 需求 ID | 实现 | 自动测试 | 手工用例 | 环境 | 证据 | 状态 | 限制 | 负责人 |
|
||||||
|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|
|
||||||
| IAM-001 | `handler/auth.go`、`middleware/token_blacklist.go` | `middleware/state_test.go` | 登录/刷新/登出/跨实例吊销 | 开发/生产 | `go test ./...` | 部分可用 | Redis 不可用时保守失败 | 后端 |
|
| IAM-001 | `handler/auth.go`、`middleware/token_blacklist.go` | `middleware/state_test.go` | 登录/刷新/登出/跨实例吊销 | 开发/生产 | `go test ./...` | 部分可用 | Redis 不可用时保守失败 | 后端 |
|
||||||
| IAM-002 | `middleware/permission.go`、`permission_seed.go` | 无专项测试 | 角色访问矩阵 | 开发 | 路由权限检查 | 部分可用 | 缺少对象级 ACL | 后端 |
|
| IAM-002 | `middleware/permission.go`、`permission_seed.go` | 无专项测试 | 角色访问矩阵 | 开发 | 路由权限检查 | 部分可用 | 对象级 ACL 已由 IAM-003 补齐 | 后端 |
|
||||||
| IAM-003 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 需组织/区域/房间 ACL | 后端 |
|
| IAM-003 | `access.go`、`organization.go`、`ws/authorizer.go`、`web/src/pages/Organizations.tsx`、`organizations`/`organization_members` | `access_test.go`、`ws/authorizer_test.go` | 跨组织访问 403 | 本地 | `go test ./...`、`npm run build` | 部分可用 | 房间/设备/传感器/阈值/告警/遥测/控制/录像/WS/通知/案例草稿已接对象授权,Web 组织管理已补;待开发服务器越权联调 | 后端 + Web |
|
||||||
| IAM-004 | 延后 Task 3 | 无 | 无 | 无 | 决策记录 | 延后 | 默认凭据和强制改密待最后阶段 | 后端+运维 |
|
| IAM-004 | 延后 Task 3 | 无 | 无 | 无 | 决策记录 | 延后 | 默认凭据和强制改密待最后阶段 | 后端+运维 |
|
||||||
| AI-INS-002 | `service/risk.go`、`ai-service` | `risk_test.go`、AI pytest | healthy/sick 图片 | 本地 | verify.ps1 | 部分可用 | 真实模型未验收 | AI+后端 |
|
| AI-INS-002 | `service/risk.go`、`ai-service` | `risk_test.go`、AI pytest | healthy/sick 图片 | 本地 | verify.ps1 | 部分可用 | 真实模型未验收 | AI+后端 |
|
||||||
| AI-INS-003 | `inspection.go`、`is_mock` | `inspection_test.go` | Mock 标识与统计排除 | 本地 | verify.ps1 | 部分可用 | 历史 mock 需人工审阅 | 后端 |
|
| AI-INS-003 | `inspection.go`、`is_mock` | `inspection_test.go` | Mock 标识与统计排除 | 本地 | verify.ps1 | 部分可用 | 历史 mock 需人工审阅 | 后端 |
|
||||||
@@ -51,16 +51,16 @@
|
|||||||
|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|
|
||||||
| FARM-001 | `tray_batch.go` | `knowledge_seed_test.go` 等 | 蚕房/批次 CRUD | 本地 | go test | 已实现 | 无 | 后端 |
|
| FARM-001 | `tray_batch.go` | `knowledge_seed_test.go` 等 | 蚕房/批次 CRUD | 本地 | go test | 已实现 | 无 | 后端 |
|
||||||
| FARM-002 | `identity_links.go` | `biosecurity_test.go` | 二维码签发/解析 | 本地 | go test | 部分可用 | 未打印扫码 | 后端+小程序 |
|
| FARM-002 | `identity_links.go` | `biosecurity_test.go` | 二维码签发/解析 | 本地 | go test | 部分可用 | 未打印扫码 | 后端+小程序 |
|
||||||
| FARM-003 | `rearing_records` | 无专项测试 | 饲养记录 | 本地 | go test | 部分可用 | 缺死亡/淘汰字段 | 后端 |
|
| FARM-003 | `rearing_records` | 无专项测试 | 饲养记录 | 本地 | go test | 部分可用 | 死亡/淘汰字段已补,缺少现场联调 | 后端 |
|
||||||
| FARM-004 | `disinfection_records` | `biosecurity_test.go` | 消毒计划/执行 | 本地 | verify.ps1 | 部分可用 | 未现场联调 | 后端+小程序 |
|
| FARM-004 | `disinfection_records` | `biosecurity_test.go` | 消毒计划/执行 | 本地 | verify.ps1 | 部分可用 | 未现场联调 | 后端+小程序 |
|
||||||
| FARM-005 | `seed_sources` | `biosecurity_test.go` | 种源链 | 本地 | go test | 部分可用 | 供应商凭证待接入 | 后端 |
|
| FARM-005 | `seed_sources` | `biosecurity_test.go` | 种源链 | 本地 | go test | 部分可用 | 供应商凭证待接入 | 后端 |
|
||||||
| FARM-006 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 产量/损失/成本 | 后端 |
|
| FARM-006 | `production_loss_records`、`farm.go`、`web/src/pages/BusinessExtensions.tsx` | `farm_stats_test.go` | 产量损失 CRUD/统计 | 本地 | `go test ./...`、`npm run build` | 部分可用 | 记录、统计与 Web 编辑/删除已实现;待现场联调 | 后端 + Web |
|
||||||
| ENV-001 | `telemetry.go`、`mqtt.go` | 无专项测试 | 遥测查询 | 开发 | 构建 | 已实现 | 数据质量标识不足 | 后端 |
|
| ENV-001 | `telemetry.go`、`mqtt.go` | 无专项测试 | 遥测查询 | 开发 | 构建 | 已实现 | 数据质量标识不足 | 后端 |
|
||||||
| ENV-002 | `threshold.go`、`alarm.go` | 无专项测试 | 阈值告警 | 开发 | 构建 | 已实现 | 无对象级授权 | 后端 |
|
| ENV-002 | `threshold.go`、`alarm.go` | 无专项测试 | 阈值告警 | 开发 | 构建 | 已实现 | 阈值/告警已按组织房间过滤 | 后端 |
|
||||||
| ENV-003 | `rule_engine.go` | `rule_engine_test.go` | 过期数据 | 本地 | go test | 部分可用 | 未接入生产遥测 | 后端 |
|
| ENV-003 | `rule_engine.go` | `rule_engine_test.go` | 过期数据 | 本地 | go test | 部分可用 | 未接入生产遥测 | 后端 |
|
||||||
| ENV-004 | `rule_engine.go`、`weather_risk.go` | `rule_engine_test.go` | 连续时窗 | 本地 | go test | 部分可用 | 密度/通风等输入待接入 | 后端 |
|
| ENV-004 | `rule_engine.go`、`weather_risk.go` | `rule_engine_test.go` | 连续时窗 | 本地 | go test | 部分可用 | 密度/通风等输入待接入 | 后端 |
|
||||||
| ENV-005 | `rule_engine.go` | `rule_engine_test.go` | 规则解释 | 本地 | go test | 部分可用 | 结果持久化未全链路 | 后端 |
|
| ENV-005 | `rule_engine.go` | `rule_engine_test.go` | 规则解释 | 本地 | go test | 部分可用 | 结果持久化未全链路 | 后端 |
|
||||||
| ENV-006 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 设备校准/维护 | 后端 |
|
| ENV-006 | `device_maintenance_records`、`device_maintenance.go` | `business_capabilities_test.go` | 校准/故障/维护/固件 CRUD | 本地 | `go test ./...` | 部分可用 | 未接设备自动回传与现场联调 | 后端 |
|
||||||
| ENV-007 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 安全控制建议 | 后端 |
|
| ENV-007 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 安全控制建议 | 后端 |
|
||||||
| EXP-001 | `consultation.go` | `consultation_test.go` | 病例快照 | 本地 | go test | 已实现 | 环境/天气摘要待增强 | 后端 |
|
| EXP-001 | `consultation.go` | `consultation_test.go` | 病例快照 | 本地 | go test | 已实现 | 环境/天气摘要待增强 | 后端 |
|
||||||
| EXP-002 | `consultation.go` | `consultation_test.go` | 会诊状态机 | 本地 | go test | 已实现 | 无 | 后端 |
|
| EXP-002 | `consultation.go` | `consultation_test.go` | 会诊状态机 | 本地 | go test | 已实现 | 无 | 后端 |
|
||||||
@@ -69,15 +69,15 @@
|
|||||||
| EXP-005 | `health_profile.go` effect | `health_profile_test.go` | 效果报告 | 本地 | go test | 部分可用 | 损失/成本缺失 | 后端 |
|
| EXP-005 | `health_profile.go` effect | `health_profile_test.go` | 效果报告 | 本地 | go test | 部分可用 | 损失/成本缺失 | 后端 |
|
||||||
| KB-001 | `knowledge.go` | `knowledge_governance_test.go` | 审核发布 | 本地 | go test | 部分可用 | 审核权限基于角色 | 后端+Web |
|
| KB-001 | `knowledge.go` | `knowledge_governance_test.go` | 审核发布 | 本地 | go test | 部分可用 | 审核权限基于角色 | 后端+Web |
|
||||||
| KB-002 | `knowledge.go` source 字段 | 无专项测试 | 来源展示 | 本地 | 构建 | 部分可用 | 专家签名待接入 | 后端 |
|
| KB-002 | `knowledge.go` source 字段 | 无专项测试 | 来源展示 | 本地 | 构建 | 部分可用 | 专家签名待接入 | 后端 |
|
||||||
| KB-003 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 案例沉淀 | 后端 |
|
| KB-003 | `case_studies`、`case_study.go`、`web/src/pages/BusinessExtensions.tsx` | `business_capabilities_test.go` | 脱敏案例创建/审核/发布 | 本地 | `go test ./...`、`npm run build` | 部分可用 | 已接入 Web 案例库;待目标环境验收 | 后端+Web |
|
||||||
| TRACE-001 | `disease_events` | `disease_event_test.go` | 发病事件 | 本地 | go test | 部分可用 | 未端到端 | 后端 |
|
| TRACE-001 | `disease_events` | `disease_event_test.go` | 发病事件 | 本地 | go test | 部分可用 | 未端到端 | 后端 |
|
||||||
| TRACE-002 | `trace.go` auto | 无专项测试 | 自动溯源 | 本地 | go test | 部分可用 | 空间/样本链不足 | 后端 |
|
| TRACE-002 | `trace.go` auto | 无专项测试 | 自动溯源 | 本地 | go test | 部分可用 | 空间/样本链不足 | 后端 |
|
||||||
| TRACE-003 | `trace.go` checklist | 无专项测试 | 二级排查 | 本地 | go test | 部分可用 | 无版本化清单 | 后端 |
|
| TRACE-003 | `trace.go` checklist | 无专项测试 | 二级排查 | 本地 | go test | 部分可用 | 无版本化清单 | 后端 |
|
||||||
| TRACE-004 | `trace.go` lab note | 无专项测试 | 三级溯源 | 本地 | go test | 部分可用 | 结构化实验室数据未做 | 后端 |
|
| TRACE-004 | `laboratory_results`、`laboratory.go`、`web/src/pages/BusinessExtensions.tsx` | `business_capabilities_test.go` | 实验室结果/分型结构化关联 | 本地 | `go test ./...`、`npm run build` | 部分可用 | Web 录入已接;设备导入待做 | 后端 + Web |
|
||||||
| TRACE-005 | `trace.go` | `trace_test.go` | 来源结论 | 本地 | go test | 部分可用 | 专家确认流程未做 | 后端 |
|
| TRACE-005 | `trace.go` | `trace_test.go` | 来源结论 | 本地 | go test | 部分可用 | 专家确认流程未做 | 后端 |
|
||||||
| TRACE-006 | `region_stat.go` | `region_stat_test.go` | 区域统计 | 本地 | go test | 部分可用 | 小样本脱敏未做 | 后端 |
|
| TRACE-006 | `region_stat.go` | `region_stat_test.go` | 区域统计 | 本地 | go test | 部分可用 | 小样本脱敏未做 | 后端 |
|
||||||
| TRACE-007 | `seed_sources` + 复发字段 | `biosecurity_test.go` | 复发关联 | 本地 | go test | 部分可用 | 人工确认未做 | 后端 |
|
| TRACE-007 | `seed_sources` + 复发字段 | `biosecurity_test.go` | 复发关联 | 本地 | go test | 部分可用 | 人工确认未做 | 后端 |
|
||||||
| VIDEO-001 | `video_auth.go` | `video_auth_test.go` | 播放授权 | 本地 | go test | 部分可用 | 对象级授权待后续 | 后端 |
|
| VIDEO-001 | `video_auth.go`、`access.go` | `video_auth_test.go`、`access_test.go` | 播放授权 | 本地 | go test | 部分可用 | 摄像头/录像已按组织房间过滤 | 后端 |
|
||||||
| VIDEO-002 | DTO 脱敏 | `video_dto.go` | 密钥不返回 | 本地 | go test | 部分可用 | 历史配置审计 | 后端 |
|
| VIDEO-002 | DTO 脱敏 | `video_dto.go` | 密钥不返回 | 本地 | go test | 部分可用 | 历史配置审计 | 后端 |
|
||||||
| VIDEO-003 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 录制状态持久化 | 后端 |
|
| VIDEO-003 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 录制状态持久化 | 后端 |
|
||||||
| VIDEO-004 | `stream_tasks.py` | AI pytest | 受限拉流 | 本地 | pytest | 部分可用 | 摄像头真实视角待接入 | AI |
|
| VIDEO-004 | `stream_tasks.py` | AI pytest | 受限拉流 | 本地 | pytest | 部分可用 | 摄像头真实视角待接入 | AI |
|
||||||
|
|||||||
@@ -135,6 +135,10 @@ func main() {
|
|||||||
handler.RegisterDetectionTaskRoutes(api, db)
|
handler.RegisterDetectionTaskRoutes(api, db)
|
||||||
handler.RegisterConsumableRoutes(api, db)
|
handler.RegisterConsumableRoutes(api, db)
|
||||||
handler.RegisterBiosecurityRoutes(api, db)
|
handler.RegisterBiosecurityRoutes(api, db)
|
||||||
|
handler.RegisterDeviceMaintenanceRoutes(api, db)
|
||||||
|
handler.RegisterFarmRoutes(api, db)
|
||||||
|
handler.RegisterCaseStudyRoutes(api, db)
|
||||||
|
handler.RegisterLaboratoryRoutes(api, db)
|
||||||
handler.RegisterConsultationRoutes(api, db)
|
handler.RegisterConsultationRoutes(api, db)
|
||||||
handler.RegisterDetectionMethodRoutes(api, db)
|
handler.RegisterDetectionMethodRoutes(api, db)
|
||||||
handler.RegisterTraceRoutes(api, db)
|
handler.RegisterTraceRoutes(api, db)
|
||||||
@@ -150,6 +154,7 @@ func main() {
|
|||||||
// 用户管理与审计日志路由(各路由内部按权限码校验)
|
// 用户管理与审计日志路由(各路由内部按权限码校验)
|
||||||
handler.RegisterAuditRoutes(api, db)
|
handler.RegisterAuditRoutes(api, db)
|
||||||
handler.RegisterUserRoutes(api, db)
|
handler.RegisterUserRoutes(api, db)
|
||||||
|
handler.RegisterOrganizationRoutes(api, db)
|
||||||
handler.RegisterPermissionRoutes(api, db)
|
handler.RegisterPermissionRoutes(api, db)
|
||||||
handler.RegisterOpsRoutes(api, db)
|
handler.RegisterOpsRoutes(api, db)
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ func Init(cfg *config.Config) error {
|
|||||||
&model.Threshold{}, &model.Alarm{}, &model.Camera{}, &model.VideoClip{},
|
&model.Threshold{}, &model.Alarm{}, &model.Camera{}, &model.VideoClip{},
|
||||||
&model.AuditLog{}, &model.Telemetry{},
|
&model.AuditLog{}, &model.Telemetry{},
|
||||||
&model.Permission{}, &model.RolePermission{},
|
&model.Permission{}, &model.RolePermission{},
|
||||||
|
&model.Organization{}, &model.OrganizationMember{},
|
||||||
|
&model.DeviceMaintenanceRecord{}, &model.ProductionLossRecord{},
|
||||||
|
&model.CaseStudy{}, &model.LaboratoryResult{},
|
||||||
&model.Disease{}, &model.KnowledgeArticle{},
|
&model.Disease{}, &model.KnowledgeArticle{},
|
||||||
&model.InspectionRecord{},
|
&model.InspectionRecord{},
|
||||||
&model.OutboxEvent{},
|
&model.OutboxEvent{},
|
||||||
@@ -60,6 +63,7 @@ func Init(cfg *config.Config) error {
|
|||||||
}
|
}
|
||||||
slog.Warn("开发环境 AutoMigrate 已启用,SQL 迁移仍是生产 schema 事实来源")
|
slog.Warn("开发环境 AutoMigrate 已启用,SQL 迁移仍是生产 schema 事实来源")
|
||||||
}
|
}
|
||||||
|
seedOrganizations(db)
|
||||||
// 初始化权限种子数据
|
// 初始化权限种子数据
|
||||||
seedPermissions(db)
|
seedPermissions(db)
|
||||||
// 初始化知识库种子数据
|
// 初始化知识库种子数据
|
||||||
@@ -68,6 +72,28 @@ func Init(cfg *config.Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedOrganizations 保证默认组织存在,并给所有现有用户补齐成员关系(幂等)。
|
||||||
|
func seedOrganizations(db *gorm.DB) {
|
||||||
|
var org model.Organization
|
||||||
|
if err := db.Where("code = ?", "default").First(&org).Error; err != nil {
|
||||||
|
org = model.Organization{Name: "默认组织", Code: "default", Status: "active"}
|
||||||
|
desc := "系统升级时创建的默认组织,用于兼容历史数据"
|
||||||
|
org.Description = &desc
|
||||||
|
if err := db.Create(&org).Error; err != nil {
|
||||||
|
slog.Warn("创建默认组织失败", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.Exec(`
|
||||||
|
INSERT INTO organization_members (organization_id, user_id, role)
|
||||||
|
SELECT ?, u.id, 'member' FROM users u
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM organization_members om
|
||||||
|
WHERE om.organization_id = ? AND om.user_id = u.id
|
||||||
|
)
|
||||||
|
`, org.ID, org.ID)
|
||||||
|
}
|
||||||
|
|
||||||
// seedKnowledge 初始化知识库种子数据(幂等:按名称/类型+标题判重)
|
// seedKnowledge 初始化知识库种子数据(幂等:按名称/类型+标题判重)
|
||||||
func seedKnowledge(db *gorm.DB) {
|
func seedKnowledge(db *gorm.DB) {
|
||||||
for _, d := range model.SeedDiseases {
|
for _, d := range model.SeedDiseases {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// CurrentSchemaVersion 是当前后端代码期望的迁移版本。
|
// CurrentSchemaVersion 是当前后端代码期望的迁移版本。
|
||||||
const CurrentSchemaVersion = "8"
|
const CurrentSchemaVersion = "9"
|
||||||
|
|
||||||
// RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。
|
// RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。
|
||||||
func RunMigrations(db *gorm.DB) error {
|
func RunMigrations(db *gorm.DB) error {
|
||||||
|
|||||||
@@ -124,4 +124,8 @@ func TestEmbeddedMigrationsIncludeBaseline(t *testing.T) {
|
|||||||
if err != nil || next != 8 {
|
if err != nil || next != 8 {
|
||||||
t.Fatalf("expected governance migration version 8, got %d (err %v)", next, err)
|
t.Fatalf("expected governance migration version 8, got %d (err %v)", next, err)
|
||||||
}
|
}
|
||||||
|
next, err = driver.Next(next)
|
||||||
|
if err != nil || next != 9 {
|
||||||
|
t.Fatalf("expected object authority migration version 9, got %d (err %v)", next, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"silk-server-go/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultOrgCode = "default"
|
||||||
|
|
||||||
|
// currentUserRole 从 JWT context 中取当前角色。
|
||||||
|
func currentUserRole(c *gin.Context) string {
|
||||||
|
user, ok := c.Get("user")
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
m, ok := user.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
role, _ := m["role"].(string)
|
||||||
|
return role
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasGlobalAccess admin 拥有跨组织全部对象访问权。
|
||||||
|
func hasGlobalAccess(c *gin.Context) bool {
|
||||||
|
return currentUserRole(c) == model.RoleAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyOrgScope 对组织归属列(如 rooms.org_id)追加当前用户可见组织过滤。
|
||||||
|
func applyOrgScope(q *gorm.DB, c *gin.Context, column string) *gorm.DB {
|
||||||
|
if hasGlobalAccess(c) {
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
userID := currentUserID(c)
|
||||||
|
if userID == nil {
|
||||||
|
return q.Where("1 = 0")
|
||||||
|
}
|
||||||
|
return q.Where("CAST("+column+" AS text) IN (SELECT organization_id::text FROM organization_members WHERE user_id = ?)", *userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyRoomScope 对业务记录的房间列追加当前用户可见房间过滤。
|
||||||
|
func applyRoomScope(q *gorm.DB, c *gin.Context, column string) *gorm.DB {
|
||||||
|
if hasGlobalAccess(c) {
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
userID := currentUserID(c)
|
||||||
|
if userID == nil {
|
||||||
|
return q.Where("1 = 0")
|
||||||
|
}
|
||||||
|
return q.Where("CAST("+column+" AS text) IN ("+
|
||||||
|
"SELECT r.id::text FROM rooms r "+
|
||||||
|
"JOIN organization_members om ON om.organization_id = r.org_id "+
|
||||||
|
"WHERE om.user_id = ?)", *userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// canAccessOrganization 判断当前用户是否属于指定组织。
|
||||||
|
func canAccessOrganization(db *gorm.DB, c *gin.Context, orgID string) bool {
|
||||||
|
if hasGlobalAccess(c) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
userID := currentUserID(c)
|
||||||
|
if userID == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
db.Model(&model.OrganizationMember{}).
|
||||||
|
Where("organization_id = ? AND user_id = ?", orgID, *userID).
|
||||||
|
Count(&count)
|
||||||
|
return count > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// canAccessRoom 判断当前用户是否能访问指定房间。
|
||||||
|
func canAccessRoom(db *gorm.DB, c *gin.Context, roomID *string) bool {
|
||||||
|
if roomID == nil || *roomID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if hasGlobalAccess(c) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
userID := currentUserID(c)
|
||||||
|
if userID == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
db.Model(&model.Room{}).
|
||||||
|
Joins("JOIN organization_members om ON om.organization_id = rooms.org_id").
|
||||||
|
Where("rooms.id = ? AND om.user_id = ?", *roomID, *userID).
|
||||||
|
Count(&count)
|
||||||
|
return count > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// canAccessDeviceKey 判断当前用户是否能访问指定 deviceKey 对应的设备。
|
||||||
|
func canAccessDeviceKey(db *gorm.DB, c *gin.Context, deviceKey string) bool {
|
||||||
|
if hasGlobalAccess(c) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if deviceKey == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if currentUserID(c) == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var device model.Device
|
||||||
|
if db.Where("device_key = ?", deviceKey).First(&device).Error != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return canAccessRoom(db, c, &device.RoomID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireDeviceKeyAccess 统一校验 deviceKey 对象授权;无权返回 403。
|
||||||
|
func requireDeviceKeyAccess(c *gin.Context, db *gorm.DB, deviceKey string) bool {
|
||||||
|
if canAccessDeviceKey(db, c, deviceKey) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if deviceKey == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "deviceKey 不能为空"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
db.Model(&model.Device{}).Where("device_key = ?", deviceKey).Count(&count)
|
||||||
|
if count == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "device not found"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该设备"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireObjectAccess 统一对象授权检查;无权返回 403。
|
||||||
|
func requireObjectAccess(c *gin.Context, db *gorm.DB, entityType, id string) bool {
|
||||||
|
if hasGlobalAccess(c) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
roomID, ok := objectRoomID(db, entityType, id)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": entityType + " not found"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !canAccessRoom(db, c, roomID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该对象"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// objectRoomID 返回实体归属房间;实体不存在时返回 false。
|
||||||
|
func objectRoomID(db *gorm.DB, entityType, id string) (*string, bool) {
|
||||||
|
switch entityType {
|
||||||
|
case "room":
|
||||||
|
var room model.Room
|
||||||
|
if db.Where("id = ?", id).First(&room).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &room.ID, true
|
||||||
|
case "device":
|
||||||
|
var device model.Device
|
||||||
|
if db.Where("id = ?", id).First(&device).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &device.RoomID, true
|
||||||
|
case "sensor":
|
||||||
|
var sensor model.Sensor
|
||||||
|
if db.Where("id = ?", id).First(&sensor).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
var device model.Device
|
||||||
|
if db.Where("id = ?", sensor.DeviceID).First(&device).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &device.RoomID, true
|
||||||
|
case "threshold":
|
||||||
|
var threshold model.Threshold
|
||||||
|
if db.Where("id = ?", id).First(&threshold).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
var sensor model.Sensor
|
||||||
|
if db.Where("id = ?", threshold.SensorID).First(&sensor).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
var device model.Device
|
||||||
|
if db.Where("id = ?", sensor.DeviceID).First(&device).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &device.RoomID, true
|
||||||
|
case "alarm":
|
||||||
|
var alarm model.Alarm
|
||||||
|
if db.Where("id = ?", id).First(&alarm).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if alarm.DeviceKey == nil || *alarm.DeviceKey == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
var device model.Device
|
||||||
|
if db.Where("device_key = ?", *alarm.DeviceKey).First(&device).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &device.RoomID, true
|
||||||
|
case "device_maintenance_record":
|
||||||
|
var rec model.DeviceMaintenanceRecord
|
||||||
|
if db.Where("id = ?", id).First(&rec).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
var device model.Device
|
||||||
|
if db.Where("id = ?", rec.DeviceID).First(&device).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &device.RoomID, true
|
||||||
|
case "camera":
|
||||||
|
var camera model.Camera
|
||||||
|
if db.Where("id = ?", id).First(&camera).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return camera.RoomID, true
|
||||||
|
case "video_clip":
|
||||||
|
var clip model.VideoClip
|
||||||
|
if db.Where("id = ?", id).First(&clip).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return clip.RoomID, true
|
||||||
|
case "tray":
|
||||||
|
var tray model.Tray
|
||||||
|
if db.Where("id = ?", id).First(&tray).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &tray.RoomID, true
|
||||||
|
case "batch":
|
||||||
|
var batch model.Batch
|
||||||
|
if db.Where("id = ?", id).First(&batch).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &batch.RoomID, true
|
||||||
|
case "rearing_record":
|
||||||
|
var rec model.RearingRecord
|
||||||
|
if db.Where("id = ?", id).First(&rec).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return batchRoomID(db, &rec.BatchID)
|
||||||
|
case "inspection":
|
||||||
|
var rec model.InspectionRecord
|
||||||
|
if db.Where("id = ?", id).First(&rec).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return rec.RoomID, true
|
||||||
|
case "lamp_test":
|
||||||
|
var rec model.LampTest
|
||||||
|
if db.Where("id = ?", id).First(&rec).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return rec.RoomID, true
|
||||||
|
case "detection_task":
|
||||||
|
var rec model.DetectionTask
|
||||||
|
if db.Where("id = ?", id).First(&rec).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return rec.RoomID, true
|
||||||
|
case "consultation":
|
||||||
|
var rec model.Consultation
|
||||||
|
if db.Where("id = ?", id).First(&rec).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return rec.RoomID, true
|
||||||
|
case "trace_record":
|
||||||
|
var rec model.TraceRecord
|
||||||
|
if db.Where("id = ?", id).First(&rec).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return rec.RoomID, true
|
||||||
|
case "disease_event":
|
||||||
|
var rec model.DiseaseEvent
|
||||||
|
if db.Where("id = ?", id).First(&rec).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return rec.RoomID, true
|
||||||
|
case "sample":
|
||||||
|
var rec model.Sample
|
||||||
|
if db.Where("id = ?", id).First(&rec).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return rec.RoomID, true
|
||||||
|
case "seed_source":
|
||||||
|
var rec model.SeedSource
|
||||||
|
if db.Where("id = ?", id).First(&rec).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return batchRoomID(db, rec.BatchID)
|
||||||
|
case "disinfection_record":
|
||||||
|
var rec model.DisinfectionRecord
|
||||||
|
if db.Where("id = ?", id).First(&rec).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if rec.RoomID != nil {
|
||||||
|
return rec.RoomID, true
|
||||||
|
}
|
||||||
|
return batchRoomID(db, rec.BatchID)
|
||||||
|
case "production_loss_record":
|
||||||
|
var rec model.ProductionLossRecord
|
||||||
|
if db.Where("id = ?", id).First(&rec).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if rec.RoomID != nil {
|
||||||
|
return rec.RoomID, true
|
||||||
|
}
|
||||||
|
return batchRoomID(db, rec.BatchID)
|
||||||
|
case "laboratory_result":
|
||||||
|
var rec model.LaboratoryResult
|
||||||
|
if db.Where("id = ?", id).First(&rec).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if rec.RoomID != nil {
|
||||||
|
return rec.RoomID, true
|
||||||
|
}
|
||||||
|
if rec.BatchID != nil {
|
||||||
|
return batchRoomID(db, rec.BatchID)
|
||||||
|
}
|
||||||
|
if rec.TraceRecordID != nil {
|
||||||
|
var trace model.TraceRecord
|
||||||
|
if db.Where("id = ?", *rec.TraceRecordID).First(&trace).Error == nil {
|
||||||
|
return trace.RoomID, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, true
|
||||||
|
default:
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchRoomID(db *gorm.DB, batchID *string) (*string, bool) {
|
||||||
|
if batchID == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
var batch model.Batch
|
||||||
|
if db.Where("id = ?", *batchID).First(&batch).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &batch.RoomID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultOrgID 返回当前用户第一个组织;管理员未指定时返回默认组织。
|
||||||
|
func defaultOrgID(db *gorm.DB, c *gin.Context) *string {
|
||||||
|
if userID := currentUserID(c); userID != nil {
|
||||||
|
var member model.OrganizationMember
|
||||||
|
if db.Where("user_id = ?", *userID).Order("created_at ASC").First(&member).Error == nil {
|
||||||
|
return &member.OrganizationID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var org model.Organization
|
||||||
|
if db.Where("code = ?", defaultOrgCode).First(&org).Error == nil {
|
||||||
|
return &org.ID
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstAccessibleRoomID 返回当前用户可见的第一个房间;用于无显式 roomId 的历史创建流程。
|
||||||
|
func firstAccessibleRoomID(db *gorm.DB, c *gin.Context) *string {
|
||||||
|
var room model.Room
|
||||||
|
q := db.Model(&model.Room{})
|
||||||
|
if !hasGlobalAccess(c) {
|
||||||
|
q = applyOrgScope(q, c, "org_id")
|
||||||
|
}
|
||||||
|
if q.Order("created_at ASC").First(&room).Error == nil {
|
||||||
|
return &room.ID
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// assignDefaultOrganization 注册新用户时补齐默认组织成员关系。
|
||||||
|
func assignDefaultOrganization(db *gorm.DB, userID string) {
|
||||||
|
var org model.Organization
|
||||||
|
if db.Where("code = ?", defaultOrgCode).First(&org).Error != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
db.Model(&model.OrganizationMember{}).
|
||||||
|
Where("organization_id = ? AND user_id = ?", org.ID, userID).
|
||||||
|
Count(&count)
|
||||||
|
if count == 0 {
|
||||||
|
_ = db.Create(&model.OrganizationMember{OrganizationID: org.ID, UserID: userID, Role: "member"}).Error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCanAccessRoomAdmin(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Set("user", map[string]interface{}{"sub": "admin-1", "role": "admin"})
|
||||||
|
roomID := "room-1"
|
||||||
|
if !canAccessRoom(nil, c, &roomID) {
|
||||||
|
t.Fatal("admin should have global object access")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanAccessRoomMissingUser(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
roomID := "room-1"
|
||||||
|
if canAccessRoom(nil, c, &roomID) {
|
||||||
|
t.Fatal("missing user should not pass room access")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyRoomScopeUsesOrganizationMembership(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
sqlDB, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
db, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open gorm: %v", err)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Set("user", map[string]interface{}{"sub": "user-1", "role": "viewer"})
|
||||||
|
|
||||||
|
mock.ExpectQuery(".*CAST\\(room_id AS text\\).*organization_members.*").
|
||||||
|
WithArgs("user-1").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "org_id"}))
|
||||||
|
|
||||||
|
var rooms []map[string]interface{}
|
||||||
|
q := applyRoomScope(db.Model(&struct{}{}).Table("rooms"), c, "room_id")
|
||||||
|
if err := q.Find(&rooms).Error; err != nil {
|
||||||
|
t.Fatalf("scoped query failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("unmet sql expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanAccessDeviceKeyAdmin(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Set("user", map[string]interface{}{"sub": "admin-1", "role": "admin"})
|
||||||
|
if !canAccessDeviceKey(nil, c, "device-a") {
|
||||||
|
t.Fatal("admin should have global device access")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanAccessDeviceKeyMissingUser(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
if canAccessDeviceKey(nil, c, "device-a") {
|
||||||
|
t.Fatal("missing user should not pass device access")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObjectRoomIDSensorResolvesThroughDevice(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
sqlDB, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
db, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open gorm: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mock.ExpectQuery(`.*FROM "sensors".*WHERE id = \$1.*`).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "metric", "unit", "data_type", "device_id", "created_at"}).
|
||||||
|
AddRow("sensor-1", "温度", "temperature", nil, nil, "device-1", time.Now()))
|
||||||
|
mock.ExpectQuery(`.*FROM "devices".*WHERE id = \$1.*`).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "device_key", "name", "kind", "model", "firmware", "online_status", "last_seen", "room_id", "topic", "created_at", "updated_at"}).
|
||||||
|
AddRow("device-1", "device-a", "温度设备", "sensor", nil, nil, "online", nil, "room-1", nil, time.Now(), time.Now()))
|
||||||
|
|
||||||
|
roomID, ok := objectRoomID(db, "sensor", "sensor-1")
|
||||||
|
if !ok || roomID == nil || *roomID != "room-1" {
|
||||||
|
t.Fatalf("sensor room = %v, ok = %v, want room-1", roomID, ok)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("unmet sql expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireObjectAccessForbiddenForForeignDevice(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
sqlDB, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
db, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open gorm: %v", err)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Set("user", map[string]interface{}{"sub": "user-1", "role": "viewer"})
|
||||||
|
|
||||||
|
mock.ExpectQuery(`.*FROM "devices".*WHERE id = \$1.*`).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "device_key", "name", "kind", "model", "firmware", "online_status", "last_seen", "room_id", "topic", "created_at", "updated_at"}).
|
||||||
|
AddRow("device-1", "device-a", "温度设备", "sensor", nil, nil, "online", nil, "room-1", nil, time.Now(), time.Now()))
|
||||||
|
mock.ExpectQuery(`.*FROM "rooms".*organization_members.*`).
|
||||||
|
WithArgs("room-1", "user-1").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||||
|
|
||||||
|
if requireObjectAccess(c, db, "device", "device-1") {
|
||||||
|
t.Fatal("foreign device should not pass object access")
|
||||||
|
}
|
||||||
|
if rec.Code != 403 {
|
||||||
|
t.Fatalf("status = %d, want 403", rec.Code)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("unmet sql expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirstAccessibleRoomIDUsesOrgScope(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
sqlDB, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
db, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open gorm: %v", err)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Set("user", map[string]interface{}{"sub": "user-1", "role": "viewer"})
|
||||||
|
|
||||||
|
mock.ExpectQuery(`.*FROM "rooms".*CAST\(org_id AS text\).*organization_members.*`).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "code", "location", "description", "capacity", "stage", "region", "org_id", "status", "created_at", "updated_at"}).
|
||||||
|
AddRow("room-1", "一号蚕房", nil, nil, nil, nil, nil, nil, "org-1", "active", time.Now(), time.Now()))
|
||||||
|
|
||||||
|
roomID := firstAccessibleRoomID(db, c)
|
||||||
|
if roomID == nil || *roomID != "room-1" {
|
||||||
|
t.Fatalf("first accessible room = %v, want room-1", roomID)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("unmet sql expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,9 +23,12 @@ func RegisterAlarmRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
|||||||
func listAlarms(db *gorm.DB) gin.HandlerFunc {
|
func listAlarms(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
limit := 500
|
limit := 500
|
||||||
q := db.Model(&model.Alarm{}).Order("triggered_at DESC").Limit(limit)
|
q := db.Model(&model.Alarm{}).
|
||||||
|
Joins("LEFT JOIN devices d ON d.device_key = alarms.device_key").
|
||||||
|
Order("alarms.triggered_at DESC").Limit(limit)
|
||||||
|
q = applyRoomScope(q, c, "d.room_id")
|
||||||
if c.Query("openOnly") == "true" {
|
if c.Query("openOnly") == "true" {
|
||||||
q = q.Where("open = true")
|
q = q.Where("alarms.open = true")
|
||||||
}
|
}
|
||||||
var alarms []model.Alarm
|
var alarms []model.Alarm
|
||||||
q.Find(&alarms)
|
q.Find(&alarms)
|
||||||
@@ -37,6 +40,9 @@ func listAlarms(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func getAlarm(db *gorm.DB) gin.HandlerFunc {
|
func getAlarm(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "alarm", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var alarm model.Alarm
|
var alarm model.Alarm
|
||||||
if db.Where("id = ?", id).First(&alarm).Error != nil {
|
if db.Where("id = ?", id).First(&alarm).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "alarm not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "alarm not found"})
|
||||||
@@ -50,6 +56,9 @@ func getAlarm(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func ackAlarm(db *gorm.DB) gin.HandlerFunc {
|
func ackAlarm(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "alarm", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var alarm model.Alarm
|
var alarm model.Alarm
|
||||||
if db.Where("id = ?", id).First(&alarm).Error != nil {
|
if db.Where("id = ?", id).First(&alarm).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "alarm not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "alarm not found"})
|
||||||
@@ -69,6 +78,9 @@ func ackAlarm(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func resolveAlarm(db *gorm.DB) gin.HandlerFunc {
|
func resolveAlarm(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "alarm", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var alarm model.Alarm
|
var alarm model.Alarm
|
||||||
if db.Where("id = ?", id).First(&alarm).Error != nil {
|
if db.Where("id = ?", id).First(&alarm).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "alarm not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "alarm not found"})
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ func getAlarmClip(db *gorm.DB) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "alarm", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 先检查告警是否存在
|
// 先检查告警是否存在
|
||||||
var alarm model.Alarm
|
var alarm model.Alarm
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ func registerHandler(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "创建用户失败"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "创建用户失败"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
assignDefaultOrganization(db, user.ID)
|
||||||
|
|
||||||
// 记录审计日志
|
// 记录审计日志
|
||||||
uid := user.ID
|
uid := user.ID
|
||||||
@@ -448,6 +449,7 @@ func ensureDefaultAdmin(db *gorm.DB, cfg *config.Config) {
|
|||||||
slog.Error("创建默认 admin 失败", "error", err)
|
slog.Error("创建默认 admin 失败", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
assignDefaultOrganization(db, admin.ID)
|
||||||
|
|
||||||
// 记录审计日志
|
// 记录审计日志
|
||||||
uid := admin.ID
|
uid := admin.ID
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ func RegisterBiosecurityRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
|||||||
|
|
||||||
func listSeedSources(db *gorm.DB) gin.HandlerFunc {
|
func listSeedSources(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
q := db.Model(&model.SeedSource{})
|
q := db.Model(&model.SeedSource{}).
|
||||||
|
Joins("LEFT JOIN batches b ON b.id = seed_sources.batch_id")
|
||||||
|
q = applyRoomScope(q, c, "b.room_id")
|
||||||
if batch := c.Query("batchId"); batch != "" {
|
if batch := c.Query("batchId"); batch != "" {
|
||||||
q = q.Where("batch_id = ?", batch)
|
q = q.Where("batch_id = ?", batch)
|
||||||
}
|
}
|
||||||
@@ -62,6 +64,12 @@ func createSeedSource(db *gorm.DB) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if body.BatchID != nil && !requireObjectAccess(c, db, "batch", *body.BatchID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.ParentID != nil && !requireObjectAccess(c, db, "seed_source", *body.ParentID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
source := model.SeedSource{
|
source := model.SeedSource{
|
||||||
PublicID: randomPublicID(),
|
PublicID: randomPublicID(),
|
||||||
BatchID: body.BatchID,
|
BatchID: body.BatchID,
|
||||||
@@ -96,6 +104,9 @@ func createSeedSource(db *gorm.DB) gin.HandlerFunc {
|
|||||||
|
|
||||||
func updateSeedSource(db *gorm.DB) gin.HandlerFunc {
|
func updateSeedSource(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
if !requireObjectAccess(c, db, "seed_source", c.Param("id")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var source model.SeedSource
|
var source model.SeedSource
|
||||||
if db.Where("id = ?", c.Param("id")).First(&source).Error != nil {
|
if db.Where("id = ?", c.Param("id")).First(&source).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "seed source not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "seed source not found"})
|
||||||
@@ -168,7 +179,10 @@ func updateSeedSource(db *gorm.DB) gin.HandlerFunc {
|
|||||||
|
|
||||||
func listDisinfectionRecords(db *gorm.DB) gin.HandlerFunc {
|
func listDisinfectionRecords(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
q := db.Model(&model.DisinfectionRecord{})
|
q := db.Model(&model.DisinfectionRecord{}).
|
||||||
|
Joins("LEFT JOIN rooms r ON r.id = disinfection_records.room_id").
|
||||||
|
Joins("LEFT JOIN batches b ON b.id = disinfection_records.batch_id")
|
||||||
|
q = applyRoomScope(q, c, "COALESCE(r.id, b.room_id)")
|
||||||
if room := c.Query("roomId"); room != "" {
|
if room := c.Query("roomId"); room != "" {
|
||||||
q = q.Where("room_id = ?", room)
|
q = q.Where("room_id = ?", room)
|
||||||
}
|
}
|
||||||
@@ -215,6 +229,13 @@ func createDisinfectionRecord(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "batchId 不是合法的 UUID"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "batchId 不是合法的 UUID"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if body.RoomID != nil && !canAccessRoom(db, c, body.RoomID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权在该蚕房下创建消毒记录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.BatchID != nil && !requireObjectAccess(c, db, "batch", *body.BatchID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := db.Create(&body).Error; err != nil {
|
if err := db.Create(&body).Error; err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "创建消毒记录失败"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "创建消毒记录失败"})
|
||||||
return
|
return
|
||||||
@@ -225,6 +246,9 @@ func createDisinfectionRecord(db *gorm.DB) gin.HandlerFunc {
|
|||||||
|
|
||||||
func updateDisinfectionRecord(db *gorm.DB) gin.HandlerFunc {
|
func updateDisinfectionRecord(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
if !requireObjectAccess(c, db, "disinfection_record", c.Param("id")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var record model.DisinfectionRecord
|
var record model.DisinfectionRecord
|
||||||
if db.Where("id = ?", c.Param("id")).First(&record).Error != nil {
|
if db.Where("id = ?", c.Param("id")).First(&record).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "disinfection record not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "disinfection record not found"})
|
||||||
@@ -284,6 +308,9 @@ func issueQR(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "entityType/entityId 无效"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "entityType/entityId 无效"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireObjectAccess(c, db, body.EntityType, body.EntityID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if !entityExists(db, body.EntityType, body.EntityID) {
|
if !entityExists(db, body.EntityType, body.EntityID) {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "二维码关联实体不存在"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "二维码关联实体不存在"})
|
||||||
return
|
return
|
||||||
@@ -333,6 +360,12 @@ func resolveQR(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "二维码关联实体不存在"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "二维码关联实体不存在"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if roomID, ok := objectRoomID(db, link.EntityType, link.EntityID); ok {
|
||||||
|
if !canAccessRoom(db, c, roomID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该二维码关联实体"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"entityType": link.EntityType,
|
"entityType": link.EntityType,
|
||||||
"publicId": link.PublicID,
|
"publicId": link.PublicID,
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"silk-server-go/internal/middleware"
|
||||||
|
"silk-server-go/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterCaseStudyRoutes 注册脱敏案例沉淀与审核发布路由。
|
||||||
|
func RegisterCaseStudyRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||||
|
read := middleware.RequirePermission(db, "case:read")
|
||||||
|
write := middleware.RequirePermission(db, "case:write")
|
||||||
|
rg.GET("/case-studies", read, listCaseStudies(db))
|
||||||
|
rg.GET("/case-studies/:id", read, getCaseStudy(db))
|
||||||
|
rg.POST("/case-studies", write, createCaseStudy(db))
|
||||||
|
rg.POST("/case-studies/from-consultation/:id", write, createCaseStudyFromConsultation(db))
|
||||||
|
rg.PATCH("/case-studies/:id", write, updateCaseStudy(db))
|
||||||
|
rg.POST("/case-studies/:id/review", write, reviewCaseStudy(db))
|
||||||
|
}
|
||||||
|
|
||||||
|
func listCaseStudies(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
q := db.Model(&model.CaseStudy{})
|
||||||
|
if status := c.Query("status"); status != "" {
|
||||||
|
if !canReviewKnowledge(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权限查看非发布案例"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q = q.Where("status = ?", status)
|
||||||
|
if !hasGlobalAccess(c) {
|
||||||
|
if userID := currentUserID(c); userID != nil {
|
||||||
|
q = q.Where(`
|
||||||
|
(source_room_id IS NOT NULL AND source_room_id IN (
|
||||||
|
SELECT r.id FROM rooms r
|
||||||
|
JOIN organization_members om ON om.organization_id = r.org_id
|
||||||
|
WHERE om.user_id = ?
|
||||||
|
)) OR created_by = ?`, *userID, *userID)
|
||||||
|
} else {
|
||||||
|
q = q.Where("1 = 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
q = q.Where("status = ?", "published")
|
||||||
|
}
|
||||||
|
if disease := c.Query("disease"); disease != "" {
|
||||||
|
q = q.Where("disease = ?", disease)
|
||||||
|
}
|
||||||
|
var list []model.CaseStudy
|
||||||
|
q.Order("published_at DESC NULLS LAST, created_at DESC").Limit(200).Find(&list)
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCaseStudy(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var study model.CaseStudy
|
||||||
|
if db.Where("id = ?", c.Param("id")).First(&study).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "case study not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if study.Status != "published" && !canReviewKnowledge(c) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "case study not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if study.Status != "published" && !hasGlobalAccess(c) && !canManageCaseStudy(db, c, study) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "case study not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, study)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createCaseStudy(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var body model.CaseStudy
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body.ID = ""
|
||||||
|
body.Status = "draft"
|
||||||
|
body.CreatedBy = currentUserID(c)
|
||||||
|
if body.Title == "" || body.Disease == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "标题和病种不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body.DesensitizedPayload) == 0 {
|
||||||
|
body.DesensitizedPayload = json.RawMessage(`{}`)
|
||||||
|
}
|
||||||
|
for _, id := range []*string{body.SourceConsultationID, body.SourceDiseaseEventID, body.SourceRoomID} {
|
||||||
|
if id != nil && *id != "" && !isUUID(*id) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "关联 ID 不是合法的 UUID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body.SourceConsultationID != nil && !requireObjectAccess(c, db, "consultation", *body.SourceConsultationID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.SourceDiseaseEventID != nil && !requireObjectAccess(c, db, "disease_event", *body.SourceDiseaseEventID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.SourceRoomID != nil && !canAccessRoom(db, c, body.SourceRoomID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权引用该蚕房案例"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := db.Create(&body).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "创建案例失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createCaseStudyFromConsultation(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "consultation", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var consultation model.Consultation
|
||||||
|
if db.Where("id = ?", id).First(&consultation).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "consultation not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload := desensitizedConsultationPayload(db, consultation)
|
||||||
|
disease := "待确认"
|
||||||
|
var trace model.TraceRecord
|
||||||
|
if db.Where("consultation_id = ?", id).Order("created_at DESC").First(&trace).Error == nil {
|
||||||
|
disease = trace.Disease
|
||||||
|
}
|
||||||
|
title := "案例:" + disease
|
||||||
|
if consultation.Title != "" {
|
||||||
|
title = consultation.Title
|
||||||
|
}
|
||||||
|
study := model.CaseStudy{
|
||||||
|
Title: title,
|
||||||
|
Disease: disease,
|
||||||
|
SourceConsultationID: &consultation.ID,
|
||||||
|
SourceRoomID: consultation.RoomID,
|
||||||
|
Summary: consultation.Summary,
|
||||||
|
DesensitizedPayload: payload,
|
||||||
|
Status: "draft",
|
||||||
|
CreatedBy: currentUserID(c),
|
||||||
|
}
|
||||||
|
if err := db.Create(&study).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "创建脱敏案例失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, study)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateCaseStudy(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var study model.CaseStudy
|
||||||
|
if db.Where("id = ?", id).First(&study).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "case study not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireCaseStudyAccess(db, c, study) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Title *string `json:"title"`
|
||||||
|
Disease *string `json:"disease"`
|
||||||
|
Region *string `json:"region"`
|
||||||
|
CaseDate *time.Time `json:"caseDate"`
|
||||||
|
Summary *string `json:"summary"`
|
||||||
|
DesensitizedPayload json.RawMessage `json:"desensitizedPayload"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates := map[string]interface{}{}
|
||||||
|
if body.Title != nil {
|
||||||
|
updates["title"] = *body.Title
|
||||||
|
}
|
||||||
|
if body.Disease != nil {
|
||||||
|
updates["disease"] = *body.Disease
|
||||||
|
}
|
||||||
|
if body.Region != nil {
|
||||||
|
updates["region"] = *body.Region
|
||||||
|
}
|
||||||
|
if body.CaseDate != nil {
|
||||||
|
updates["case_date"] = *body.CaseDate
|
||||||
|
}
|
||||||
|
if body.Summary != nil {
|
||||||
|
updates["summary"] = *body.Summary
|
||||||
|
}
|
||||||
|
if len(body.DesensitizedPayload) > 0 {
|
||||||
|
updates["desensitized_payload"] = body.DesensitizedPayload
|
||||||
|
}
|
||||||
|
if len(updates) > 0 {
|
||||||
|
if err := db.Model(&model.CaseStudy{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "更新案例失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.Where("id = ?", id).First(&study)
|
||||||
|
c.JSON(http.StatusOK, study)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reviewCaseStudy(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var study model.CaseStudy
|
||||||
|
if db.Where("id = ?", id).First(&study).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "case study not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireCaseStudyAccess(db, c, study) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Note *string `json:"note"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !model.ValidCaseStudyTransition(study.Status, body.Status) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "非法的案例状态流转"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"status": body.Status,
|
||||||
|
"reviewer_id": currentUserID(c),
|
||||||
|
"reviewed_at": now,
|
||||||
|
}
|
||||||
|
if body.Note != nil {
|
||||||
|
updates["review_note"] = *body.Note
|
||||||
|
}
|
||||||
|
if body.Status == "published" {
|
||||||
|
updates["published_at"] = now
|
||||||
|
}
|
||||||
|
if err := db.Model(&model.CaseStudy{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "审核案例失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.Where("id = ?", id).First(&study)
|
||||||
|
c.JSON(http.StatusOK, study)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireCaseStudyAccess(db *gorm.DB, c *gin.Context, study model.CaseStudy) bool {
|
||||||
|
if canManageCaseStudy(db, c, study) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权管理该案例"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func canManageCaseStudy(db *gorm.DB, c *gin.Context, study model.CaseStudy) bool {
|
||||||
|
if hasGlobalAccess(c) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if study.SourceRoomID != nil && canAccessRoom(db, c, study.SourceRoomID) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
userID := currentUserID(c)
|
||||||
|
if study.CreatedBy != nil && userID != nil && *study.CreatedBy == *userID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func desensitizedConsultationPayload(db *gorm.DB, consultation model.Consultation) json.RawMessage {
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"sourceStatus": consultation.Status,
|
||||||
|
"createdAt": consultation.CreatedAt,
|
||||||
|
}
|
||||||
|
if consultation.Summary != nil {
|
||||||
|
data["summary"] = *consultation.Summary
|
||||||
|
}
|
||||||
|
if consultation.Opinion != nil {
|
||||||
|
data["opinion"] = *consultation.Opinion
|
||||||
|
}
|
||||||
|
if consultation.Plan != nil {
|
||||||
|
data["plan"] = *consultation.Plan
|
||||||
|
}
|
||||||
|
if consultation.RoomID != nil {
|
||||||
|
var room model.Room
|
||||||
|
if db.Select("region").Where("id = ?", *consultation.RoomID).First(&room).Error == nil && room.Region != nil {
|
||||||
|
data["region"] = *room.Region
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if consultation.LampTestID != nil {
|
||||||
|
var lamp model.LampTest
|
||||||
|
if db.Select("method", "result", "resulted_at", "status").
|
||||||
|
Where("id = ?", *consultation.LampTestID).First(&lamp).Error == nil {
|
||||||
|
data["method"] = lamp.Method
|
||||||
|
data["result"] = lamp.Result
|
||||||
|
data["detectionStatus"] = lamp.Status
|
||||||
|
data["resultedAt"] = lamp.ResultedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(data)
|
||||||
|
return raw
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ func RegisterConsultationRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
|||||||
// listConsultations 会诊单列表(status 过滤)
|
// listConsultations 会诊单列表(status 过滤)
|
||||||
func listConsultations(db *gorm.DB) gin.HandlerFunc {
|
func listConsultations(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
q := db.Model(&model.Consultation{})
|
q := applyRoomScope(db.Model(&model.Consultation{}), c, "room_id")
|
||||||
if status := c.Query("status"); status != "" {
|
if status := c.Query("status"); status != "" {
|
||||||
q = q.Where("status = ?", status)
|
q = q.Where("status = ?", status)
|
||||||
}
|
}
|
||||||
@@ -57,6 +57,9 @@ func fillConsultationRoomNames(db *gorm.DB, list []model.Consultation) {
|
|||||||
// getConsultation 会诊单详情
|
// getConsultation 会诊单详情
|
||||||
func getConsultation(db *gorm.DB) gin.HandlerFunc {
|
func getConsultation(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
if !requireObjectAccess(c, db, "consultation", c.Param("id")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var t model.Consultation
|
var t model.Consultation
|
||||||
if db.Where("id = ?", c.Param("id")).First(&t).Error != nil {
|
if db.Where("id = ?", c.Param("id")).First(&t).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "consultation not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "consultation not found"})
|
||||||
@@ -87,6 +90,16 @@ func createConsultation(db *gorm.DB) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if body.RoomID != nil && !canAccessRoom(db, c, body.RoomID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权为该蚕房发起会诊"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.BatchID != nil && !requireObjectAccess(c, db, "batch", *body.BatchID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.LampTestID != nil && !requireObjectAccess(c, db, "lamp_test", *body.LampTestID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
rec := model.Consultation{
|
rec := model.Consultation{
|
||||||
RoomID: body.RoomID,
|
RoomID: body.RoomID,
|
||||||
@@ -167,6 +180,9 @@ func buildConsultationSnapshot(db *gorm.DB, roomID, batchID *string, lamp *model
|
|||||||
func updateConsultation(db *gorm.DB) gin.HandlerFunc {
|
func updateConsultation(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "consultation", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var t model.Consultation
|
var t model.Consultation
|
||||||
if db.Where("id = ?", id).First(&t).Error != nil {
|
if db.Where("id = ?", id).First(&t).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "consultation not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "consultation not found"})
|
||||||
@@ -177,6 +193,21 @@ func updateConsultation(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if roomID, ok := updates["room_id"].(string); ok && !canAccessRoom(db, c, &roomID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权迁移会诊到指定蚕房"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, ref := range []struct {
|
||||||
|
key string
|
||||||
|
kind string
|
||||||
|
}{
|
||||||
|
{key: "batch_id", kind: "batch"},
|
||||||
|
{key: "lamp_test_id", kind: "lamp_test"},
|
||||||
|
} {
|
||||||
|
if id, ok := updates[ref.key].(string); ok && !requireObjectAccess(c, db, ref.kind, id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if s, ok := updates["status"]; ok {
|
if s, ok := updates["status"]; ok {
|
||||||
to, _ := s.(string)
|
to, _ := s.(string)
|
||||||
if !model.ValidConsultationTransition(t.Status, to) {
|
if !model.ValidConsultationTransition(t.Status, to) {
|
||||||
@@ -206,6 +237,9 @@ func updateConsultation(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func resolveConsultation(db *gorm.DB) gin.HandlerFunc {
|
func resolveConsultation(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "consultation", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var t model.Consultation
|
var t model.Consultation
|
||||||
if db.Where("id = ?", id).First(&t).Error != nil {
|
if db.Where("id = ?", id).First(&t).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "consultation not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "consultation not found"})
|
||||||
@@ -271,6 +305,9 @@ func consultationStrPtr(value string) *string {
|
|||||||
func archiveConsultation(db *gorm.DB) gin.HandlerFunc {
|
func archiveConsultation(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "consultation", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var t model.Consultation
|
var t model.Consultation
|
||||||
if db.Where("id = ?", id).First(&t).Error != nil {
|
if db.Where("id = ?", id).First(&t).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "consultation not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "consultation not found"})
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ type ControlCommand struct {
|
|||||||
// RegisterControlRoutes 注册控制命令路由
|
// RegisterControlRoutes 注册控制命令路由
|
||||||
func RegisterControlRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
func RegisterControlRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||||
perm := middleware.RequirePermission(db, "device:control")
|
perm := middleware.RequirePermission(db, "device:control")
|
||||||
rg.POST("/control/send", perm, sendControl())
|
rg.POST("/control/send", perm, sendControl(db))
|
||||||
rg.POST("/control/batch", perm, batchControl())
|
rg.POST("/control/batch", perm, batchControl(db))
|
||||||
rg.POST("/devices/:id/gstmb1/command", perm, sendGSTMB1Command(db))
|
rg.POST("/devices/:id/gstmb1/command", perm, sendGSTMB1Command(db))
|
||||||
rg.POST("/devices/:id/gstmb1/info", perm, sendGSTMB1Info(db))
|
rg.POST("/devices/:id/gstmb1/info", perm, sendGSTMB1Info(db))
|
||||||
rg.POST("/devices/:id/gstmb1/restart", perm, sendGSTMB1Restart(db))
|
rg.POST("/devices/:id/gstmb1/restart", perm, sendGSTMB1Restart(db))
|
||||||
@@ -70,13 +70,16 @@ func RegisterControlRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sendControl 下发单条控制命令(通过 MQTT 发布到 devices/{deviceKey}/cmd 主题)
|
// sendControl 下发单条控制命令(通过 MQTT 发布到 devices/{deviceKey}/cmd 主题)
|
||||||
func sendControl() gin.HandlerFunc {
|
func sendControl(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var cmd ControlCommand
|
var cmd ControlCommand
|
||||||
if err := c.ShouldBindJSON(&cmd); err != nil {
|
if err := c.ShouldBindJSON(&cmd); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireDeviceKeyAccess(c, db, cmd.DeviceKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
result, err := publishControl(cmd)
|
result, err := publishControl(cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -91,6 +94,9 @@ func sendControl() gin.HandlerFunc {
|
|||||||
// sendPlugOn 插座通电 {"type":"event","key":1}
|
// sendPlugOn 插座通电 {"type":"event","key":1}
|
||||||
func sendPlugOn(db *gorm.DB) gin.HandlerFunc {
|
func sendPlugOn(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
if !requireObjectAccess(c, db, "device", c.Param("id")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
||||||
"type": "event",
|
"type": "event",
|
||||||
"key": 1,
|
"key": 1,
|
||||||
@@ -106,6 +112,9 @@ func sendPlugOn(db *gorm.DB) gin.HandlerFunc {
|
|||||||
// sendPlugOff 插座断电 {"type":"event","key":0}
|
// sendPlugOff 插座断电 {"type":"event","key":0}
|
||||||
func sendPlugOff(db *gorm.DB) gin.HandlerFunc {
|
func sendPlugOff(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
if !requireObjectAccess(c, db, "device", c.Param("id")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
||||||
"type": "event",
|
"type": "event",
|
||||||
"key": 0,
|
"key": 0,
|
||||||
@@ -121,6 +130,9 @@ func sendPlugOff(db *gorm.DB) gin.HandlerFunc {
|
|||||||
// sendPlugStatistic 查询插座电量信息 {"type":"statistic"}
|
// sendPlugStatistic 查询插座电量信息 {"type":"statistic"}
|
||||||
func sendPlugStatistic(db *gorm.DB) gin.HandlerFunc {
|
func sendPlugStatistic(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
if !requireObjectAccess(c, db, "device", c.Param("id")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
||||||
"type": "statistic",
|
"type": "statistic",
|
||||||
"messageId": fmt.Sprintf("%d", time.Now().UnixMilli()),
|
"messageId": fmt.Sprintf("%d", time.Now().UnixMilli()),
|
||||||
@@ -138,6 +150,9 @@ func sendPlugStatistic(db *gorm.DB) gin.HandlerFunc {
|
|||||||
// sendIRLearn 学习红外码 {"type":"infrared","action":"learn","data":{"no":N}}
|
// sendIRLearn 学习红外码 {"type":"infrared","action":"learn","data":{"no":N}}
|
||||||
func sendIRLearn(db *gorm.DB) gin.HandlerFunc {
|
func sendIRLearn(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
if !requireObjectAccess(c, db, "device", c.Param("id")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var body struct {
|
var body struct {
|
||||||
No int `json:"no"`
|
No int `json:"no"`
|
||||||
}
|
}
|
||||||
@@ -161,6 +176,9 @@ func sendIRLearn(db *gorm.DB) gin.HandlerFunc {
|
|||||||
// sendIREmit 发射红外码 {"type":"infrared","action":"emit","data":{"no":N}}
|
// sendIREmit 发射红外码 {"type":"infrared","action":"emit","data":{"no":N}}
|
||||||
func sendIREmit(db *gorm.DB) gin.HandlerFunc {
|
func sendIREmit(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
if !requireObjectAccess(c, db, "device", c.Param("id")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var body struct {
|
var body struct {
|
||||||
No int `json:"no"`
|
No int `json:"no"`
|
||||||
}
|
}
|
||||||
@@ -184,6 +202,9 @@ func sendIREmit(db *gorm.DB) gin.HandlerFunc {
|
|||||||
// sendIRCancel 取消学习 {"type":"infrared","action":"learnCancel"}
|
// sendIRCancel 取消学习 {"type":"infrared","action":"learnCancel"}
|
||||||
func sendIRCancel(db *gorm.DB) gin.HandlerFunc {
|
func sendIRCancel(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
if !requireObjectAccess(c, db, "device", c.Param("id")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
||||||
"type": "infrared",
|
"type": "infrared",
|
||||||
"action": "learnCancel",
|
"action": "learnCancel",
|
||||||
@@ -199,6 +220,9 @@ func sendIRCancel(db *gorm.DB) gin.HandlerFunc {
|
|||||||
// sendIRErase 擦除全部红外码 {"type":"infrared","action":"erase"}
|
// sendIRErase 擦除全部红外码 {"type":"infrared","action":"erase"}
|
||||||
func sendIRErase(db *gorm.DB) gin.HandlerFunc {
|
func sendIRErase(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
if !requireObjectAccess(c, db, "device", c.Param("id")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
||||||
"type": "infrared",
|
"type": "infrared",
|
||||||
"action": "erase",
|
"action": "erase",
|
||||||
@@ -214,6 +238,9 @@ func sendIRErase(db *gorm.DB) gin.HandlerFunc {
|
|||||||
// getIRStatus 查询红外操作结果
|
// getIRStatus 查询红外操作结果
|
||||||
func getIRStatus(db *gorm.DB) gin.HandlerFunc {
|
func getIRStatus(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
if !requireObjectAccess(c, db, "device", c.Param("id")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
deviceKey, err := getDeviceKey(db, c.Param("id"))
|
deviceKey, err := getDeviceKey(db, c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
@@ -234,7 +261,7 @@ func getIRStatus(db *gorm.DB) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// batchControl 批量下发控制命令
|
// batchControl 批量下发控制命令
|
||||||
func batchControl() gin.HandlerFunc {
|
func batchControl(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var body struct {
|
var body struct {
|
||||||
Commands []ControlCommand `json:"commands"`
|
Commands []ControlCommand `json:"commands"`
|
||||||
@@ -246,6 +273,9 @@ func batchControl() gin.HandlerFunc {
|
|||||||
|
|
||||||
results := make([]gin.H, 0, len(body.Commands))
|
results := make([]gin.H, 0, len(body.Commands))
|
||||||
for _, cmd := range body.Commands {
|
for _, cmd := range body.Commands {
|
||||||
|
if !requireDeviceKeyAccess(c, db, cmd.DeviceKey) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
result, err := publishControl(cmd)
|
result, err := publishControl(cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
results = append(results, gin.H{"error": err.Error(), "deviceKey": cmd.DeviceKey})
|
results = append(results, gin.H{"error": err.Error(), "deviceKey": cmd.DeviceKey})
|
||||||
@@ -328,6 +358,9 @@ func publishGSTMB1Command(db *gorm.DB, deviceID string, payload map[string]inter
|
|||||||
// sendGSTMB1Command 发送自定义 GSTMB1 命令(body 原样转发)
|
// sendGSTMB1Command 发送自定义 GSTMB1 命令(body 原样转发)
|
||||||
func sendGSTMB1Command(db *gorm.DB) gin.HandlerFunc {
|
func sendGSTMB1Command(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
if !requireObjectAccess(c, db, "device", c.Param("id")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var body map[string]interface{}
|
var body map[string]interface{}
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -345,6 +378,9 @@ func sendGSTMB1Command(db *gorm.DB) gin.HandlerFunc {
|
|||||||
// sendGSTMB1Info 获取设备信息 {"type":"info"}
|
// sendGSTMB1Info 获取设备信息 {"type":"info"}
|
||||||
func sendGSTMB1Info(db *gorm.DB) gin.HandlerFunc {
|
func sendGSTMB1Info(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
if !requireObjectAccess(c, db, "device", c.Param("id")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
||||||
"type": "info",
|
"type": "info",
|
||||||
"messageId": fmt.Sprintf("%d", time.Now().UnixMilli()),
|
"messageId": fmt.Sprintf("%d", time.Now().UnixMilli()),
|
||||||
@@ -360,6 +396,9 @@ func sendGSTMB1Info(db *gorm.DB) gin.HandlerFunc {
|
|||||||
// sendGSTMB1Restart 重启设备 {"type":"setting","system":"restart"}
|
// sendGSTMB1Restart 重启设备 {"type":"setting","system":"restart"}
|
||||||
func sendGSTMB1Restart(db *gorm.DB) gin.HandlerFunc {
|
func sendGSTMB1Restart(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
if !requireObjectAccess(c, db, "device", c.Param("id")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{
|
||||||
"type": "setting",
|
"type": "setting",
|
||||||
"system": "restart",
|
"system": "restart",
|
||||||
@@ -375,6 +414,9 @@ func sendGSTMB1Restart(db *gorm.DB) gin.HandlerFunc {
|
|||||||
// sendGSTMB1Interval 设置定时上报间隔 {"type":"setting","timerEnable":1,"timerInterval":N}
|
// sendGSTMB1Interval 设置定时上报间隔 {"type":"setting","timerEnable":1,"timerInterval":N}
|
||||||
func sendGSTMB1Interval(db *gorm.DB) gin.HandlerFunc {
|
func sendGSTMB1Interval(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
if !requireObjectAccess(c, db, "device", c.Param("id")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var body struct {
|
var body struct {
|
||||||
Interval int `json:"interval"`
|
Interval int `json:"interval"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func RegisterDetectionTaskRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
|||||||
|
|
||||||
func listDetectionTasks(db *gorm.DB) gin.HandlerFunc {
|
func listDetectionTasks(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
q := db.Model(&model.DetectionTask{})
|
q := applyRoomScope(db.Model(&model.DetectionTask{}), c, "room_id")
|
||||||
if status := c.Query("status"); status != "" {
|
if status := c.Query("status"); status != "" {
|
||||||
q = q.Where("status = ?", status)
|
q = q.Where("status = ?", status)
|
||||||
}
|
}
|
||||||
@@ -55,6 +55,9 @@ func listDetectionTasks(db *gorm.DB) gin.HandlerFunc {
|
|||||||
|
|
||||||
func getDetectionTask(db *gorm.DB) gin.HandlerFunc {
|
func getDetectionTask(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
if !requireObjectAccess(c, db, "detection_task", c.Param("id")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var task model.DetectionTask
|
var task model.DetectionTask
|
||||||
if db.Where("id = ?", c.Param("id")).First(&task).Error != nil {
|
if db.Where("id = ?", c.Param("id")).First(&task).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "detection task not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "detection task not found"})
|
||||||
@@ -98,6 +101,16 @@ func createDetectionTask(db *gorm.DB) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if body.RoomID != nil && !canAccessRoom(db, c, body.RoomID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权在该蚕房下创建检测任务"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.BatchID != nil && !requireObjectAccess(c, db, "batch", *body.BatchID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.InspectionID != nil && !requireObjectAccess(c, db, "inspection", *body.InspectionID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
sourceKey := body.SourceKey
|
sourceKey := body.SourceKey
|
||||||
if sourceKey == "" {
|
if sourceKey == "" {
|
||||||
sourceKey = fmt.Sprintf("manual-%d", time.Now().UnixNano())
|
sourceKey = fmt.Sprintf("manual-%d", time.Now().UnixNano())
|
||||||
@@ -141,6 +154,9 @@ func createDetectionTask(db *gorm.DB) gin.HandlerFunc {
|
|||||||
|
|
||||||
func updateDetectionTask(db *gorm.DB) gin.HandlerFunc {
|
func updateDetectionTask(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
if !requireObjectAccess(c, db, "detection_task", c.Param("id")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var task model.DetectionTask
|
var task model.DetectionTask
|
||||||
if db.Where("id = ?", c.Param("id")).First(&task).Error != nil {
|
if db.Where("id = ?", c.Param("id")).First(&task).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "detection task not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "detection task not found"})
|
||||||
@@ -220,6 +236,9 @@ func updateDetectionTask(db *gorm.DB) gin.HandlerFunc {
|
|||||||
|
|
||||||
func listSamples(db *gorm.DB) gin.HandlerFunc {
|
func listSamples(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
if !requireObjectAccess(c, db, "detection_task", c.Param("id")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var samples []model.Sample
|
var samples []model.Sample
|
||||||
db.Where("detection_task_id = ?", c.Param("id")).Order("created_at ASC").Find(&samples)
|
db.Where("detection_task_id = ?", c.Param("id")).Order("created_at ASC").Find(&samples)
|
||||||
c.JSON(http.StatusOK, samples)
|
c.JSON(http.StatusOK, samples)
|
||||||
@@ -228,6 +247,9 @@ func listSamples(db *gorm.DB) gin.HandlerFunc {
|
|||||||
|
|
||||||
func createSample(db *gorm.DB) gin.HandlerFunc {
|
func createSample(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
if !requireObjectAccess(c, db, "detection_task", c.Param("id")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var task model.DetectionTask
|
var task model.DetectionTask
|
||||||
if db.Where("id = ?", c.Param("id")).First(&task).Error != nil {
|
if db.Where("id = ?", c.Param("id")).First(&task).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "detection task not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "detection task not found"})
|
||||||
@@ -281,6 +303,9 @@ func createSample(db *gorm.DB) gin.HandlerFunc {
|
|||||||
|
|
||||||
func updateSample(db *gorm.DB) gin.HandlerFunc {
|
func updateSample(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
if !requireObjectAccess(c, db, "sample", c.Param("id")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var sample model.Sample
|
var sample model.Sample
|
||||||
if db.Where("id = ?", c.Param("id")).First(&sample).Error != nil {
|
if db.Where("id = ?", c.Param("id")).First(&sample).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "sample not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "sample not found"})
|
||||||
@@ -335,7 +360,7 @@ func updateSample(db *gorm.DB) gin.HandlerFunc {
|
|||||||
|
|
||||||
func listDiseaseEvents(db *gorm.DB) gin.HandlerFunc {
|
func listDiseaseEvents(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
q := db.Model(&model.DiseaseEvent{})
|
q := applyRoomScope(db.Model(&model.DiseaseEvent{}), c, "room_id")
|
||||||
if status := c.Query("status"); status != "" {
|
if status := c.Query("status"); status != "" {
|
||||||
q = q.Where("status = ?", status)
|
q = q.Where("status = ?", status)
|
||||||
}
|
}
|
||||||
@@ -380,6 +405,25 @@ func createDiseaseEvent(db *gorm.DB) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if body.RoomID != nil && !canAccessRoom(db, c, body.RoomID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权在该蚕房下创建发病事件"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.BatchID != nil && !requireObjectAccess(c, db, "batch", *body.BatchID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.DetectionTaskID != nil && !requireObjectAccess(c, db, "detection_task", *body.DetectionTaskID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.LampTestID != nil && !requireObjectAccess(c, db, "lamp_test", *body.LampTestID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.ConsultationID != nil && !requireObjectAccess(c, db, "consultation", *body.ConsultationID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.InspectionID != nil && !requireObjectAccess(c, db, "inspection", *body.InspectionID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if body.Status == "" {
|
if body.Status == "" {
|
||||||
body.Status = "suspected"
|
body.Status = "suspected"
|
||||||
}
|
}
|
||||||
@@ -429,6 +473,9 @@ func createDiseaseEvent(db *gorm.DB) gin.HandlerFunc {
|
|||||||
|
|
||||||
func updateDiseaseEvent(db *gorm.DB) gin.HandlerFunc {
|
func updateDiseaseEvent(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
if !requireObjectAccess(c, db, "disease_event", c.Param("id")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var event model.DiseaseEvent
|
var event model.DiseaseEvent
|
||||||
if db.Where("id = ?", c.Param("id")).First(&event).Error != nil {
|
if db.Where("id = ?", c.Param("id")).First(&event).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "disease event not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "disease event not found"})
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func RegisterDeviceRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
|||||||
func listDevices(db *gorm.DB) gin.HandlerFunc {
|
func listDevices(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var devices []model.Device
|
var devices []model.Device
|
||||||
query := db.Order("created_at DESC")
|
query := applyRoomScope(db.Model(&model.Device{}), c, "room_id").Order("created_at DESC")
|
||||||
if kind := c.Query("kind"); kind != "" {
|
if kind := c.Query("kind"); kind != "" {
|
||||||
query = query.Where("kind = ?", kind)
|
query = query.Where("kind = ?", kind)
|
||||||
}
|
}
|
||||||
@@ -36,6 +36,9 @@ func listDevices(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func getDevice(db *gorm.DB) gin.HandlerFunc {
|
func getDevice(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "device", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var device model.Device
|
var device model.Device
|
||||||
if db.Where("id = ?", id).First(&device).Error != nil {
|
if db.Where("id = ?", id).First(&device).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "device not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "device not found"})
|
||||||
@@ -64,6 +67,10 @@ func createDevice(db *gorm.DB) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
device.ID = "" // 让数据库自动生成
|
device.ID = "" // 让数据库自动生成
|
||||||
|
if !canAccessRoom(db, c, &device.RoomID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权在该蚕房下新建设备"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := db.Create(&device).Error; err != nil {
|
if err := db.Create(&device).Error; err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"})
|
||||||
return
|
return
|
||||||
@@ -76,6 +83,9 @@ func createDevice(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func updateDevice(db *gorm.DB) gin.HandlerFunc {
|
func updateDevice(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "device", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var device model.Device
|
var device model.Device
|
||||||
if db.Where("id = ?", id).First(&device).Error != nil {
|
if db.Where("id = ?", id).First(&device).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "device not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "device not found"})
|
||||||
@@ -86,6 +96,10 @@ func updateDevice(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if roomID, ok := updates["room_id"].(string); ok && !canAccessRoom(db, c, &roomID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权迁移设备到指定蚕房"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if len(updates) > 0 {
|
if len(updates) > 0 {
|
||||||
db.Model(&model.Device{}).Where("id = ?", id).Updates(updates)
|
db.Model(&model.Device{}).Where("id = ?", id).Updates(updates)
|
||||||
}
|
}
|
||||||
@@ -98,6 +112,9 @@ func updateDevice(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func deleteDevice(db *gorm.DB) gin.HandlerFunc {
|
func deleteDevice(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "device", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var device model.Device
|
var device model.Device
|
||||||
if db.Where("id = ?", id).First(&device).Error != nil {
|
if db.Where("id = ?", id).First(&device).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "device not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "device not found"})
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"silk-server-go/internal/middleware"
|
||||||
|
"silk-server-go/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterDeviceMaintenanceRoutes 注册设备校准/故障/维护/固件记录路由。
|
||||||
|
func RegisterDeviceMaintenanceRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||||
|
read := middleware.RequirePermission(db, "device:read")
|
||||||
|
write := middleware.RequirePermission(db, "device:write")
|
||||||
|
rg.GET("/device-maintenance", read, listDeviceMaintenance(db))
|
||||||
|
rg.POST("/device-maintenance", write, createDeviceMaintenance(db))
|
||||||
|
rg.PATCH("/device-maintenance/:id", write, updateDeviceMaintenance(db))
|
||||||
|
rg.DELETE("/device-maintenance/:id", write, deleteDeviceMaintenance(db))
|
||||||
|
}
|
||||||
|
|
||||||
|
func listDeviceMaintenance(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
q := db.Model(&model.DeviceMaintenanceRecord{}).
|
||||||
|
Joins("JOIN devices d ON d.id = device_maintenance_records.device_id")
|
||||||
|
q = applyRoomScope(q, c, "d.room_id")
|
||||||
|
if deviceID := c.Query("deviceId"); deviceID != "" {
|
||||||
|
q = q.Where("device_maintenance_records.device_id = ?", deviceID)
|
||||||
|
}
|
||||||
|
if kind := c.Query("kind"); kind != "" {
|
||||||
|
q = q.Where("device_maintenance_records.kind = ?", kind)
|
||||||
|
}
|
||||||
|
var list []model.DeviceMaintenanceRecord
|
||||||
|
q.Order("device_maintenance_records.created_at DESC").Limit(200).Find(&list)
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createDeviceMaintenance(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
DeviceID string `json:"deviceId"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
ScheduledAt *time.Time `json:"scheduledAt"`
|
||||||
|
PerformedAt *time.Time `json:"performedAt"`
|
||||||
|
PerformerID *string `json:"performerId"`
|
||||||
|
Result *string `json:"result"`
|
||||||
|
FirmwareFrom *string `json:"firmwareFrom"`
|
||||||
|
FirmwareTo *string `json:"firmwareTo"`
|
||||||
|
CostAmount *float64 `json:"costAmount"`
|
||||||
|
CostUnit *string `json:"costUnit"`
|
||||||
|
Note *string `json:"note"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isUUID(body.DeviceID) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "deviceId 不是合法的 UUID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireObjectAccess(c, db, "device", body.DeviceID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
record := model.DeviceMaintenanceRecord{
|
||||||
|
DeviceID: body.DeviceID, Kind: body.Kind, Title: body.Title,
|
||||||
|
ScheduledAt: body.ScheduledAt, PerformedAt: body.PerformedAt,
|
||||||
|
PerformerID: body.PerformerID, Result: body.Result,
|
||||||
|
FirmwareFrom: body.FirmwareFrom, FirmwareTo: body.FirmwareTo,
|
||||||
|
CostAmount: body.CostAmount, CostUnit: body.CostUnit, Note: body.Note,
|
||||||
|
CreatedBy: currentUserID(c),
|
||||||
|
}
|
||||||
|
if err := model.ValidateDeviceMaintenanceRecord(record); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := db.Create(&record).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "创建设备维护记录失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateDeviceMaintenance(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "device_maintenance_record", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var record model.DeviceMaintenanceRecord
|
||||||
|
if db.Where("id = ?", id).First(&record).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "device maintenance record not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Kind *string `json:"kind"`
|
||||||
|
Title *string `json:"title"`
|
||||||
|
ScheduledAt *time.Time `json:"scheduledAt"`
|
||||||
|
PerformedAt *time.Time `json:"performedAt"`
|
||||||
|
PerformerID *string `json:"performerId"`
|
||||||
|
Result *string `json:"result"`
|
||||||
|
FirmwareFrom *string `json:"firmwareFrom"`
|
||||||
|
FirmwareTo *string `json:"firmwareTo"`
|
||||||
|
CostAmount *float64 `json:"costAmount"`
|
||||||
|
CostUnit *string `json:"costUnit"`
|
||||||
|
Note *string `json:"note"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates := map[string]interface{}{}
|
||||||
|
if body.Kind != nil {
|
||||||
|
if !model.ValidMaintenanceKind(*body.Kind) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "kind 仅支持 calibration/fault/maintenance/firmware"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["kind"] = *body.Kind
|
||||||
|
}
|
||||||
|
if body.Title != nil {
|
||||||
|
updates["title"] = *body.Title
|
||||||
|
}
|
||||||
|
if body.ScheduledAt != nil {
|
||||||
|
updates["scheduled_at"] = *body.ScheduledAt
|
||||||
|
}
|
||||||
|
if body.PerformedAt != nil {
|
||||||
|
updates["performed_at"] = *body.PerformedAt
|
||||||
|
}
|
||||||
|
if body.PerformerID != nil {
|
||||||
|
updates["performer_id"] = *body.PerformerID
|
||||||
|
}
|
||||||
|
if body.Result != nil {
|
||||||
|
updates["result"] = *body.Result
|
||||||
|
}
|
||||||
|
if body.FirmwareFrom != nil {
|
||||||
|
updates["firmware_from"] = *body.FirmwareFrom
|
||||||
|
}
|
||||||
|
if body.FirmwareTo != nil {
|
||||||
|
updates["firmware_to"] = *body.FirmwareTo
|
||||||
|
}
|
||||||
|
if body.CostAmount != nil {
|
||||||
|
updates["cost_amount"] = *body.CostAmount
|
||||||
|
}
|
||||||
|
if body.CostUnit != nil {
|
||||||
|
updates["cost_unit"] = *body.CostUnit
|
||||||
|
}
|
||||||
|
if body.Note != nil {
|
||||||
|
updates["note"] = *body.Note
|
||||||
|
}
|
||||||
|
if len(updates) > 0 {
|
||||||
|
if err := db.Model(&model.DeviceMaintenanceRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "更新设备维护记录失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.Where("id = ?", id).First(&record)
|
||||||
|
c.JSON(http.StatusOK, record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteDeviceMaintenance(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "device_maintenance_record", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var record model.DeviceMaintenanceRecord
|
||||||
|
if db.Where("id = ?", id).First(&record).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "device maintenance record not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.Where("id = ?", id).Delete(&model.DeviceMaintenanceRecord{})
|
||||||
|
c.JSON(http.StatusOK, gin.H{"id": id})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"silk-server-go/internal/middleware"
|
||||||
|
"silk-server-go/internal/model"
|
||||||
|
"silk-server-go/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterFarmRoutes 注册产量、死亡、淘汰、损失和成本记录路由。
|
||||||
|
func RegisterFarmRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||||
|
read := middleware.RequirePermission(db, "farm:read")
|
||||||
|
write := middleware.RequirePermission(db, "farm:write")
|
||||||
|
rg.GET("/production-loss-records", read, listProductionLossRecords(db))
|
||||||
|
rg.GET("/production-loss-records/stats", read, productionLossStats(db))
|
||||||
|
rg.POST("/production-loss-records", write, createProductionLossRecord(db))
|
||||||
|
rg.PATCH("/production-loss-records/:id", write, updateProductionLossRecord(db))
|
||||||
|
rg.DELETE("/production-loss-records/:id", write, deleteProductionLossRecord(db))
|
||||||
|
}
|
||||||
|
|
||||||
|
func listProductionLossRecords(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
q := db.Model(&model.ProductionLossRecord{}).
|
||||||
|
Joins("LEFT JOIN batches b ON b.id = production_loss_records.batch_id")
|
||||||
|
q = applyRoomScope(q, c, "COALESCE(production_loss_records.room_id, b.room_id)")
|
||||||
|
if roomID := c.Query("roomId"); roomID != "" {
|
||||||
|
q = q.Where("production_loss_records.room_id = ?", roomID)
|
||||||
|
}
|
||||||
|
if batchID := c.Query("batchId"); batchID != "" {
|
||||||
|
q = q.Where("production_loss_records.batch_id = ?", batchID)
|
||||||
|
}
|
||||||
|
var list []model.ProductionLossRecord
|
||||||
|
q.Order("production_loss_records.record_date DESC").Limit(200).Find(&list)
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createProductionLossRecord(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
RoomID *string `json:"roomId"`
|
||||||
|
BatchID *string `json:"batchId"`
|
||||||
|
RecordDate *time.Time `json:"recordDate"`
|
||||||
|
DeathCount *int `json:"deathCount"`
|
||||||
|
CulledCount *int `json:"culledCount"`
|
||||||
|
YieldKg *float64 `json:"yieldKg"`
|
||||||
|
LossKg *float64 `json:"lossKg"`
|
||||||
|
CostType *string `json:"costType"`
|
||||||
|
CostAmount *float64 `json:"costAmount"`
|
||||||
|
Note *string `json:"note"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.BatchID != nil {
|
||||||
|
if !requireObjectAccess(c, db, "batch", *body.BatchID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.RoomID == nil {
|
||||||
|
if roomID, ok := objectRoomID(db, "batch", *body.BatchID); ok {
|
||||||
|
body.RoomID = roomID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body.RoomID != nil && !canAccessRoom(db, c, body.RoomID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权在该蚕房下记录产量损失"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
record := model.ProductionLossRecord{
|
||||||
|
RoomID: body.RoomID, BatchID: body.BatchID,
|
||||||
|
DeathCount: body.DeathCount, CulledCount: body.CulledCount,
|
||||||
|
YieldKg: body.YieldKg, LossKg: body.LossKg,
|
||||||
|
CostType: body.CostType, CostAmount: body.CostAmount,
|
||||||
|
Note: body.Note, CreatedBy: currentUserID(c),
|
||||||
|
}
|
||||||
|
if body.RecordDate != nil {
|
||||||
|
record.RecordDate = *body.RecordDate
|
||||||
|
} else {
|
||||||
|
record.RecordDate = time.Now()
|
||||||
|
}
|
||||||
|
if err := model.ValidateProductionLossRecord(record); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := db.Create(&record).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "创建产量损失记录失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateProductionLossRecord(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "production_loss_record", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var record model.ProductionLossRecord
|
||||||
|
if db.Where("id = ?", id).First(&record).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "production loss record not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates, err := bindUpdates(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if roomID, ok := updates["room_id"].(string); ok && !canAccessRoom(db, c, &roomID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权迁移该记录到指定蚕房"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if batchID, ok := updates["batch_id"].(string); ok && !requireObjectAccess(c, db, "batch", batchID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(updates) > 0 {
|
||||||
|
if err := db.Model(&model.ProductionLossRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "更新产量损失记录失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.Where("id = ?", id).First(&record)
|
||||||
|
c.JSON(http.StatusOK, record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteProductionLossRecord(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "production_loss_record", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var record model.ProductionLossRecord
|
||||||
|
if db.Where("id = ?", id).First(&record).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "production loss record not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.Where("id = ?", id).Delete(&model.ProductionLossRecord{})
|
||||||
|
c.JSON(http.StatusOK, gin.H{"id": id})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func productionLossStats(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
q := db.Table("production_loss_records pl").
|
||||||
|
Select(`
|
||||||
|
COALESCE(pl.room_id::text, '') AS room_id,
|
||||||
|
COALESCE(pl.batch_id::text, '') AS batch_id,
|
||||||
|
COALESCE(SUM(pl.death_count), 0) AS death_count,
|
||||||
|
COALESCE(SUM(pl.culled_count), 0) AS culled_count,
|
||||||
|
COALESCE(SUM(pl.yield_kg), 0) AS yield_kg,
|
||||||
|
COALESCE(SUM(pl.loss_kg), 0) AS loss_kg,
|
||||||
|
COALESCE(SUM(pl.cost_amount), 0) AS cost_amount,
|
||||||
|
COUNT(*) AS record_count
|
||||||
|
`).
|
||||||
|
Joins("LEFT JOIN batches b ON b.id = pl.batch_id")
|
||||||
|
q = applyRoomScope(q, c, "COALESCE(pl.room_id, b.room_id)")
|
||||||
|
if from := c.Query("from"); from != "" {
|
||||||
|
q = q.Where("pl.record_date >= ?", from)
|
||||||
|
}
|
||||||
|
if to := c.Query("to"); to != "" {
|
||||||
|
q = q.Where("pl.record_date <= ?", to)
|
||||||
|
}
|
||||||
|
var rows []service.ProductionLossRow
|
||||||
|
q.Group("pl.room_id, pl.batch_id").Scan(&rows)
|
||||||
|
stats := service.AggregateProductionLoss(rows)
|
||||||
|
fillProductionStatNames(db, stats)
|
||||||
|
c.JSON(http.StatusOK, stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fillProductionStatNames(db *gorm.DB, stats []service.ProductionLossStat) {
|
||||||
|
roomNames := make(map[string]string)
|
||||||
|
batchNames := make(map[string]string)
|
||||||
|
for _, s := range stats {
|
||||||
|
if s.RoomID != "" {
|
||||||
|
if _, ok := roomNames[s.RoomID]; !ok {
|
||||||
|
var room model.Room
|
||||||
|
if db.Select("id", "name").Where("id = ?", s.RoomID).First(&room).Error == nil {
|
||||||
|
roomNames[s.RoomID] = room.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.BatchID != "" {
|
||||||
|
if _, ok := batchNames[s.BatchID]; !ok {
|
||||||
|
var batch model.Batch
|
||||||
|
if db.Select("id", "name").Where("id = ?", s.BatchID).First(&batch).Error == nil {
|
||||||
|
batchNames[s.BatchID] = batch.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range stats {
|
||||||
|
stats[i].RoomName = roomNames[stats[i].RoomID]
|
||||||
|
stats[i].BatchName = batchNames[stats[i].BatchID]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,10 @@ func RegisterHealthProfileRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
|||||||
func roomHealthProfile(db *gorm.DB) gin.HandlerFunc {
|
func roomHealthProfile(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("roomId")
|
id := c.Param("roomId")
|
||||||
|
if !canAccessRoom(db, c, &id) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该蚕房"})
|
||||||
|
return
|
||||||
|
}
|
||||||
var room model.Room
|
var room model.Room
|
||||||
if db.Where("id = ?", id).First(&room).Error != nil {
|
if db.Where("id = ?", id).First(&room).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "room not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "room not found"})
|
||||||
@@ -107,6 +111,10 @@ func roomHealthProfile(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func roomEffectReport(db *gorm.DB) gin.HandlerFunc {
|
func roomEffectReport(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
roomID := c.Param("roomId")
|
roomID := c.Param("roomId")
|
||||||
|
if !canAccessRoom(db, c, &roomID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该蚕房"})
|
||||||
|
return
|
||||||
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
parse := func(key string, fallback time.Time) time.Time {
|
parse := func(key string, fallback time.Time) time.Time {
|
||||||
raw := c.Query(key)
|
raw := c.Query(key)
|
||||||
|
|||||||
@@ -123,6 +123,10 @@ func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient,
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "roomId 不是合法的 UUID"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "roomId 不是合法的 UUID"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !canAccessRoom(db, c, &roomID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权在该蚕房下发起巡检"})
|
||||||
|
return
|
||||||
|
}
|
||||||
rec.RoomID = &roomID
|
rec.RoomID = &roomID
|
||||||
}
|
}
|
||||||
if idemKey != "" {
|
if idemKey != "" {
|
||||||
@@ -283,7 +287,7 @@ func loadRoomRisk(db *gorm.DB, roomID string) (*float64, *float64) {
|
|||||||
// listInspections 巡检记录列表(roomId/limit 过滤)
|
// listInspections 巡检记录列表(roomId/limit 过滤)
|
||||||
func listInspections(db *gorm.DB) gin.HandlerFunc {
|
func listInspections(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
q := db.Model(&model.InspectionRecord{})
|
q := applyRoomScope(db.Model(&model.InspectionRecord{}), c, "room_id")
|
||||||
if room := c.Query("roomId"); room != "" {
|
if room := c.Query("roomId"); room != "" {
|
||||||
q = q.Where("room_id = ?", room)
|
q = q.Where("room_id = ?", room)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"silk-server-go/internal/middleware"
|
||||||
|
"silk-server-go/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterLaboratoryRoutes 注册实验室结构化结果和分子分型路由。
|
||||||
|
func RegisterLaboratoryRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||||
|
read := middleware.RequirePermission(db, "lab:read")
|
||||||
|
write := middleware.RequirePermission(db, "lab:write")
|
||||||
|
rg.GET("/laboratory-results", read, listLaboratoryResults(db))
|
||||||
|
rg.POST("/laboratory-results", write, createLaboratoryResult(db))
|
||||||
|
rg.PATCH("/laboratory-results/:id", write, updateLaboratoryResult(db))
|
||||||
|
rg.DELETE("/laboratory-results/:id", write, deleteLaboratoryResult(db))
|
||||||
|
}
|
||||||
|
|
||||||
|
func listLaboratoryResults(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
q := db.Model(&model.LaboratoryResult{}).
|
||||||
|
Joins("LEFT JOIN batches b ON b.id = laboratory_results.batch_id").
|
||||||
|
Joins("LEFT JOIN trace_records tr ON tr.id = laboratory_results.trace_record_id")
|
||||||
|
q = applyRoomScope(q, c, "COALESCE(laboratory_results.room_id, b.room_id, tr.room_id)")
|
||||||
|
if traceID := c.Query("traceRecordId"); traceID != "" {
|
||||||
|
q = q.Where("laboratory_results.trace_record_id = ?", traceID)
|
||||||
|
}
|
||||||
|
if diseaseEventID := c.Query("diseaseEventId"); diseaseEventID != "" {
|
||||||
|
q = q.Where("laboratory_results.disease_event_id = ?", diseaseEventID)
|
||||||
|
}
|
||||||
|
if roomID := c.Query("roomId"); roomID != "" {
|
||||||
|
q = q.Where("laboratory_results.room_id = ?", roomID)
|
||||||
|
}
|
||||||
|
var list []model.LaboratoryResult
|
||||||
|
q.Order("laboratory_results.tested_at DESC NULLS LAST, laboratory_results.created_at DESC").Limit(200).Find(&list)
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createLaboratoryResult(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
TraceRecordID *string `json:"traceRecordId"`
|
||||||
|
DiseaseEventID *string `json:"diseaseEventId"`
|
||||||
|
SampleID *string `json:"sampleId"`
|
||||||
|
RoomID *string `json:"roomId"`
|
||||||
|
BatchID *string `json:"batchId"`
|
||||||
|
LabName string `json:"labName"`
|
||||||
|
ReportNo string `json:"reportNo"`
|
||||||
|
TestType string `json:"testType"`
|
||||||
|
ResultType string `json:"resultType"`
|
||||||
|
Pathogen *string `json:"pathogen"`
|
||||||
|
Genotype *string `json:"genotype"`
|
||||||
|
Method *string `json:"method"`
|
||||||
|
SampleNo *string `json:"sampleNo"`
|
||||||
|
SampleType *string `json:"sampleType"`
|
||||||
|
Findings *string `json:"findings"`
|
||||||
|
ReportURL *string `json:"reportUrl"`
|
||||||
|
TestedAt *time.Time `json:"testedAt"`
|
||||||
|
ConcludedAt *time.Time `json:"concludedAt"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, id := range []*string{body.TraceRecordID, body.DiseaseEventID, body.SampleID, body.RoomID, body.BatchID} {
|
||||||
|
if id != nil && *id != "" && !isUUID(*id) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "关联 ID 不是合法的 UUID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body.TraceRecordID != nil && !requireObjectAccess(c, db, "trace_record", *body.TraceRecordID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.DiseaseEventID != nil && !requireObjectAccess(c, db, "disease_event", *body.DiseaseEventID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.SampleID != nil && !requireObjectAccess(c, db, "sample", *body.SampleID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.RoomID != nil && !canAccessRoom(db, c, body.RoomID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权录入该蚕房实验室结果"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.BatchID != nil && !requireObjectAccess(c, db, "batch", *body.BatchID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := model.LaboratoryResult{
|
||||||
|
TraceRecordID: body.TraceRecordID, DiseaseEventID: body.DiseaseEventID,
|
||||||
|
SampleID: body.SampleID, RoomID: body.RoomID, BatchID: body.BatchID,
|
||||||
|
LabName: body.LabName, ReportNo: body.ReportNo,
|
||||||
|
TestType: body.TestType, ResultType: body.ResultType,
|
||||||
|
Pathogen: body.Pathogen, Genotype: body.Genotype, Method: body.Method,
|
||||||
|
SampleNo: body.SampleNo, SampleType: body.SampleType,
|
||||||
|
Findings: body.Findings, ReportURL: body.ReportURL,
|
||||||
|
TestedAt: body.TestedAt, ConcludedAt: body.ConcludedAt,
|
||||||
|
CreatedBy: currentUserID(c),
|
||||||
|
}
|
||||||
|
if result.RoomID == nil && body.TraceRecordID != nil {
|
||||||
|
var trace model.TraceRecord
|
||||||
|
if db.Where("id = ?", *body.TraceRecordID).First(&trace).Error == nil {
|
||||||
|
result.RoomID = trace.RoomID
|
||||||
|
if result.BatchID == nil {
|
||||||
|
result.BatchID = batchFromTrace(db, trace)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result.RoomID == nil && body.SampleID != nil {
|
||||||
|
var sample model.Sample
|
||||||
|
if db.Where("id = ?", *body.SampleID).First(&sample).Error == nil {
|
||||||
|
result.RoomID = sample.RoomID
|
||||||
|
if result.BatchID == nil {
|
||||||
|
result.BatchID = sample.BatchID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := model.ValidateLaboratoryResult(result); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := db.Create(&result).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "创建实验室结果失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateLaboratoryResult(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "laboratory_result", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var result model.LaboratoryResult
|
||||||
|
if db.Where("id = ?", id).First(&result).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "laboratory result not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates, err := bindUpdates(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if roomID, ok := updates["room_id"].(string); ok && !canAccessRoom(db, c, &roomID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权迁移该结果到指定蚕房"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, ref := range []struct {
|
||||||
|
key string
|
||||||
|
kind string
|
||||||
|
}{
|
||||||
|
{key: "trace_record_id", kind: "trace_record"},
|
||||||
|
{key: "disease_event_id", kind: "disease_event"},
|
||||||
|
{key: "sample_id", kind: "sample"},
|
||||||
|
{key: "batch_id", kind: "batch"},
|
||||||
|
} {
|
||||||
|
if id, ok := updates[ref.key].(string); ok && !requireObjectAccess(c, db, ref.kind, id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(updates) > 0 {
|
||||||
|
if err := db.Model(&model.LaboratoryResult{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "更新实验室结果失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.Where("id = ?", id).First(&result)
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteLaboratoryResult(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "laboratory_result", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var result model.LaboratoryResult
|
||||||
|
if db.Where("id = ?", id).First(&result).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "laboratory result not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.Where("id = ?", id).Delete(&model.LaboratoryResult{})
|
||||||
|
c.JSON(http.StatusOK, gin.H{"id": id})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchFromTrace(db *gorm.DB, trace model.TraceRecord) *string {
|
||||||
|
if trace.LampTestID != nil {
|
||||||
|
var lamp model.LampTest
|
||||||
|
if db.Where("id = ?", *trace.LampTestID).First(&lamp).Error == nil {
|
||||||
|
return lamp.BatchID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ func RegisterLampRoutes(rg *gin.RouterGroup, db *gorm.DB, s3 *service.S3Service,
|
|||||||
// listLampTests 检测任务单列表(roomId/batchId/status 过滤)
|
// listLampTests 检测任务单列表(roomId/batchId/status 过滤)
|
||||||
func listLampTests(db *gorm.DB) gin.HandlerFunc {
|
func listLampTests(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
q := db.Model(&model.LampTest{})
|
q := applyRoomScope(db.Model(&model.LampTest{}), c, "room_id")
|
||||||
if room := c.Query("roomId"); room != "" {
|
if room := c.Query("roomId"); room != "" {
|
||||||
q = q.Where("room_id = ?", room)
|
q = q.Where("room_id = ?", room)
|
||||||
}
|
}
|
||||||
@@ -76,14 +76,24 @@ func createLampTest(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "roomId 不是合法的 UUID"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "roomId 不是合法的 UUID"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if t.RoomID != nil && !canAccessRoom(db, c, t.RoomID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权在该蚕房下创建检测任务"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if t.BatchID != nil && !isUUID(*t.BatchID) {
|
if t.BatchID != nil && !isUUID(*t.BatchID) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "batchId 不是合法的 UUID"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "batchId 不是合法的 UUID"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if t.BatchID != nil && !requireObjectAccess(c, db, "batch", *t.BatchID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if t.DetectionTaskID != nil && !isUUID(*t.DetectionTaskID) {
|
if t.DetectionTaskID != nil && !isUUID(*t.DetectionTaskID) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "detectionTaskId 不是合法的 UUID"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "detectionTaskId 不是合法的 UUID"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if t.DetectionTaskID != nil && !requireObjectAccess(c, db, "detection_task", *t.DetectionTaskID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if t.Status == "" {
|
if t.Status == "" {
|
||||||
t.Status = "pending"
|
t.Status = "pending"
|
||||||
}
|
}
|
||||||
@@ -107,6 +117,9 @@ func createLampTest(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func judgeQPCR(db *gorm.DB) gin.HandlerFunc {
|
func judgeQPCR(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "lamp_test", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var t model.LampTest
|
var t model.LampTest
|
||||||
if db.Where("id = ?", id).First(&t).Error != nil {
|
if db.Where("id = ?", id).First(&t).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "lamp test not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "lamp test not found"})
|
||||||
@@ -144,6 +157,9 @@ func judgeQPCR(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func uploadLampSpectrum(db *gorm.DB, s3 *service.S3Service, bucket string) gin.HandlerFunc {
|
func uploadLampSpectrum(db *gorm.DB, s3 *service.S3Service, bucket string) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "lamp_test", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var t model.LampTest
|
var t model.LampTest
|
||||||
if db.Where("id = ?", id).First(&t).Error != nil {
|
if db.Where("id = ?", id).First(&t).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "lamp test not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "lamp test not found"})
|
||||||
@@ -286,6 +302,9 @@ func deleteSpectrumEntry(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func updateLampTest(db *gorm.DB) gin.HandlerFunc {
|
func updateLampTest(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "lamp_test", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var t model.LampTest
|
var t model.LampTest
|
||||||
if db.Where("id = ?", id).First(&t).Error != nil {
|
if db.Where("id = ?", id).First(&t).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "lamp test not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "lamp test not found"})
|
||||||
@@ -296,6 +315,21 @@ func updateLampTest(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if roomID, ok := updates["room_id"].(string); ok && !canAccessRoom(db, c, &roomID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权迁移检测任务到指定蚕房"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, ref := range []struct {
|
||||||
|
key string
|
||||||
|
kind string
|
||||||
|
}{
|
||||||
|
{key: "batch_id", kind: "batch"},
|
||||||
|
{key: "detection_task_id", kind: "detection_task"},
|
||||||
|
} {
|
||||||
|
if id, ok := updates[ref.key].(string); ok && !requireObjectAccess(c, db, ref.kind, id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
resultSet := false
|
resultSet := false
|
||||||
resultValue := ""
|
resultValue := ""
|
||||||
if r, ok := updates["result"]; ok {
|
if r, ok := updates["result"]; ok {
|
||||||
@@ -429,6 +463,9 @@ func runCrossValidation(db *gorm.DB, lampTestID, lampResult string) error {
|
|||||||
func getLampCrossValidation(db *gorm.DB) gin.HandlerFunc {
|
func getLampCrossValidation(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "lamp_test", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var t model.LampTest
|
var t model.LampTest
|
||||||
if db.Where("id = ?", id).First(&t).Error != nil {
|
if db.Where("id = ?", id).First(&t).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "lamp test not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "lamp test not found"})
|
||||||
@@ -463,6 +500,9 @@ func getLampCrossValidation(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func deleteLampTest(db *gorm.DB) gin.HandlerFunc {
|
func deleteLampTest(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "lamp_test", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var t model.LampTest
|
var t model.LampTest
|
||||||
if db.Where("id = ?", id).First(&t).Error != nil {
|
if db.Where("id = ?", id).First(&t).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "lamp test not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "lamp test not found"})
|
||||||
@@ -478,6 +518,9 @@ func deleteLampTest(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func listLampTestSteps(db *gorm.DB) gin.HandlerFunc {
|
func listLampTestSteps(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "lamp_test", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var t model.LampTest
|
var t model.LampTest
|
||||||
if db.Where("id = ?", id).First(&t).Error != nil {
|
if db.Where("id = ?", id).First(&t).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "lamp test not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "lamp test not found"})
|
||||||
@@ -493,6 +536,9 @@ func listLampTestSteps(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func updateLampTestStep(db *gorm.DB) gin.HandlerFunc {
|
func updateLampTestStep(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "lamp_test", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
stepNo, err := strconv.Atoi(c.Param("stepNo"))
|
stepNo, err := strconv.Atoi(c.Param("stepNo"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stepNo 不合法"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "stepNo 不合法"})
|
||||||
@@ -538,6 +584,9 @@ func updateLampTestStep(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func uploadLampResultImage(db *gorm.DB, s3 *service.S3Service, bucket string) gin.HandlerFunc {
|
func uploadLampResultImage(db *gorm.DB, s3 *service.S3Service, bucket string) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "lamp_test", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var t model.LampTest
|
var t model.LampTest
|
||||||
if db.Where("id = ?", id).First(&t).Error != nil {
|
if db.Where("id = ?", id).First(&t).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "lamp test not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "lamp test not found"})
|
||||||
|
|||||||
@@ -22,7 +22,15 @@ func RegisterNotificationRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
|||||||
func listNotifications(db *gorm.DB) gin.HandlerFunc {
|
func listNotifications(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var list []model.Notification
|
var list []model.Notification
|
||||||
db.Order("created_at DESC").Limit(200).Find(&list)
|
q := db.Order("created_at DESC").Limit(200)
|
||||||
|
if !hasGlobalAccess(c) {
|
||||||
|
if userID := currentUserID(c); userID != nil {
|
||||||
|
q = q.Where("user_id = ?", *userID)
|
||||||
|
} else {
|
||||||
|
q = q.Where("1 = 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
q.Find(&list)
|
||||||
c.JSON(http.StatusOK, list)
|
c.JSON(http.StatusOK, list)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"silk-server-go/internal/middleware"
|
||||||
|
"silk-server-go/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterOrganizationRoutes 注册组织和成员数据范围路由。
|
||||||
|
func RegisterOrganizationRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||||
|
perm := middleware.RequirePermission(db, "organization:manage")
|
||||||
|
rg.GET("/organizations", perm, listOrganizations(db))
|
||||||
|
rg.POST("/organizations", perm, createOrganization(db))
|
||||||
|
rg.PATCH("/organizations/:id", perm, updateOrganization(db))
|
||||||
|
rg.GET("/organizations/:id/members", perm, listOrganizationMembers(db))
|
||||||
|
rg.POST("/organizations/:id/members", perm, addOrganizationMember(db))
|
||||||
|
rg.DELETE("/organizations/:id/members/:userId", perm, removeOrganizationMember(db))
|
||||||
|
}
|
||||||
|
|
||||||
|
func listOrganizations(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
q := db.Model(&model.Organization{})
|
||||||
|
if !hasGlobalAccess(c) {
|
||||||
|
q = applyOrgScope(q, c, "organizations.id")
|
||||||
|
}
|
||||||
|
var list []model.Organization
|
||||||
|
q.Order("created_at DESC").Find(&list)
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createOrganization(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(body.Name) == "" || strings.TrimSpace(body.Code) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "组织名称和编码不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
db.Model(&model.Organization{}).Where("code = ?", body.Code).Count(&count)
|
||||||
|
if count > 0 {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "组织编码已存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
org := model.Organization{Name: body.Name, Code: body.Code, Description: body.Description, Status: "active"}
|
||||||
|
if err := db.Create(&org).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "创建组织失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, org)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateOrganization(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var org model.Organization
|
||||||
|
if db.Where("id = ?", id).First(&org).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "organization not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !canAccessOrganization(db, c, id) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权管理该组织"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates, err := bindUpdates(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(updates) > 0 {
|
||||||
|
if err := db.Model(&model.Organization{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "更新组织失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.Where("id = ?", id).First(&org)
|
||||||
|
c.JSON(http.StatusOK, org)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addOrganizationMember(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
orgID := c.Param("id")
|
||||||
|
var org model.Organization
|
||||||
|
if db.Where("id = ?", orgID).First(&org).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "organization not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !canAccessOrganization(db, c, orgID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权管理该组织"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isUUID(body.UserID) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "userId 不是合法的 UUID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var user model.User
|
||||||
|
if db.Where("id = ?", body.UserID).First(&user).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
role := body.Role
|
||||||
|
if role == "" {
|
||||||
|
role = "member"
|
||||||
|
}
|
||||||
|
if role != "member" && role != "admin" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "组织角色仅支持 member/admin"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
db.Model(&model.OrganizationMember{}).
|
||||||
|
Where("organization_id = ? AND user_id = ?", orgID, body.UserID).
|
||||||
|
Count(&count)
|
||||||
|
if count > 0 {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "用户已在该组织"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
member := model.OrganizationMember{OrganizationID: orgID, UserID: body.UserID, Role: role}
|
||||||
|
if err := db.Create(&member).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "添加成员失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, member)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listOrganizationMembers(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
orgID := c.Param("id")
|
||||||
|
var org model.Organization
|
||||||
|
if db.Where("id = ?", orgID).First(&org).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "organization not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !canAccessOrganization(db, c, orgID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权查看该组织成员"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var rows []struct {
|
||||||
|
ID string `gorm:"column:id" json:"id"`
|
||||||
|
OrganizationID string `gorm:"column:organization_id" json:"organizationId"`
|
||||||
|
UserID string `gorm:"column:user_id" json:"userId"`
|
||||||
|
Role string `gorm:"column:role" json:"role"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||||
|
Username string `gorm:"column:username" json:"username"`
|
||||||
|
Email string `gorm:"column:email" json:"email"`
|
||||||
|
FullName *string `gorm:"column:full_name" json:"fullName,omitempty"`
|
||||||
|
}
|
||||||
|
if err := db.Table("organization_members om").
|
||||||
|
Select("om.id, om.organization_id, om.user_id, om.role, om.created_at, u.username, u.email, u.full_name").
|
||||||
|
Joins("JOIN users u ON u.id = om.user_id").
|
||||||
|
Where("om.organization_id = ?", orgID).
|
||||||
|
Order("om.created_at DESC").
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "查询组织成员失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeOrganizationMember(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
orgID := c.Param("id")
|
||||||
|
userID := c.Param("userId")
|
||||||
|
var member model.OrganizationMember
|
||||||
|
if db.Where("organization_id = ? AND user_id = ?", orgID, userID).First(&member).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "organization member not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !canAccessOrganization(db, c, orgID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权管理该组织"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.Where("id = ?", member.ID).Delete(&model.OrganizationMember{})
|
||||||
|
c.JSON(http.StatusOK, gin.H{"organizationId": orgID, "userId": userID})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,8 @@ func RegisterRoomRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
|||||||
func listRooms(db *gorm.DB) gin.HandlerFunc {
|
func listRooms(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var rooms []model.Room
|
var rooms []model.Room
|
||||||
db.Order("created_at DESC").Find(&rooms)
|
q := applyOrgScope(db.Model(&model.Room{}), c, "org_id")
|
||||||
|
q.Order("created_at DESC").Find(&rooms)
|
||||||
c.JSON(http.StatusOK, rooms)
|
c.JSON(http.StatusOK, rooms)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,6 +35,9 @@ func listRooms(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func getRoom(db *gorm.DB) gin.HandlerFunc {
|
func getRoom(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "room", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var room model.Room
|
var room model.Room
|
||||||
if db.Where("id = ?", id).First(&room).Error != nil {
|
if db.Where("id = ?", id).First(&room).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "room not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "room not found"})
|
||||||
@@ -52,6 +56,12 @@ func createRoom(db *gorm.DB) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
room.ID = "" // 让数据库自动生成
|
room.ID = "" // 让数据库自动生成
|
||||||
|
if room.OrgID == nil {
|
||||||
|
room.OrgID = defaultOrgID(db, c)
|
||||||
|
} else if !canAccessOrganization(db, c, *room.OrgID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权在该组织下创建蚕房"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := db.Create(&room).Error; err != nil {
|
if err := db.Create(&room).Error; err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"})
|
||||||
return
|
return
|
||||||
@@ -64,6 +74,9 @@ func createRoom(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func updateRoom(db *gorm.DB) gin.HandlerFunc {
|
func updateRoom(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "room", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var room model.Room
|
var room model.Room
|
||||||
if db.Where("id = ?", id).First(&room).Error != nil {
|
if db.Where("id = ?", id).First(&room).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "room not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "room not found"})
|
||||||
@@ -74,6 +87,12 @@ func updateRoom(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if orgID, ok := updates["org_id"]; ok {
|
||||||
|
if orgStr, ok := orgID.(string); ok && !canAccessOrganization(db, c, orgStr) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权迁移蚕房到该组织"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(updates) > 0 {
|
if len(updates) > 0 {
|
||||||
db.Model(&model.Room{}).Where("id = ?", id).Updates(updates)
|
db.Model(&model.Room{}).Where("id = ?", id).Updates(updates)
|
||||||
}
|
}
|
||||||
@@ -86,6 +105,9 @@ func updateRoom(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func deleteRoom(db *gorm.DB) gin.HandlerFunc {
|
func deleteRoom(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "room", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var room model.Room
|
var room model.Room
|
||||||
if db.Where("id = ?", id).First(&room).Error != nil {
|
if db.Where("id = ?", id).First(&room).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "room not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "room not found"})
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ func RegisterSensorRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
|||||||
func listSensors(db *gorm.DB) gin.HandlerFunc {
|
func listSensors(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var sensors []model.Sensor
|
var sensors []model.Sensor
|
||||||
db.Order("created_at DESC").Find(&sensors)
|
q := db.Model(&model.Sensor{}).
|
||||||
|
Joins("JOIN devices d ON d.id = sensors.device_id")
|
||||||
|
q = applyRoomScope(q, c, "d.room_id")
|
||||||
|
q.Order("sensors.created_at DESC").Find(&sensors)
|
||||||
c.JSON(http.StatusOK, sensors)
|
c.JSON(http.StatusOK, sensors)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,6 +35,9 @@ func listSensors(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func getSensor(db *gorm.DB) gin.HandlerFunc {
|
func getSensor(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "sensor", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var sensor model.Sensor
|
var sensor model.Sensor
|
||||||
if db.Where("id = ?", id).First(&sensor).Error != nil {
|
if db.Where("id = ?", id).First(&sensor).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "sensor not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "sensor not found"})
|
||||||
@@ -50,6 +56,13 @@ func createSensor(db *gorm.DB) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
sensor.ID = "" // 让数据库自动生成
|
sensor.ID = "" // 让数据库自动生成
|
||||||
|
if !isUUID(sensor.DeviceID) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "deviceId 不是合法的 UUID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireObjectAccess(c, db, "device", sensor.DeviceID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := db.Create(&sensor).Error; err != nil {
|
if err := db.Create(&sensor).Error; err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"})
|
||||||
return
|
return
|
||||||
@@ -62,6 +75,9 @@ func createSensor(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func updateSensor(db *gorm.DB) gin.HandlerFunc {
|
func updateSensor(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "sensor", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var sensor model.Sensor
|
var sensor model.Sensor
|
||||||
if db.Where("id = ?", id).First(&sensor).Error != nil {
|
if db.Where("id = ?", id).First(&sensor).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "sensor not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "sensor not found"})
|
||||||
@@ -72,6 +88,9 @@ func updateSensor(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if deviceID, ok := updates["device_id"].(string); ok && !requireObjectAccess(c, db, "device", deviceID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if len(updates) > 0 {
|
if len(updates) > 0 {
|
||||||
db.Model(&model.Sensor{}).Where("id = ?", id).Updates(updates)
|
db.Model(&model.Sensor{}).Where("id = ?", id).Updates(updates)
|
||||||
}
|
}
|
||||||
@@ -84,6 +103,9 @@ func updateSensor(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func deleteSensor(db *gorm.DB) gin.HandlerFunc {
|
func deleteSensor(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "sensor", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var sensor model.Sensor
|
var sensor model.Sensor
|
||||||
if db.Where("id = ?", id).First(&sensor).Error != nil {
|
if db.Where("id = ?", id).First(&sensor).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "sensor not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "sensor not found"})
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ func listTelemetry(db *gorm.DB, iotdb *service.IoTDBService) gin.HandlerFunc {
|
|||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 2000
|
limit = 2000
|
||||||
}
|
}
|
||||||
|
if deviceKey != "" && !requireDeviceKeyAccess(c, db, deviceKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 优先 IoTDB
|
// 优先 IoTDB
|
||||||
if iotdb.IsAvailable() && deviceKey != "" && metric != "" {
|
if iotdb.IsAvailable() && deviceKey != "" && metric != "" {
|
||||||
@@ -45,16 +48,19 @@ func listTelemetry(db *gorm.DB, iotdb *service.IoTDBService) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 降级 PostgreSQL
|
// 降级 PostgreSQL
|
||||||
q := db.Model(&model.Telemetry{}).Order("timestamp DESC").Limit(limit)
|
q := db.Model(&model.Telemetry{}).
|
||||||
|
Joins("JOIN devices d ON d.device_key = telemetry.device_key").
|
||||||
|
Order("telemetry.timestamp DESC").Limit(limit)
|
||||||
|
q = applyRoomScope(q, c, "d.room_id")
|
||||||
if deviceKey != "" {
|
if deviceKey != "" {
|
||||||
q = q.Where("device_key = ?", deviceKey)
|
q = q.Where("telemetry.device_key = ?", deviceKey)
|
||||||
}
|
}
|
||||||
if metric != "" {
|
if metric != "" {
|
||||||
q = q.Where("metric = ?", metric)
|
q = q.Where("telemetry.metric = ?", metric)
|
||||||
}
|
}
|
||||||
if from != "" || to != "" {
|
if from != "" || to != "" {
|
||||||
fromVal, toVal := parseTimeRangeStr(from, to)
|
fromVal, toVal := parseTimeRangeStr(from, to)
|
||||||
q = q.Where("timestamp BETWEEN ? AND ?", fromVal, toVal)
|
q = q.Where("telemetry.timestamp BETWEEN ? AND ?", fromVal, toVal)
|
||||||
}
|
}
|
||||||
var rows []model.Telemetry
|
var rows []model.Telemetry
|
||||||
q.Find(&rows)
|
q.Find(&rows)
|
||||||
@@ -66,6 +72,9 @@ func listTelemetry(db *gorm.DB, iotdb *service.IoTDBService) gin.HandlerFunc {
|
|||||||
func listMetrics(iotdb *service.IoTDBService, db *gorm.DB) gin.HandlerFunc {
|
func listMetrics(iotdb *service.IoTDBService, db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
deviceKey := c.Param("deviceKey")
|
deviceKey := c.Param("deviceKey")
|
||||||
|
if !requireDeviceKeyAccess(c, db, deviceKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 优先 IoTDB
|
// 优先 IoTDB
|
||||||
if iotdb.IsAvailable() {
|
if iotdb.IsAvailable() {
|
||||||
@@ -87,6 +96,9 @@ func listMetrics(iotdb *service.IoTDBService, db *gorm.DB) gin.HandlerFunc {
|
|||||||
func latestTelemetry(iotdb *service.IoTDBService, db *gorm.DB) gin.HandlerFunc {
|
func latestTelemetry(iotdb *service.IoTDBService, db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
deviceKey := c.Param("deviceKey")
|
deviceKey := c.Param("deviceKey")
|
||||||
|
if !requireDeviceKeyAccess(c, db, deviceKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 查询 PG 中该设备每个指标的最新记录
|
// 查询 PG 中该设备每个指标的最新记录
|
||||||
var records []model.Telemetry
|
var records []model.Telemetry
|
||||||
@@ -105,6 +117,9 @@ func historyBucket(iotdb *service.IoTDBService, db *gorm.DB) gin.HandlerFunc {
|
|||||||
metric := c.Param("metric")
|
metric := c.Param("metric")
|
||||||
from := c.Query("from")
|
from := c.Query("from")
|
||||||
to := c.Query("to")
|
to := c.Query("to")
|
||||||
|
if !requireDeviceKeyAccess(c, db, deviceKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if from == "" || to == "" {
|
if from == "" || to == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "from/to required"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "from/to required"})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ func RegisterThresholdRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
|||||||
func listThresholds(db *gorm.DB) gin.HandlerFunc {
|
func listThresholds(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var thresholds []model.Threshold
|
var thresholds []model.Threshold
|
||||||
db.Find(&thresholds)
|
q := db.Model(&model.Threshold{}).
|
||||||
|
Joins("JOIN sensors s ON s.id = thresholds.sensor_id").
|
||||||
|
Joins("JOIN devices d ON d.id = s.device_id")
|
||||||
|
q = applyRoomScope(q, c, "d.room_id")
|
||||||
|
q.Order("thresholds.created_at DESC").Find(&thresholds)
|
||||||
c.JSON(http.StatusOK, thresholds)
|
c.JSON(http.StatusOK, thresholds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,6 +36,9 @@ func listThresholds(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func getThreshold(db *gorm.DB) gin.HandlerFunc {
|
func getThreshold(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "threshold", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var threshold model.Threshold
|
var threshold model.Threshold
|
||||||
if db.Where("id = ?", id).First(&threshold).Error != nil {
|
if db.Where("id = ?", id).First(&threshold).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "threshold not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "threshold not found"})
|
||||||
@@ -50,6 +57,13 @@ func createThreshold(db *gorm.DB) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
threshold.ID = "" // 让数据库自动生成
|
threshold.ID = "" // 让数据库自动生成
|
||||||
|
if !isUUID(threshold.SensorID) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "sensorId 不是合法的 UUID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireObjectAccess(c, db, "sensor", threshold.SensorID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := db.Create(&threshold).Error; err != nil {
|
if err := db.Create(&threshold).Error; err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"})
|
||||||
return
|
return
|
||||||
@@ -62,6 +76,9 @@ func createThreshold(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func updateThreshold(db *gorm.DB) gin.HandlerFunc {
|
func updateThreshold(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "threshold", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var threshold model.Threshold
|
var threshold model.Threshold
|
||||||
if db.Where("id = ?", id).First(&threshold).Error != nil {
|
if db.Where("id = ?", id).First(&threshold).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "threshold not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "threshold not found"})
|
||||||
@@ -72,6 +89,9 @@ func updateThreshold(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if sensorID, ok := updates["sensor_id"].(string); ok && !requireObjectAccess(c, db, "sensor", sensorID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if len(updates) > 0 {
|
if len(updates) > 0 {
|
||||||
db.Model(&model.Threshold{}).Where("id = ?", id).Updates(updates)
|
db.Model(&model.Threshold{}).Where("id = ?", id).Updates(updates)
|
||||||
}
|
}
|
||||||
@@ -84,6 +104,9 @@ func updateThreshold(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func deleteThreshold(db *gorm.DB) gin.HandlerFunc {
|
func deleteThreshold(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "threshold", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var threshold model.Threshold
|
var threshold model.Threshold
|
||||||
if db.Where("id = ?", id).First(&threshold).Error != nil {
|
if db.Where("id = ?", id).First(&threshold).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "threshold not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "threshold not found"})
|
||||||
|
|||||||
@@ -43,10 +43,11 @@ func traceMonthlyStats(db *gorm.DB) gin.HandlerFunc {
|
|||||||
Month string `gorm:"column:month"`
|
Month string `gorm:"column:month"`
|
||||||
Disease string `gorm:"column:disease"`
|
Disease string `gorm:"column:disease"`
|
||||||
}
|
}
|
||||||
db.Table("trace_records").
|
q := db.Table("trace_records").
|
||||||
Select("to_char(created_at, 'YYYY-MM') AS month, disease").
|
Select("to_char(created_at, 'YYYY-MM') AS month, disease").
|
||||||
Where("created_at >= ? AND created_at < ?", start, end).
|
Where("created_at >= ? AND created_at < ?", start, end)
|
||||||
Scan(&rows)
|
q = applyRoomScope(q, c, "room_id")
|
||||||
|
q.Scan(&rows)
|
||||||
entries := make([]service.MonthDiseaseEntry, 0, len(rows))
|
entries := make([]service.MonthDiseaseEntry, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
entries = append(entries, service.MonthDiseaseEntry{Month: r.Month, Disease: r.Disease})
|
entries = append(entries, service.MonthDiseaseEntry{Month: r.Month, Disease: r.Disease})
|
||||||
@@ -66,6 +67,7 @@ func traceRegionStats(db *gorm.DB) gin.HandlerFunc {
|
|||||||
Select("rooms.region AS region, trace_records.disease AS disease").
|
Select("rooms.region AS region, trace_records.disease AS disease").
|
||||||
Joins("LEFT JOIN rooms ON rooms.id = trace_records.room_id").
|
Joins("LEFT JOIN rooms ON rooms.id = trace_records.room_id").
|
||||||
Where("trace_records.created_at >= ?", time.Now().Add(-time.Duration(days)*24*time.Hour))
|
Where("trace_records.created_at >= ?", time.Now().Add(-time.Duration(days)*24*time.Hour))
|
||||||
|
q = applyRoomScope(q, c, "trace_records.room_id")
|
||||||
if disease := c.Query("disease"); disease != "" {
|
if disease := c.Query("disease"); disease != "" {
|
||||||
q = q.Where("trace_records.disease = ?", disease)
|
q = q.Where("trace_records.disease = ?", disease)
|
||||||
}
|
}
|
||||||
@@ -84,7 +86,7 @@ func traceRegionStats(db *gorm.DB) gin.HandlerFunc {
|
|||||||
|
|
||||||
func listTraceRecords(db *gorm.DB) gin.HandlerFunc {
|
func listTraceRecords(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
q := db.Model(&model.TraceRecord{})
|
q := applyRoomScope(db.Model(&model.TraceRecord{}), c, "room_id")
|
||||||
if status := c.Query("status"); status != "" {
|
if status := c.Query("status"); status != "" {
|
||||||
q = q.Where("status = ?", status)
|
q = q.Where("status = ?", status)
|
||||||
}
|
}
|
||||||
@@ -116,6 +118,9 @@ func fillTraceRoomNames(db *gorm.DB, list []model.TraceRecord) {
|
|||||||
|
|
||||||
func getTraceRecord(db *gorm.DB) gin.HandlerFunc {
|
func getTraceRecord(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
if !requireObjectAccess(c, db, "trace_record", c.Param("id")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var t model.TraceRecord
|
var t model.TraceRecord
|
||||||
if db.Where("id = ?", c.Param("id")).First(&t).Error != nil {
|
if db.Where("id = ?", c.Param("id")).First(&t).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "trace record not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "trace record not found"})
|
||||||
@@ -145,6 +150,19 @@ func createTraceRecord(db *gorm.DB) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if body.RoomID != nil && !canAccessRoom(db, c, body.RoomID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权为该蚕房创建溯源记录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.DiseaseEventID != nil && !requireObjectAccess(c, db, "disease_event", *body.DiseaseEventID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.LampTestID != nil && !requireObjectAccess(c, db, "lamp_test", *body.LampTestID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.ConsultationID != nil && !requireObjectAccess(c, db, "consultation", *body.ConsultationID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if body.Disease == "" {
|
if body.Disease == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "病种不能为空"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "病种不能为空"})
|
||||||
return
|
return
|
||||||
@@ -165,6 +183,9 @@ func createTraceRecord(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func updateTraceRecord(db *gorm.DB) gin.HandlerFunc {
|
func updateTraceRecord(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "trace_record", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var t model.TraceRecord
|
var t model.TraceRecord
|
||||||
if db.Where("id = ?", id).First(&t).Error != nil {
|
if db.Where("id = ?", id).First(&t).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "trace record not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "trace record not found"})
|
||||||
@@ -175,6 +196,22 @@ func updateTraceRecord(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if roomID, ok := updates["room_id"].(string); ok && !canAccessRoom(db, c, &roomID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权迁移溯源记录到指定蚕房"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, ref := range []struct {
|
||||||
|
key string
|
||||||
|
kind string
|
||||||
|
}{
|
||||||
|
{key: "disease_event_id", kind: "disease_event"},
|
||||||
|
{key: "lamp_test_id", kind: "lamp_test"},
|
||||||
|
{key: "consultation_id", kind: "consultation"},
|
||||||
|
} {
|
||||||
|
if id, ok := updates[ref.key].(string); ok && !requireObjectAccess(c, db, ref.kind, id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(updates) > 0 {
|
if len(updates) > 0 {
|
||||||
db.Model(&model.TraceRecord{}).Where("id = ?", id).Updates(updates)
|
db.Model(&model.TraceRecord{}).Where("id = ?", id).Updates(updates)
|
||||||
}
|
}
|
||||||
@@ -186,6 +223,9 @@ func updateTraceRecord(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func deleteTraceRecord(db *gorm.DB) gin.HandlerFunc {
|
func deleteTraceRecord(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "trace_record", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var t model.TraceRecord
|
var t model.TraceRecord
|
||||||
if db.Where("id = ?", id).First(&t).Error != nil {
|
if db.Where("id = ?", id).First(&t).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "trace record not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "trace record not found"})
|
||||||
@@ -200,6 +240,9 @@ func deleteTraceRecord(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func autoTrace(db *gorm.DB) gin.HandlerFunc {
|
func autoTrace(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "trace_record", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var t model.TraceRecord
|
var t model.TraceRecord
|
||||||
if db.Where("id = ?", id).First(&t).Error != nil {
|
if db.Where("id = ?", id).First(&t).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "trace record not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "trace record not found"})
|
||||||
@@ -318,6 +361,9 @@ func autoTrace(db *gorm.DB) gin.HandlerFunc {
|
|||||||
// getTraceChecklist 分病种二级排查清单
|
// getTraceChecklist 分病种二级排查清单
|
||||||
func getTraceChecklist(db *gorm.DB) gin.HandlerFunc {
|
func getTraceChecklist(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
if !requireObjectAccess(c, db, "trace_record", c.Param("id")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var t model.TraceRecord
|
var t model.TraceRecord
|
||||||
if db.Where("id = ?", c.Param("id")).First(&t).Error != nil {
|
if db.Where("id = ?", c.Param("id")).First(&t).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "trace record not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "trace record not found"})
|
||||||
@@ -331,6 +377,9 @@ func getTraceChecklist(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func submitTraceChecklist(db *gorm.DB) gin.HandlerFunc {
|
func submitTraceChecklist(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "trace_record", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var t model.TraceRecord
|
var t model.TraceRecord
|
||||||
if db.Where("id = ?", id).First(&t).Error != nil {
|
if db.Where("id = ?", id).First(&t).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "trace record not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "trace record not found"})
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func RegisterTrayBatchRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
|||||||
|
|
||||||
func listTrays(db *gorm.DB) gin.HandlerFunc {
|
func listTrays(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
q := db.Model(&model.Tray{})
|
q := applyRoomScope(db.Model(&model.Tray{}), c, "room_id")
|
||||||
if room := c.Query("roomId"); room != "" {
|
if room := c.Query("roomId"); room != "" {
|
||||||
q = q.Where("room_id = ?", room)
|
q = q.Where("room_id = ?", room)
|
||||||
}
|
}
|
||||||
@@ -69,6 +69,10 @@ func createTray(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "roomId 不是合法的 UUID"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "roomId 不是合法的 UUID"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !canAccessRoom(db, c, &t.RoomID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权在该蚕房下创建蚕匾"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := db.Create(&t).Error; err != nil {
|
if err := db.Create(&t).Error; err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"})
|
||||||
return
|
return
|
||||||
@@ -80,6 +84,9 @@ func createTray(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func updateTray(db *gorm.DB) gin.HandlerFunc {
|
func updateTray(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "tray", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var t model.Tray
|
var t model.Tray
|
||||||
if db.Where("id = ?", id).First(&t).Error != nil {
|
if db.Where("id = ?", id).First(&t).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "tray not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "tray not found"})
|
||||||
@@ -90,6 +97,10 @@ func updateTray(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if roomID, ok := updates["room_id"].(string); ok && !canAccessRoom(db, c, &roomID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权迁移蚕匾到指定蚕房"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if len(updates) > 0 {
|
if len(updates) > 0 {
|
||||||
db.Model(&model.Tray{}).Where("id = ?", id).Updates(updates)
|
db.Model(&model.Tray{}).Where("id = ?", id).Updates(updates)
|
||||||
}
|
}
|
||||||
@@ -101,6 +112,9 @@ func updateTray(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func deleteTray(db *gorm.DB) gin.HandlerFunc {
|
func deleteTray(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "tray", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var t model.Tray
|
var t model.Tray
|
||||||
if db.Where("id = ?", id).First(&t).Error != nil {
|
if db.Where("id = ?", id).First(&t).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "tray not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "tray not found"})
|
||||||
@@ -115,7 +129,7 @@ func deleteTray(db *gorm.DB) gin.HandlerFunc {
|
|||||||
|
|
||||||
func listBatches(db *gorm.DB) gin.HandlerFunc {
|
func listBatches(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
q := db.Model(&model.Batch{})
|
q := applyRoomScope(db.Model(&model.Batch{}), c, "room_id")
|
||||||
if room := c.Query("roomId"); room != "" {
|
if room := c.Query("roomId"); room != "" {
|
||||||
q = q.Where("room_id = ?", room)
|
q = q.Where("room_id = ?", room)
|
||||||
}
|
}
|
||||||
@@ -144,6 +158,10 @@ func createBatch(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "roomId 不是合法的 UUID"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "roomId 不是合法的 UUID"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !canAccessRoom(db, c, &b.RoomID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权在该蚕房下创建批次"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if b.Status == "" {
|
if b.Status == "" {
|
||||||
b.Status = "rearing"
|
b.Status = "rearing"
|
||||||
}
|
}
|
||||||
@@ -158,6 +176,9 @@ func createBatch(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func updateBatch(db *gorm.DB) gin.HandlerFunc {
|
func updateBatch(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "batch", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var b model.Batch
|
var b model.Batch
|
||||||
if db.Where("id = ?", id).First(&b).Error != nil {
|
if db.Where("id = ?", id).First(&b).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "batch not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "batch not found"})
|
||||||
@@ -168,6 +189,10 @@ func updateBatch(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if roomID, ok := updates["room_id"].(string); ok && !canAccessRoom(db, c, &roomID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权迁移批次到指定蚕房"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if len(updates) > 0 {
|
if len(updates) > 0 {
|
||||||
db.Model(&model.Batch{}).Where("id = ?", id).Updates(updates)
|
db.Model(&model.Batch{}).Where("id = ?", id).Updates(updates)
|
||||||
}
|
}
|
||||||
@@ -179,6 +204,9 @@ func updateBatch(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func deleteBatch(db *gorm.DB) gin.HandlerFunc {
|
func deleteBatch(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "batch", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var b model.Batch
|
var b model.Batch
|
||||||
if db.Where("id = ?", id).First(&b).Error != nil {
|
if db.Where("id = ?", id).First(&b).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "batch not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "batch not found"})
|
||||||
@@ -195,7 +223,7 @@ func deleteBatch(db *gorm.DB) gin.HandlerFunc {
|
|||||||
|
|
||||||
func listRearingRecords(db *gorm.DB) gin.HandlerFunc {
|
func listRearingRecords(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
q := db.Model(&model.RearingRecord{})
|
q := applyRoomScope(db.Table("rearing_records").Joins("JOIN batches b ON b.id = rearing_records.batch_id"), c, "b.room_id")
|
||||||
if batch := c.Query("batchId"); batch != "" {
|
if batch := c.Query("batchId"); batch != "" {
|
||||||
q = q.Where("batch_id = ?", batch)
|
q = q.Where("batch_id = ?", batch)
|
||||||
}
|
}
|
||||||
@@ -217,6 +245,9 @@ func createRearingRecord(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "batchId 不是合法的 UUID"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "batchId 不是合法的 UUID"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireObjectAccess(c, db, "batch", r.BatchID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if r.RecordDate.IsZero() {
|
if r.RecordDate.IsZero() {
|
||||||
r.RecordDate = time.Now()
|
r.RecordDate = time.Now()
|
||||||
}
|
}
|
||||||
@@ -231,6 +262,9 @@ func createRearingRecord(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func updateRearingRecord(db *gorm.DB) gin.HandlerFunc {
|
func updateRearingRecord(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "rearing_record", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var r model.RearingRecord
|
var r model.RearingRecord
|
||||||
if db.Where("id = ?", id).First(&r).Error != nil {
|
if db.Where("id = ?", id).First(&r).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "rearing record not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "rearing record not found"})
|
||||||
@@ -252,6 +286,9 @@ func updateRearingRecord(db *gorm.DB) gin.HandlerFunc {
|
|||||||
func deleteRearingRecord(db *gorm.DB) gin.HandlerFunc {
|
func deleteRearingRecord(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "rearing_record", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var r model.RearingRecord
|
var r model.RearingRecord
|
||||||
if db.Where("id = ?", id).First(&r).Error != nil {
|
if db.Where("id = ?", id).First(&r).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "rearing record not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "rearing record not found"})
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ func RegisterVideoCameraRoutes(rg *gin.RouterGroup, db *gorm.DB, media *service.
|
|||||||
func listCameras(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
func listCameras(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var cameras []model.Camera
|
var cameras []model.Camera
|
||||||
db.Order("created_at DESC").Find(&cameras)
|
q := applyRoomScope(db.Model(&model.Camera{}), c, "room_id")
|
||||||
|
q.Order("created_at DESC").Find(&cameras)
|
||||||
|
|
||||||
// 先用 DB 中的 is_online 初始化 Online 字段(gorm:"-" 不会自动填充)
|
// 先用 DB 中的 is_online 初始化 Online 字段(gorm:"-" 不会自动填充)
|
||||||
for i := range cameras {
|
for i := range cameras {
|
||||||
@@ -98,6 +99,9 @@ func listCameras(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
|||||||
func getCamera(db *gorm.DB) gin.HandlerFunc {
|
func getCamera(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "camera", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var camera model.Camera
|
var camera model.Camera
|
||||||
if db.Where("id = ?", id).First(&camera).Error != nil {
|
if db.Where("id = ?", id).First(&camera).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
|
||||||
@@ -118,8 +122,15 @@ func createCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
|||||||
camera := input.toModel()
|
camera := input.toModel()
|
||||||
camera.ID = 0 // 让数据库自动生成
|
camera.ID = 0 // 让数据库自动生成
|
||||||
if camera.RoomID == nil {
|
if camera.RoomID == nil {
|
||||||
defaultRoom := "1"
|
camera.RoomID = firstAccessibleRoomID(db, c)
|
||||||
camera.RoomID = &defaultRoom
|
if camera.RoomID == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "当前账号无可用蚕房,请先选择 roomId"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !canAccessRoom(db, c, camera.RoomID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权在该蚕房下创建摄像头"})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if err := db.Create(&camera).Error; err != nil {
|
if err := db.Create(&camera).Error; err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"})
|
||||||
@@ -150,6 +161,9 @@ func createCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
|||||||
func updateCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
func updateCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "camera", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var camera model.Camera
|
var camera model.Camera
|
||||||
if db.Where("id = ?", id).First(&camera).Error != nil {
|
if db.Where("id = ?", id).First(&camera).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
|
||||||
@@ -160,6 +174,10 @@ func updateCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if roomID, ok := updates["room_id"].(string); ok && !canAccessRoom(db, c, &roomID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权迁移摄像头到指定蚕房"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if len(updates) > 0 {
|
if len(updates) > 0 {
|
||||||
if err := db.Model(&model.Camera{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
if err := db.Model(&model.Camera{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新失败: " + err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新失败: " + err.Error()})
|
||||||
@@ -198,6 +216,9 @@ func updateCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
|||||||
func deleteCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
func deleteCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "camera", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var camera model.Camera
|
var camera model.Camera
|
||||||
if db.Where("id = ?", id).First(&camera).Error != nil {
|
if db.Where("id = ?", id).First(&camera).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
|
||||||
@@ -224,6 +245,9 @@ func playCamera(db *gorm.DB, media *service.MediaService) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "camera", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var camera model.Camera
|
var camera model.Camera
|
||||||
if db.Where("id = ?", id).First(&camera).Error != nil {
|
if db.Where("id = ?", id).First(&camera).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
|
||||||
@@ -287,6 +311,9 @@ func playbackCamera(db *gorm.DB) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "camera", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
limit := 50
|
limit := 50
|
||||||
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 {
|
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 {
|
||||||
@@ -335,10 +362,10 @@ func getWvpConfig(media *service.MediaService) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"sipId": sip["id"],
|
"sipId": sip["id"],
|
||||||
"sipDomain": sip["domain"],
|
"sipDomain": sip["domain"],
|
||||||
"sipPort": sip["port"],
|
"sipPort": sip["port"],
|
||||||
"sipShowIp": sip["showIp"],
|
"sipShowIp": sip["showIp"],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func listClips(db *gorm.DB) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
q := db.Model(&model.VideoClip{}).Order("start_at DESC").Limit(limit)
|
q := applyRoomScope(db.Model(&model.VideoClip{}), c, "room_id").Order("start_at DESC").Limit(limit)
|
||||||
if cameraId := c.Query("cameraId"); cameraId != "" {
|
if cameraId := c.Query("cameraId"); cameraId != "" {
|
||||||
q = q.Where("camera_id = ?", cameraId)
|
q = q.Where("camera_id = ?", cameraId)
|
||||||
}
|
}
|
||||||
@@ -77,6 +77,9 @@ func playClip(db *gorm.DB) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clipId := c.Param("clipId")
|
clipId := c.Param("clipId")
|
||||||
|
if !requireObjectAccess(c, db, "video_clip", clipId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var clip model.VideoClip
|
var clip model.VideoClip
|
||||||
if db.Where("id = ?", clipId).First(&clip).Error != nil {
|
if db.Where("id = ?", clipId).First(&clip).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "video clip not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "video clip not found"})
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func RegisterVideoRecordRoutes(rg *gin.RouterGroup, db *gorm.DB, media *service.
|
|||||||
readPerm := middleware.RequirePermission(db, "video:read")
|
readPerm := middleware.RequirePermission(db, "video:read")
|
||||||
rg.POST("/video/cameras/:id/record/start", recordPerm, startRecording(db, media, cfg))
|
rg.POST("/video/cameras/:id/record/start", recordPerm, startRecording(db, media, cfg))
|
||||||
rg.POST("/video/cameras/:id/record/stop", recordPerm, stopRecording(db, media, cfg))
|
rg.POST("/video/cameras/:id/record/stop", recordPerm, stopRecording(db, media, cfg))
|
||||||
rg.GET("/video/recordings/active", readPerm, listActiveRecordings(cfg))
|
rg.GET("/video/recordings/active", readPerm, listActiveRecordings(db, cfg))
|
||||||
rg.POST("/video/recordings/internal/end", endRecordingInternal(media, cfg)) // 白名单接口,无需权限
|
rg.POST("/video/recordings/internal/end", endRecordingInternal(media, cfg)) // 白名单接口,无需权限
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +50,9 @@ func RegisterVideoRecordRoutes(rg *gin.RouterGroup, db *gorm.DB, media *service.
|
|||||||
func startRecording(db *gorm.DB, media *service.MediaService, cfg *config.Config) gin.HandlerFunc {
|
func startRecording(db *gorm.DB, media *service.MediaService, cfg *config.Config) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
cameraId := c.Param("id")
|
cameraId := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "camera", cameraId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var camera model.Camera
|
var camera model.Camera
|
||||||
if db.Where("id = ?", cameraId).First(&camera).Error != nil {
|
if db.Where("id = ?", cameraId).First(&camera).Error != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "camera not found"})
|
||||||
@@ -129,6 +132,9 @@ func startRecording(db *gorm.DB, media *service.MediaService, cfg *config.Config
|
|||||||
func stopRecording(db *gorm.DB, media *service.MediaService, cfg *config.Config) gin.HandlerFunc {
|
func stopRecording(db *gorm.DB, media *service.MediaService, cfg *config.Config) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
cameraId := c.Param("id")
|
cameraId := c.Param("id")
|
||||||
|
if !requireObjectAccess(c, db, "camera", cameraId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
activeMu.Lock()
|
activeMu.Lock()
|
||||||
rec, ok := activeRecordings[cameraId]
|
rec, ok := activeRecordings[cameraId]
|
||||||
@@ -217,7 +223,7 @@ func endRecordingInternal(media *service.MediaService, cfg *config.Config) gin.H
|
|||||||
|
|
||||||
// listActiveRecordings 返回活跃录制列表
|
// listActiveRecordings 返回活跃录制列表
|
||||||
// 优先以 recorder-go 服务的实际状态为准(Go 后端重启后内存 map 会丢失,但 recorder-go 仍在录制)
|
// 优先以 recorder-go 服务的实际状态为准(Go 后端重启后内存 map 会丢失,但 recorder-go 仍在录制)
|
||||||
func listActiveRecordings(cfg *config.Config) gin.HandlerFunc {
|
func listActiveRecordings(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
// 1. 查询 recorder-go 实际录制状态
|
// 1. 查询 recorder-go 实际录制状态
|
||||||
recorderBase := strings.TrimSuffix(cfg.RecorderAPIBase, "/")
|
recorderBase := strings.TrimSuffix(cfg.RecorderAPIBase, "/")
|
||||||
@@ -270,7 +276,7 @@ func listActiveRecordings(cfg *config.Config) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
activeMu.Unlock()
|
activeMu.Unlock()
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, list)
|
c.JSON(http.StatusOK, filterAccessibleRecordings(db, c, list))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,6 +289,23 @@ func listActiveRecordings(cfg *config.Config) gin.HandlerFunc {
|
|||||||
list = append(list, rec)
|
list = append(list, rec)
|
||||||
}
|
}
|
||||||
activeMu.Unlock()
|
activeMu.Unlock()
|
||||||
c.JSON(http.StatusOK, list)
|
c.JSON(http.StatusOK, filterAccessibleRecordings(db, c, list))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func filterAccessibleRecordings(db *gorm.DB, c *gin.Context, list []*ActiveRecording) []*ActiveRecording {
|
||||||
|
if hasGlobalAccess(c) {
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
filtered := make([]*ActiveRecording, 0, len(list))
|
||||||
|
for _, rec := range list {
|
||||||
|
var camera model.Camera
|
||||||
|
if db.Where("id = ?", rec.CameraID).First(&camera).Error != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if canAccessRoom(db, c, camera.RoomID) {
|
||||||
|
filtered = append(filtered, rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Organization 组织/养殖场/合作社,用于对象级数据授权。
|
||||||
|
type Organization struct {
|
||||||
|
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||||
|
Name string `gorm:"size:128" json:"name"`
|
||||||
|
Code string `gorm:"size:64;uniqueIndex" json:"code"`
|
||||||
|
Description *string `gorm:"type:text" json:"description,omitempty"`
|
||||||
|
Status string `gorm:"size:16;default:active" json:"status"`
|
||||||
|
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Organization) TableName() string { return "organizations" }
|
||||||
|
|
||||||
|
// OrganizationMember 用户与组织归属关系。
|
||||||
|
type OrganizationMember struct {
|
||||||
|
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||||
|
OrganizationID string `gorm:"column:organization_id;type:uuid;uniqueIndex:idx_org_members_org_user,priority:1" json:"organizationId"`
|
||||||
|
UserID string `gorm:"column:user_id;type:uuid;uniqueIndex:idx_org_members_org_user,priority:2" json:"userId"`
|
||||||
|
Role string `gorm:"size:16;default:member" json:"role"`
|
||||||
|
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (OrganizationMember) TableName() string { return "organization_members" }
|
||||||
|
|
||||||
|
// DeviceMaintenanceRecord 设备校准/故障/维护/固件记录。
|
||||||
|
type DeviceMaintenanceRecord struct {
|
||||||
|
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||||
|
DeviceID string `gorm:"column:device_id;type:uuid;index" json:"deviceId"`
|
||||||
|
Kind string `gorm:"size:16;index" json:"kind"` // calibration/fault/maintenance/firmware
|
||||||
|
Title string `gorm:"size:128" json:"title"`
|
||||||
|
ScheduledAt *time.Time `gorm:"column:scheduled_at;type:timestamptz" json:"scheduledAt,omitempty"`
|
||||||
|
PerformedAt *time.Time `gorm:"column:performed_at;type:timestamptz" json:"performedAt,omitempty"`
|
||||||
|
PerformerID *string `gorm:"column:performer_id;type:uuid" json:"performerId,omitempty"`
|
||||||
|
Result *string `gorm:"type:text" json:"result,omitempty"`
|
||||||
|
FirmwareFrom *string `gorm:"column:firmware_from;size:64" json:"firmwareFrom,omitempty"`
|
||||||
|
FirmwareTo *string `gorm:"column:firmware_to;size:64" json:"firmwareTo,omitempty"`
|
||||||
|
CostAmount *float64 `gorm:"column:cost_amount;type:float" json:"costAmount,omitempty"`
|
||||||
|
CostUnit *string `gorm:"column:cost_unit;size:16" json:"costUnit,omitempty"`
|
||||||
|
Note *string `gorm:"type:text" json:"note,omitempty"`
|
||||||
|
CreatedBy *string `gorm:"column:created_by;type:uuid" json:"createdBy,omitempty"`
|
||||||
|
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (DeviceMaintenanceRecord) TableName() string { return "device_maintenance_records" }
|
||||||
|
|
||||||
|
// ValidMaintenanceKind 设备维护记录类型。
|
||||||
|
func ValidMaintenanceKind(kind string) bool {
|
||||||
|
switch kind {
|
||||||
|
case "calibration", "fault", "maintenance", "firmware":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateDeviceMaintenanceRecord 设备维护必填项。
|
||||||
|
func ValidateDeviceMaintenanceRecord(record DeviceMaintenanceRecord) error {
|
||||||
|
if !ValidMaintenanceKind(record.Kind) {
|
||||||
|
return errors.New("kind 仅支持 calibration/fault/maintenance/firmware")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(record.Title) == "" {
|
||||||
|
return errors.New("维护标题不能为空")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductionLossRecord 产量、死亡、淘汰、损失和防控成本记录。
|
||||||
|
type ProductionLossRecord struct {
|
||||||
|
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||||
|
RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"`
|
||||||
|
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
|
||||||
|
RecordDate time.Time `gorm:"column:record_date;type:timestamptz;index" json:"recordDate"`
|
||||||
|
DeathCount *int `gorm:"column:death_count;type:int" json:"deathCount,omitempty"`
|
||||||
|
CulledCount *int `gorm:"column:culled_count;type:int" json:"culledCount,omitempty"`
|
||||||
|
YieldKg *float64 `gorm:"column:yield_kg;type:float" json:"yieldKg,omitempty"`
|
||||||
|
LossKg *float64 `gorm:"column:loss_kg;type:float" json:"lossKg,omitempty"`
|
||||||
|
CostType *string `gorm:"column:cost_type;size:32" json:"costType,omitempty"` // medicine/disinfection/detection/labor/other
|
||||||
|
CostAmount *float64 `gorm:"column:cost_amount;type:float" json:"costAmount,omitempty"`
|
||||||
|
Note *string `gorm:"type:text" json:"note,omitempty"`
|
||||||
|
CreatedBy *string `gorm:"column:created_by;type:uuid" json:"createdBy,omitempty"`
|
||||||
|
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ProductionLossRecord) TableName() string { return "production_loss_records" }
|
||||||
|
|
||||||
|
// ValidateProductionLossRecord 产量损失记录必须至少有一个业务数值。
|
||||||
|
func ValidateProductionLossRecord(record ProductionLossRecord) error {
|
||||||
|
if record.RoomID == nil && record.BatchID == nil {
|
||||||
|
return errors.New("roomId/batchId 至少填一个")
|
||||||
|
}
|
||||||
|
if record.RecordDate.IsZero() {
|
||||||
|
return errors.New("记录日期不能为空")
|
||||||
|
}
|
||||||
|
hasValue := record.DeathCount != nil || record.CulledCount != nil ||
|
||||||
|
record.YieldKg != nil || record.LossKg != nil || record.CostAmount != nil
|
||||||
|
if !hasValue {
|
||||||
|
return errors.New("死亡/淘汰/产量/损失/成本至少填写一项")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaseStudy 已脱敏病例沉淀,必须经审核后发布。
|
||||||
|
type CaseStudy struct {
|
||||||
|
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||||
|
Title string `gorm:"size:128" json:"title"`
|
||||||
|
Disease string `gorm:"size:64;index" json:"disease"`
|
||||||
|
SourceConsultationID *string `gorm:"column:source_consultation_id;type:uuid;index" json:"sourceConsultationId,omitempty"`
|
||||||
|
SourceDiseaseEventID *string `gorm:"column:source_disease_event_id;type:uuid;index" json:"sourceDiseaseEventId,omitempty"`
|
||||||
|
SourceRoomID *string `gorm:"column:source_room_id;type:uuid;index" json:"sourceRoomId,omitempty"`
|
||||||
|
Region *string `gorm:"size:64" json:"region,omitempty"`
|
||||||
|
CaseDate *time.Time `gorm:"column:case_date;type:timestamptz" json:"caseDate,omitempty"`
|
||||||
|
Summary *string `gorm:"type:text" json:"summary,omitempty"`
|
||||||
|
DesensitizedPayload json.RawMessage `gorm:"column:desensitized_payload;type:jsonb" json:"desensitizedPayload,omitempty"`
|
||||||
|
Status string `gorm:"size:16;default:draft;index" json:"status"` // draft/pending_review/published/rejected
|
||||||
|
ReviewNote *string `gorm:"column:review_note;type:text" json:"reviewNote,omitempty"`
|
||||||
|
ReviewerID *string `gorm:"column:reviewer_id;type:uuid" json:"reviewerId,omitempty"`
|
||||||
|
ReviewedAt *time.Time `gorm:"column:reviewed_at;type:timestamptz" json:"reviewedAt,omitempty"`
|
||||||
|
PublishedAt *time.Time `gorm:"column:published_at;type:timestamptz" json:"publishedAt,omitempty"`
|
||||||
|
CreatedBy *string `gorm:"column:created_by;type:uuid" json:"createdBy,omitempty"`
|
||||||
|
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CaseStudy) TableName() string { return "case_studies" }
|
||||||
|
|
||||||
|
// ValidCaseStudyTransition 案例审核状态流转。
|
||||||
|
func ValidCaseStudyTransition(from, to string) bool {
|
||||||
|
switch from {
|
||||||
|
case "draft":
|
||||||
|
return to == "pending_review" || to == "rejected"
|
||||||
|
case "pending_review":
|
||||||
|
return to == "published" || to == "rejected"
|
||||||
|
case "published":
|
||||||
|
return to == "rejected" || to == "pending_review"
|
||||||
|
case "rejected":
|
||||||
|
return to == "draft" || to == "pending_review"
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LaboratoryResult 实验室结构化结果,关联三级溯源、发病事件和样本。
|
||||||
|
type LaboratoryResult struct {
|
||||||
|
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||||
|
TraceRecordID *string `gorm:"column:trace_record_id;type:uuid;index" json:"traceRecordId,omitempty"`
|
||||||
|
DiseaseEventID *string `gorm:"column:disease_event_id;type:uuid;index" json:"diseaseEventId,omitempty"`
|
||||||
|
SampleID *string `gorm:"column:sample_id;type:uuid;index" json:"sampleId,omitempty"`
|
||||||
|
RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"`
|
||||||
|
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
|
||||||
|
LabName string `gorm:"column:lab_name;size:128" json:"labName"`
|
||||||
|
ReportNo string `gorm:"column:report_no;size:64" json:"reportNo"`
|
||||||
|
TestType string `gorm:"column:test_type;size:32" json:"testType"` // molecular_typing/pathogen/environment_sample/other
|
||||||
|
ResultType string `gorm:"column:result_type;size:16" json:"resultType"`
|
||||||
|
Pathogen *string `gorm:"size:128" json:"pathogen,omitempty"`
|
||||||
|
Genotype *string `gorm:"size:128" json:"genotype,omitempty"`
|
||||||
|
Method *string `gorm:"size:128" json:"method,omitempty"`
|
||||||
|
SampleNo *string `gorm:"column:sample_no;size:64" json:"sampleNo,omitempty"`
|
||||||
|
SampleType *string `gorm:"column:sample_type;size:32" json:"sampleType,omitempty"`
|
||||||
|
Findings *string `gorm:"type:text" json:"findings,omitempty"`
|
||||||
|
ReportURL *string `gorm:"column:report_url;size:512" json:"reportUrl,omitempty"`
|
||||||
|
TestedAt *time.Time `gorm:"column:tested_at;type:timestamptz" json:"testedAt,omitempty"`
|
||||||
|
ConcludedAt *time.Time `gorm:"column:concluded_at;type:timestamptz" json:"concludedAt,omitempty"`
|
||||||
|
CreatedBy *string `gorm:"column:created_by;type:uuid" json:"createdBy,omitempty"`
|
||||||
|
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (LaboratoryResult) TableName() string { return "laboratory_results" }
|
||||||
|
|
||||||
|
// ValidateLaboratoryResult 实验室结果必填项。
|
||||||
|
func ValidateLaboratoryResult(result LaboratoryResult) error {
|
||||||
|
if strings.TrimSpace(result.LabName) == "" {
|
||||||
|
return errors.New("实验室名称不能为空")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(result.ReportNo) == "" {
|
||||||
|
return errors.New("报告编号不能为空")
|
||||||
|
}
|
||||||
|
if !ValidLaboratoryTestType(result.TestType) {
|
||||||
|
return errors.New("testType 仅支持 molecular_typing/pathogen/environment_sample/other")
|
||||||
|
}
|
||||||
|
if !ValidLaboratoryResultType(result.ResultType) {
|
||||||
|
return errors.New("resultType 仅支持 positive/negative/indeterminate/invalid")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidLaboratoryTestType 实验室检测类型。
|
||||||
|
func ValidLaboratoryTestType(testType string) bool {
|
||||||
|
switch testType {
|
||||||
|
case "molecular_typing", "pathogen", "environment_sample", "other":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidLaboratoryResultType 实验室结果类型。
|
||||||
|
func ValidLaboratoryResultType(resultType string) bool {
|
||||||
|
switch resultType {
|
||||||
|
case "positive", "negative", "indeterminate", "invalid":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidMaintenanceKind(t *testing.T) {
|
||||||
|
for _, kind := range []string{"calibration", "fault", "maintenance", "firmware"} {
|
||||||
|
if !ValidMaintenanceKind(kind) {
|
||||||
|
t.Errorf("%s should be valid", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ValidMaintenanceKind("inspection") {
|
||||||
|
t.Error("inspection is not a maintenance kind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDeviceMaintenanceRecord(t *testing.T) {
|
||||||
|
record := DeviceMaintenanceRecord{Kind: "calibration", Title: "温度传感器校准"}
|
||||||
|
if err := ValidateDeviceMaintenanceRecord(record); err != nil {
|
||||||
|
t.Fatalf("valid record should pass: %v", err)
|
||||||
|
}
|
||||||
|
record.Title = ""
|
||||||
|
if err := ValidateDeviceMaintenanceRecord(record); err == nil {
|
||||||
|
t.Fatal("missing title should fail")
|
||||||
|
}
|
||||||
|
record.Title = "校准"
|
||||||
|
record.Kind = "unknown"
|
||||||
|
if err := ValidateDeviceMaintenanceRecord(record); err == nil {
|
||||||
|
t.Fatal("invalid kind should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateProductionLossRecord(t *testing.T) {
|
||||||
|
roomID := "room-1"
|
||||||
|
yield := 3.2
|
||||||
|
record := ProductionLossRecord{RoomID: &roomID, RecordDate: time.Now(), YieldKg: &yield}
|
||||||
|
if err := ValidateProductionLossRecord(record); err != nil {
|
||||||
|
t.Fatalf("valid record should pass: %v", err)
|
||||||
|
}
|
||||||
|
record.YieldKg = nil
|
||||||
|
if err := ValidateProductionLossRecord(record); err == nil {
|
||||||
|
t.Fatal("record without business value should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidCaseStudyTransition(t *testing.T) {
|
||||||
|
ok := [][2]string{
|
||||||
|
{"draft", "pending_review"},
|
||||||
|
{"pending_review", "published"},
|
||||||
|
{"pending_review", "rejected"},
|
||||||
|
{"published", "rejected"},
|
||||||
|
}
|
||||||
|
for _, tr := range ok {
|
||||||
|
if !ValidCaseStudyTransition(tr[0], tr[1]) {
|
||||||
|
t.Errorf("expected %s -> %s", tr[0], tr[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ValidCaseStudyTransition("draft", "published") {
|
||||||
|
t.Error("draft cannot jump to published")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLaboratoryResult(t *testing.T) {
|
||||||
|
result := LaboratoryResult{
|
||||||
|
LabName: "省蚕科所", ReportNo: "LAB-001",
|
||||||
|
TestType: "molecular_typing", ResultType: "positive",
|
||||||
|
}
|
||||||
|
if err := ValidateLaboratoryResult(result); err != nil {
|
||||||
|
t.Fatalf("valid result should pass: %v", err)
|
||||||
|
}
|
||||||
|
result.ResultType = "unknown"
|
||||||
|
if err := ValidateLaboratoryResult(result); err == nil {
|
||||||
|
t.Fatal("invalid result type should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ type Room struct {
|
|||||||
Capacity *int `gorm:"type:int" json:"capacity,omitempty"`
|
Capacity *int `gorm:"type:int" json:"capacity,omitempty"`
|
||||||
Stage *string `gorm:"size:32" json:"stage,omitempty"`
|
Stage *string `gorm:"size:32" json:"stage,omitempty"`
|
||||||
Region *string `gorm:"size:64" json:"region,omitempty"`
|
Region *string `gorm:"size:64" json:"region,omitempty"`
|
||||||
|
OrgID *string `gorm:"column:org_id;type:uuid;index" json:"orgId,omitempty"`
|
||||||
Status string `gorm:"default:active" json:"status"`
|
Status string `gorm:"default:active" json:"status"`
|
||||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||||
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||||
@@ -109,38 +110,38 @@ func (Alarm) TableName() string { return "alarms" }
|
|||||||
|
|
||||||
// Camera 摄像头表
|
// Camera 摄像头表
|
||||||
type Camera struct {
|
type Camera struct {
|
||||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
RoomID *string `gorm:"column:room_id;index" json:"roomId,omitempty"`
|
RoomID *string `gorm:"column:room_id;index" json:"roomId,omitempty"`
|
||||||
Code string `gorm:"size:64" json:"code"`
|
Code string `gorm:"size:64" json:"code"`
|
||||||
Name string `gorm:"size:128" json:"name"`
|
Name string `gorm:"size:128" json:"name"`
|
||||||
RtspURL *string `gorm:"column:rtsp_url;size:512" json:"rtspUrl,omitempty"`
|
RtspURL *string `gorm:"column:rtsp_url;size:512" json:"rtspUrl,omitempty"`
|
||||||
HTTPURL *string `gorm:"column:http_url;size:512" json:"httpUrl,omitempty"`
|
HTTPURL *string `gorm:"column:http_url;size:512" json:"httpUrl,omitempty"`
|
||||||
Username *string `gorm:"size:64" json:"username,omitempty"`
|
Username *string `gorm:"size:64" json:"username,omitempty"`
|
||||||
PasswordEnc *string `gorm:"column:password_enc;size:255" json:"-"`
|
PasswordEnc *string `gorm:"column:password_enc;size:255" json:"-"`
|
||||||
Position *string `gorm:"size:255" json:"position,omitempty"`
|
Position *string `gorm:"size:255" json:"position,omitempty"`
|
||||||
Resolution *string `gorm:"size:32" json:"resolution,omitempty"`
|
Resolution *string `gorm:"size:32" json:"resolution,omitempty"`
|
||||||
FPS *int `gorm:"type:int" json:"fps,omitempty"`
|
FPS *int `gorm:"type:int" json:"fps,omitempty"`
|
||||||
IsOnline bool `gorm:"column:is_online;default:true" json:"isOnline"`
|
IsOnline bool `gorm:"column:is_online;default:true" json:"isOnline"`
|
||||||
GbDeviceID *string `gorm:"column:gb_device_id;size:20" json:"gbDeviceId,omitempty"`
|
GbDeviceID *string `gorm:"column:gb_device_id;size:20" json:"gbDeviceId,omitempty"`
|
||||||
GbChannelID *string `gorm:"column:gb_channel_id;size:20" json:"gbChannelId,omitempty"`
|
GbChannelID *string `gorm:"column:gb_channel_id;size:20" json:"gbChannelId,omitempty"`
|
||||||
GbAuthID *string `gorm:"column:gb_auth_id;size:20" json:"gbAuthId,omitempty"`
|
GbAuthID *string `gorm:"column:gb_auth_id;size:20" json:"gbAuthId,omitempty"`
|
||||||
GbAuthPassword *string `gorm:"column:gb_auth_password;size:255" json:"-"`
|
GbAuthPassword *string `gorm:"column:gb_auth_password;size:255" json:"-"`
|
||||||
GbStreamType *string `gorm:"column:gb_stream_type;size:10" json:"gbStreamType,omitempty"`
|
GbStreamType *string `gorm:"column:gb_stream_type;size:10" json:"gbStreamType,omitempty"`
|
||||||
GbTransport *string `gorm:"column:gb_transport;size:10" json:"gbTransport,omitempty"`
|
GbTransport *string `gorm:"column:gb_transport;size:10" json:"gbTransport,omitempty"`
|
||||||
GbAlarmChannelID *string `gorm:"column:gb_alarm_channel_id;size:20" json:"gbAlarmChannelId,omitempty"`
|
GbAlarmChannelID *string `gorm:"column:gb_alarm_channel_id;size:20" json:"gbAlarmChannelId,omitempty"`
|
||||||
GbVoiceChannelID *string `gorm:"column:gb_voice_channel_id;size:20" json:"gbVoiceChannelId,omitempty"`
|
GbVoiceChannelID *string `gorm:"column:gb_voice_channel_id;size:20" json:"gbVoiceChannelId,omitempty"`
|
||||||
GbManufacturer *string `gorm:"column:gb_manufacturer;size:64" json:"gbManufacturer,omitempty"`
|
GbManufacturer *string `gorm:"column:gb_manufacturer;size:64" json:"gbManufacturer,omitempty"`
|
||||||
ManufacturerID *string `gorm:"column:manufacturer_id;size:64" json:"manufacturerId,omitempty"`
|
ManufacturerID *string `gorm:"column:manufacturer_id;size:64" json:"manufacturerId,omitempty"`
|
||||||
// 以下字段不在数据库中,运行时通过 WVP API 填充
|
// 以下字段不在数据库中,运行时通过 WVP API 填充
|
||||||
StreamURL *string `gorm:"-" json:"streamUrl,omitempty"`
|
StreamURL *string `gorm:"-" json:"streamUrl,omitempty"`
|
||||||
HlsURL *string `gorm:"-" json:"hlsUrl,omitempty"`
|
HlsURL *string `gorm:"-" json:"hlsUrl,omitempty"`
|
||||||
FlvURL *string `gorm:"-" json:"flvUrl,omitempty"`
|
FlvURL *string `gorm:"-" json:"flvUrl,omitempty"`
|
||||||
WebrtcURL *string `gorm:"-" json:"webrtcUrl,omitempty"`
|
WebrtcURL *string `gorm:"-" json:"webrtcUrl,omitempty"`
|
||||||
SnapshotURL *string `gorm:"-" json:"snapshotUrl,omitempty"`
|
SnapshotURL *string `gorm:"-" json:"snapshotUrl,omitempty"`
|
||||||
Online bool `gorm:"-" json:"online"`
|
Online bool `gorm:"-" json:"online"`
|
||||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||||
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Camera) TableName() string { return "cameras" }
|
func (Camera) TableName() string { return "cameras" }
|
||||||
@@ -167,8 +168,8 @@ type VideoClip struct {
|
|||||||
AlarmID *string `gorm:"column:alarm_id;index" json:"alarmId,omitempty"`
|
AlarmID *string `gorm:"column:alarm_id;index" json:"alarmId,omitempty"`
|
||||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||||
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||||
S3Bucket *string `gorm:"column:s3_bucket" json:"s3Bucket,omitempty"`
|
S3Bucket *string `gorm:"column:s3_bucket" json:"s3Bucket,omitempty"`
|
||||||
S3Key *string `gorm:"column:s3_key" json:"s3Key,omitempty"`
|
S3Key *string `gorm:"column:s3_key" json:"s3Key,omitempty"`
|
||||||
// 以下字段不在数据库中,运行时填充
|
// 以下字段不在数据库中,运行时填充
|
||||||
PlaybackURL *string `gorm:"-" json:"playbackUrl,omitempty"`
|
PlaybackURL *string `gorm:"-" json:"playbackUrl,omitempty"`
|
||||||
Format string `gorm:"-" json:"format"`
|
Format string `gorm:"-" json:"format"`
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ var AllPermissions = []PermissionDef{
|
|||||||
{"biosecurity:write", "生物安全管理", "维护种源、消毒记录并签发二维码"},
|
{"biosecurity:write", "生物安全管理", "维护种源、消毒记录并签发二维码"},
|
||||||
{"user:manage", "用户管理", "管理用户、角色和权限"},
|
{"user:manage", "用户管理", "管理用户、角色和权限"},
|
||||||
{"audit:read", "审计查看", "查看审计日志"},
|
{"audit:read", "审计查看", "查看审计日志"},
|
||||||
|
{"organization:manage", "组织管理", "维护组织及成员数据范围"},
|
||||||
|
{"device:write", "设备管理", "维护设备资产、校准和固件信息"},
|
||||||
|
{"farm:read", "产量损失查看", "查看产量、死亡、淘汰和成本记录"},
|
||||||
|
{"farm:write", "产量损失管理", "新增、编辑产量和损失记录"},
|
||||||
|
{"case:read", "案例查看", "查看已脱敏案例库"},
|
||||||
|
{"case:write", "案例管理", "创建、审核和发布脱敏案例"},
|
||||||
|
{"lab:read", "实验室结果查看", "查看实验室结构化结果"},
|
||||||
|
{"lab:write", "实验室结果管理", "录入实验室结果和分子分型"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// RolePermissionMap 角色-权限码映射(种子数据)
|
// RolePermissionMap 角色-权限码映射(种子数据)
|
||||||
@@ -66,6 +74,8 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"trace:read", "trace:write",
|
"trace:read", "trace:write",
|
||||||
"biosecurity:read", "biosecurity:write",
|
"biosecurity:read", "biosecurity:write",
|
||||||
"user:manage", "audit:read",
|
"user:manage", "audit:read",
|
||||||
|
"organization:manage", "device:write",
|
||||||
|
"farm:read", "farm:write", "case:read", "case:write", "lab:read", "lab:write",
|
||||||
},
|
},
|
||||||
RoleOperator: {
|
RoleOperator: {
|
||||||
"dashboard:view", "room:read", "room:write", "device:read", "device:control",
|
"dashboard:view", "room:read", "room:write", "device:read", "device:control",
|
||||||
@@ -81,6 +91,7 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"consultation:read", "consultation:write",
|
"consultation:read", "consultation:write",
|
||||||
"trace:read", "trace:write",
|
"trace:read", "trace:write",
|
||||||
"biosecurity:read", "biosecurity:write",
|
"biosecurity:read", "biosecurity:write",
|
||||||
|
"device:write", "farm:read", "farm:write", "case:read", "case:write", "lab:read", "lab:write",
|
||||||
},
|
},
|
||||||
RoleViewer: {
|
RoleViewer: {
|
||||||
"dashboard:view", "room:read", "device:read",
|
"dashboard:view", "room:read", "device:read",
|
||||||
@@ -95,6 +106,7 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"consultation:read",
|
"consultation:read",
|
||||||
"trace:read",
|
"trace:read",
|
||||||
"biosecurity:read",
|
"biosecurity:read",
|
||||||
|
"farm:read", "case:read", "lab:read",
|
||||||
},
|
},
|
||||||
RoleFarmer: {
|
RoleFarmer: {
|
||||||
"dashboard:view", "room:read", "device:read", "device:control",
|
"dashboard:view", "room:read", "device:read", "device:control",
|
||||||
@@ -109,5 +121,6 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"consultation:read",
|
"consultation:read",
|
||||||
"trace:read",
|
"trace:read",
|
||||||
"biosecurity:read", "biosecurity:write",
|
"biosecurity:read", "biosecurity:write",
|
||||||
|
"farm:read", "farm:write", "case:read", "lab:read",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ type RearingRecord struct {
|
|||||||
Instar *int `gorm:"type:int" json:"instar,omitempty"`
|
Instar *int `gorm:"type:int" json:"instar,omitempty"`
|
||||||
MulberrySource *string `gorm:"column:mulberry_source;size:128" json:"mulberrySource,omitempty"` // 桑叶来源
|
MulberrySource *string `gorm:"column:mulberry_source;size:128" json:"mulberrySource,omitempty"` // 桑叶来源
|
||||||
Density *string `gorm:"size:64" json:"density,omitempty"` // 饲养密度
|
Density *string `gorm:"size:64" json:"density,omitempty"` // 饲养密度
|
||||||
|
DeathCount *int `gorm:"column:death_count;type:int" json:"deathCount,omitempty"`
|
||||||
|
CulledCount *int `gorm:"column:culled_count;type:int" json:"culledCount,omitempty"`
|
||||||
Note *string `gorm:"type:text" json:"note,omitempty"`
|
Note *string `gorm:"type:text" json:"note,omitempty"`
|
||||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||||
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
// ProductionLossRow 产量损失统计原始行。
|
||||||
|
type ProductionLossRow struct {
|
||||||
|
BatchID string `gorm:"column:batch_id"`
|
||||||
|
RoomID string `gorm:"column:room_id"`
|
||||||
|
DeathCount int64 `gorm:"column:death_count"`
|
||||||
|
CulledCount int64 `gorm:"column:culled_count"`
|
||||||
|
YieldKg float64 `gorm:"column:yield_kg"`
|
||||||
|
LossKg float64 `gorm:"column:loss_kg"`
|
||||||
|
CostAmount float64 `gorm:"column:cost_amount"`
|
||||||
|
RecordCount int64 `gorm:"column:record_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductionLossStat 按房间/批次聚合后的产量损失统计。
|
||||||
|
type ProductionLossStat struct {
|
||||||
|
BatchID string `json:"batchId"`
|
||||||
|
RoomID string `json:"roomId"`
|
||||||
|
RoomName string `json:"roomName,omitempty"`
|
||||||
|
BatchName string `json:"batchName,omitempty"`
|
||||||
|
DeathCount int64 `json:"deathCount"`
|
||||||
|
CulledCount int64 `json:"culledCount"`
|
||||||
|
YieldKg float64 `json:"yieldKg"`
|
||||||
|
LossKg float64 `json:"lossKg"`
|
||||||
|
CostAmount float64 `json:"costAmount"`
|
||||||
|
RecordCount int64 `json:"recordCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AggregateProductionLoss 聚合产量损失记录;空输入返回空数组。
|
||||||
|
func AggregateProductionLoss(rows []ProductionLossRow) []ProductionLossStat {
|
||||||
|
byKey := make(map[string]*ProductionLossStat)
|
||||||
|
var order []string
|
||||||
|
for _, r := range rows {
|
||||||
|
key := r.RoomID + "|" + r.BatchID
|
||||||
|
stat, ok := byKey[key]
|
||||||
|
if !ok {
|
||||||
|
stat = &ProductionLossStat{RoomID: r.RoomID, BatchID: r.BatchID}
|
||||||
|
byKey[key] = stat
|
||||||
|
order = append(order, key)
|
||||||
|
}
|
||||||
|
stat.DeathCount += r.DeathCount
|
||||||
|
stat.CulledCount += r.CulledCount
|
||||||
|
stat.YieldKg += r.YieldKg
|
||||||
|
stat.LossKg += r.LossKg
|
||||||
|
stat.CostAmount += r.CostAmount
|
||||||
|
stat.RecordCount += r.RecordCount
|
||||||
|
}
|
||||||
|
result := make([]ProductionLossStat, 0, len(order))
|
||||||
|
for _, key := range order {
|
||||||
|
result = append(result, *byKey[key])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestAggregateProductionLoss(t *testing.T) {
|
||||||
|
rows := []ProductionLossRow{
|
||||||
|
{RoomID: "room-a", DeathCount: 10, CulledCount: 2, YieldKg: 5, LossKg: 1, CostAmount: 20, RecordCount: 1},
|
||||||
|
{RoomID: "room-a", DeathCount: 3, CulledCount: 1, YieldKg: 2, LossKg: 0.5, CostAmount: 30, RecordCount: 1},
|
||||||
|
}
|
||||||
|
stats := AggregateProductionLoss(rows)
|
||||||
|
if len(stats) != 1 {
|
||||||
|
t.Fatalf("stats len = %d, want 1", len(stats))
|
||||||
|
}
|
||||||
|
if stats[0].DeathCount != 13 || stats[0].CulledCount != 3 || stats[0].CostAmount != 50 {
|
||||||
|
t.Fatalf("unexpected aggregate: %+v", stats[0])
|
||||||
|
}
|
||||||
|
if stats[0].RecordCount != 2 {
|
||||||
|
t.Fatalf("record count = %d, want 2", stats[0].RecordCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAggregateProductionLossEmpty(t *testing.T) {
|
||||||
|
if stats := AggregateProductionLoss(nil); len(stats) != 0 {
|
||||||
|
t.Fatalf("empty input should return empty, got %d", len(stats))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ type DeviceAuthorizer interface {
|
|||||||
CanReadDevice(userID, deviceKey string) (bool, error)
|
CanReadDevice(userID, deviceKey string) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DBDeviceAuthorizer 使用现有 RBAC 权限判断设备读取权;当前未做用户级资源 ACL。
|
// DBDeviceAuthorizer 使用 RBAC + 组织/房间归属判断设备读取权。
|
||||||
type DBDeviceAuthorizer struct {
|
type DBDeviceAuthorizer struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
@@ -36,12 +36,28 @@ func (a *DBDeviceAuthorizer) CanReadDevice(userID, deviceKey string) (bool, erro
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var count int64
|
var roleCount int64
|
||||||
err := a.db.Table("role_permissions").
|
err := a.db.Table("role_permissions").
|
||||||
Joins("JOIN permissions ON permissions.id = role_permissions.permission_id").
|
Joins("JOIN permissions ON permissions.id = role_permissions.permission_id").
|
||||||
Where("role_permissions.role = ? AND permissions.code = ?", user.Role, "device:read").
|
Where("role_permissions.role = ? AND permissions.code = ?", user.Role, "device:read").
|
||||||
Count(&count).Error
|
Count(&roleCount).Error
|
||||||
return count > 0, err
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if roleCount == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var device model.Device
|
||||||
|
if err := a.db.Where("device_key = ?", deviceKey).First(&device).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
var roomCount int64
|
||||||
|
err = a.db.Table("rooms").
|
||||||
|
Joins("JOIN organization_members om ON om.organization_id = rooms.org_id").
|
||||||
|
Where("rooms.id = ? AND om.user_id = ?", device.RoomID, userID).
|
||||||
|
Count(&roomCount).Error
|
||||||
|
return roomCount > 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) authorizeSubscription(userID, deviceKey string) error {
|
func (h *Hub) authorizeSubscription(userID, deviceKey string) error {
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDBDeviceAuthorizerRequiresOrganizationMembership(t *testing.T) {
|
||||||
|
sqlDB, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
db, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open gorm: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mock.ExpectQuery(`.*FROM "users".*WHERE id = \$1.*`).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "username", "email", "password_hash", "full_name", "role", "active", "created_at", "updated_at"}).
|
||||||
|
AddRow("user-1", "viewer", "viewer@example.com", "hash", nil, "viewer", true, time.Now(), time.Now()))
|
||||||
|
mock.ExpectQuery(`.*role_permissions.*permissions.*`).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||||
|
mock.ExpectQuery(`.*FROM "devices".*WHERE device_key = \$1.*`).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "device_key", "name", "kind", "model", "firmware", "online_status", "last_seen", "room_id", "topic", "created_at", "updated_at"}).
|
||||||
|
AddRow("device-1", "device-a", "温度设备", "sensor", nil, nil, "online", nil, "room-1", nil, time.Now(), time.Now()))
|
||||||
|
mock.ExpectQuery(`.*organization_members.*`).
|
||||||
|
WithArgs("room-1", "user-1").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||||
|
|
||||||
|
authorizer := NewDBDeviceAuthorizer(db)
|
||||||
|
ok, err := authorizer.CanReadDevice("user-1", "device-a")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CanReadDevice error: %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("member should read device in same organization")
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("unmet sql expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDBDeviceAuthorizerDeniesForeignRoom(t *testing.T) {
|
||||||
|
sqlDB, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
db, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open gorm: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mock.ExpectQuery(`.*FROM "users".*WHERE id = \$1.*`).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "username", "email", "password_hash", "full_name", "role", "active", "created_at", "updated_at"}).
|
||||||
|
AddRow("user-1", "viewer", "viewer@example.com", "hash", nil, "viewer", true, time.Now(), time.Now()))
|
||||||
|
mock.ExpectQuery(`.*role_permissions.*permissions.*`).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||||
|
mock.ExpectQuery(`.*FROM "devices".*WHERE device_key = \$1.*`).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "device_key", "name", "kind", "model", "firmware", "online_status", "last_seen", "room_id", "topic", "created_at", "updated_at"}).
|
||||||
|
AddRow("device-1", "device-a", "温度设备", "sensor", nil, nil, "online", nil, "room-1", nil, time.Now(), time.Now()))
|
||||||
|
mock.ExpectQuery(`.*organization_members.*`).
|
||||||
|
WithArgs("room-1", "user-1").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||||
|
|
||||||
|
authorizer := NewDBDeviceAuthorizer(db)
|
||||||
|
ok, err := authorizer.CanReadDevice("user-1", "device-a")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CanReadDevice error: %v", err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
t.Fatal("user in another organization should not read device")
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("unmet sql expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
ALTER TABLE rearing_records
|
||||||
|
DROP COLUMN IF EXISTS culled_count,
|
||||||
|
DROP COLUMN IF EXISTS death_count;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS laboratory_results CASCADE;
|
||||||
|
DROP TABLE IF EXISTS case_studies CASCADE;
|
||||||
|
DROP TABLE IF EXISTS production_loss_records CASCADE;
|
||||||
|
DROP TABLE IF EXISTS device_maintenance_records CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE rooms
|
||||||
|
DROP COLUMN IF EXISTS org_id;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS organization_members CASCADE;
|
||||||
|
DROP TABLE IF EXISTS organizations CASCADE;
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS organizations (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name varchar(128) NOT NULL,
|
||||||
|
code varchar(64) NOT NULL,
|
||||||
|
description text,
|
||||||
|
status varchar(16) NOT NULL DEFAULT 'active',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_organizations_code ON organizations (code);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS organization_members (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id uuid NOT NULL,
|
||||||
|
user_id uuid NOT NULL,
|
||||||
|
role varchar(16) NOT NULL DEFAULT 'member',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_org_members_org_user
|
||||||
|
ON organization_members (organization_id, user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_org_members_user_id ON organization_members (user_id);
|
||||||
|
|
||||||
|
ALTER TABLE rooms
|
||||||
|
ADD COLUMN IF NOT EXISTS org_id uuid;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rooms_org_id ON rooms (org_id);
|
||||||
|
|
||||||
|
INSERT INTO organizations (name, code, description, status)
|
||||||
|
SELECT '默认组织', 'default', '系统升级时创建的默认组织,用于兼容历史数据', 'active'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM organizations WHERE code = 'default');
|
||||||
|
|
||||||
|
UPDATE rooms
|
||||||
|
SET org_id = (SELECT id FROM organizations WHERE code = 'default')
|
||||||
|
WHERE org_id IS NULL;
|
||||||
|
|
||||||
|
INSERT INTO organization_members (organization_id, user_id, role)
|
||||||
|
SELECT o.id, u.id, 'member'
|
||||||
|
FROM organizations o, users u
|
||||||
|
WHERE o.code = 'default'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM organization_members om
|
||||||
|
WHERE om.organization_id = o.id AND om.user_id = u.id
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS device_maintenance_records (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
device_id uuid NOT NULL,
|
||||||
|
kind varchar(16) NOT NULL,
|
||||||
|
title varchar(128) NOT NULL,
|
||||||
|
scheduled_at timestamptz,
|
||||||
|
performed_at timestamptz,
|
||||||
|
performer_id uuid,
|
||||||
|
result text,
|
||||||
|
firmware_from varchar(64),
|
||||||
|
firmware_to varchar(64),
|
||||||
|
cost_amount double precision,
|
||||||
|
cost_unit varchar(16),
|
||||||
|
note text,
|
||||||
|
created_by uuid,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_device_maintenance_device_id
|
||||||
|
ON device_maintenance_records (device_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_device_maintenance_kind
|
||||||
|
ON device_maintenance_records (kind);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS production_loss_records (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
room_id uuid,
|
||||||
|
batch_id uuid,
|
||||||
|
record_date timestamptz NOT NULL,
|
||||||
|
death_count integer,
|
||||||
|
culled_count integer,
|
||||||
|
yield_kg double precision,
|
||||||
|
loss_kg double precision,
|
||||||
|
cost_type varchar(32),
|
||||||
|
cost_amount double precision,
|
||||||
|
note text,
|
||||||
|
created_by uuid,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_production_loss_room_date
|
||||||
|
ON production_loss_records (room_id, record_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_production_loss_batch_date
|
||||||
|
ON production_loss_records (batch_id, record_date);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS case_studies (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
title varchar(128) NOT NULL,
|
||||||
|
disease varchar(64) NOT NULL,
|
||||||
|
source_consultation_id uuid,
|
||||||
|
source_disease_event_id uuid,
|
||||||
|
source_room_id uuid,
|
||||||
|
region varchar(64),
|
||||||
|
case_date timestamptz,
|
||||||
|
summary text,
|
||||||
|
desensitized_payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
status varchar(16) NOT NULL DEFAULT 'draft',
|
||||||
|
review_note text,
|
||||||
|
reviewer_id uuid,
|
||||||
|
reviewed_at timestamptz,
|
||||||
|
published_at timestamptz,
|
||||||
|
created_by uuid,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_case_studies_disease ON case_studies (disease);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_case_studies_status ON case_studies (status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_case_studies_source_consultation
|
||||||
|
ON case_studies (source_consultation_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS laboratory_results (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
trace_record_id uuid,
|
||||||
|
disease_event_id uuid,
|
||||||
|
sample_id uuid,
|
||||||
|
room_id uuid,
|
||||||
|
batch_id uuid,
|
||||||
|
lab_name varchar(128) NOT NULL,
|
||||||
|
report_no varchar(64) NOT NULL,
|
||||||
|
test_type varchar(32) NOT NULL,
|
||||||
|
result_type varchar(16) NOT NULL,
|
||||||
|
pathogen varchar(128),
|
||||||
|
genotype varchar(128),
|
||||||
|
method varchar(128),
|
||||||
|
sample_no varchar(64),
|
||||||
|
sample_type varchar(32),
|
||||||
|
findings text,
|
||||||
|
report_url varchar(512),
|
||||||
|
tested_at timestamptz,
|
||||||
|
concluded_at timestamptz,
|
||||||
|
created_by uuid,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_laboratory_results_trace_record
|
||||||
|
ON laboratory_results (trace_record_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_laboratory_results_disease_event
|
||||||
|
ON laboratory_results (disease_event_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_laboratory_results_room_date
|
||||||
|
ON laboratory_results (room_id, tested_at);
|
||||||
|
|
||||||
|
ALTER TABLE rearing_records
|
||||||
|
ADD COLUMN IF NOT EXISTS death_count integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS culled_count integer;
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { get, post, patch, del } from '../api/http';
|
||||||
|
|
||||||
|
export interface DeviceMaintenanceRecord {
|
||||||
|
id: string;
|
||||||
|
deviceId: string;
|
||||||
|
kind: 'calibration' | 'fault' | 'maintenance' | 'firmware';
|
||||||
|
title: string;
|
||||||
|
scheduledAt?: string;
|
||||||
|
performedAt?: string;
|
||||||
|
performerId?: string;
|
||||||
|
result?: string;
|
||||||
|
firmwareFrom?: string;
|
||||||
|
firmwareTo?: string;
|
||||||
|
costAmount?: number;
|
||||||
|
costUnit?: string;
|
||||||
|
note?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductionLossRecord {
|
||||||
|
id: string;
|
||||||
|
roomId?: string;
|
||||||
|
batchId?: string;
|
||||||
|
recordDate: string;
|
||||||
|
deathCount?: number;
|
||||||
|
culledCount?: number;
|
||||||
|
yieldKg?: number;
|
||||||
|
lossKg?: number;
|
||||||
|
costType?: 'medicine' | 'disinfection' | 'detection' | 'labor' | 'other';
|
||||||
|
costAmount?: number;
|
||||||
|
note?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductionLossStat {
|
||||||
|
roomId: string;
|
||||||
|
roomName?: string;
|
||||||
|
batchId: string;
|
||||||
|
batchName?: string;
|
||||||
|
deathCount: number;
|
||||||
|
culledCount: number;
|
||||||
|
yieldKg: number;
|
||||||
|
lossKg: number;
|
||||||
|
costAmount: number;
|
||||||
|
recordCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaseStudy {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
disease: string;
|
||||||
|
sourceConsultationId?: string;
|
||||||
|
sourceDiseaseEventId?: string;
|
||||||
|
sourceRoomId?: string;
|
||||||
|
region?: string;
|
||||||
|
caseDate?: string;
|
||||||
|
summary?: string;
|
||||||
|
desensitizedPayload?: Record<string, unknown>;
|
||||||
|
status: 'draft' | 'pending_review' | 'published' | 'rejected';
|
||||||
|
reviewNote?: string;
|
||||||
|
reviewedAt?: string;
|
||||||
|
publishedAt?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LaboratoryResult {
|
||||||
|
id: string;
|
||||||
|
traceRecordId?: string;
|
||||||
|
diseaseEventId?: string;
|
||||||
|
sampleId?: string;
|
||||||
|
roomId?: string;
|
||||||
|
batchId?: string;
|
||||||
|
labName: string;
|
||||||
|
reportNo: string;
|
||||||
|
testType: 'molecular_typing' | 'pathogen' | 'environment_sample' | 'other';
|
||||||
|
resultType: 'positive' | 'negative' | 'indeterminate' | 'invalid';
|
||||||
|
pathogen?: string;
|
||||||
|
genotype?: string;
|
||||||
|
method?: string;
|
||||||
|
sampleNo?: string;
|
||||||
|
sampleType?: string;
|
||||||
|
findings?: string;
|
||||||
|
reportUrl?: string;
|
||||||
|
testedAt?: string;
|
||||||
|
concludedAt?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listDeviceMaintenance = (params?: any) =>
|
||||||
|
get<DeviceMaintenanceRecord[]>('/device-maintenance', { params });
|
||||||
|
export const createDeviceMaintenance = (data: Partial<DeviceMaintenanceRecord>) =>
|
||||||
|
post<DeviceMaintenanceRecord>('/device-maintenance', data);
|
||||||
|
export const updateDeviceMaintenance = (id: string, data: Partial<DeviceMaintenanceRecord>) =>
|
||||||
|
patch<DeviceMaintenanceRecord>(`/device-maintenance/${id}`, data);
|
||||||
|
export const deleteDeviceMaintenance = (id: string) =>
|
||||||
|
del<{ id: string }>(`/device-maintenance/${id}`);
|
||||||
|
|
||||||
|
export const listProductionLossRecords = (params?: any) =>
|
||||||
|
get<ProductionLossRecord[]>('/production-loss-records', { params });
|
||||||
|
export const getProductionLossStats = (params?: any) =>
|
||||||
|
get<ProductionLossStat[]>('/production-loss-records/stats', { params });
|
||||||
|
export const createProductionLossRecord = (data: Partial<ProductionLossRecord>) =>
|
||||||
|
post<ProductionLossRecord>('/production-loss-records', data);
|
||||||
|
export const updateProductionLossRecord = (id: string, data: Partial<ProductionLossRecord>) =>
|
||||||
|
patch<ProductionLossRecord>(`/production-loss-records/${id}`, data);
|
||||||
|
export const deleteProductionLossRecord = (id: string) =>
|
||||||
|
del<{ id: string }>(`/production-loss-records/${id}`);
|
||||||
|
|
||||||
|
export const listCaseStudies = (params?: any) =>
|
||||||
|
get<CaseStudy[]>('/case-studies', { params });
|
||||||
|
export const createCaseStudy = (data: Partial<CaseStudy>) =>
|
||||||
|
post<CaseStudy>('/case-studies', data);
|
||||||
|
export const createCaseStudyFromConsultation = (id: string) =>
|
||||||
|
post<CaseStudy>(`/case-studies/from-consultation/${id}`);
|
||||||
|
export const updateCaseStudy = (id: string, data: Partial<CaseStudy>) =>
|
||||||
|
patch<CaseStudy>(`/case-studies/${id}`, data);
|
||||||
|
export const reviewCaseStudy = (id: string, data: { status: string; note?: string }) =>
|
||||||
|
post<CaseStudy>(`/case-studies/${id}/review`, data);
|
||||||
|
|
||||||
|
export const listLaboratoryResults = (params?: any) =>
|
||||||
|
get<LaboratoryResult[]>('/laboratory-results', { params });
|
||||||
|
export const createLaboratoryResult = (data: Partial<LaboratoryResult>) =>
|
||||||
|
post<LaboratoryResult>('/laboratory-results', data);
|
||||||
|
export const updateLaboratoryResult = (id: string, data: Partial<LaboratoryResult>) =>
|
||||||
|
patch<LaboratoryResult>(`/laboratory-results/${id}`, data);
|
||||||
|
export const deleteLaboratoryResult = (id: string) => del<{ id: string }>(`/laboratory-results/${id}`);
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { get, post, patch, del } from '../api/http';
|
||||||
|
|
||||||
|
export interface Organization {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
description?: string;
|
||||||
|
status: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrganizationMember {
|
||||||
|
id: string;
|
||||||
|
organizationId: string;
|
||||||
|
userId: string;
|
||||||
|
role: string;
|
||||||
|
createdAt?: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
fullName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserOption {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
fullName?: string;
|
||||||
|
role: string;
|
||||||
|
active: boolean;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listOrganizations = () => get<Organization[]>('/organizations');
|
||||||
|
export const createOrganization = (data: Partial<Organization>) =>
|
||||||
|
post<Organization>('/organizations', data);
|
||||||
|
export const updateOrganization = (id: string, data: Partial<Organization>) =>
|
||||||
|
patch<Organization>(`/organizations/${id}`, data);
|
||||||
|
|
||||||
|
export const listOrganizationMembers = (organizationId: string) =>
|
||||||
|
get<OrganizationMember[]>(`/organizations/${organizationId}/members`);
|
||||||
|
export const addOrganizationMember = (organizationId: string, data: { userId: string; role: string }) =>
|
||||||
|
post<OrganizationMember>(`/organizations/${organizationId}/members`, data);
|
||||||
|
export const removeOrganizationMember = (organizationId: string, userId: string) =>
|
||||||
|
del<{ organizationId: string; userId: string }>(`/organizations/${organizationId}/members/${userId}`);
|
||||||
|
|
||||||
|
export const listUsers = () => get<UserOption[]>('/users');
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
ApiOutlined,
|
ApiOutlined,
|
||||||
ControlOutlined as ControlOutlinedIcon,
|
ControlOutlined as ControlOutlinedIcon,
|
||||||
KeyOutlined,
|
KeyOutlined,
|
||||||
|
AppstoreOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { ProLayout } from '@ant-design/pro-components';
|
import { ProLayout } from '@ant-design/pro-components';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -38,6 +39,7 @@ const menuData = [
|
|||||||
{ path: '/alerts', name: '告警中心', icon: <BellOutlined />, permission: 'alarm:read' },
|
{ path: '/alerts', name: '告警中心', icon: <BellOutlined />, permission: 'alarm:read' },
|
||||||
{ path: '/videos', name: '视频监控', icon: <VideoCameraOutlined />, permission: 'video:read' },
|
{ path: '/videos', name: '视频监控', icon: <VideoCameraOutlined />, permission: 'video:read' },
|
||||||
{ path: '/logs', name: '控制日志', icon: <FileTextOutlined />, permission: 'log:read' },
|
{ path: '/logs', name: '控制日志', icon: <FileTextOutlined />, permission: 'log:read' },
|
||||||
|
{ path: '/organizations', name: '组织管理', icon: <TeamOutlined />, permission: 'organization:manage' },
|
||||||
{ path: '/knowledge', name: '知识库', icon: <BookOutlined />, permission: 'knowledge:read' },
|
{ path: '/knowledge', name: '知识库', icon: <BookOutlined />, permission: 'knowledge:read' },
|
||||||
{ path: '/batches', name: '批次管理', icon: <ProfileOutlined />, permission: 'batch:read' },
|
{ path: '/batches', name: '批次管理', icon: <ProfileOutlined />, permission: 'batch:read' },
|
||||||
{ path: '/lamp-tests', name: 'LAMP 检测', icon: <ExperimentOutlined />, permission: 'lamp:read' },
|
{ path: '/lamp-tests', name: 'LAMP 检测', icon: <ExperimentOutlined />, permission: 'lamp:read' },
|
||||||
@@ -47,6 +49,7 @@ const menuData = [
|
|||||||
{ path: '/inspections', name: '巡检记录', icon: <CameraOutlined />, permission: 'inspection:read' },
|
{ path: '/inspections', name: '巡检记录', icon: <CameraOutlined />, permission: 'inspection:read' },
|
||||||
{ path: '/consultations', name: '专家会诊', icon: <TeamOutlined />, permission: 'consultation:read' },
|
{ path: '/consultations', name: '专家会诊', icon: <TeamOutlined />, permission: 'consultation:read' },
|
||||||
{ path: '/traces', name: '疫病溯源', icon: <DeploymentUnitOutlined />, permission: 'trace:read' },
|
{ path: '/traces', name: '疫病溯源', icon: <DeploymentUnitOutlined />, permission: 'trace:read' },
|
||||||
|
{ path: '/business', name: '运营能力', icon: <AppstoreOutlined />, permission: 'case:read' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// 角色中文名
|
// 角色中文名
|
||||||
|
|||||||
@@ -0,0 +1,543 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button, Card, DatePicker, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Tabs, Tag, message,
|
||||||
|
} from 'antd';
|
||||||
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
|
import { ProTable, type ActionType, type ProColumns } from '@ant-design/pro-components';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import {
|
||||||
|
createCaseStudy,
|
||||||
|
createCaseStudyFromConsultation,
|
||||||
|
createDeviceMaintenance,
|
||||||
|
createLaboratoryResult,
|
||||||
|
createProductionLossRecord,
|
||||||
|
deleteDeviceMaintenance,
|
||||||
|
deleteLaboratoryResult,
|
||||||
|
deleteProductionLossRecord,
|
||||||
|
getProductionLossStats,
|
||||||
|
listCaseStudies,
|
||||||
|
listDeviceMaintenance,
|
||||||
|
listLaboratoryResults,
|
||||||
|
listProductionLossRecords,
|
||||||
|
reviewCaseStudy,
|
||||||
|
updateDeviceMaintenance,
|
||||||
|
updateCaseStudy,
|
||||||
|
updateLaboratoryResult,
|
||||||
|
updateProductionLossRecord,
|
||||||
|
type CaseStudy,
|
||||||
|
type DeviceMaintenanceRecord,
|
||||||
|
type LaboratoryResult,
|
||||||
|
type ProductionLossRecord,
|
||||||
|
type ProductionLossStat,
|
||||||
|
} from '../dal/business';
|
||||||
|
import { fetchRooms } from '../dal/dashboard';
|
||||||
|
import { listBatches, type Batch } from '../dal/trayBatch';
|
||||||
|
import { authService } from '../services/auth';
|
||||||
|
|
||||||
|
const canWrite = (permission: string) => authService.hasPermission(permission);
|
||||||
|
|
||||||
|
const MAINTENANCE_KINDS: Record<string, string> = {
|
||||||
|
calibration: '校准',
|
||||||
|
fault: '故障',
|
||||||
|
maintenance: '维护',
|
||||||
|
firmware: '固件',
|
||||||
|
};
|
||||||
|
|
||||||
|
const COST_TYPES: Record<string, string> = {
|
||||||
|
medicine: '用药',
|
||||||
|
disinfection: '消毒',
|
||||||
|
detection: '检测',
|
||||||
|
labor: '人工',
|
||||||
|
other: '其他',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CASE_STATUS: Record<string, string> = {
|
||||||
|
draft: '草稿',
|
||||||
|
pending_review: '待审核',
|
||||||
|
published: '已发布',
|
||||||
|
rejected: '已驳回',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEST_TYPES: Record<string, string> = {
|
||||||
|
molecular_typing: '分子分型',
|
||||||
|
pathogen: '病原检测',
|
||||||
|
environment_sample: '环境样本',
|
||||||
|
other: '其他',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RESULT_TYPES: Record<string, string> = {
|
||||||
|
positive: '阳性',
|
||||||
|
negative: '阴性',
|
||||||
|
indeterminate: '待复核',
|
||||||
|
invalid: '无效',
|
||||||
|
};
|
||||||
|
|
||||||
|
function ProductionStats() {
|
||||||
|
const [stats, setStats] = useState<ProductionLossStat[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
getProductionLossStats().then(setStats).catch(() => setStats([]));
|
||||||
|
}, []);
|
||||||
|
if (stats.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<Card title="产量损失统计" size="small" style={{ marginBottom: 12 }}>
|
||||||
|
<Space wrap size={[16, 8]}>
|
||||||
|
{stats.map((s) => (
|
||||||
|
<Tag key={`${s.roomId}-${s.batchId}`} color="blue">
|
||||||
|
{s.roomName || s.roomId || '-'}
|
||||||
|
{s.batchName ? ` / ${s.batchName}` : ''}
|
||||||
|
:死亡 {s.deathCount}、淘汰 {s.culledCount}、产量 {s.yieldKg}kg、损失 {s.lossKg}kg、成本 {s.costAmount}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BusinessExtensionsPage() {
|
||||||
|
const maintenanceAction = useRef<ActionType>();
|
||||||
|
const productionAction = useRef<ActionType>();
|
||||||
|
const caseAction = useRef<ActionType>();
|
||||||
|
const labAction = useRef<ActionType>();
|
||||||
|
const [rooms, setRooms] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
const [batches, setBatches] = useState<Batch[]>([]);
|
||||||
|
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
|
||||||
|
const [productionOpen, setProductionOpen] = useState(false);
|
||||||
|
const [caseOpen, setCaseOpen] = useState(false);
|
||||||
|
const [reviewOpen, setReviewOpen] = useState(false);
|
||||||
|
const [labOpen, setLabOpen] = useState(false);
|
||||||
|
const [reviewing, setReviewing] = useState<CaseStudy | null>(null);
|
||||||
|
const [maintenanceEditing, setMaintenanceEditing] = useState<DeviceMaintenanceRecord | null>(null);
|
||||||
|
const [productionEditing, setProductionEditing] = useState<ProductionLossRecord | null>(null);
|
||||||
|
const [labEditing, setLabEditing] = useState<LaboratoryResult | null>(null);
|
||||||
|
const [maintenanceForm] = Form.useForm();
|
||||||
|
const [productionForm] = Form.useForm();
|
||||||
|
const [caseForm] = Form.useForm();
|
||||||
|
const [reviewForm] = Form.useForm();
|
||||||
|
const [labForm] = Form.useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
fetchRooms().catch(() => [] as { id: string; name: string }[]),
|
||||||
|
listBatches().catch(() => [] as Batch[]),
|
||||||
|
]).then(([roomRes, batchRes]) => {
|
||||||
|
setRooms(roomRes);
|
||||||
|
setBatches(batchRes);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const roomName = (id?: string) => rooms.find((r) => r.id === id)?.name || id || '-';
|
||||||
|
const batchName = (id?: string) => batches.find((b) => b.id === id)?.name || id || '-';
|
||||||
|
|
||||||
|
const maintenanceColumns: ProColumns<DeviceMaintenanceRecord>[] = [
|
||||||
|
{ title: '类型', dataIndex: 'kind', render: (_, r) => <Tag>{MAINTENANCE_KINDS[r.kind] || r.kind}</Tag> },
|
||||||
|
{ title: '标题', dataIndex: 'title' },
|
||||||
|
{ title: '设备 ID', dataIndex: 'deviceId', search: false },
|
||||||
|
{ title: '计划时间', dataIndex: 'scheduledAt', search: false, render: (_, r) => r.scheduledAt ? dayjs(r.scheduledAt).format('YYYY-MM-DD HH:mm') : '-' },
|
||||||
|
{ title: '执行时间', dataIndex: 'performedAt', search: false, render: (_, r) => r.performedAt ? dayjs(r.performedAt).format('YYYY-MM-DD HH:mm') : '-' },
|
||||||
|
{ title: '固件', dataIndex: 'firmwareTo', search: false, render: (_, r) => (r.firmwareFrom && r.firmwareTo ? `${r.firmwareFrom} → ${r.firmwareTo}` : r.firmwareTo || '-') },
|
||||||
|
{ title: '成本', dataIndex: 'costAmount', search: false, render: (_, r) => (r.costAmount ? `${r.costAmount}${r.costUnit || ''}` : '-') },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
valueType: 'option',
|
||||||
|
render: (_, r) => [
|
||||||
|
<a key="edit" onClick={() => {
|
||||||
|
setMaintenanceEditing(r);
|
||||||
|
maintenanceForm.setFieldsValue({
|
||||||
|
...r,
|
||||||
|
scheduledAt: r.scheduledAt ? dayjs(r.scheduledAt) : undefined,
|
||||||
|
performedAt: r.performedAt ? dayjs(r.performedAt) : undefined,
|
||||||
|
});
|
||||||
|
setMaintenanceOpen(true);
|
||||||
|
}}>编辑</a>,
|
||||||
|
...(canWrite('device:write')
|
||||||
|
? [
|
||||||
|
<Popconfirm key="del" title="确认删除?" onConfirm={async () => {
|
||||||
|
await deleteDeviceMaintenance(r.id);
|
||||||
|
message.success('已删除');
|
||||||
|
maintenanceAction.current?.reload();
|
||||||
|
}}>
|
||||||
|
<a style={{ color: '#ff4d4f' }}>删除</a>
|
||||||
|
</Popconfirm>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const productionColumns: ProColumns<ProductionLossRecord>[] = [
|
||||||
|
{ title: '日期', dataIndex: 'recordDate', valueType: 'date', render: (_, r) => dayjs(r.recordDate).format('YYYY-MM-DD') },
|
||||||
|
{ title: '蚕房', dataIndex: 'roomId', search: false, render: (_, r) => roomName(r.roomId) },
|
||||||
|
{ title: '批次', dataIndex: 'batchId', search: false, render: (_, r) => batchName(r.batchId) },
|
||||||
|
{ title: '死亡', dataIndex: 'deathCount', search: false },
|
||||||
|
{ title: '淘汰', dataIndex: 'culledCount', search: false },
|
||||||
|
{ title: '产量 kg', dataIndex: 'yieldKg', search: false },
|
||||||
|
{ title: '损失 kg', dataIndex: 'lossKg', search: false },
|
||||||
|
{ title: '成本', dataIndex: 'costAmount', search: false, render: (_, r) => (r.costAmount ? `${r.costAmount}(${COST_TYPES[r.costType || ''] || r.costType || '-'})` : '-') },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
valueType: 'option',
|
||||||
|
render: (_, r) => [
|
||||||
|
<a key="edit" onClick={() => {
|
||||||
|
setProductionEditing(r);
|
||||||
|
productionForm.setFieldsValue({
|
||||||
|
...r,
|
||||||
|
recordDate: r.recordDate ? dayjs(r.recordDate) : undefined,
|
||||||
|
});
|
||||||
|
setProductionOpen(true);
|
||||||
|
}}>编辑</a>,
|
||||||
|
...(canWrite('farm:write')
|
||||||
|
? [
|
||||||
|
<Popconfirm key="del" title="确认删除?" onConfirm={async () => {
|
||||||
|
await deleteProductionLossRecord(r.id);
|
||||||
|
message.success('已删除');
|
||||||
|
productionAction.current?.reload();
|
||||||
|
}}>
|
||||||
|
<a style={{ color: '#ff4d4f' }}>删除</a>
|
||||||
|
</Popconfirm>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const caseColumns: ProColumns<CaseStudy>[] = [
|
||||||
|
{ title: '标题', dataIndex: 'title' },
|
||||||
|
{ title: '病种', dataIndex: 'disease' },
|
||||||
|
{ title: '区域', dataIndex: 'region', search: false },
|
||||||
|
{ title: '状态', dataIndex: 'status', render: (_, r) => <Tag>{CASE_STATUS[r.status] || r.status}</Tag> },
|
||||||
|
{ title: '审核备注', dataIndex: 'reviewNote', search: false },
|
||||||
|
{ title: '发布时间', dataIndex: 'publishedAt', search: false, render: (_, r) => r.publishedAt ? dayjs(r.publishedAt).format('YYYY-MM-DD HH:mm') : '-' },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
valueType: 'option',
|
||||||
|
render: (_, r) => [
|
||||||
|
<a key="edit" onClick={() => {
|
||||||
|
setReviewing(r);
|
||||||
|
caseForm.setFieldsValue({
|
||||||
|
...r,
|
||||||
|
caseDate: r.caseDate ? dayjs(r.caseDate) : undefined,
|
||||||
|
desensitizedPayload: JSON.stringify(r.desensitizedPayload || {}, null, 2),
|
||||||
|
});
|
||||||
|
setCaseOpen(true);
|
||||||
|
}}>编辑</a>,
|
||||||
|
...(canWrite('case:write')
|
||||||
|
? [
|
||||||
|
<a key="review" onClick={() => {
|
||||||
|
setReviewing(r);
|
||||||
|
reviewForm.resetFields();
|
||||||
|
reviewForm.setFieldsValue({ status: r.status === 'draft' ? 'pending_review' : 'published' });
|
||||||
|
setReviewOpen(true);
|
||||||
|
}}>审核</a>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const labColumns: ProColumns<LaboratoryResult>[] = [
|
||||||
|
{ title: '实验室', dataIndex: 'labName' },
|
||||||
|
{ title: '报告编号', dataIndex: 'reportNo' },
|
||||||
|
{ title: '检测类型', dataIndex: 'testType', render: (_, r) => TEST_TYPES[r.testType] || r.testType },
|
||||||
|
{ title: '结果', dataIndex: 'resultType', render: (_, r) => <Tag color={r.resultType === 'positive' ? 'red' : 'default'}>{RESULT_TYPES[r.resultType] || r.resultType}</Tag> },
|
||||||
|
{ title: '病原', dataIndex: 'pathogen', search: false },
|
||||||
|
{ title: '分型', dataIndex: 'genotype', search: false },
|
||||||
|
{ title: '检测时间', dataIndex: 'testedAt', search: false, render: (_, r) => r.testedAt ? dayjs(r.testedAt).format('YYYY-MM-DD HH:mm') : '-' },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
valueType: 'option',
|
||||||
|
render: (_, r) => [
|
||||||
|
<a key="edit" onClick={() => {
|
||||||
|
setLabEditing(r);
|
||||||
|
labForm.setFieldsValue({
|
||||||
|
...r,
|
||||||
|
testedAt: r.testedAt ? dayjs(r.testedAt) : undefined,
|
||||||
|
concludedAt: r.concludedAt ? dayjs(r.concludedAt) : undefined,
|
||||||
|
});
|
||||||
|
setLabOpen(true);
|
||||||
|
}}>编辑</a>,
|
||||||
|
...(canWrite('lab:write')
|
||||||
|
? [
|
||||||
|
<Popconfirm key="del" title="确认删除?" onConfirm={async () => {
|
||||||
|
await deleteLaboratoryResult(r.id);
|
||||||
|
message.success('已删除');
|
||||||
|
labAction.current?.reload();
|
||||||
|
}}>
|
||||||
|
<a style={{ color: '#ff4d4f' }}>删除</a>
|
||||||
|
</Popconfirm>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tabs
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'maintenance',
|
||||||
|
label: '设备维护',
|
||||||
|
children: (
|
||||||
|
<ProTable<DeviceMaintenanceRecord>
|
||||||
|
actionRef={maintenanceAction}
|
||||||
|
rowKey="id"
|
||||||
|
columns={maintenanceColumns}
|
||||||
|
search={false}
|
||||||
|
request={async () => {
|
||||||
|
const res = await listDeviceMaintenance();
|
||||||
|
return { data: res, total: res.length, success: true };
|
||||||
|
}}
|
||||||
|
toolBarRender={() => canWrite('device:write') ? [
|
||||||
|
<Button key="new" type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||||
|
setMaintenanceEditing(null);
|
||||||
|
maintenanceForm.resetFields();
|
||||||
|
setMaintenanceOpen(true);
|
||||||
|
}}>新增维护</Button>,
|
||||||
|
] : []}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'production',
|
||||||
|
label: '产量损失',
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<ProductionStats />
|
||||||
|
<ProTable<ProductionLossRecord>
|
||||||
|
actionRef={productionAction}
|
||||||
|
rowKey="id"
|
||||||
|
columns={productionColumns}
|
||||||
|
search={false}
|
||||||
|
request={async () => {
|
||||||
|
const res = await listProductionLossRecords();
|
||||||
|
return { data: res, total: res.length, success: true };
|
||||||
|
}}
|
||||||
|
toolBarRender={() => canWrite('farm:write') ? [
|
||||||
|
<Button key="new" type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||||
|
setProductionEditing(null);
|
||||||
|
productionForm.resetFields();
|
||||||
|
productionForm.setFieldsValue({ recordDate: dayjs() });
|
||||||
|
setProductionOpen(true);
|
||||||
|
}}>新增记录</Button>,
|
||||||
|
] : []}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cases',
|
||||||
|
label: '案例库',
|
||||||
|
children: (
|
||||||
|
<ProTable<CaseStudy>
|
||||||
|
actionRef={caseAction}
|
||||||
|
rowKey="id"
|
||||||
|
columns={caseColumns}
|
||||||
|
search={false}
|
||||||
|
request={async () => {
|
||||||
|
const res = await listCaseStudies();
|
||||||
|
return { data: res, total: res.length, success: true };
|
||||||
|
}}
|
||||||
|
toolBarRender={() => canWrite('case:write') ? [
|
||||||
|
<Button key="new" type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||||
|
setReviewing(null);
|
||||||
|
caseForm.resetFields();
|
||||||
|
caseForm.setFieldsValue({ desensitizedPayload: '{}' });
|
||||||
|
setCaseOpen(true);
|
||||||
|
}}>新增案例</Button>,
|
||||||
|
<Button key="from" onClick={async () => {
|
||||||
|
const id = window.prompt('请输入会诊单 ID');
|
||||||
|
if (!id) return;
|
||||||
|
await createCaseStudyFromConsultation(id);
|
||||||
|
message.success('已生成案例草稿');
|
||||||
|
caseAction.current?.reload();
|
||||||
|
}}>从会诊生成</Button>,
|
||||||
|
] : []}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lab',
|
||||||
|
label: '实验室结果',
|
||||||
|
children: (
|
||||||
|
<ProTable<LaboratoryResult>
|
||||||
|
actionRef={labAction}
|
||||||
|
rowKey="id"
|
||||||
|
columns={labColumns}
|
||||||
|
search={false}
|
||||||
|
request={async () => {
|
||||||
|
const res = await listLaboratoryResults();
|
||||||
|
return { data: res, total: res.length, success: true };
|
||||||
|
}}
|
||||||
|
toolBarRender={() => canWrite('lab:write') ? [
|
||||||
|
<Button key="new" type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||||
|
setLabEditing(null);
|
||||||
|
labForm.resetFields();
|
||||||
|
setLabOpen(true);
|
||||||
|
}}>新增结果</Button>,
|
||||||
|
] : []}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
title={maintenanceEditing ? '编辑设备维护' : '新增设备维护'}
|
||||||
|
open={maintenanceOpen}
|
||||||
|
onCancel={() => setMaintenanceOpen(false)}
|
||||||
|
onOk={async () => {
|
||||||
|
const v = await maintenanceForm.validateFields();
|
||||||
|
if (maintenanceEditing) {
|
||||||
|
await updateDeviceMaintenance(maintenanceEditing.id, v);
|
||||||
|
message.success('维护记录已更新');
|
||||||
|
} else {
|
||||||
|
await createDeviceMaintenance(v);
|
||||||
|
message.success('维护记录已创建');
|
||||||
|
}
|
||||||
|
setMaintenanceOpen(false);
|
||||||
|
maintenanceAction.current?.reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={maintenanceForm} layout="vertical">
|
||||||
|
<Form.Item label="设备 ID" name="deviceId" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item label="类型" name="kind" rules={[{ required: true }]}>
|
||||||
|
<Select options={Object.entries(MAINTENANCE_KINDS).map(([value, label]) => ({ value, label }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="标题" name="title" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item label="计划时间" name="scheduledAt"><DatePicker showTime style={{ width: '100%' }} /></Form.Item>
|
||||||
|
<Form.Item label="执行时间" name="performedAt"><DatePicker showTime style={{ width: '100%' }} /></Form.Item>
|
||||||
|
<Form.Item label="结果" name="result"><Input.TextArea rows={2} /></Form.Item>
|
||||||
|
<Space.Compact block>
|
||||||
|
<Form.Item label="固件版本" name="firmwareFrom" style={{ width: '50%' }}><Input placeholder="升级前" /></Form.Item>
|
||||||
|
<Form.Item label="目标版本" name="firmwareTo" style={{ width: '50%' }}><Input placeholder="升级后" /></Form.Item>
|
||||||
|
</Space.Compact>
|
||||||
|
<Form.Item label="成本" name="costAmount"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||||
|
<Form.Item label="成本单位" name="costUnit"><Input /></Form.Item>
|
||||||
|
<Form.Item label="备注" name="note"><Input.TextArea rows={2} /></Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
title={productionEditing ? '编辑产量损失记录' : '新增产量损失记录'}
|
||||||
|
open={productionOpen}
|
||||||
|
onCancel={() => setProductionOpen(false)}
|
||||||
|
onOk={async () => {
|
||||||
|
const v = await productionForm.validateFields();
|
||||||
|
const body = { ...v, recordDate: v.recordDate?.toISOString() };
|
||||||
|
if (productionEditing) {
|
||||||
|
await updateProductionLossRecord(productionEditing.id, body);
|
||||||
|
message.success('记录已更新');
|
||||||
|
} else {
|
||||||
|
await createProductionLossRecord(body);
|
||||||
|
message.success('记录已创建');
|
||||||
|
}
|
||||||
|
setProductionOpen(false);
|
||||||
|
productionAction.current?.reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={productionForm} layout="vertical">
|
||||||
|
<Form.Item label="蚕房" name="roomId"><Select allowClear options={rooms.map((r) => ({ value: r.id, label: r.name }))} /></Form.Item>
|
||||||
|
<Form.Item label="批次" name="batchId"><Select allowClear options={batches.map((b) => ({ value: b.id, label: b.name }))} /></Form.Item>
|
||||||
|
<Form.Item label="记录日期" name="recordDate" rules={[{ required: true }]}><DatePicker style={{ width: '100%' }} /></Form.Item>
|
||||||
|
<Form.Item label="死亡数" name="deathCount"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||||
|
<Form.Item label="淘汰数" name="culledCount"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||||
|
<Form.Item label="产量 kg" name="yieldKg"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||||
|
<Form.Item label="损失 kg" name="lossKg"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||||
|
<Form.Item label="成本类型" name="costType"><Select allowClear options={Object.entries(COST_TYPES).map(([value, label]) => ({ value, label }))} /></Form.Item>
|
||||||
|
<Form.Item label="成本金额" name="costAmount"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||||
|
<Form.Item label="备注" name="note"><Input.TextArea rows={2} /></Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
title={reviewing ? '编辑案例' : '新增案例'}
|
||||||
|
open={caseOpen}
|
||||||
|
onCancel={() => setCaseOpen(false)}
|
||||||
|
onOk={async () => {
|
||||||
|
const v = await caseForm.validateFields();
|
||||||
|
const payload = typeof v.desensitizedPayload === 'string' ? JSON.parse(v.desensitizedPayload || '{}') : v.desensitizedPayload;
|
||||||
|
const body = {
|
||||||
|
...v,
|
||||||
|
caseDate: v.caseDate?.toISOString(),
|
||||||
|
desensitizedPayload: payload,
|
||||||
|
};
|
||||||
|
if (reviewing) await updateCaseStudy(reviewing.id, body);
|
||||||
|
else await createCaseStudy(body);
|
||||||
|
message.success('案例已保存');
|
||||||
|
setCaseOpen(false);
|
||||||
|
caseAction.current?.reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={caseForm} layout="vertical">
|
||||||
|
<Form.Item label="标题" name="title" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item label="病种" name="disease" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item label="区域" name="region"><Input /></Form.Item>
|
||||||
|
<Form.Item label="案例日期" name="caseDate"><DatePicker showTime style={{ width: '100%' }} /></Form.Item>
|
||||||
|
<Form.Item label="摘要" name="summary"><Input.TextArea rows={2} /></Form.Item>
|
||||||
|
<Form.Item label="脱敏载荷 JSON" name="desensitizedPayload" rules={[{ required: true }]}><Input.TextArea rows={6} /></Form.Item>
|
||||||
|
<Form.Item label="来源会诊 ID" name="sourceConsultationId"><Input /></Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
title="审核案例"
|
||||||
|
open={reviewOpen}
|
||||||
|
onCancel={() => setReviewOpen(false)}
|
||||||
|
onOk={async () => {
|
||||||
|
const v = await reviewForm.validateFields();
|
||||||
|
if (!reviewing) return;
|
||||||
|
await reviewCaseStudy(reviewing.id, v);
|
||||||
|
message.success('审核完成');
|
||||||
|
setReviewOpen(false);
|
||||||
|
caseAction.current?.reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={reviewForm} layout="vertical">
|
||||||
|
<Form.Item label="状态" name="status" rules={[{ required: true }]}>
|
||||||
|
<Select options={Object.entries(CASE_STATUS).map(([value, label]) => ({ value, label }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="审核备注" name="note"><Input.TextArea rows={3} /></Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
title={labEditing ? '编辑实验室结果' : '新增实验室结果'}
|
||||||
|
open={labOpen}
|
||||||
|
onCancel={() => setLabOpen(false)}
|
||||||
|
onOk={async () => {
|
||||||
|
const v = await labForm.validateFields();
|
||||||
|
const body = {
|
||||||
|
...v,
|
||||||
|
testedAt: v.testedAt?.toISOString(),
|
||||||
|
concludedAt: v.concludedAt?.toISOString(),
|
||||||
|
};
|
||||||
|
if (labEditing) await updateLaboratoryResult(labEditing.id, body);
|
||||||
|
else await createLaboratoryResult(body);
|
||||||
|
message.success('结果已保存');
|
||||||
|
setLabOpen(false);
|
||||||
|
labAction.current?.reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={labForm} layout="vertical">
|
||||||
|
<Form.Item label="实验室名称" name="labName" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item label="报告编号" name="reportNo" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item label="检测类型" name="testType" rules={[{ required: true }]}>
|
||||||
|
<Select options={Object.entries(TEST_TYPES).map(([value, label]) => ({ value, label }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="结果" name="resultType" rules={[{ required: true }]}>
|
||||||
|
<Select options={Object.entries(RESULT_TYPES).map(([value, label]) => ({ value, label }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="溯源 ID" name="traceRecordId"><Input /></Form.Item>
|
||||||
|
<Form.Item label="发病事件 ID" name="diseaseEventId"><Input /></Form.Item>
|
||||||
|
<Form.Item label="样本 ID" name="sampleId"><Input /></Form.Item>
|
||||||
|
<Form.Item label="蚕房" name="roomId"><Select allowClear options={rooms.map((r) => ({ value: r.id, label: r.name }))} /></Form.Item>
|
||||||
|
<Form.Item label="批次" name="batchId"><Select allowClear options={batches.map((b) => ({ value: b.id, label: b.name }))} /></Form.Item>
|
||||||
|
<Form.Item label="病原" name="pathogen"><Input /></Form.Item>
|
||||||
|
<Form.Item label="分型" name="genotype"><Input /></Form.Item>
|
||||||
|
<Form.Item label="方法" name="method"><Input /></Form.Item>
|
||||||
|
<Form.Item label="样本号" name="sampleNo"><Input /></Form.Item>
|
||||||
|
<Form.Item label="样本类型" name="sampleType"><Input /></Form.Item>
|
||||||
|
<Form.Item label="结论" name="findings"><Input.TextArea rows={2} /></Form.Item>
|
||||||
|
<Form.Item label="报告 URL" name="reportUrl"><Input /></Form.Item>
|
||||||
|
<Form.Item label="检测时间" name="testedAt"><DatePicker showTime style={{ width: '100%' }} /></Form.Item>
|
||||||
|
<Form.Item label="出具时间" name="concludedAt"><DatePicker showTime style={{ width: '100%' }} /></Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, message,
|
||||||
|
} from 'antd';
|
||||||
|
import { PlusOutlined, TeamOutlined } from '@ant-design/icons';
|
||||||
|
import { ProTable, type ActionType, type ProColumns } from '@ant-design/pro-components';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import {
|
||||||
|
addOrganizationMember,
|
||||||
|
createOrganization,
|
||||||
|
listOrganizationMembers,
|
||||||
|
listOrganizations,
|
||||||
|
listUsers,
|
||||||
|
removeOrganizationMember,
|
||||||
|
updateOrganization,
|
||||||
|
type Organization,
|
||||||
|
type OrganizationMember,
|
||||||
|
type UserOption,
|
||||||
|
} from '../dal/organization';
|
||||||
|
|
||||||
|
export default function OrganizationsPage() {
|
||||||
|
const actionRef = useRef<ActionType>();
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<Organization | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [currentOrg, setCurrentOrg] = useState<Organization | null>(null);
|
||||||
|
const [members, setMembers] = useState<OrganizationMember[]>([]);
|
||||||
|
const [users, setUsers] = useState<UserOption[]>([]);
|
||||||
|
const [memberLoading, setMemberLoading] = useState(false);
|
||||||
|
const [memberForm] = Form.useForm();
|
||||||
|
|
||||||
|
const loadMembers = useCallback(async (orgId: string) => {
|
||||||
|
setMemberLoading(true);
|
||||||
|
try {
|
||||||
|
const rows = await listOrganizationMembers(orgId);
|
||||||
|
setMembers(rows);
|
||||||
|
} finally {
|
||||||
|
setMemberLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listUsers().then(setUsers).catch(() => setUsers([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openMembers = async (org: Organization) => {
|
||||||
|
setCurrentOrg(org);
|
||||||
|
setMembers([]);
|
||||||
|
setDrawerOpen(true);
|
||||||
|
memberForm.resetFields();
|
||||||
|
memberForm.setFieldsValue({ role: 'member' });
|
||||||
|
await loadMembers(org.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ProColumns<Organization>[] = [
|
||||||
|
{ title: '组织名称', dataIndex: 'name' },
|
||||||
|
{ title: '编码', dataIndex: 'code', search: false },
|
||||||
|
{ title: '说明', dataIndex: 'description', search: false, render: (_, r) => r.description || '-' },
|
||||||
|
{ title: '状态', dataIndex: 'status', search: false, render: (_, r) => <Tag color={r.status === 'active' ? 'green' : 'default'}>{r.status === 'active' ? '启用' : r.status}</Tag> },
|
||||||
|
{ title: '创建时间', dataIndex: 'createdAt', search: false, render: (_, r) => (r.createdAt ? dayjs(r.createdAt).format('YYYY-MM-DD HH:mm') : '-') },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
valueType: 'option',
|
||||||
|
render: (_, r) => [
|
||||||
|
<a key="members" onClick={() => openMembers(r)}>
|
||||||
|
<TeamOutlined /> 成员
|
||||||
|
</a>,
|
||||||
|
<a
|
||||||
|
key="edit"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(r);
|
||||||
|
form.setFieldsValue(r);
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</a>,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const memberColumns = [
|
||||||
|
{ title: '用户名', dataIndex: 'username' },
|
||||||
|
{ title: '姓名', dataIndex: 'fullName', render: (_: unknown, r: OrganizationMember) => r.fullName || '-' },
|
||||||
|
{ title: '组织角色', dataIndex: 'role', render: (_: unknown, r: OrganizationMember) => <Tag>{r.role}</Tag> },
|
||||||
|
{ title: '邮箱', dataIndex: 'email' },
|
||||||
|
{ title: '加入时间', dataIndex: 'createdAt', render: (_: unknown, r: OrganizationMember) => (r.createdAt ? dayjs(r.createdAt).format('YYYY-MM-DD HH:mm') : '-') },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
render: (_: unknown, r: OrganizationMember) => (
|
||||||
|
<Popconfirm
|
||||||
|
title="确认移除该成员?"
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!currentOrg) return;
|
||||||
|
await removeOrganizationMember(currentOrg.id, r.userId);
|
||||||
|
message.success('已移除');
|
||||||
|
await loadMembers(currentOrg.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<a style={{ color: '#ff4d4f' }}>移除</a>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const availableUsers = users.filter((u) => !members.some((m) => m.userId === u.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ProTable<Organization>
|
||||||
|
actionRef={actionRef}
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
search={false}
|
||||||
|
request={async () => {
|
||||||
|
const res = await listOrganizations();
|
||||||
|
return { data: res, total: res.length, success: true };
|
||||||
|
}}
|
||||||
|
toolBarRender={() => [
|
||||||
|
<Button
|
||||||
|
key="new"
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(null);
|
||||||
|
form.resetFields();
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新增组织
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editing ? '编辑组织' : '新增组织'}
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
onOk={async () => {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
if (editing) {
|
||||||
|
await updateOrganization(editing.id, values);
|
||||||
|
message.success('组织已更新');
|
||||||
|
} else {
|
||||||
|
await createOrganization(values);
|
||||||
|
message.success('组织已创建');
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
actionRef.current?.reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item label="组织名称" name="name" rules={[{ required: true, message: '请输入组织名称' }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="组织编码" name="code" rules={[{ required: true, message: '请输入组织编码' }]}>
|
||||||
|
<Input placeholder="如:farm-001" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="说明" name="description">
|
||||||
|
<Input.TextArea rows={3} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
title={currentOrg ? `成员管理:${currentOrg.name}` : '成员管理'}
|
||||||
|
width={720}
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
>
|
||||||
|
<Form form={memberForm} layout="inline" style={{ marginBottom: 16 }}>
|
||||||
|
<Form.Item label="用户" name="userId" rules={[{ required: true, message: '请选择用户' }]}>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
style={{ minWidth: 240 }}
|
||||||
|
placeholder="选择用户"
|
||||||
|
options={availableUsers.map((u) => ({
|
||||||
|
value: u.id,
|
||||||
|
label: `${u.username}${u.fullName ? `(${u.fullName})` : ''}`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="组织角色" name="role" rules={[{ required: true, message: '请选择角色' }]}>
|
||||||
|
<Select
|
||||||
|
style={{ minWidth: 120 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'member', label: '成员' },
|
||||||
|
{ value: 'admin', label: '管理员' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={async () => {
|
||||||
|
const values = await memberForm.validateFields();
|
||||||
|
if (!currentOrg) return;
|
||||||
|
await addOrganizationMember(currentOrg.id, values);
|
||||||
|
message.success('成员已添加');
|
||||||
|
memberForm.resetFields();
|
||||||
|
memberForm.setFieldsValue({ role: 'member' });
|
||||||
|
await loadMembers(currentOrg.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
添加成员
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
loading={memberLoading}
|
||||||
|
dataSource={members}
|
||||||
|
columns={memberColumns}
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
<Space style={{ marginTop: 16 }} direction="vertical">
|
||||||
|
<span>默认组织用于兼容历史数据,新注册用户会自动加入。</span>
|
||||||
|
<span>组织成员范围决定其可访问的蚕房、设备、巡检、检测、会诊和溯源数据。</span>
|
||||||
|
</Space>
|
||||||
|
</Drawer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ import Biosecurity from './pages/Biosecurity';
|
|||||||
import Inspections from './pages/Inspections';
|
import Inspections from './pages/Inspections';
|
||||||
import Consultations from './pages/Consultations';
|
import Consultations from './pages/Consultations';
|
||||||
import Traces from './pages/Traces';
|
import Traces from './pages/Traces';
|
||||||
|
import BusinessExtensions from './pages/BusinessExtensions';
|
||||||
|
import Organizations from './pages/Organizations';
|
||||||
import NotFound from './pages/NotFound';
|
import NotFound from './pages/NotFound';
|
||||||
import { BasicLayout } from './layout/BasicLayout';
|
import { BasicLayout } from './layout/BasicLayout';
|
||||||
import { authService } from './services/auth';
|
import { authService } from './services/auth';
|
||||||
@@ -64,6 +66,8 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'inspections', element: <RequirePermission permission="inspection:read"><Inspections /></RequirePermission> },
|
{ path: 'inspections', element: <RequirePermission permission="inspection:read"><Inspections /></RequirePermission> },
|
||||||
{ path: 'consultations', element: <RequirePermission permission="consultation:read"><Consultations /></RequirePermission> },
|
{ path: 'consultations', element: <RequirePermission permission="consultation:read"><Consultations /></RequirePermission> },
|
||||||
{ path: 'traces', element: <RequirePermission permission="trace:read"><Traces /></RequirePermission> },
|
{ path: 'traces', element: <RequirePermission permission="trace:read"><Traces /></RequirePermission> },
|
||||||
|
{ path: 'business', element: <RequirePermission permission="case:read"><BusinessExtensions /></RequirePermission> },
|
||||||
|
{ path: 'organizations', element: <RequirePermission permission="organization:manage"><Organizations /></RequirePermission> },
|
||||||
{ path: '404', element: <NotFound /> },
|
{ path: '404', element: <NotFound /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,34 @@
|
|||||||
# 变更记录
|
# 变更记录
|
||||||
|
|
||||||
|
## 2026-08-17 对象授权、运营能力与组织管理(本地 v9,未部署)
|
||||||
|
|
||||||
|
### 变更背景
|
||||||
|
|
||||||
|
按《后续工作计划》推进 IAM-003 组织/房间级对象授权及 P2 业务能力。本地已完成 v9 迁移、对象授权代码、Web 运营能力页和 Web 组织管理页;开发服务器仍为已部署的 schema v8,本次变更未发布。
|
||||||
|
|
||||||
|
### 变更内容
|
||||||
|
|
||||||
|
| 文件 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| `server-go/migrations/000009_object_authority_and_business_capabilities.up/down.sql`(新增) | `organizations`、`organization_members`、`device_maintenance_records`、`production_loss_records`、`case_studies`、`laboratory_results`;`rooms.org_id`、`rearing_records.death_count/culled_count` |
|
||||||
|
| `server-go/internal/handler/access.go`(新增) | 组织/房间对象授权、设备 key 授权、统一对象归属解析 |
|
||||||
|
| `server-go/internal/handler/organization.go`(新增) | 组织 CRUD、成员列表/添加/移除 |
|
||||||
|
| `server-go/internal/ws/authorizer.go` | WebSocket 设备订阅改为 RBAC + 组织成员校验 |
|
||||||
|
| `server-go/internal/handler/alarm.go`、`sensor.go`、`threshold.go`、`telemetry.go`、`control.go`、`video_record.go` 等 | 传感器/阈值/告警/遥测/控制/录像等设备链路按组织房间隔离 |
|
||||||
|
| `web/src/pages/Organizations.tsx`、`web/src/dal/organization.ts`(新增) | 组织列表/编辑、成员添加/移除 |
|
||||||
|
| `web/src/pages/BusinessExtensions.tsx`、`web/src/dal/business.ts` | 设备维护、产量损失编辑/删除闭环 |
|
||||||
|
|
||||||
|
### 验证结果
|
||||||
|
|
||||||
|
- `scripts/verify.ps1` exit 0:Go test/vet/build、Web test/lint/build、小程序 test/typecheck/build、APP typecheck/lint、AI pytest 均通过。
|
||||||
|
- 新增测试覆盖传感器设备归属、跨组织设备 403、组织范围蚕房选择、WS 组织成员放行/拒绝。
|
||||||
|
- 未部署开发服务器,未执行 `000009` 迁移,未做真实多组织越权联调。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
- 本地代码尚未提交,回滚可恢复工作区修改;
|
||||||
|
- 若已执行 v9,数据库回滚执行 `000009_object_authority_and_business_capabilities.down.sql` 或恢复迁移前 `pg_dump`,随后回退旧二进制。
|
||||||
|
|
||||||
## 2026-08-12 Web 知识库图谱预览与保存修复(故障排查 #三十二)
|
## 2026-08-12 Web 知识库图谱预览与保存修复(故障排查 #三十二)
|
||||||
|
|
||||||
### 变更背景
|
### 变更背景
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 后续工作计划
|
# 后续工作计划
|
||||||
|
|
||||||
> **完成状态(2026-08-14 更新)**:#5-#24、#27 已完成,Task 0/1/2/4/5/6/8/9/10/11/12/13/14 整改代码/验收文档完成(详见 `开发交接记录.md`);#1-4 因物理机问题挂起;#23/#26 骨架完成;Task 3/7 延后到最后处理;微信/天气真实数据待凭证。
|
> **完成状态(2026-08-17 更新)**:#5-#24、#27 已完成,Task 0/1/2/4/5/6/8/9/10/11/12/13/14 整改代码/验收文档完成(详见 `开发交接记录.md`);#1-4 因物理机问题挂起;#23/#26 骨架完成;Task 3/7 延后到最后处理;微信/天气真实数据待凭证;本地新增 v9 对象授权/运营能力/组织管理,尚未部署开发服务器。
|
||||||
|
|
||||||
## 整改实施计划 Wave 0-4(2026-08-13 启动)
|
## 整改实施计划 Wave 0-4(2026-08-13 启动)
|
||||||
|
|
||||||
@@ -26,21 +26,21 @@
|
|||||||
|
|
||||||
## 下一步工作计划(2026-08-14 交接版)
|
## 下一步工作计划(2026-08-14 交接版)
|
||||||
|
|
||||||
当前基线:`dev_wjs`,开发服务器已部署,Go schema v8。以下事项按优先级交接给后续同事。
|
当前基线:`dev_wjs`;开发服务器仍为已部署的 Go schema v8,本地 v9 迁移与对象授权代码未提交、未部署。以下事项按优先级交接给后续同事。
|
||||||
|
|
||||||
| 优先级 | 事项 | 负责人 | 阻塞 | 完成标准/证据 |
|
| 优先级 | 事项 | 负责人 | 阻塞 | 完成标准/证据 |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| P0 | E2E-02 真实模型“sick → 橙色 → 仅一个待确认任务” | AI + 后端 | YOLO 二分类训练 #1-4 | 真实 `best.onnx` 接入后,上传 sick 图片仅产生 1 个 pending 检测任务 |
|
| P0 | E2E-02 真实模型“sick → 橙色 → 仅一个待确认任务” | AI + 后端 | YOLO 二分类训练 #1-4 | 真实 `best.onnx` 接入后,上传 sick 图片仅产生 1 个 pending 检测任务 |
|
||||||
| P0 | IAM-003 组织/区域/房间级对象授权 | 后端 | 当前只有 RBAC,无组织模型 | 无权用户访问房间/设备/业务记录返回 403,越权测试通过 |
|
| P0 | IAM-003 组织/区域/房间级对象授权 | 后端 + Web | 代码、单测与 Web 组织管理已完成;待开发服务器越权联调 | 无权用户访问房间/设备/业务记录返回 403,越权测试通过 |
|
||||||
| P1 | E2E-07 微信开发者工具离线取证 | 小程序 | 微信开发者工具/真机环境 | 离线→重启→联网→仅同步一次,截图+日志存档 |
|
| P1 | E2E-07 微信开发者工具离线取证 | 小程序 | 微信开发者工具/真机环境 | 离线→重启→联网→仅同步一次,截图+日志存档 |
|
||||||
| P1 | 1000 WebSocket 持续 30 分钟 | 运维 + 后端 | 无 | 掉线率、消息 P95、CPU/内存曲线达标并记录 |
|
| P1 | 1000 WebSocket 持续 30 分钟 | 运维 + 后端 | 无 | 掉线率、消息 P95、CPU/内存曲线达标并记录 |
|
||||||
| P1 | AI T4 峰值测试 | AI + 运维 | T4 主机、真实模型 | p50/p95、失败率、GPU 显存记录 |
|
| P1 | AI T4 峰值测试 | AI + 运维 | T4 主机、真实模型 | p50/p95、失败率、GPU 显存记录 |
|
||||||
| P1 | Task 3 默认密钥/管理员初始化 | 后端 + 运维 | 最后阶段窗口 | 生产缺关键配置拒绝启动;历史密钥轮换完成 |
|
| P1 | Task 3 默认密钥/管理员初始化 | 后端 + 运维 | 最后阶段窗口 | 生产缺关键配置拒绝启动;历史密钥轮换完成 |
|
||||||
| P1 | Task 7 qPCR 判读质控 | 后端 + 领域专家 | 专家签字确认 protocolVersion/阈值 | 四态结果和对照质控实现并测试 |
|
| P1 | Task 7 qPCR 判读质控 | 后端 + 领域专家 | 专家签字确认 protocolVersion/阈值 | 四态结果和对照质控实现并测试 |
|
||||||
| P2 | ENV-006 设备校准/故障/维护/固件 | 后端 | 无 | 设备资产与维护记录 CRUD |
|
| P2 | ENV-006 设备校准/故障/维护/固件 | 后端 + Web | 后端 CRUD 与 Web 维护编辑/删除已补;待现场联调 | 设备资产与维护记录 CRUD |
|
||||||
| P2 | FARM-006 产量/死亡/淘汰/损失/成本 | 后端 | 无 | 记录字段和统计接口 |
|
| P2 | FARM-006 产量/死亡/淘汰/损失/成本 | 后端 + Web | 记录、统计接口与 Web 编辑/删除已补;待现场联调 | 记录字段和统计接口 |
|
||||||
| P2 | KB-003 脱敏案例沉淀 | 后端 + Web | 无 | 已脱敏病例审核发布流程 |
|
| P2 | KB-003 脱敏案例沉淀 | 后端 + Web | 后端审核发布与 Web 案例库已补;待目标环境验收 | 已脱敏病例审核发布流程 |
|
||||||
| P2 | TRACE-004 实验室结构化数据 | 后端 | 无 | 实验室结果/分型结构化关联 |
|
| P2 | TRACE-004 实验室结构化数据 | 后端 + Web | 结构化 CRUD 与 Web 录入已补;设备导入待做 | 实验室结果/分型结构化关联 |
|
||||||
| P2 | CI/OpenAPI/制品仓库 | 运维 + 测试 | CI 平台决策 | 自动门禁、OpenAPI、制品保留 |
|
| P2 | CI/OpenAPI/制品仓库 | 运维 + 测试 | CI 平台决策 | 自动门禁、OpenAPI、制品保留 |
|
||||||
| P2 | 数据保留与审计策略 | 运维 + 产品 | 业务确认 | 保留期限、删除/导出/匿名化流程 |
|
| P2 | 数据保留与审计策略 | 运维 + 产品 | 业务确认 | 保留期限、删除/导出/匿名化流程 |
|
||||||
| P2 | 推送远端并接入发布流程 | 运维 | Git 远端地址 | 提交推送到远端,正式发布门禁执行 |
|
| P2 | 推送远端并接入发布流程 | 运维 | Git 远端地址 | 提交推送到远端,正式发布门禁执行 |
|
||||||
|
|||||||
@@ -9,6 +9,100 @@
|
|||||||
- 《后续工作计划》(2026-08-11 更新)确定混合架构:现有 Go 底座 + Python AI 微服务(`ai-service/`,规划中);生产环境全部上云,本地仅开发/训练/测试。
|
- 《后续工作计划》(2026-08-11 更新)确定混合架构:现有 Go 底座 + Python AI 微服务(`ai-service/`,规划中);生产环境全部上云,本地仅开发/训练/测试。
|
||||||
- **YOLO 训练相关(计划 #1-4:数据集修复、训练环境、上传、基线训练)因物理机问题挂起**,当前按用户要求优先做「独立性强、见效快」的模块。
|
- **YOLO 训练相关(计划 #1-4:数据集修复、训练环境、上传、基线训练)因物理机问题挂起**,当前按用户要求优先做「独立性强、见效快」的模块。
|
||||||
- 开发服务器:`100.83.103.1`(Ubuntu 22.04,用户 `pan`),通过 NetBird VPN 访问;部署规则见 AGENTS.md「部署到开发服务器」章节。
|
- 开发服务器:`100.83.103.1`(Ubuntu 22.04,用户 `pan`),通过 NetBird VPN 访问;部署规则见 AGENTS.md「部署到开发服务器」章节。
|
||||||
|
- 当前工作区:本地 v9 对象授权、运营能力、组织管理代码已实现但未提交;开发服务器仍为已部署的 schema v8。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-08-16 补充:组织管理 Web 页面与成员列表接口
|
||||||
|
|
||||||
|
### 做了什么
|
||||||
|
|
||||||
|
- 后端新增 `GET /api/v1/organizations/:id/members`,返回成员及其用户用户名/姓名/邮箱/加入时间,并对非管理员做组织归属校验;
|
||||||
|
- 新增 `web/src/pages/Organizations.tsx` 与 `web/src/dal/organization.ts`:组织列表、新增/编辑、成员抽屉、添加成员、移除成员;
|
||||||
|
- Web 新增 `/organizations` 路由和「组织管理」菜单,权限为 `organization:manage`;
|
||||||
|
- 组织成员角色限定为 `member/admin`,避免误填任意字符串。
|
||||||
|
|
||||||
|
### 设计思路与决策依据
|
||||||
|
|
||||||
|
- IAM-003 需要管理员可见的“组织 → 成员”操作界面,因此先补齐成员列表接口,再接入页面;
|
||||||
|
- 页面复用现有 Ant Design + ProTable 风格,成员抽屉直接展示当前组织成员,添加时过滤已在组织内的用户;
|
||||||
|
- 组织成员权限仍沿用现有后端 `organization:manage`,不在前端保存或修改授权规则。
|
||||||
|
|
||||||
|
### 验证结果
|
||||||
|
|
||||||
|
- `go test ./...`、`go build ./...` 通过;
|
||||||
|
- `npm run build`、`npm run lint` 通过(仅既有无关 warning);
|
||||||
|
- 未部署开发服务器,未执行 v9 迁移和真实组织成员管理联调。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
- 本任务尚未提交;删除 `web/src/pages/Organizations.tsx`、`web/src/dal/organization.ts`、路由/菜单,并还原 `organization.go` 即可;
|
||||||
|
- 数据库无新增 schema;如已部署 v9,回滚仍按 v9 down migration 处理。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-08-15 补充:对象授权覆盖设备链路与运营能力前端闭环
|
||||||
|
|
||||||
|
### 做了什么
|
||||||
|
|
||||||
|
- IAM-003 从“房间/设备/业务记录”扩展到完整设备链路:`sensors`、`thresholds`、`alarms`、`telemetry`、`control`、`video record`、`alarm clip` 均按设备所属蚕房/组织过滤或返回 403;
|
||||||
|
- WebSocket 设备订阅改为 RBAC + `organization_members` 双重校验,跨组织用户不能再订阅同设备遥测;
|
||||||
|
- 通知列表按当前用户隔离,管理员保留全局查看;
|
||||||
|
- 案例草稿/待审核/驳回内容按来源蚕房组织或创建者可见,已发布案例仍全局共享;案例编辑和审核增加对象访问校验;
|
||||||
|
- 修复 `firstAccessibleRoomID` 在无 `roomId` 创建摄像头时错误按 `rooms.room_id` 过滤的 SQL 缺陷,改为 `rooms.org_id`;
|
||||||
|
- Web「运营能力」页补齐设备维护、产量损失的编辑/删除闭环,原有列表、新增、统计、案例库和实验室结果保持不变。
|
||||||
|
|
||||||
|
### 设计思路与决策依据
|
||||||
|
|
||||||
|
- 对象授权继续使用“组织 → 房间 → 业务对象”主链;`deviceKey` 作为遥测、告警、控制的统一入口,先解析到设备房间再判断;
|
||||||
|
- WS 订阅不允许仅凭 `device:read` 角色权限跨组织访问,与 HTTP 对象授权保持一致;
|
||||||
|
- 案例库是知识共享资产,因此已发布内容保持公开;非发布内容才按来源组织和创建者隔离;
|
||||||
|
- 设备维护/产量损失延续现有后端状态与权限种子,不在前端引入新的数据访问层。
|
||||||
|
|
||||||
|
### 验证结果
|
||||||
|
|
||||||
|
- `scripts/verify.ps1` exit 0:Go test/vet/build、Web test/lint/build、小程序 test/typecheck/build、APP typecheck/lint、AI pytest 均通过;
|
||||||
|
- 新增单测覆盖 sensor→device 归属解析、跨组织设备 403、`firstAccessibleRoomID` 组织范围、WS 组织成员放行/拒绝;
|
||||||
|
- 未部署开发服务器,未执行 v9 迁移和真实多组织账号越权联调。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
- 本任务尚未提交;回滚代码时恢复工作区对应修改即可;
|
||||||
|
- 若已执行 v9,数据库回滚执行 `migrations/000009_object_authority_and_business_capabilities.down.sql` 或恢复迁移前 `pg_dump`,随后回退旧二进制。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-08-14 补充:对象授权与后续计划 P2 业务能力
|
||||||
|
|
||||||
|
### 做了什么
|
||||||
|
|
||||||
|
- 新增 schema v9:`organizations`、`organization_members`、`rooms.org_id`、`device_maintenance_records`、`production_loss_records`、`case_studies`、`laboratory_results`,并给 `rearing_records` 增加死亡/淘汰字段;
|
||||||
|
- 实现 IAM-003 对象级授权底座:默认组织兼容历史数据、用户成员关系、房间/设备/视频/巡检/检测/会诊/溯源/生物安全等核心记录按组织房间过滤,列表隐藏、详情和写操作越权返回 403;
|
||||||
|
- 新增组织管理接口:`GET/POST/PATCH /organizations`、组织成员添加/移除;
|
||||||
|
- 新增 ENV-006 设备资产能力:`GET/POST/PATCH/DELETE /device-maintenance`,覆盖校准/故障/维护/固件记录;
|
||||||
|
- 新增 FARM-006 产量损失能力:`GET/POST/PATCH/DELETE /production-loss-records` 与 `/production-loss-records/stats`;
|
||||||
|
- 新增 KB-003 脱敏案例沉淀:`/case-studies` CRUD、从会诊生成草稿、`POST /case-studies/:id/review` 审核发布状态机;
|
||||||
|
- 新增 TRACE-004 实验室结构化数据:`GET/POST/PATCH/DELETE /laboratory-results`,关联溯源/发病事件/样本并支持病原、分型、环境样本和报告字段;
|
||||||
|
- Web 新增「运营能力」页:设备维护、产量损失统计、案例库审核、实验室结果四个工作区,路由 `/business`;
|
||||||
|
- 新增权限种子:`organization:manage`、`device:write`、`farm:read/write`、`case:read/write`、`lab:read/write`,并按角色补入权限映射。
|
||||||
|
|
||||||
|
### 设计思路与决策依据
|
||||||
|
|
||||||
|
- 对象授权以“组织 → 房间 → 业务记录”为主链,设备、视频、批次、检测、会诊和溯源统一通过房间归属收敛,避免给每张表重复维护对象权限;
|
||||||
|
- 历史数据通过 `default` 组织自动回填,新注册用户自动加入默认组织,避免升级后现有非管理员账号立刻失去可见性;
|
||||||
|
- 新增业务能力沿用现有 CRUD、权限中间件和版本化 SQL 迁移模式,不引入新的数据访问层。
|
||||||
|
|
||||||
|
### 验证结果
|
||||||
|
|
||||||
|
- 已执行 `go test ./...` 通过;`go build ./...` 通过;
|
||||||
|
- 已执行 `go vet ./...` 通过;`npm run lint`、`npm run build` 通过;
|
||||||
|
- 新增单测覆盖对象授权查询、设备维护/产量损失/案例状态机/实验室结果校验、产量损失聚合;
|
||||||
|
- 未部署开发服务器,schema 仍为本地 v9 迁移文件;服务器部署前需按 AGENTS.md 先备份、记录回滚点并执行迁移。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
- 本任务尚未提交;回滚代码时恢复工作区对应修改即可;
|
||||||
|
- 若已在开发库执行 v9,回滚需先恢复迁移前 `pg_dump`,或执行 `migrations/000009_object_authority_and_business_capabilities.down.sql` 后再恢复旧二进制。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+11
-9
@@ -9,7 +9,7 @@
|
|||||||
| 内核 | 6.8.0-124-generic |
|
| 内核 | 6.8.0-124-generic |
|
||||||
| 磁盘 | 387GB(/dev/sda6),可用 356GB |
|
| 磁盘 | 387GB(/dev/sda6),可用 356GB |
|
||||||
| 内存 | 16GB |
|
| 内存 | 16GB |
|
||||||
| 网络 | 192.168.1.4(物理网卡 enp4s0)+ 100.83.103.1(WireGuard VPN wt0) |
|
| 网络 | 192.168.1.4(物理网卡 enp4s0)+ 100.83.103.1(NetBird VPN) |
|
||||||
| SSH | pan@100.83.103.1 密码 pan |
|
| SSH | pan@100.83.103.1 密码 pan |
|
||||||
|
|
||||||
> 与 WSL2 部署的关键区别:无需 portproxy、无需 WSL2 IP 修复、文件系统不会变只读、Ceph 需从头配置。
|
> 与 WSL2 部署的关键区别:无需 portproxy、无需 WSL2 IP 修复、文件系统不会变只读、Ceph 需从头配置。
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
│ ├── Mgr (a)
|
│ ├── Mgr (a)
|
||||||
│ ├── OSD 0/1 (loop device, 各 50GB, 共 100GB)
|
│ ├── OSD 0/1 (loop device, 各 50GB, 共 100GB)
|
||||||
│ └── RGW :7480 S3 兼容存储
|
│ └── RGW :7480 S3 兼容存储
|
||||||
├── PostgreSQL 16 :5432 业务数据库
|
├── PostgreSQL 14 :5432 业务数据库(实际版本 14)
|
||||||
├── IoTDB 2.0.8 :18081 时序数据库
|
├── IoTDB 2.0.8 :18081 时序数据库
|
||||||
├── VerneMQ :1883 MQTT broker
|
├── VerneMQ :1883 MQTT broker
|
||||||
├── Go 后端 :3000 silk-server-go
|
├── Go 后端 :3000 silk-server-go
|
||||||
@@ -425,7 +425,7 @@ sudo docker run -d --name polaris-wvp --network media-net --restart always \
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 在 Windows 上交叉编译
|
# 在 Windows 上交叉编译
|
||||||
# cd e:\silk\server-go
|
# cd D:\silk-master-84e4ddc\server-go
|
||||||
# $env:GOOS="linux"; $env:GOARCH="amd64"; go build -o silk-server-go-linux ./cmd/server/
|
# $env:GOOS="linux"; $env:GOARCH="amd64"; go build -o silk-server-go-linux ./cmd/server/
|
||||||
# pscp -pw pan silk-server-go-linux pan@100.83.103.1:/home/pan/
|
# pscp -pw pan silk-server-go-linux pan@100.83.103.1:/home/pan/
|
||||||
|
|
||||||
@@ -461,11 +461,13 @@ nohup /home/pan/silk-server-go-linux > server-go.log 2>&1 < /dev/null &
|
|||||||
|
|
||||||
> **数据库迁移**:Go 后端启动时会自动执行 `server-go/migrations/` 下的 SQL 迁移并校验 `schema_migrations` 版本;生产环境不要开启 `ALLOW_DEV_AUTOMIGRATE=true`。升级前先按 AGENTS.md 完成数据库备份。
|
> **数据库迁移**:Go 后端启动时会自动执行 `server-go/migrations/` 下的 SQL 迁移并校验 `schema_migrations` 版本;生产环境不要开启 `ALLOW_DEV_AUTOMIGRATE=true`。升级前先按 AGENTS.md 完成数据库备份。
|
||||||
|
|
||||||
|
> **当前迁移版本**:开发服务器已部署 `schemaVersion=8`;本地代码 `CurrentSchemaVersion=9`,v9 新增 `organizations`、`organization_members`、设备维护、产量损失、脱敏案例、实验室结果等表,并给 `rooms.org_id`、`rearing_records` 增加列。部署 v9 前必须先 `pg_dump` 全库备份,并确认 `000009_object_authority_and_business_capabilities.up.sql` 应用成功。
|
||||||
|
|
||||||
### 11. 部署 Go 录制服务
|
### 11. 部署 Go 录制服务
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 在 Windows 上交叉编译
|
# 在 Windows 上交叉编译
|
||||||
# cd e:\silk\wvp\recorder-go
|
# cd D:\silk-master-84e4ddc\wvp\recorder-go
|
||||||
# $env:GOOS="linux"; $env:GOARCH="amd64"; go build -o recorder-go-linux .
|
# $env:GOOS="linux"; $env:GOARCH="amd64"; go build -o recorder-go-linux .
|
||||||
# pscp -pw pan recorder-go-linux pan@100.83.103.1:/home/pan/
|
# pscp -pw pan recorder-go-linux pan@100.83.103.1:/home/pan/
|
||||||
|
|
||||||
@@ -477,10 +479,10 @@ nohup /home/pan/recorder-go-linux > /home/pan/recorder.log 2>&1 < /dev/null &
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 在 Windows 上构建
|
# 在 Windows 上构建
|
||||||
# cd e:\silk\web; npm run build
|
# cd D:\silk-master-84e4ddc\web; npm run build
|
||||||
# tar -czf dist.tar.gz dist
|
# tar -czf dist.tar.gz dist
|
||||||
# pscp -pw pan dist.tar.gz pan@100.83.103.1:/home/pan/
|
# pscp -pw pan dist.tar.gz pan@100.83.103.1:/home/pan/
|
||||||
# pscp -pw pan e:\silk\wvp\recorder-go\server.js pan@100.83.103.1:/home/pan/silk/web/server.cjs
|
# pscp -pw pan D:\silk-master-84e4ddc\wvp\recorder-go\server.js pan@100.83.103.1:/home/pan/silk/web/server.cjs
|
||||||
|
|
||||||
mkdir -p /home/pan/silk/web
|
mkdir -p /home/pan/silk/web
|
||||||
cd /home/pan/silk/web
|
cd /home/pan/silk/web
|
||||||
@@ -647,13 +649,13 @@ nohup node /home/pan/silk/web/server.cjs > /home/pan/silk/web/web.log 2>&1 < /de
|
|||||||
|
|
||||||
| 参数 | 值 | 说明 |
|
| 参数 | 值 | 说明 |
|
||||||
|------|-----|------|
|
|------|-----|------|
|
||||||
| SIP 服务器 IP | `100.83.103.1` | WireGuard VPN IP |
|
| SIP 服务器 IP | `100.83.103.1` | NetBird VPN IP |
|
||||||
| SIP 服务器端口 | `8116` | TCP/UDP,Docker 映射 `-p 8116:8116/tcp -p 8116:8116/udp` |
|
| SIP 服务器端口 | `8116` | TCP/UDP,Docker 映射 `-p 8116:8116/tcp -p 8116:8116/udp` |
|
||||||
| SIP 域 | `3402000000` | |
|
| SIP 域 | `3402000000` | |
|
||||||
| SIP 服务器 ID | `34020000002000000001` | |
|
| SIP 服务器 ID | `34020000002000000001` | |
|
||||||
| SIP 密码 | `12345678` | |
|
| SIP 密码 | `12345678` | |
|
||||||
|
|
||||||
> 摄像头需能访问 `100.83.103.1`(WireGuard VPN)。若摄像头不在 VPN 网络,改用物理网卡 IP `192.168.1.4`,同时修改 WVP 配置中的 `sip.ip`、`media.sdp-ip`、`media.stream-ip`。
|
> 摄像头需能访问 `100.83.103.1`(NetBird VPN)。若摄像头不在 VPN 网络,改用物理网卡 IP `192.168.1.4`,同时修改 WVP 配置中的 `sip.ip`、`media.sdp-ip`、`media.stream-ip`。
|
||||||
|
|
||||||
## 八、常见问题
|
## 八、常见问题
|
||||||
|
|
||||||
@@ -790,7 +792,7 @@ func fixZlmPort(u string) string {
|
|||||||
重新交叉编译并部署:
|
重新交叉编译并部署:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd e:\silk\server-go
|
cd D:\silk-master-84e4ddc\server-go
|
||||||
$env:GOOS="linux"; $env:GOARCH="amd64"
|
$env:GOOS="linux"; $env:GOARCH="amd64"
|
||||||
go build -o silk-server-go-linux ./cmd/server/
|
go build -o silk-server-go-linux ./cmd/server/
|
||||||
$env:GOOS=""; $env:GOARCH=""
|
$env:GOOS=""; $env:GOARCH=""
|
||||||
|
|||||||
Reference in New Issue
Block a user