From d205d4845bf17ac7346aad35e7edf5f58f66dd9c Mon Sep 17 00:00:00 2001 From: weijuesen Date: Mon, 17 Aug 2026 21:43:26 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E5=90=8C=E6=AD=A5=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=20v9=20=E6=95=B4=E6=94=B9=E4=B8=8E=E8=BF=90=E8=90=A5=E8=83=BD?= =?UTF-8?q?=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 11 +- README.md | 35 +- docs/acceptance/core-e2e-cases.md | 10 + docs/acceptance/release-checklist.md | 3 + docs/acceptance/requirements-traceability.md | 18 +- server-go/cmd/server/main.go | 5 + server-go/internal/database/db.go | 26 + server-go/internal/database/migrate.go | 2 +- server-go/internal/database/migrate_test.go | 4 + server-go/internal/handler/access.go | 383 ++++++++++++ server-go/internal/handler/access_test.go | 170 ++++++ server-go/internal/handler/alarm.go | 16 +- server-go/internal/handler/alarm_clip.go | 3 + server-go/internal/handler/auth.go | 2 + server-go/internal/handler/biosecurity.go | 37 +- server-go/internal/handler/case_study.go | 310 ++++++++++ server-go/internal/handler/consultation.go | 39 +- server-go/internal/handler/control.go | 50 +- server-go/internal/handler/detection_task.go | 51 +- server-go/internal/handler/device.go | 19 +- .../internal/handler/device_maintenance.go | 179 ++++++ server-go/internal/handler/farm.go | 203 +++++++ server-go/internal/handler/health.go | 8 + server-go/internal/handler/inspection.go | 6 +- server-go/internal/handler/laboratory.go | 202 +++++++ server-go/internal/handler/lamp.go | 51 +- server-go/internal/handler/notification.go | 10 +- server-go/internal/handler/organization.go | 201 +++++++ server-go/internal/handler/room.go | 24 +- server-go/internal/handler/sensor.go | 24 +- server-go/internal/handler/telemetry.go | 23 +- server-go/internal/handler/threshold.go | 25 +- server-go/internal/handler/trace.go | 57 +- server-go/internal/handler/tray_batch.go | 43 +- server-go/internal/handler/video_camera.go | 41 +- server-go/internal/handler/video_clip.go | 5 +- server-go/internal/handler/video_record.go | 31 +- .../internal/model/business_capabilities.go | 216 +++++++ .../model/business_capabilities_test.go | 77 +++ server-go/internal/model/models.go | 63 +- server-go/internal/model/permission_seed.go | 13 + server-go/internal/model/tray_batch.go | 2 + server-go/internal/service/farm_stats.go | 53 ++ server-go/internal/service/farm_stats_test.go | 26 + server-go/internal/ws/authorizer.go | 24 +- server-go/internal/ws/authorizer_test.go | 82 +++ ...thority_and_business_capabilities.down.sql | 14 + ...authority_and_business_capabilities.up.sql | 153 +++++ web/src/dal/business.ts | 126 ++++ web/src/dal/organization.ts | 47 ++ web/src/layout/BasicLayout.tsx | 3 + web/src/pages/BusinessExtensions.tsx | 543 ++++++++++++++++++ web/src/pages/Organizations.tsx | 227 ++++++++ web/src/router.tsx | 4 + 变更记录.md | 29 + 后续工作计划.md | 14 +- 开发交接记录.md | 94 +++ 部署指南(物理机).md | 20 +- 58 files changed, 4042 insertions(+), 115 deletions(-) create mode 100644 server-go/internal/handler/access.go create mode 100644 server-go/internal/handler/access_test.go create mode 100644 server-go/internal/handler/case_study.go create mode 100644 server-go/internal/handler/device_maintenance.go create mode 100644 server-go/internal/handler/farm.go create mode 100644 server-go/internal/handler/laboratory.go create mode 100644 server-go/internal/handler/organization.go create mode 100644 server-go/internal/model/business_capabilities.go create mode 100644 server-go/internal/model/business_capabilities_test.go create mode 100644 server-go/internal/service/farm_stats.go create mode 100644 server-go/internal/service/farm_stats_test.go create mode 100644 server-go/internal/ws/authorizer_test.go create mode 100644 server-go/migrations/000009_object_authority_and_business_capabilities.down.sql create mode 100644 server-go/migrations/000009_object_authority_and_business_capabilities.up.sql create mode 100644 web/src/dal/business.ts create mode 100644 web/src/dal/organization.ts create mode 100644 web/src/pages/BusinessExtensions.tsx create mode 100644 web/src/pages/Organizations.tsx diff --git a/AGENTS.md b/AGENTS.md index 47dcd0f..9414f65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ 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` 交叉编译生成。 -## 已完成功能(截至 2026-08-14) +## 已完成功能(截至 2026-08-17) 按《后续工作计划》推进(YOLO 训练相关 #1-4 因物理机问题挂起)。详细设计、验证与回滚点见根目录 `变更记录.md`、`开发交接记录.md` 及服务器 `/home/pan/backups/rollback-20260812/README.md`。 @@ -70,9 +70,16 @@ - 已完成安全与正确性整改:AI 风险语义、Mock 隔离、WebSocket 越权、AI 流 SSRF、视频访问与密钥输出、用户级巡检幂等; - 已完成工程可靠性与业务闭环:版本化迁移、可靠通知/Outbox、统一检测任务/样本/发病事件、生物安全、离线巡检、会诊治理、知识审核、效果评估、可观测性; - 已完成规格追踪、核心 E2E 用例和发布门禁文档,见 `docs/acceptance/`; -- 整改基线已合并到 `dev_wjs` 并部署开发服务器;当前 schema 版本为 8; +- 整改基线已合并到 `dev_wjs` 并部署开发服务器;开发服务器当前 schema v8,本地新增 v9 尚未部署; - 开发服务器已验证: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`);正式巡检任务编排需摄像头视角数据,仍按计划二期。 diff --git a/README.md b/README.md index 96dac6b..f510f80 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 系统提供 **Web 后台**、**React Native 移动端 APP**、**微信小程序** 三端界面,后端统一由 **Go 服务**提供 API。 -> **最新进度**:AI 巡检闭环、知识库、分子检测、专家会诊、疫病溯源、天气预警、健康画像等已完成;整改 Task 0~14 代码与文档已完成并部署到开发服务器,详见根目录 `AGENTS.md`「已完成功能(截至 2026-08-14)」与 `开发交接记录.md`。 +> **最新进度**:AI 巡检闭环、知识库、分子检测、专家会诊、疫病溯源、天气预警、健康画像等已完成;整改 Task 0~14 代码与文档已完成并部署到开发服务器。本地新增 v9 组织/房间对象授权、运营能力和组织管理页面,尚未部署,详见 `开发交接记录.md`。 ## 1. 核心能力 @@ -43,7 +43,7 @@ ### 1.5 RBAC 权限管理 - 4 个角色:`admin`(管理员)、`operator`(操作员)、`viewer`(查看者)、`farmer`(养殖员) -- 36 个权限码:基础看板/蚕房/设备/阈值/告警/视频/能耗/日志/用户/审计,并扩展知识库、巡检、蚕匾批次、分子检测、耗材、会诊、溯源、天气、订阅等 +- 46 个权限码:基础看板/蚕房/设备/阈值/告警/视频/能耗/日志/用户/审计,并扩展知识库、巡检、蚕匾批次、分子检测、耗材、会诊、溯源、天气、订阅、组织、设备维护、产量损失、案例库、实验室等 - 后端 `RequirePermission` 中间件校验权限码,admin 自动放行 - 前端路由守卫 + 菜单动态过滤 + `` 按钮级控制 - 新用户注册默认角色为 `viewer` @@ -65,6 +65,7 @@ - 蚕匾(`trays`)、蚕种批次(`batches`,含品种/蚕种来源/批次号/检疫证明/龄期/入房/上蔟)、饲养记录(`rearing_records`)三表 + CRUD - Web「批次管理」页(批次 + 蚕匾 + 饲养记录);小程序蚕房详情展示蚕匾与当前批次 - 生物安全:种源与检疫链、消毒计划/执行、批次/蚕匾/样本二维码身份;小程序可扫码并现场提交消毒执行 +- 运营能力:组织/房间级对象授权、设备校准/故障/维护/固件、产量损失统计、脱敏案例库、实验室结构化结果 ### 1.9 AI 拍照巡检闭环(计划 #5/#6/#8/#9) @@ -120,6 +121,13 @@ - 用户 openid 绑定与订阅授权(`/wechat/bind|subscribe`);巡检风险非绿异步触发 - 真实推送需配置 `WECHAT_APPID/SECRET/TEMPLATE_*` 并申请订阅消息模板 +### 1.18 组织与对象级授权(IAM-003) + +- `organizations` / `organization_members` + `rooms.org_id` 组成“组织 → 房间 → 业务记录”授权链 +- 房间、设备、传感器、阈值、告警、遥测、控制、录像、巡检、检测、会诊、溯源、生物安全等核心对象按组织/房间过滤,越权返回 403 +- WebSocket 设备订阅同时校验 RBAC 与组织成员关系 +- Web「组织管理」页支持组织新增/编辑、成员添加/移除;默认组织兼容历史数据,新注册用户自动加入 + ## 2. 技术架构 ```text @@ -355,6 +363,11 @@ Authorization: Bearer | `GET/POST /wechat/binding`、`/wechat/bind`、`/wechat/subscribe` | 微信订阅绑定/授权 | | `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 /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 /detection-methods/recommend` | 多检测方式推荐 | | `GET/POST/PATCH/DELETE /consumables`、`/consumables/alerts`、`/consumables/purchase-suggestions` | 耗材管理 | @@ -431,7 +444,7 @@ Payload 示例: ### 10.1 PostgreSQL(业务数据) -保存用户、蚕房、设备、传感器、阈值、告警、摄像头、录像片段、审计日志、权限等业务数据。系统启动时先执行 `server-go/migrations/` 下的版本化 SQL 迁移,再初始化权限种子数据;生产环境禁用 AutoMigrate。 +保存用户、蚕房、设备、传感器、阈值、告警、摄像头、录像片段、审计日志、权限等业务数据。系统启动时先执行 `server-go/migrations/` 下的版本化 SQL 迁移,再初始化权限种子数据;生产环境禁用 AutoMigrate。本地代码当前为 schema v9(对象授权与运营能力),开发服务器仍为已部署的 schema v8。 ```env PG=postgresql://postgres:pan@localhost:5432/silk @@ -473,7 +486,7 @@ S3_BUCKET=silk-video-events ├── Go 后端 :3000 API 后端 (Gin) ├── 前端静态服务 :5174 Node.js server.cjs ├── Go 录制服务 :9090 recorder-go - ├── PostgreSQL 16 :5432 业务数据库 + ├── PostgreSQL 14 :5432 业务数据库(实际版本 14) ├── IoTDB 2.0.8 :18081 时序数据库 ├── Valkey :6379 缓存 ├── VerneMQ :1883 MQTT broker @@ -489,13 +502,13 @@ S3_BUCKET=silk-video-events ```powershell # 交叉编译 -cd e:\silk\server-go +cd D:\silk-master-84e4ddc\server-go $env:GOOS="linux"; $env:GOARCH="amd64" go build -o server-go-linux ./cmd/server $env:GOOS=""; $env:GOARCH="" # 上传 + 启动(使用 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 /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 前端 ```powershell -cd e:\silk\web +cd D:\silk-master-84e4ddc\web npm install npm run dev # 开发模式,端口 5173,代理 /api 到 :3000 npm run build # 生产构建 @@ -545,7 +558,7 @@ npm test # Vitest 单元测试 ### 12.2 React Native APP ```powershell -cd e:\silk\app +cd D:\silk-master-84e4ddc\app npm install # 配置后端地址(.env 文件) @@ -566,7 +579,7 @@ cd android ### 12.3 微信小程序 ```powershell -cd e:\silk\miniapp +cd D:\silk-master-84e4ddc\miniapp npm install npm run dev:weapp # 微信小程序开发模式 @@ -578,7 +591,7 @@ npm run build:h5 # H5 生产构建 ### 12.4 设备模拟器 ```powershell -cd e:\silk\tools\simulator +cd D:\silk-master-84e4ddc\tools\simulator npm install npm run start ``` @@ -593,7 +606,7 @@ npm run start ### 12.6 ai-service(AI 推理服务) ```powershell -cd e:\silk\ai-service +cd D:\silk-master-84e4ddc\ai-service python -m venv .venv .venv\Scripts\pip install -r requirements.txt # Linux: .venv/bin/pip install -r requirements.txt .venv\Scripts\python -m pytest tests -q # 单元测试 diff --git a/docs/acceptance/core-e2e-cases.md b/docs/acceptance/core-e2e-cases.md index 0de4aa9..507353c 100644 --- a/docs/acceptance/core-e2e-cases.md +++ b/docs/acceptance/core-e2e-cases.md @@ -61,6 +61,16 @@ 2. 核对巡检、检测、会诊、溯源、图片/光谱/录像引用。 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 开发服务器执行结果 环境:`100.83.103.1`,Go schema v8,Web :5174,AI :8000(mock)。 diff --git a/docs/acceptance/release-checklist.md b/docs/acceptance/release-checklist.md index 79a464d..20602ab 100644 --- a/docs/acceptance/release-checklist.md +++ b/docs/acceptance/release-checklist.md @@ -6,6 +6,7 @@ - [ ] 执行 `powershell -ExecutionPolicy Bypass -File scripts/verify.ps1`,exit 0。 - [ ] 执行 `git diff --check`,exit 0。 - [ ] 涉及 schema 时完成 `pg_dump` 备份并记录恢复命令。 +- [ ] v9 发布前确认 `000009_object_authority_and_business_capabilities.up.sql`、`.down.sql` 路径和回滚顺序。 - [ ] 检查磁盘、端口、服务进程和依赖健康。 - [ ] 核对需求追踪矩阵:本次涉及的需求 ID 有实现、测试和限制说明。 @@ -16,12 +17,14 @@ - [ ] 执行数据库迁移。 - [ ] 重启服务。 - [ ] 健康检查和核心接口冒烟。 +- [ ] 登录管理员并冒烟 `/organizations`、`/organizations/:id/members`、Web `/organizations` 页面。 ## 发布后 - [ ] 记录部署 commit、迁移版本、备份路径、冒烟结果。 - [ ] 更新 `变更记录.md` 和 `开发交接记录.md`。 - [ ] 执行核心端到端用例中受影响的 E2E。 +- [ ] 对象授权改动时执行 E2E-11 跨组织越权用例。 - [ ] 如失败,按回滚预案恢复并记录原因。 ## 回滚 diff --git a/docs/acceptance/requirements-traceability.md b/docs/acceptance/requirements-traceability.md index 8291c07..9e45842 100644 --- a/docs/acceptance/requirements-traceability.md +++ b/docs/acceptance/requirements-traceability.md @@ -14,8 +14,8 @@ | 需求 ID | 实现 | 自动测试 | 手工用例 | 环境 | 证据 | 状态 | 限制 | 负责人 | |---|---|---|---|---|---|---|---|---| | 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-003 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 需组织/区域/房间 ACL | 后端 | +| IAM-002 | `middleware/permission.go`、`permission_seed.go` | 无专项测试 | 角色访问矩阵 | 开发 | 路由权限检查 | 部分可用 | 对象级 ACL 已由 IAM-003 补齐 | 后端 | +| 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 | 无 | 无 | 无 | 决策记录 | 延后 | 默认凭据和强制改密待最后阶段 | 后端+运维 | | 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 需人工审阅 | 后端 | @@ -51,16 +51,16 @@ |---|---|---|---|---|---|---|---|---| | FARM-001 | `tray_batch.go` | `knowledge_seed_test.go` 等 | 蚕房/批次 CRUD | 本地 | 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-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-002 | `threshold.go`、`alarm.go` | 无专项测试 | 阈值告警 | 开发 | 构建 | 已实现 | 无对象级授权 | 后端 | +| ENV-002 | `threshold.go`、`alarm.go` | 无专项测试 | 阈值告警 | 开发 | 构建 | 已实现 | 阈值/告警已按组织房间过滤 | 后端 | | 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-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 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 安全控制建议 | 后端 | | EXP-001 | `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 | 部分可用 | 损失/成本缺失 | 后端 | | KB-001 | `knowledge.go` | `knowledge_governance_test.go` | 审核发布 | 本地 | go test | 部分可用 | 审核权限基于角色 | 后端+Web | | 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-002 | `trace.go` auto | 无专项测试 | 自动溯源 | 本地 | 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-006 | `region_stat.go` | `region_stat_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-003 | 未完成 | 无 | 无 | 无 | 无 | 未完成 | 录制状态持久化 | 后端 | | VIDEO-004 | `stream_tasks.py` | AI pytest | 受限拉流 | 本地 | pytest | 部分可用 | 摄像头真实视角待接入 | AI | diff --git a/server-go/cmd/server/main.go b/server-go/cmd/server/main.go index 8d798b8..da240a3 100644 --- a/server-go/cmd/server/main.go +++ b/server-go/cmd/server/main.go @@ -135,6 +135,10 @@ func main() { handler.RegisterDetectionTaskRoutes(api, db) handler.RegisterConsumableRoutes(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.RegisterDetectionMethodRoutes(api, db) handler.RegisterTraceRoutes(api, db) @@ -150,6 +154,7 @@ func main() { // 用户管理与审计日志路由(各路由内部按权限码校验) handler.RegisterAuditRoutes(api, db) handler.RegisterUserRoutes(api, db) + handler.RegisterOrganizationRoutes(api, db) handler.RegisterPermissionRoutes(api, db) handler.RegisterOpsRoutes(api, db) diff --git a/server-go/internal/database/db.go b/server-go/internal/database/db.go index 3894b29..23f65ba 100644 --- a/server-go/internal/database/db.go +++ b/server-go/internal/database/db.go @@ -34,6 +34,9 @@ func Init(cfg *config.Config) error { &model.Threshold{}, &model.Alarm{}, &model.Camera{}, &model.VideoClip{}, &model.AuditLog{}, &model.Telemetry{}, &model.Permission{}, &model.RolePermission{}, + &model.Organization{}, &model.OrganizationMember{}, + &model.DeviceMaintenanceRecord{}, &model.ProductionLossRecord{}, + &model.CaseStudy{}, &model.LaboratoryResult{}, &model.Disease{}, &model.KnowledgeArticle{}, &model.InspectionRecord{}, &model.OutboxEvent{}, @@ -60,6 +63,7 @@ func Init(cfg *config.Config) error { } slog.Warn("开发环境 AutoMigrate 已启用,SQL 迁移仍是生产 schema 事实来源") } + seedOrganizations(db) // 初始化权限种子数据 seedPermissions(db) // 初始化知识库种子数据 @@ -68,6 +72,28 @@ func Init(cfg *config.Config) error { 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 初始化知识库种子数据(幂等:按名称/类型+标题判重) func seedKnowledge(db *gorm.DB) { for _, d := range model.SeedDiseases { diff --git a/server-go/internal/database/migrate.go b/server-go/internal/database/migrate.go index e581b86..fba9dac 100644 --- a/server-go/internal/database/migrate.go +++ b/server-go/internal/database/migrate.go @@ -13,7 +13,7 @@ import ( ) // CurrentSchemaVersion 是当前后端代码期望的迁移版本。 -const CurrentSchemaVersion = "8" +const CurrentSchemaVersion = "9" // RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。 func RunMigrations(db *gorm.DB) error { diff --git a/server-go/internal/database/migrate_test.go b/server-go/internal/database/migrate_test.go index 5a83e75..8430e0a 100644 --- a/server-go/internal/database/migrate_test.go +++ b/server-go/internal/database/migrate_test.go @@ -124,4 +124,8 @@ func TestEmbeddedMigrationsIncludeBaseline(t *testing.T) { if err != nil || next != 8 { 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) + } } diff --git a/server-go/internal/handler/access.go b/server-go/internal/handler/access.go new file mode 100644 index 0000000..84a1d49 --- /dev/null +++ b/server-go/internal/handler/access.go @@ -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 + } +} diff --git a/server-go/internal/handler/access_test.go b/server-go/internal/handler/access_test.go new file mode 100644 index 0000000..c3e0900 --- /dev/null +++ b/server-go/internal/handler/access_test.go @@ -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) + } +} diff --git a/server-go/internal/handler/alarm.go b/server-go/internal/handler/alarm.go index 5b1f47d..15bd2d4 100644 --- a/server-go/internal/handler/alarm.go +++ b/server-go/internal/handler/alarm.go @@ -23,9 +23,12 @@ func RegisterAlarmRoutes(rg *gin.RouterGroup, db *gorm.DB) { func listAlarms(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { 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" { - q = q.Where("open = true") + q = q.Where("alarms.open = true") } var alarms []model.Alarm q.Find(&alarms) @@ -37,6 +40,9 @@ func listAlarms(db *gorm.DB) gin.HandlerFunc { func getAlarm(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "alarm", id) { + return + } var alarm model.Alarm if db.Where("id = ?", id).First(&alarm).Error != nil { 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 { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "alarm", id) { + return + } var alarm model.Alarm if db.Where("id = ?", id).First(&alarm).Error != nil { 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 { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "alarm", id) { + return + } var alarm model.Alarm if db.Where("id = ?", id).First(&alarm).Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "alarm not found"}) diff --git a/server-go/internal/handler/alarm_clip.go b/server-go/internal/handler/alarm_clip.go index 536e102..053ff83 100644 --- a/server-go/internal/handler/alarm_clip.go +++ b/server-go/internal/handler/alarm_clip.go @@ -27,6 +27,9 @@ func getAlarmClip(db *gorm.DB) gin.HandlerFunc { } id := c.Param("id") + if !requireObjectAccess(c, db, "alarm", id) { + return + } // 先检查告警是否存在 var alarm model.Alarm diff --git a/server-go/internal/handler/auth.go b/server-go/internal/handler/auth.go index 1d6157c..b316eba 100644 --- a/server-go/internal/handler/auth.go +++ b/server-go/internal/handler/auth.go @@ -85,6 +85,7 @@ func registerHandler(db *gorm.DB, cfg *config.Config) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": "创建用户失败"}) return } + assignDefaultOrganization(db, user.ID) // 记录审计日志 uid := user.ID @@ -448,6 +449,7 @@ func ensureDefaultAdmin(db *gorm.DB, cfg *config.Config) { slog.Error("创建默认 admin 失败", "error", err) return } + assignDefaultOrganization(db, admin.ID) // 记录审计日志 uid := admin.ID diff --git a/server-go/internal/handler/biosecurity.go b/server-go/internal/handler/biosecurity.go index 2aa6560..d0e6c1a 100644 --- a/server-go/internal/handler/biosecurity.go +++ b/server-go/internal/handler/biosecurity.go @@ -29,7 +29,9 @@ func RegisterBiosecurityRoutes(rg *gin.RouterGroup, db *gorm.DB) { func listSeedSources(db *gorm.DB) gin.HandlerFunc { 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 != "" { q = q.Where("batch_id = ?", batch) } @@ -62,6 +64,12 @@ func createSeedSource(db *gorm.DB) gin.HandlerFunc { 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{ PublicID: randomPublicID(), BatchID: body.BatchID, @@ -96,6 +104,9 @@ func createSeedSource(db *gorm.DB) gin.HandlerFunc { func updateSeedSource(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { + if !requireObjectAccess(c, db, "seed_source", c.Param("id")) { + return + } var source model.SeedSource if db.Where("id = ?", c.Param("id")).First(&source).Error != nil { 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 { 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 != "" { 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"}) 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 { c.JSON(http.StatusBadRequest, gin.H{"error": "创建消毒记录失败"}) return @@ -225,6 +246,9 @@ func createDisinfectionRecord(db *gorm.DB) gin.HandlerFunc { func updateDisinfectionRecord(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { + if !requireObjectAccess(c, db, "disinfection_record", c.Param("id")) { + return + } var record model.DisinfectionRecord if db.Where("id = ?", c.Param("id")).First(&record).Error != nil { 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 无效"}) return } + if !requireObjectAccess(c, db, body.EntityType, body.EntityID) { + return + } if !entityExists(db, body.EntityType, body.EntityID) { c.JSON(http.StatusNotFound, gin.H{"error": "二维码关联实体不存在"}) return @@ -333,6 +360,12 @@ func resolveQR(db *gorm.DB) gin.HandlerFunc { c.JSON(http.StatusNotFound, gin.H{"error": "二维码关联实体不存在"}) 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{ "entityType": link.EntityType, "publicId": link.PublicID, diff --git a/server-go/internal/handler/case_study.go b/server-go/internal/handler/case_study.go new file mode 100644 index 0000000..3ba4132 --- /dev/null +++ b/server-go/internal/handler/case_study.go @@ -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 +} diff --git a/server-go/internal/handler/consultation.go b/server-go/internal/handler/consultation.go index 2e3e3db..e94b139 100644 --- a/server-go/internal/handler/consultation.go +++ b/server-go/internal/handler/consultation.go @@ -27,7 +27,7 @@ func RegisterConsultationRoutes(rg *gin.RouterGroup, db *gorm.DB) { // listConsultations 会诊单列表(status 过滤) func listConsultations(db *gorm.DB) gin.HandlerFunc { 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 != "" { q = q.Where("status = ?", status) } @@ -57,6 +57,9 @@ func fillConsultationRoomNames(db *gorm.DB, list []model.Consultation) { // getConsultation 会诊单详情 func getConsultation(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { + if !requireObjectAccess(c, db, "consultation", c.Param("id")) { + return + } var t model.Consultation if db.Where("id = ?", c.Param("id")).First(&t).Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "consultation not found"}) @@ -87,6 +90,16 @@ func createConsultation(db *gorm.DB) gin.HandlerFunc { 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{ RoomID: body.RoomID, @@ -167,6 +180,9 @@ func buildConsultationSnapshot(db *gorm.DB, roomID, batchID *string, lamp *model func updateConsultation(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "consultation", id) { + return + } var t model.Consultation if db.Where("id = ?", id).First(&t).Error != nil { 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()}) 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 { to, _ := s.(string) if !model.ValidConsultationTransition(t.Status, to) { @@ -206,6 +237,9 @@ func updateConsultation(db *gorm.DB) gin.HandlerFunc { func resolveConsultation(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "consultation", id) { + return + } var t model.Consultation if db.Where("id = ?", id).First(&t).Error != nil { 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 { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "consultation", id) { + return + } var t model.Consultation if db.Where("id = ?", id).First(&t).Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "consultation not found"}) diff --git a/server-go/internal/handler/control.go b/server-go/internal/handler/control.go index b498cef..5f4222d 100644 --- a/server-go/internal/handler/control.go +++ b/server-go/internal/handler/control.go @@ -51,8 +51,8 @@ type ControlCommand struct { // RegisterControlRoutes 注册控制命令路由 func RegisterControlRoutes(rg *gin.RouterGroup, db *gorm.DB) { perm := middleware.RequirePermission(db, "device:control") - rg.POST("/control/send", perm, sendControl()) - rg.POST("/control/batch", perm, batchControl()) + rg.POST("/control/send", perm, sendControl(db)) + rg.POST("/control/batch", perm, batchControl(db)) rg.POST("/devices/:id/gstmb1/command", perm, sendGSTMB1Command(db)) rg.POST("/devices/:id/gstmb1/info", perm, sendGSTMB1Info(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 主题) -func sendControl() gin.HandlerFunc { +func sendControl(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { var cmd ControlCommand if err := c.ShouldBindJSON(&cmd); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + if !requireDeviceKeyAccess(c, db, cmd.DeviceKey) { + return + } result, err := publishControl(cmd) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -91,6 +94,9 @@ func sendControl() gin.HandlerFunc { // sendPlugOn 插座通电 {"type":"event","key":1} func sendPlugOn(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { + if !requireObjectAccess(c, db, "device", c.Param("id")) { + return + } result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{ "type": "event", "key": 1, @@ -106,6 +112,9 @@ func sendPlugOn(db *gorm.DB) gin.HandlerFunc { // sendPlugOff 插座断电 {"type":"event","key":0} func sendPlugOff(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { + if !requireObjectAccess(c, db, "device", c.Param("id")) { + return + } result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{ "type": "event", "key": 0, @@ -121,6 +130,9 @@ func sendPlugOff(db *gorm.DB) gin.HandlerFunc { // sendPlugStatistic 查询插座电量信息 {"type":"statistic"} func sendPlugStatistic(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { + if !requireObjectAccess(c, db, "device", c.Param("id")) { + return + } result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{ "type": "statistic", "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}} func sendIRLearn(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { + if !requireObjectAccess(c, db, "device", c.Param("id")) { + return + } var body struct { No int `json:"no"` } @@ -161,6 +176,9 @@ func sendIRLearn(db *gorm.DB) gin.HandlerFunc { // sendIREmit 发射红外码 {"type":"infrared","action":"emit","data":{"no":N}} func sendIREmit(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { + if !requireObjectAccess(c, db, "device", c.Param("id")) { + return + } var body struct { No int `json:"no"` } @@ -184,6 +202,9 @@ func sendIREmit(db *gorm.DB) gin.HandlerFunc { // sendIRCancel 取消学习 {"type":"infrared","action":"learnCancel"} func sendIRCancel(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { + if !requireObjectAccess(c, db, "device", c.Param("id")) { + return + } result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{ "type": "infrared", "action": "learnCancel", @@ -199,6 +220,9 @@ func sendIRCancel(db *gorm.DB) gin.HandlerFunc { // sendIRErase 擦除全部红外码 {"type":"infrared","action":"erase"} func sendIRErase(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { + if !requireObjectAccess(c, db, "device", c.Param("id")) { + return + } result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{ "type": "infrared", "action": "erase", @@ -214,6 +238,9 @@ func sendIRErase(db *gorm.DB) gin.HandlerFunc { // getIRStatus 查询红外操作结果 func getIRStatus(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { + if !requireObjectAccess(c, db, "device", c.Param("id")) { + return + } deviceKey, err := getDeviceKey(db, c.Param("id")) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) @@ -234,7 +261,7 @@ func getIRStatus(db *gorm.DB) gin.HandlerFunc { } // batchControl 批量下发控制命令 -func batchControl() gin.HandlerFunc { +func batchControl(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { var body struct { Commands []ControlCommand `json:"commands"` @@ -246,6 +273,9 @@ func batchControl() gin.HandlerFunc { results := make([]gin.H, 0, len(body.Commands)) for _, cmd := range body.Commands { + if !requireDeviceKeyAccess(c, db, cmd.DeviceKey) { + continue + } result, err := publishControl(cmd) if err != nil { 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 原样转发) func sendGSTMB1Command(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { + if !requireObjectAccess(c, db, "device", c.Param("id")) { + return + } var body map[string]interface{} if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -345,6 +378,9 @@ func sendGSTMB1Command(db *gorm.DB) gin.HandlerFunc { // sendGSTMB1Info 获取设备信息 {"type":"info"} func sendGSTMB1Info(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { + if !requireObjectAccess(c, db, "device", c.Param("id")) { + return + } result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{ "type": "info", "messageId": fmt.Sprintf("%d", time.Now().UnixMilli()), @@ -360,6 +396,9 @@ func sendGSTMB1Info(db *gorm.DB) gin.HandlerFunc { // sendGSTMB1Restart 重启设备 {"type":"setting","system":"restart"} func sendGSTMB1Restart(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { + if !requireObjectAccess(c, db, "device", c.Param("id")) { + return + } result, err := publishGSTMB1Command(db, c.Param("id"), map[string]interface{}{ "type": "setting", "system": "restart", @@ -375,6 +414,9 @@ func sendGSTMB1Restart(db *gorm.DB) gin.HandlerFunc { // sendGSTMB1Interval 设置定时上报间隔 {"type":"setting","timerEnable":1,"timerInterval":N} func sendGSTMB1Interval(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { + if !requireObjectAccess(c, db, "device", c.Param("id")) { + return + } var body struct { Interval int `json:"interval"` } diff --git a/server-go/internal/handler/detection_task.go b/server-go/internal/handler/detection_task.go index 9613f0a..432c022 100644 --- a/server-go/internal/handler/detection_task.go +++ b/server-go/internal/handler/detection_task.go @@ -36,7 +36,7 @@ func RegisterDetectionTaskRoutes(rg *gin.RouterGroup, db *gorm.DB) { func listDetectionTasks(db *gorm.DB) gin.HandlerFunc { 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 != "" { q = q.Where("status = ?", status) } @@ -55,6 +55,9 @@ func listDetectionTasks(db *gorm.DB) gin.HandlerFunc { func getDetectionTask(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { + if !requireObjectAccess(c, db, "detection_task", c.Param("id")) { + return + } var task model.DetectionTask if db.Where("id = ?", c.Param("id")).First(&task).Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "detection task not found"}) @@ -98,6 +101,16 @@ func createDetectionTask(db *gorm.DB) gin.HandlerFunc { 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 if sourceKey == "" { 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 { return func(c *gin.Context) { + if !requireObjectAccess(c, db, "detection_task", c.Param("id")) { + return + } var task model.DetectionTask if db.Where("id = ?", c.Param("id")).First(&task).Error != nil { 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 { return func(c *gin.Context) { + if !requireObjectAccess(c, db, "detection_task", c.Param("id")) { + return + } var samples []model.Sample db.Where("detection_task_id = ?", c.Param("id")).Order("created_at ASC").Find(&samples) c.JSON(http.StatusOK, samples) @@ -228,6 +247,9 @@ func listSamples(db *gorm.DB) gin.HandlerFunc { func createSample(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { + if !requireObjectAccess(c, db, "detection_task", c.Param("id")) { + return + } var task model.DetectionTask if db.Where("id = ?", c.Param("id")).First(&task).Error != nil { 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 { return func(c *gin.Context) { + if !requireObjectAccess(c, db, "sample", c.Param("id")) { + return + } var sample model.Sample if db.Where("id = ?", c.Param("id")).First(&sample).Error != nil { 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 { 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 != "" { q = q.Where("status = ?", status) } @@ -380,6 +405,25 @@ func createDiseaseEvent(db *gorm.DB) gin.HandlerFunc { 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 == "" { body.Status = "suspected" } @@ -429,6 +473,9 @@ func createDiseaseEvent(db *gorm.DB) gin.HandlerFunc { func updateDiseaseEvent(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { + if !requireObjectAccess(c, db, "disease_event", c.Param("id")) { + return + } var event model.DiseaseEvent if db.Where("id = ?", c.Param("id")).First(&event).Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "disease event not found"}) diff --git a/server-go/internal/handler/device.go b/server-go/internal/handler/device.go index 676f3d0..4a87a63 100644 --- a/server-go/internal/handler/device.go +++ b/server-go/internal/handler/device.go @@ -23,7 +23,7 @@ func RegisterDeviceRoutes(rg *gin.RouterGroup, db *gorm.DB) { func listDevices(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { 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 != "" { query = query.Where("kind = ?", kind) } @@ -36,6 +36,9 @@ func listDevices(db *gorm.DB) gin.HandlerFunc { func getDevice(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "device", id) { + return + } var device model.Device if db.Where("id = ?", id).First(&device).Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "device not found"}) @@ -64,6 +67,10 @@ func createDevice(db *gorm.DB) gin.HandlerFunc { return } device.ID = "" // 让数据库自动生成 + if !canAccessRoom(db, c, &device.RoomID) { + c.JSON(http.StatusForbidden, gin.H{"error": "无权在该蚕房下新建设备"}) + return + } if err := db.Create(&device).Error; err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"}) return @@ -76,6 +83,9 @@ func createDevice(db *gorm.DB) gin.HandlerFunc { func updateDevice(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "device", id) { + return + } var device model.Device if db.Where("id = ?", id).First(&device).Error != nil { 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()}) 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 { 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 { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "device", id) { + return + } var device model.Device if db.Where("id = ?", id).First(&device).Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "device not found"}) diff --git a/server-go/internal/handler/device_maintenance.go b/server-go/internal/handler/device_maintenance.go new file mode 100644 index 0000000..0041a45 --- /dev/null +++ b/server-go/internal/handler/device_maintenance.go @@ -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}) + } +} diff --git a/server-go/internal/handler/farm.go b/server-go/internal/handler/farm.go new file mode 100644 index 0000000..adb0f9c --- /dev/null +++ b/server-go/internal/handler/farm.go @@ -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] + } +} diff --git a/server-go/internal/handler/health.go b/server-go/internal/handler/health.go index 805aa0a..b932322 100644 --- a/server-go/internal/handler/health.go +++ b/server-go/internal/handler/health.go @@ -33,6 +33,10 @@ func RegisterHealthProfileRoutes(rg *gin.RouterGroup, db *gorm.DB) { func roomHealthProfile(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("roomId") + if !canAccessRoom(db, c, &id) { + c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该蚕房"}) + return + } var room model.Room if db.Where("id = ?", id).First(&room).Error != nil { 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 { return func(c *gin.Context) { roomID := c.Param("roomId") + if !canAccessRoom(db, c, &roomID) { + c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该蚕房"}) + return + } now := time.Now() parse := func(key string, fallback time.Time) time.Time { raw := c.Query(key) diff --git a/server-go/internal/handler/inspection.go b/server-go/internal/handler/inspection.go index fdc9da1..bd830fb 100644 --- a/server-go/internal/handler/inspection.go +++ b/server-go/internal/handler/inspection.go @@ -123,6 +123,10 @@ func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient, c.JSON(http.StatusBadRequest, gin.H{"error": "roomId 不是合法的 UUID"}) return } + if !canAccessRoom(db, c, &roomID) { + c.JSON(http.StatusForbidden, gin.H{"error": "无权在该蚕房下发起巡检"}) + return + } rec.RoomID = &roomID } if idemKey != "" { @@ -283,7 +287,7 @@ func loadRoomRisk(db *gorm.DB, roomID string) (*float64, *float64) { // listInspections 巡检记录列表(roomId/limit 过滤) func listInspections(db *gorm.DB) gin.HandlerFunc { 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 != "" { q = q.Where("room_id = ?", room) } diff --git a/server-go/internal/handler/laboratory.go b/server-go/internal/handler/laboratory.go new file mode 100644 index 0000000..5e0e7dd --- /dev/null +++ b/server-go/internal/handler/laboratory.go @@ -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 +} diff --git a/server-go/internal/handler/lamp.go b/server-go/internal/handler/lamp.go index 732a555..57245c0 100644 --- a/server-go/internal/handler/lamp.go +++ b/server-go/internal/handler/lamp.go @@ -40,7 +40,7 @@ func RegisterLampRoutes(rg *gin.RouterGroup, db *gorm.DB, s3 *service.S3Service, // listLampTests 检测任务单列表(roomId/batchId/status 过滤) func listLampTests(db *gorm.DB) gin.HandlerFunc { 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 != "" { 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"}) 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) { c.JSON(http.StatusBadRequest, gin.H{"error": "batchId 不是合法的 UUID"}) return } + if t.BatchID != nil && !requireObjectAccess(c, db, "batch", *t.BatchID) { + return + } if t.DetectionTaskID != nil && !isUUID(*t.DetectionTaskID) { c.JSON(http.StatusBadRequest, gin.H{"error": "detectionTaskId 不是合法的 UUID"}) return } + if t.DetectionTaskID != nil && !requireObjectAccess(c, db, "detection_task", *t.DetectionTaskID) { + return + } if t.Status == "" { t.Status = "pending" } @@ -107,6 +117,9 @@ func createLampTest(db *gorm.DB) gin.HandlerFunc { func judgeQPCR(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "lamp_test", id) { + return + } var t model.LampTest if db.Where("id = ?", id).First(&t).Error != nil { 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 { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "lamp_test", id) { + return + } var t model.LampTest if db.Where("id = ?", id).First(&t).Error != nil { 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 { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "lamp_test", id) { + return + } var t model.LampTest if db.Where("id = ?", id).First(&t).Error != nil { 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()}) 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 resultValue := "" 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 { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "lamp_test", id) { + return + } var t model.LampTest if db.Where("id = ?", id).First(&t).Error != nil { 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 { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "lamp_test", id) { + return + } var t model.LampTest if db.Where("id = ?", id).First(&t).Error != nil { 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 { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "lamp_test", id) { + return + } var t model.LampTest if db.Where("id = ?", id).First(&t).Error != nil { 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 { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "lamp_test", id) { + return + } stepNo, err := strconv.Atoi(c.Param("stepNo")) if err != nil { 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 { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "lamp_test", id) { + return + } var t model.LampTest if db.Where("id = ?", id).First(&t).Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "lamp test not found"}) diff --git a/server-go/internal/handler/notification.go b/server-go/internal/handler/notification.go index dd8417b..63a2120 100644 --- a/server-go/internal/handler/notification.go +++ b/server-go/internal/handler/notification.go @@ -22,7 +22,15 @@ func RegisterNotificationRoutes(rg *gin.RouterGroup, db *gorm.DB) { func listNotifications(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { 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) } } diff --git a/server-go/internal/handler/organization.go b/server-go/internal/handler/organization.go new file mode 100644 index 0000000..8910aae --- /dev/null +++ b/server-go/internal/handler/organization.go @@ -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}) + } +} diff --git a/server-go/internal/handler/room.go b/server-go/internal/handler/room.go index 0b61b90..413756c 100644 --- a/server-go/internal/handler/room.go +++ b/server-go/internal/handler/room.go @@ -25,7 +25,8 @@ func RegisterRoomRoutes(rg *gin.RouterGroup, db *gorm.DB) { func listRooms(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { 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) } } @@ -34,6 +35,9 @@ func listRooms(db *gorm.DB) gin.HandlerFunc { func getRoom(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "room", id) { + return + } var room model.Room if db.Where("id = ?", id).First(&room).Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "room not found"}) @@ -52,6 +56,12 @@ func createRoom(db *gorm.DB) gin.HandlerFunc { return } 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 { c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"}) return @@ -64,6 +74,9 @@ func createRoom(db *gorm.DB) gin.HandlerFunc { func updateRoom(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "room", id) { + return + } var room model.Room if db.Where("id = ?", id).First(&room).Error != nil { 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()}) 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 { 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 { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "room", id) { + return + } var room model.Room if db.Where("id = ?", id).First(&room).Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "room not found"}) diff --git a/server-go/internal/handler/sensor.go b/server-go/internal/handler/sensor.go index 5b91c49..53312d5 100644 --- a/server-go/internal/handler/sensor.go +++ b/server-go/internal/handler/sensor.go @@ -23,7 +23,10 @@ func RegisterSensorRoutes(rg *gin.RouterGroup, db *gorm.DB) { func listSensors(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { 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) } } @@ -32,6 +35,9 @@ func listSensors(db *gorm.DB) gin.HandlerFunc { func getSensor(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "sensor", id) { + return + } var sensor model.Sensor if db.Where("id = ?", id).First(&sensor).Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "sensor not found"}) @@ -50,6 +56,13 @@ func createSensor(db *gorm.DB) gin.HandlerFunc { return } 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 { c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"}) return @@ -62,6 +75,9 @@ func createSensor(db *gorm.DB) gin.HandlerFunc { func updateSensor(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "sensor", id) { + return + } var sensor model.Sensor if db.Where("id = ?", id).First(&sensor).Error != nil { 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()}) return } + if deviceID, ok := updates["device_id"].(string); ok && !requireObjectAccess(c, db, "device", deviceID) { + return + } if len(updates) > 0 { 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 { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "sensor", id) { + return + } var sensor model.Sensor if db.Where("id = ?", id).First(&sensor).Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "sensor not found"}) diff --git a/server-go/internal/handler/telemetry.go b/server-go/internal/handler/telemetry.go index 8a02a8e..d5f00ca 100644 --- a/server-go/internal/handler/telemetry.go +++ b/server-go/internal/handler/telemetry.go @@ -33,6 +33,9 @@ func listTelemetry(db *gorm.DB, iotdb *service.IoTDBService) gin.HandlerFunc { if limit <= 0 { limit = 2000 } + if deviceKey != "" && !requireDeviceKeyAccess(c, db, deviceKey) { + return + } // 优先 IoTDB if iotdb.IsAvailable() && deviceKey != "" && metric != "" { @@ -45,16 +48,19 @@ func listTelemetry(db *gorm.DB, iotdb *service.IoTDBService) gin.HandlerFunc { } // 降级 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 != "" { - q = q.Where("device_key = ?", deviceKey) + q = q.Where("telemetry.device_key = ?", deviceKey) } if metric != "" { - q = q.Where("metric = ?", metric) + q = q.Where("telemetry.metric = ?", metric) } if 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 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 { return func(c *gin.Context) { deviceKey := c.Param("deviceKey") + if !requireDeviceKeyAccess(c, db, deviceKey) { + return + } // 优先 IoTDB 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 { return func(c *gin.Context) { deviceKey := c.Param("deviceKey") + if !requireDeviceKeyAccess(c, db, deviceKey) { + return + } // 查询 PG 中该设备每个指标的最新记录 var records []model.Telemetry @@ -105,6 +117,9 @@ func historyBucket(iotdb *service.IoTDBService, db *gorm.DB) gin.HandlerFunc { metric := c.Param("metric") from := c.Query("from") to := c.Query("to") + if !requireDeviceKeyAccess(c, db, deviceKey) { + return + } if from == "" || to == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "from/to required"}) return diff --git a/server-go/internal/handler/threshold.go b/server-go/internal/handler/threshold.go index 7bf8ec5..3c09a30 100644 --- a/server-go/internal/handler/threshold.go +++ b/server-go/internal/handler/threshold.go @@ -23,7 +23,11 @@ func RegisterThresholdRoutes(rg *gin.RouterGroup, db *gorm.DB) { func listThresholds(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { 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) } } @@ -32,6 +36,9 @@ func listThresholds(db *gorm.DB) gin.HandlerFunc { func getThreshold(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "threshold", id) { + return + } var threshold model.Threshold if db.Where("id = ?", id).First(&threshold).Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "threshold not found"}) @@ -50,6 +57,13 @@ func createThreshold(db *gorm.DB) gin.HandlerFunc { return } 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 { c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"}) return @@ -62,6 +76,9 @@ func createThreshold(db *gorm.DB) gin.HandlerFunc { func updateThreshold(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "threshold", id) { + return + } var threshold model.Threshold if db.Where("id = ?", id).First(&threshold).Error != nil { 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()}) return } + if sensorID, ok := updates["sensor_id"].(string); ok && !requireObjectAccess(c, db, "sensor", sensorID) { + return + } if len(updates) > 0 { 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 { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "threshold", id) { + return + } var threshold model.Threshold if db.Where("id = ?", id).First(&threshold).Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "threshold not found"}) diff --git a/server-go/internal/handler/trace.go b/server-go/internal/handler/trace.go index 7724a73..e653efd 100644 --- a/server-go/internal/handler/trace.go +++ b/server-go/internal/handler/trace.go @@ -43,10 +43,11 @@ func traceMonthlyStats(db *gorm.DB) gin.HandlerFunc { Month string `gorm:"column:month"` Disease string `gorm:"column:disease"` } - db.Table("trace_records"). + q := db.Table("trace_records"). Select("to_char(created_at, 'YYYY-MM') AS month, disease"). - Where("created_at >= ? AND created_at < ?", start, end). - Scan(&rows) + Where("created_at >= ? AND created_at < ?", start, end) + q = applyRoomScope(q, c, "room_id") + q.Scan(&rows) entries := make([]service.MonthDiseaseEntry, 0, len(rows)) for _, r := range rows { 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"). 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)) + q = applyRoomScope(q, c, "trace_records.room_id") if disease := c.Query("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 { 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 != "" { q = q.Where("status = ?", status) } @@ -116,6 +118,9 @@ func fillTraceRoomNames(db *gorm.DB, list []model.TraceRecord) { func getTraceRecord(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { + if !requireObjectAccess(c, db, "trace_record", c.Param("id")) { + return + } var t model.TraceRecord if db.Where("id = ?", c.Param("id")).First(&t).Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "trace record not found"}) @@ -145,6 +150,19 @@ func createTraceRecord(db *gorm.DB) gin.HandlerFunc { 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 == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "病种不能为空"}) return @@ -165,6 +183,9 @@ func createTraceRecord(db *gorm.DB) gin.HandlerFunc { func updateTraceRecord(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "trace_record", id) { + return + } var t model.TraceRecord if db.Where("id = ?", id).First(&t).Error != nil { 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()}) 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 { 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 { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "trace_record", id) { + return + } var t model.TraceRecord if db.Where("id = ?", id).First(&t).Error != nil { 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 { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "trace_record", id) { + return + } var t model.TraceRecord if db.Where("id = ?", id).First(&t).Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "trace record not found"}) @@ -318,6 +361,9 @@ func autoTrace(db *gorm.DB) gin.HandlerFunc { // getTraceChecklist 分病种二级排查清单 func getTraceChecklist(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { + if !requireObjectAccess(c, db, "trace_record", c.Param("id")) { + return + } var t model.TraceRecord if db.Where("id = ?", c.Param("id")).First(&t).Error != nil { 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 { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "trace_record", id) { + return + } var t model.TraceRecord if db.Where("id = ?", id).First(&t).Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "trace record not found"}) diff --git a/server-go/internal/handler/tray_batch.go b/server-go/internal/handler/tray_batch.go index 75d0188..9c4d4f6 100644 --- a/server-go/internal/handler/tray_batch.go +++ b/server-go/internal/handler/tray_batch.go @@ -40,7 +40,7 @@ func RegisterTrayBatchRoutes(rg *gin.RouterGroup, db *gorm.DB) { func listTrays(db *gorm.DB) gin.HandlerFunc { 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 != "" { 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"}) return } + if !canAccessRoom(db, c, &t.RoomID) { + c.JSON(http.StatusForbidden, gin.H{"error": "无权在该蚕房下创建蚕匾"}) + return + } if err := db.Create(&t).Error; err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"}) return @@ -80,6 +84,9 @@ func createTray(db *gorm.DB) gin.HandlerFunc { func updateTray(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "tray", id) { + return + } var t model.Tray if db.Where("id = ?", id).First(&t).Error != nil { 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()}) 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 { 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 { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "tray", id) { + return + } var t model.Tray if db.Where("id = ?", id).First(&t).Error != nil { 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 { 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 != "" { 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"}) return } + if !canAccessRoom(db, c, &b.RoomID) { + c.JSON(http.StatusForbidden, gin.H{"error": "无权在该蚕房下创建批次"}) + return + } if b.Status == "" { b.Status = "rearing" } @@ -158,6 +176,9 @@ func createBatch(db *gorm.DB) gin.HandlerFunc { func updateBatch(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "batch", id) { + return + } var b model.Batch if db.Where("id = ?", id).First(&b).Error != nil { 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()}) 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 { 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 { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "batch", id) { + return + } var b model.Batch if db.Where("id = ?", id).First(&b).Error != nil { 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 { 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 != "" { 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"}) return } + if !requireObjectAccess(c, db, "batch", r.BatchID) { + return + } if r.RecordDate.IsZero() { r.RecordDate = time.Now() } @@ -231,6 +262,9 @@ func createRearingRecord(db *gorm.DB) gin.HandlerFunc { func updateRearingRecord(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "rearing_record", id) { + return + } var r model.RearingRecord if db.Where("id = ?", id).First(&r).Error != nil { 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 { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "rearing_record", id) { + return + } var r model.RearingRecord if db.Where("id = ?", id).First(&r).Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "rearing record not found"}) diff --git a/server-go/internal/handler/video_camera.go b/server-go/internal/handler/video_camera.go index 3a18906..34730ef 100644 --- a/server-go/internal/handler/video_camera.go +++ b/server-go/internal/handler/video_camera.go @@ -37,7 +37,8 @@ func RegisterVideoCameraRoutes(rg *gin.RouterGroup, db *gorm.DB, media *service. func listCameras(db *gorm.DB, media *service.MediaService) gin.HandlerFunc { return func(c *gin.Context) { 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:"-" 不会自动填充) 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 { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "camera", id) { + return + } var camera model.Camera if db.Where("id = ?", id).First(&camera).Error != nil { 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.ID = 0 // 让数据库自动生成 if camera.RoomID == nil { - defaultRoom := "1" - camera.RoomID = &defaultRoom + camera.RoomID = firstAccessibleRoomID(db, c) + 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 { 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 { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "camera", id) { + return + } var camera model.Camera if db.Where("id = ?", id).First(&camera).Error != nil { 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()}) 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 err := db.Model(&model.Camera{}).Where("id = ?", id).Updates(updates).Error; err != nil { 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 { return func(c *gin.Context) { id := c.Param("id") + if !requireObjectAccess(c, db, "camera", id) { + return + } var camera model.Camera if db.Where("id = ?", id).First(&camera).Error != nil { 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") + if !requireObjectAccess(c, db, "camera", id) { + return + } var camera model.Camera if db.Where("id = ?", id).First(&camera).Error != nil { 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") + if !requireObjectAccess(c, db, "camera", id) { + return + } limit := 50 if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 { @@ -335,10 +362,10 @@ func getWvpConfig(media *service.MediaService) gin.HandlerFunc { return } c.JSON(http.StatusOK, gin.H{ - "sipId": sip["id"], - "sipDomain": sip["domain"], - "sipPort": sip["port"], - "sipShowIp": sip["showIp"], + "sipId": sip["id"], + "sipDomain": sip["domain"], + "sipPort": sip["port"], + "sipShowIp": sip["showIp"], }) } } diff --git a/server-go/internal/handler/video_clip.go b/server-go/internal/handler/video_clip.go index cc97d46..a8bc9da 100644 --- a/server-go/internal/handler/video_clip.go +++ b/server-go/internal/handler/video_clip.go @@ -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 != "" { q = q.Where("camera_id = ?", cameraId) } @@ -77,6 +77,9 @@ func playClip(db *gorm.DB) gin.HandlerFunc { } clipId := c.Param("clipId") + if !requireObjectAccess(c, db, "video_clip", clipId) { + return + } var clip model.VideoClip if db.Where("id = ?", clipId).First(&clip).Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "video clip not found"}) diff --git a/server-go/internal/handler/video_record.go b/server-go/internal/handler/video_record.go index 80a14ea..fc34370 100644 --- a/server-go/internal/handler/video_record.go +++ b/server-go/internal/handler/video_record.go @@ -42,7 +42,7 @@ func RegisterVideoRecordRoutes(rg *gin.RouterGroup, db *gorm.DB, media *service. readPerm := middleware.RequirePermission(db, "video:read") 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.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)) // 白名单接口,无需权限 } @@ -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 { return func(c *gin.Context) { cameraId := c.Param("id") + if !requireObjectAccess(c, db, "camera", cameraId) { + return + } var camera model.Camera if db.Where("id = ?", cameraId).First(&camera).Error != nil { 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 { return func(c *gin.Context) { cameraId := c.Param("id") + if !requireObjectAccess(c, db, "camera", cameraId) { + return + } activeMu.Lock() rec, ok := activeRecordings[cameraId] @@ -217,7 +223,7 @@ func endRecordingInternal(media *service.MediaService, cfg *config.Config) gin.H // listActiveRecordings 返回活跃录制列表 // 优先以 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) { // 1. 查询 recorder-go 实际录制状态 recorderBase := strings.TrimSuffix(cfg.RecorderAPIBase, "/") @@ -270,7 +276,7 @@ func listActiveRecordings(cfg *config.Config) gin.HandlerFunc { } activeMu.Unlock() } - c.JSON(http.StatusOK, list) + c.JSON(http.StatusOK, filterAccessibleRecordings(db, c, list)) return } } @@ -283,6 +289,23 @@ func listActiveRecordings(cfg *config.Config) gin.HandlerFunc { list = append(list, rec) } 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 +} diff --git a/server-go/internal/model/business_capabilities.go b/server-go/internal/model/business_capabilities.go new file mode 100644 index 0000000..9a924eb --- /dev/null +++ b/server-go/internal/model/business_capabilities.go @@ -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 + } +} diff --git a/server-go/internal/model/business_capabilities_test.go b/server-go/internal/model/business_capabilities_test.go new file mode 100644 index 0000000..b809bed --- /dev/null +++ b/server-go/internal/model/business_capabilities_test.go @@ -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") + } +} diff --git a/server-go/internal/model/models.go b/server-go/internal/model/models.go index 8931982..ec720fb 100644 --- a/server-go/internal/model/models.go +++ b/server-go/internal/model/models.go @@ -32,6 +32,7 @@ type Room struct { Capacity *int `gorm:"type:int" json:"capacity,omitempty"` Stage *string `gorm:"size:32" json:"stage,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"` CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"` @@ -109,38 +110,38 @@ func (Alarm) TableName() string { return "alarms" } // Camera 摄像头表 type Camera struct { - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - RoomID *string `gorm:"column:room_id;index" json:"roomId,omitempty"` - Code string `gorm:"size:64" json:"code"` - Name string `gorm:"size:128" json:"name"` - RtspURL *string `gorm:"column:rtsp_url;size:512" json:"rtspUrl,omitempty"` - HTTPURL *string `gorm:"column:http_url;size:512" json:"httpUrl,omitempty"` - Username *string `gorm:"size:64" json:"username,omitempty"` - PasswordEnc *string `gorm:"column:password_enc;size:255" json:"-"` - Position *string `gorm:"size:255" json:"position,omitempty"` - Resolution *string `gorm:"size:32" json:"resolution,omitempty"` - FPS *int `gorm:"type:int" json:"fps,omitempty"` - IsOnline bool `gorm:"column:is_online;default:true" json:"isOnline"` - GbDeviceID *string `gorm:"column:gb_device_id;size:20" json:"gbDeviceId,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"` - GbAuthPassword *string `gorm:"column:gb_auth_password;size:255" json:"-"` - GbStreamType *string `gorm:"column:gb_stream_type;size:10" json:"gbStreamType,omitempty"` - GbTransport *string `gorm:"column:gb_transport;size:10" json:"gbTransport,omitempty"` + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + RoomID *string `gorm:"column:room_id;index" json:"roomId,omitempty"` + Code string `gorm:"size:64" json:"code"` + Name string `gorm:"size:128" json:"name"` + RtspURL *string `gorm:"column:rtsp_url;size:512" json:"rtspUrl,omitempty"` + HTTPURL *string `gorm:"column:http_url;size:512" json:"httpUrl,omitempty"` + Username *string `gorm:"size:64" json:"username,omitempty"` + PasswordEnc *string `gorm:"column:password_enc;size:255" json:"-"` + Position *string `gorm:"size:255" json:"position,omitempty"` + Resolution *string `gorm:"size:32" json:"resolution,omitempty"` + FPS *int `gorm:"type:int" json:"fps,omitempty"` + IsOnline bool `gorm:"column:is_online;default:true" json:"isOnline"` + GbDeviceID *string `gorm:"column:gb_device_id;size:20" json:"gbDeviceId,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"` + GbAuthPassword *string `gorm:"column:gb_auth_password;size:255" json:"-"` + GbStreamType *string `gorm:"column:gb_stream_type;size:10" json:"gbStreamType,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"` GbVoiceChannelID *string `gorm:"column:gb_voice_channel_id;size:20" json:"gbVoiceChannelId,omitempty"` - GbManufacturer *string `gorm:"column:gb_manufacturer;size:64" json:"gbManufacturer,omitempty"` - ManufacturerID *string `gorm:"column:manufacturer_id;size:64" json:"manufacturerId,omitempty"` + GbManufacturer *string `gorm:"column:gb_manufacturer;size:64" json:"gbManufacturer,omitempty"` + ManufacturerID *string `gorm:"column:manufacturer_id;size:64" json:"manufacturerId,omitempty"` // 以下字段不在数据库中,运行时通过 WVP API 填充 - StreamURL *string `gorm:"-" json:"streamUrl,omitempty"` - HlsURL *string `gorm:"-" json:"hlsUrl,omitempty"` - FlvURL *string `gorm:"-" json:"flvUrl,omitempty"` - WebrtcURL *string `gorm:"-" json:"webrtcUrl,omitempty"` - SnapshotURL *string `gorm:"-" json:"snapshotUrl,omitempty"` - Online bool `gorm:"-" json:"online"` - Enabled bool `gorm:"default:true" json:"enabled"` - CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` - UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"` + StreamURL *string `gorm:"-" json:"streamUrl,omitempty"` + HlsURL *string `gorm:"-" json:"hlsUrl,omitempty"` + FlvURL *string `gorm:"-" json:"flvUrl,omitempty"` + WebrtcURL *string `gorm:"-" json:"webrtcUrl,omitempty"` + SnapshotURL *string `gorm:"-" json:"snapshotUrl,omitempty"` + Online bool `gorm:"-" json:"online"` + Enabled bool `gorm:"default:true" json:"enabled"` + CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` + UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"` } func (Camera) TableName() string { return "cameras" } @@ -167,8 +168,8 @@ type VideoClip struct { AlarmID *string `gorm:"column:alarm_id;index" json:"alarmId,omitempty"` CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"` - S3Bucket *string `gorm:"column:s3_bucket" json:"s3Bucket,omitempty"` - S3Key *string `gorm:"column:s3_key" json:"s3Key,omitempty"` + S3Bucket *string `gorm:"column:s3_bucket" json:"s3Bucket,omitempty"` + S3Key *string `gorm:"column:s3_key" json:"s3Key,omitempty"` // 以下字段不在数据库中,运行时填充 PlaybackURL *string `gorm:"-" json:"playbackUrl,omitempty"` Format string `gorm:"-" json:"format"` diff --git a/server-go/internal/model/permission_seed.go b/server-go/internal/model/permission_seed.go index d4656f0..47b331d 100644 --- a/server-go/internal/model/permission_seed.go +++ b/server-go/internal/model/permission_seed.go @@ -47,6 +47,14 @@ var AllPermissions = []PermissionDef{ {"biosecurity:write", "生物安全管理", "维护种源、消毒记录并签发二维码"}, {"user:manage", "用户管理", "管理用户、角色和权限"}, {"audit:read", "审计查看", "查看审计日志"}, + {"organization:manage", "组织管理", "维护组织及成员数据范围"}, + {"device:write", "设备管理", "维护设备资产、校准和固件信息"}, + {"farm:read", "产量损失查看", "查看产量、死亡、淘汰和成本记录"}, + {"farm:write", "产量损失管理", "新增、编辑产量和损失记录"}, + {"case:read", "案例查看", "查看已脱敏案例库"}, + {"case:write", "案例管理", "创建、审核和发布脱敏案例"}, + {"lab:read", "实验室结果查看", "查看实验室结构化结果"}, + {"lab:write", "实验室结果管理", "录入实验室结果和分子分型"}, } // RolePermissionMap 角色-权限码映射(种子数据) @@ -66,6 +74,8 @@ var RolePermissionMap = map[string][]string{ "trace:read", "trace:write", "biosecurity:read", "biosecurity:write", "user:manage", "audit:read", + "organization:manage", "device:write", + "farm:read", "farm:write", "case:read", "case:write", "lab:read", "lab:write", }, RoleOperator: { "dashboard:view", "room:read", "room:write", "device:read", "device:control", @@ -81,6 +91,7 @@ var RolePermissionMap = map[string][]string{ "consultation:read", "consultation:write", "trace:read", "trace:write", "biosecurity:read", "biosecurity:write", + "device:write", "farm:read", "farm:write", "case:read", "case:write", "lab:read", "lab:write", }, RoleViewer: { "dashboard:view", "room:read", "device:read", @@ -95,6 +106,7 @@ var RolePermissionMap = map[string][]string{ "consultation:read", "trace:read", "biosecurity:read", + "farm:read", "case:read", "lab:read", }, RoleFarmer: { "dashboard:view", "room:read", "device:read", "device:control", @@ -109,5 +121,6 @@ var RolePermissionMap = map[string][]string{ "consultation:read", "trace:read", "biosecurity:read", "biosecurity:write", + "farm:read", "farm:write", "case:read", "lab:read", }, } diff --git a/server-go/internal/model/tray_batch.go b/server-go/internal/model/tray_batch.go index 6d61e75..f682570 100644 --- a/server-go/internal/model/tray_batch.go +++ b/server-go/internal/model/tray_batch.go @@ -45,6 +45,8 @@ type RearingRecord struct { Instar *int `gorm:"type:int" json:"instar,omitempty"` MulberrySource *string `gorm:"column:mulberry_source;size:128" json:"mulberrySource,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"` CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"` diff --git a/server-go/internal/service/farm_stats.go b/server-go/internal/service/farm_stats.go new file mode 100644 index 0000000..8ea8e11 --- /dev/null +++ b/server-go/internal/service/farm_stats.go @@ -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 +} diff --git a/server-go/internal/service/farm_stats_test.go b/server-go/internal/service/farm_stats_test.go new file mode 100644 index 0000000..01cd91f --- /dev/null +++ b/server-go/internal/service/farm_stats_test.go @@ -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)) + } +} diff --git a/server-go/internal/ws/authorizer.go b/server-go/internal/ws/authorizer.go index 9f17914..ea856ca 100644 --- a/server-go/internal/ws/authorizer.go +++ b/server-go/internal/ws/authorizer.go @@ -14,7 +14,7 @@ type DeviceAuthorizer interface { CanReadDevice(userID, deviceKey string) (bool, error) } -// DBDeviceAuthorizer 使用现有 RBAC 权限判断设备读取权;当前未做用户级资源 ACL。 +// DBDeviceAuthorizer 使用 RBAC + 组织/房间归属判断设备读取权。 type DBDeviceAuthorizer struct { db *gorm.DB } @@ -36,12 +36,28 @@ func (a *DBDeviceAuthorizer) CanReadDevice(userID, deviceKey string) (bool, erro return true, nil } - var count int64 + var roleCount int64 err := a.db.Table("role_permissions"). Joins("JOIN permissions ON permissions.id = role_permissions.permission_id"). Where("role_permissions.role = ? AND permissions.code = ?", user.Role, "device:read"). - Count(&count).Error - return count > 0, err + Count(&roleCount).Error + 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 { diff --git a/server-go/internal/ws/authorizer_test.go b/server-go/internal/ws/authorizer_test.go new file mode 100644 index 0000000..e5e50e0 --- /dev/null +++ b/server-go/internal/ws/authorizer_test.go @@ -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) + } +} diff --git a/server-go/migrations/000009_object_authority_and_business_capabilities.down.sql b/server-go/migrations/000009_object_authority_and_business_capabilities.down.sql new file mode 100644 index 0000000..693684e --- /dev/null +++ b/server-go/migrations/000009_object_authority_and_business_capabilities.down.sql @@ -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; diff --git a/server-go/migrations/000009_object_authority_and_business_capabilities.up.sql b/server-go/migrations/000009_object_authority_and_business_capabilities.up.sql new file mode 100644 index 0000000..b99156b --- /dev/null +++ b/server-go/migrations/000009_object_authority_and_business_capabilities.up.sql @@ -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; diff --git a/web/src/dal/business.ts b/web/src/dal/business.ts new file mode 100644 index 0000000..a08c969 --- /dev/null +++ b/web/src/dal/business.ts @@ -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; + 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('/device-maintenance', { params }); +export const createDeviceMaintenance = (data: Partial) => + post('/device-maintenance', data); +export const updateDeviceMaintenance = (id: string, data: Partial) => + patch(`/device-maintenance/${id}`, data); +export const deleteDeviceMaintenance = (id: string) => + del<{ id: string }>(`/device-maintenance/${id}`); + +export const listProductionLossRecords = (params?: any) => + get('/production-loss-records', { params }); +export const getProductionLossStats = (params?: any) => + get('/production-loss-records/stats', { params }); +export const createProductionLossRecord = (data: Partial) => + post('/production-loss-records', data); +export const updateProductionLossRecord = (id: string, data: Partial) => + patch(`/production-loss-records/${id}`, data); +export const deleteProductionLossRecord = (id: string) => + del<{ id: string }>(`/production-loss-records/${id}`); + +export const listCaseStudies = (params?: any) => + get('/case-studies', { params }); +export const createCaseStudy = (data: Partial) => + post('/case-studies', data); +export const createCaseStudyFromConsultation = (id: string) => + post(`/case-studies/from-consultation/${id}`); +export const updateCaseStudy = (id: string, data: Partial) => + patch(`/case-studies/${id}`, data); +export const reviewCaseStudy = (id: string, data: { status: string; note?: string }) => + post(`/case-studies/${id}/review`, data); + +export const listLaboratoryResults = (params?: any) => + get('/laboratory-results', { params }); +export const createLaboratoryResult = (data: Partial) => + post('/laboratory-results', data); +export const updateLaboratoryResult = (id: string, data: Partial) => + patch(`/laboratory-results/${id}`, data); +export const deleteLaboratoryResult = (id: string) => del<{ id: string }>(`/laboratory-results/${id}`); diff --git a/web/src/dal/organization.ts b/web/src/dal/organization.ts new file mode 100644 index 0000000..1732392 --- /dev/null +++ b/web/src/dal/organization.ts @@ -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('/organizations'); +export const createOrganization = (data: Partial) => + post('/organizations', data); +export const updateOrganization = (id: string, data: Partial) => + patch(`/organizations/${id}`, data); + +export const listOrganizationMembers = (organizationId: string) => + get(`/organizations/${organizationId}/members`); +export const addOrganizationMember = (organizationId: string, data: { userId: string; role: string }) => + post(`/organizations/${organizationId}/members`, data); +export const removeOrganizationMember = (organizationId: string, userId: string) => + del<{ organizationId: string; userId: string }>(`/organizations/${organizationId}/members/${userId}`); + +export const listUsers = () => get('/users'); diff --git a/web/src/layout/BasicLayout.tsx b/web/src/layout/BasicLayout.tsx index 1b538e2..3252cf6 100644 --- a/web/src/layout/BasicLayout.tsx +++ b/web/src/layout/BasicLayout.tsx @@ -22,6 +22,7 @@ import { ApiOutlined, ControlOutlined as ControlOutlinedIcon, KeyOutlined, + AppstoreOutlined, } from '@ant-design/icons'; import { ProLayout } from '@ant-design/pro-components'; import { useState } from 'react'; @@ -38,6 +39,7 @@ const menuData = [ { path: '/alerts', name: '告警中心', icon: , permission: 'alarm:read' }, { path: '/videos', name: '视频监控', icon: , permission: 'video:read' }, { path: '/logs', name: '控制日志', icon: , permission: 'log:read' }, + { path: '/organizations', name: '组织管理', icon: , permission: 'organization:manage' }, { path: '/knowledge', name: '知识库', icon: , permission: 'knowledge:read' }, { path: '/batches', name: '批次管理', icon: , permission: 'batch:read' }, { path: '/lamp-tests', name: 'LAMP 检测', icon: , permission: 'lamp:read' }, @@ -47,6 +49,7 @@ const menuData = [ { path: '/inspections', name: '巡检记录', icon: , permission: 'inspection:read' }, { path: '/consultations', name: '专家会诊', icon: , permission: 'consultation:read' }, { path: '/traces', name: '疫病溯源', icon: , permission: 'trace:read' }, + { path: '/business', name: '运营能力', icon: , permission: 'case:read' }, ]; // 角色中文名 diff --git a/web/src/pages/BusinessExtensions.tsx b/web/src/pages/BusinessExtensions.tsx new file mode 100644 index 0000000..8478cc5 --- /dev/null +++ b/web/src/pages/BusinessExtensions.tsx @@ -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 = { + calibration: '校准', + fault: '故障', + maintenance: '维护', + firmware: '固件', +}; + +const COST_TYPES: Record = { + medicine: '用药', + disinfection: '消毒', + detection: '检测', + labor: '人工', + other: '其他', +}; + +const CASE_STATUS: Record = { + draft: '草稿', + pending_review: '待审核', + published: '已发布', + rejected: '已驳回', +}; + +const TEST_TYPES: Record = { + molecular_typing: '分子分型', + pathogen: '病原检测', + environment_sample: '环境样本', + other: '其他', +}; + +const RESULT_TYPES: Record = { + positive: '阳性', + negative: '阴性', + indeterminate: '待复核', + invalid: '无效', +}; + +function ProductionStats() { + const [stats, setStats] = useState([]); + useEffect(() => { + getProductionLossStats().then(setStats).catch(() => setStats([])); + }, []); + if (stats.length === 0) return null; + return ( + + + {stats.map((s) => ( + + {s.roomName || s.roomId || '-'} + {s.batchName ? ` / ${s.batchName}` : ''} + :死亡 {s.deathCount}、淘汰 {s.culledCount}、产量 {s.yieldKg}kg、损失 {s.lossKg}kg、成本 {s.costAmount} + + ))} + + + ); +} + +export default function BusinessExtensionsPage() { + const maintenanceAction = useRef(); + const productionAction = useRef(); + const caseAction = useRef(); + const labAction = useRef(); + const [rooms, setRooms] = useState<{ id: string; name: string }[]>([]); + const [batches, setBatches] = useState([]); + 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(null); + const [maintenanceEditing, setMaintenanceEditing] = useState(null); + const [productionEditing, setProductionEditing] = useState(null); + const [labEditing, setLabEditing] = useState(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[] = [ + { title: '类型', dataIndex: 'kind', render: (_, r) => {MAINTENANCE_KINDS[r.kind] || r.kind} }, + { 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) => [ + { + setMaintenanceEditing(r); + maintenanceForm.setFieldsValue({ + ...r, + scheduledAt: r.scheduledAt ? dayjs(r.scheduledAt) : undefined, + performedAt: r.performedAt ? dayjs(r.performedAt) : undefined, + }); + setMaintenanceOpen(true); + }}>编辑, + ...(canWrite('device:write') + ? [ + { + await deleteDeviceMaintenance(r.id); + message.success('已删除'); + maintenanceAction.current?.reload(); + }}> + 删除 + , + ] + : []), + ], + }, + ]; + + const productionColumns: ProColumns[] = [ + { 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) => [ + { + setProductionEditing(r); + productionForm.setFieldsValue({ + ...r, + recordDate: r.recordDate ? dayjs(r.recordDate) : undefined, + }); + setProductionOpen(true); + }}>编辑, + ...(canWrite('farm:write') + ? [ + { + await deleteProductionLossRecord(r.id); + message.success('已删除'); + productionAction.current?.reload(); + }}> + 删除 + , + ] + : []), + ], + }, + ]; + + const caseColumns: ProColumns[] = [ + { title: '标题', dataIndex: 'title' }, + { title: '病种', dataIndex: 'disease' }, + { title: '区域', dataIndex: 'region', search: false }, + { title: '状态', dataIndex: 'status', render: (_, r) => {CASE_STATUS[r.status] || r.status} }, + { 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) => [ + { + setReviewing(r); + caseForm.setFieldsValue({ + ...r, + caseDate: r.caseDate ? dayjs(r.caseDate) : undefined, + desensitizedPayload: JSON.stringify(r.desensitizedPayload || {}, null, 2), + }); + setCaseOpen(true); + }}>编辑, + ...(canWrite('case:write') + ? [ + { + setReviewing(r); + reviewForm.resetFields(); + reviewForm.setFieldsValue({ status: r.status === 'draft' ? 'pending_review' : 'published' }); + setReviewOpen(true); + }}>审核, + ] + : []), + ], + }, + ]; + + const labColumns: ProColumns[] = [ + { title: '实验室', dataIndex: 'labName' }, + { title: '报告编号', dataIndex: 'reportNo' }, + { title: '检测类型', dataIndex: 'testType', render: (_, r) => TEST_TYPES[r.testType] || r.testType }, + { title: '结果', dataIndex: 'resultType', render: (_, r) => {RESULT_TYPES[r.resultType] || r.resultType} }, + { 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) => [ + { + setLabEditing(r); + labForm.setFieldsValue({ + ...r, + testedAt: r.testedAt ? dayjs(r.testedAt) : undefined, + concludedAt: r.concludedAt ? dayjs(r.concludedAt) : undefined, + }); + setLabOpen(true); + }}>编辑, + ...(canWrite('lab:write') + ? [ + { + await deleteLaboratoryResult(r.id); + message.success('已删除'); + labAction.current?.reload(); + }}> + 删除 + , + ] + : []), + ], + }, + ]; + + return ( + <> + + 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') ? [ + , + ] : []} + /> + ), + }, + { + key: 'production', + label: '产量损失', + children: ( + <> + + + 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') ? [ + , + ] : []} + /> + + ), + }, + { + key: 'cases', + label: '案例库', + children: ( + + 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') ? [ + , + , + ] : []} + /> + ), + }, + { + key: 'lab', + label: '实验室结果', + children: ( + + 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') ? [ + , + ] : []} + /> + ), + }, + ]} + /> + 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(); + }} + > +
+ + + + + + + + + + + + + +
+
+ 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(); + }} + > +
+ ({ value: b.id, label: b.name }))} /> + + + + + + + + + + + + +
+
+ setReviewOpen(false)} + onOk={async () => { + const v = await reviewForm.validateFields(); + if (!reviewing) return; + await reviewCaseStudy(reviewing.id, v); + message.success('审核完成'); + setReviewOpen(false); + caseAction.current?.reload(); + }} + > +
+ + + + + ({ value, label }))} /> + + + + + ({ value: b.id, label: b.name }))} /> + + + + + + + + + +
+
+ + ); +} diff --git a/web/src/pages/Organizations.tsx b/web/src/pages/Organizations.tsx new file mode 100644 index 0000000..c500c33 --- /dev/null +++ b/web/src/pages/Organizations.tsx @@ -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(); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + + const [drawerOpen, setDrawerOpen] = useState(false); + const [currentOrg, setCurrentOrg] = useState(null); + const [members, setMembers] = useState([]); + const [users, setUsers] = useState([]); + 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[] = [ + { title: '组织名称', dataIndex: 'name' }, + { title: '编码', dataIndex: 'code', search: false }, + { title: '说明', dataIndex: 'description', search: false, render: (_, r) => r.description || '-' }, + { title: '状态', dataIndex: 'status', search: false, render: (_, r) => {r.status === 'active' ? '启用' : r.status} }, + { title: '创建时间', dataIndex: 'createdAt', search: false, render: (_, r) => (r.createdAt ? dayjs(r.createdAt).format('YYYY-MM-DD HH:mm') : '-') }, + { + title: '操作', + valueType: 'option', + render: (_, r) => [ + openMembers(r)}> + 成员 + , + { + setEditing(r); + form.setFieldsValue(r); + setModalOpen(true); + }} + > + 编辑 + , + ], + }, + ]; + + const memberColumns = [ + { title: '用户名', dataIndex: 'username' }, + { title: '姓名', dataIndex: 'fullName', render: (_: unknown, r: OrganizationMember) => r.fullName || '-' }, + { title: '组织角色', dataIndex: 'role', render: (_: unknown, r: OrganizationMember) => {r.role} }, + { 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) => ( + { + if (!currentOrg) return; + await removeOrganizationMember(currentOrg.id, r.userId); + message.success('已移除'); + await loadMembers(currentOrg.id); + }} + > + 移除 + + ), + }, + ]; + + const availableUsers = users.filter((u) => !members.some((m) => m.userId === u.id)); + + return ( + <> + + actionRef={actionRef} + rowKey="id" + columns={columns} + search={false} + request={async () => { + const res = await listOrganizations(); + return { data: res, total: res.length, success: true }; + }} + toolBarRender={() => [ + , + ]} + /> + + 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(); + }} + > +
+ + + + + + + + + +
+
+ + setDrawerOpen(false)} + > +
+ + + + + + +
+ + + 默认组织用于兼容历史数据,新注册用户会自动加入。 + 组织成员范围决定其可访问的蚕房、设备、巡检、检测、会诊和溯源数据。 + + + + ); +} diff --git a/web/src/router.tsx b/web/src/router.tsx index 8bf4bf2..0c60ef9 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -19,6 +19,8 @@ import Biosecurity from './pages/Biosecurity'; import Inspections from './pages/Inspections'; import Consultations from './pages/Consultations'; import Traces from './pages/Traces'; +import BusinessExtensions from './pages/BusinessExtensions'; +import Organizations from './pages/Organizations'; import NotFound from './pages/NotFound'; import { BasicLayout } from './layout/BasicLayout'; import { authService } from './services/auth'; @@ -64,6 +66,8 @@ export const router = createBrowserRouter([ { path: 'inspections', element: }, { path: 'consultations', element: }, { path: 'traces', element: }, + { path: 'business', element: }, + { path: 'organizations', element: }, { path: '404', element: }, ], }, diff --git a/变更记录.md b/变更记录.md index 8ffadc1..d0b0edf 100644 --- a/变更记录.md +++ b/变更记录.md @@ -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 知识库图谱预览与保存修复(故障排查 #三十二) ### 变更背景 diff --git a/后续工作计划.md b/后续工作计划.md index 6b0af29..503d920 100644 --- a/后续工作计划.md +++ b/后续工作计划.md @@ -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 启动) @@ -26,21 +26,21 @@ ## 下一步工作计划(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 | IAM-003 组织/区域/房间级对象授权 | 后端 | 当前只有 RBAC,无组织模型 | 无权用户访问房间/设备/业务记录返回 403,越权测试通过 | +| P0 | IAM-003 组织/区域/房间级对象授权 | 后端 + Web | 代码、单测与 Web 组织管理已完成;待开发服务器越权联调 | 无权用户访问房间/设备/业务记录返回 403,越权测试通过 | | P1 | E2E-07 微信开发者工具离线取证 | 小程序 | 微信开发者工具/真机环境 | 离线→重启→联网→仅同步一次,截图+日志存档 | | P1 | 1000 WebSocket 持续 30 分钟 | 运维 + 后端 | 无 | 掉线率、消息 P95、CPU/内存曲线达标并记录 | | P1 | AI T4 峰值测试 | AI + 运维 | T4 主机、真实模型 | p50/p95、失败率、GPU 显存记录 | | P1 | Task 3 默认密钥/管理员初始化 | 后端 + 运维 | 最后阶段窗口 | 生产缺关键配置拒绝启动;历史密钥轮换完成 | | P1 | Task 7 qPCR 判读质控 | 后端 + 领域专家 | 专家签字确认 protocolVersion/阈值 | 四态结果和对照质控实现并测试 | -| P2 | ENV-006 设备校准/故障/维护/固件 | 后端 | 无 | 设备资产与维护记录 CRUD | -| P2 | FARM-006 产量/死亡/淘汰/损失/成本 | 后端 | 无 | 记录字段和统计接口 | -| P2 | KB-003 脱敏案例沉淀 | 后端 + Web | 无 | 已脱敏病例审核发布流程 | -| P2 | TRACE-004 实验室结构化数据 | 后端 | 无 | 实验室结果/分型结构化关联 | +| P2 | ENV-006 设备校准/故障/维护/固件 | 后端 + Web | 后端 CRUD 与 Web 维护编辑/删除已补;待现场联调 | 设备资产与维护记录 CRUD | +| P2 | FARM-006 产量/死亡/淘汰/损失/成本 | 后端 + Web | 记录、统计接口与 Web 编辑/删除已补;待现场联调 | 记录字段和统计接口 | +| P2 | KB-003 脱敏案例沉淀 | 后端 + Web | 后端审核发布与 Web 案例库已补;待目标环境验收 | 已脱敏病例审核发布流程 | +| P2 | TRACE-004 实验室结构化数据 | 后端 + Web | 结构化 CRUD 与 Web 录入已补;设备导入待做 | 实验室结果/分型结构化关联 | | P2 | CI/OpenAPI/制品仓库 | 运维 + 测试 | CI 平台决策 | 自动门禁、OpenAPI、制品保留 | | P2 | 数据保留与审计策略 | 运维 + 产品 | 业务确认 | 保留期限、删除/导出/匿名化流程 | | P2 | 推送远端并接入发布流程 | 运维 | Git 远端地址 | 提交推送到远端,正式发布门禁执行 | diff --git a/开发交接记录.md b/开发交接记录.md index 629f56f..873fd5a 100644 --- a/开发交接记录.md +++ b/开发交接记录.md @@ -9,6 +9,100 @@ - 《后续工作计划》(2026-08-11 更新)确定混合架构:现有 Go 底座 + Python AI 微服务(`ai-service/`,规划中);生产环境全部上云,本地仅开发/训练/测试。 - **YOLO 训练相关(计划 #1-4:数据集修复、训练环境、上传、基线训练)因物理机问题挂起**,当前按用户要求优先做「独立性强、见效快」的模块。 - 开发服务器:`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` 后再恢复旧二进制。 --- diff --git a/部署指南(物理机).md b/部署指南(物理机).md index f67f352..b06a26c 100644 --- a/部署指南(物理机).md +++ b/部署指南(物理机).md @@ -9,7 +9,7 @@ | 内核 | 6.8.0-124-generic | | 磁盘 | 387GB(/dev/sda6),可用 356GB | | 内存 | 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 | > 与 WSL2 部署的关键区别:无需 portproxy、无需 WSL2 IP 修复、文件系统不会变只读、Ceph 需从头配置。 @@ -38,7 +38,7 @@ │ ├── Mgr (a) │ ├── OSD 0/1 (loop device, 各 50GB, 共 100GB) │ └── RGW :7480 S3 兼容存储 - ├── PostgreSQL 16 :5432 业务数据库 + ├── PostgreSQL 14 :5432 业务数据库(实际版本 14) ├── IoTDB 2.0.8 :18081 时序数据库 ├── VerneMQ :1883 MQTT broker ├── Go 后端 :3000 silk-server-go @@ -425,7 +425,7 @@ sudo docker run -d --name polaris-wvp --network media-net --restart always \ ```bash # 在 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/ # 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 完成数据库备份。 +> **当前迁移版本**:开发服务器已部署 `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 录制服务 ```bash # 在 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 . # 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 # 在 Windows 上构建 -# cd e:\silk\web; npm run build +# cd D:\silk-master-84e4ddc\web; npm run build # tar -czf dist.tar.gz dist # 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 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 域 | `3402000000` | | | SIP 服务器 ID | `34020000002000000001` | | | 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 -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=""; $env:GOARCH=""