From 6b222abb7b688e921d677815ab087b3ba3bd8f37 Mon Sep 17 00:00:00 2001 From: weijuesen Date: Fri, 14 Aug 2026 08:39:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=A5=E9=BD=90=E6=B6=88=E6=AF=92?= =?UTF-8?q?=E3=80=81=E7=A7=8D=E6=BA=90=E4=B8=8E=E4=BA=8C=E7=BB=B4=E7=A0=81?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + miniapp/src/api/biosecurity.ts | 33 ++ miniapp/src/app.config.ts | 1 + .../src/pages/biosecurity/index.module.scss | 62 +++ miniapp/src/pages/biosecurity/index.tsx | 127 ++++++ miniapp/src/pages/settings/index.tsx | 8 + server-go/cmd/server/main.go | 1 + server-go/internal/database/db.go | 3 + server-go/internal/database/migrate.go | 2 +- server-go/internal/database/migrate_test.go | 4 + server-go/internal/handler/biosecurity.go | 413 ++++++++++++++++++ .../internal/handler/biosecurity_test.go | 66 +++ server-go/internal/handler/trace.go | 29 ++ server-go/internal/model/biosecurity.go | 144 ++++++ server-go/internal/model/permission_seed.go | 6 + server-go/internal/model/tray_batch.go | 13 +- .../migrations/000006_biosecurity.down.sql | 4 + .../migrations/000006_biosecurity.up.sql | 64 +++ web/src/dal/biosecurity.ts | 61 +++ web/src/layout/BasicLayout.tsx | 2 + web/src/pages/Biosecurity.tsx | 272 ++++++++++++ web/src/router.tsx | 2 + 后续工作计划.md | 4 +- 开发交接记录.md | 31 ++ 24 files changed, 1344 insertions(+), 9 deletions(-) create mode 100644 miniapp/src/api/biosecurity.ts create mode 100644 miniapp/src/pages/biosecurity/index.module.scss create mode 100644 miniapp/src/pages/biosecurity/index.tsx create mode 100644 server-go/internal/handler/biosecurity.go create mode 100644 server-go/internal/handler/biosecurity_test.go create mode 100644 server-go/internal/model/biosecurity.go create mode 100644 server-go/migrations/000006_biosecurity.down.sql create mode 100644 server-go/migrations/000006_biosecurity.up.sql create mode 100644 web/src/dal/biosecurity.ts create mode 100644 web/src/pages/Biosecurity.tsx diff --git a/README.md b/README.md index c266c0c..31103a5 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ - 蚕匾(`trays`)、蚕种批次(`batches`,含品种/蚕种来源/批次号/检疫证明/龄期/入房/上蔟)、饲养记录(`rearing_records`)三表 + CRUD - Web「批次管理」页(批次 + 蚕匾 + 饲养记录);小程序蚕房详情展示蚕匾与当前批次 +- 生物安全:种源与检疫链、消毒计划/执行、批次/蚕匾/样本二维码身份;小程序可扫码并现场提交消毒执行 ### 1.9 AI 拍照巡检闭环(计划 #5/#6/#8/#9) diff --git a/miniapp/src/api/biosecurity.ts b/miniapp/src/api/biosecurity.ts new file mode 100644 index 0000000..e09bba8 --- /dev/null +++ b/miniapp/src/api/biosecurity.ts @@ -0,0 +1,33 @@ +import { get, post } from './request'; + +export interface QRResolveResult { + entityType: string; + publicId: string; + entity: { + id: string; + name?: string; + roomId?: string; + batchId?: string; + sampleNo?: string; + state?: string; + }; +} + +export interface DisinfectionRecord { + roomId?: string; + batchId?: string; + kind: 'plan' | 'execution'; + agent: string; + concentration: string; + amount?: string; + note?: string; +} + +export const resolveQR = (payload: string) => + post('/biosecurity/qr/resolve', { payload }); + +export const createDisinfectionRecord = (data: DisinfectionRecord) => + post('/biosecurity/disinfection-records', data as unknown as Record); + +export const listDisinfectionRecords = (params?: any) => + get('/biosecurity/disinfection-records', { params }); diff --git a/miniapp/src/app.config.ts b/miniapp/src/app.config.ts index 5b6936f..57f6011 100644 --- a/miniapp/src/app.config.ts +++ b/miniapp/src/app.config.ts @@ -16,6 +16,7 @@ export default defineAppConfig({ 'pages/inspection/index', 'pages/notification/index', 'pages/lamp/index', + 'pages/biosecurity/index', ], window: { backgroundTextStyle: 'dark', diff --git a/miniapp/src/pages/biosecurity/index.module.scss b/miniapp/src/pages/biosecurity/index.module.scss new file mode 100644 index 0000000..11776fd --- /dev/null +++ b/miniapp/src/pages/biosecurity/index.module.scss @@ -0,0 +1,62 @@ +.page { + min-height: 100vh; + padding: 24px; + background: #f5f6f7; +} + +.card { + background: #ffffff; + border-radius: 12px; + padding: 24px; + margin-bottom: 16px; +} + +.title { + display: block; + font-size: 32px; + font-weight: 600; + margin-bottom: 20px; +} + +.label { + display: block; + font-size: 26px; + color: #4e5969; + margin: 18px 0 8px; +} + +.input { + height: 76px; + background: #f2f3f5; + border-radius: 8px; + padding: 0 20px; + font-size: 28px; +} + +.btnPrimary { + margin-top: 24px; + background: #10b981; + color: #ffffff; + border-radius: 8px; + font-size: 28px; +} + +.entityBox { + margin-top: 20px; + padding: 20px; + background: #f0fdf4; + border-radius: 8px; +} + +.entityType { + display: block; + color: #4e5969; + font-size: 24px; +} + +.entityName { + display: block; + margin-top: 8px; + font-size: 30px; + font-weight: 600; +} diff --git a/miniapp/src/pages/biosecurity/index.tsx b/miniapp/src/pages/biosecurity/index.tsx new file mode 100644 index 0000000..1bb13c8 --- /dev/null +++ b/miniapp/src/pages/biosecurity/index.tsx @@ -0,0 +1,127 @@ +import React, { useState } from 'react'; +import { Button, Input, Text, View } from '@tarojs/components'; +import Taro from '@tarojs/taro'; +import styles from './index.module.scss'; +import { createDisinfectionRecord, resolveQR, type QRResolveResult } from '@/api/biosecurity'; + +const BiosecurityPage: React.FC = () => { + const [entity, setEntity] = useState(null); + const [agent, setAgent] = useState(''); + const [concentration, setConcentration] = useState(''); + const [amount, setAmount] = useState(''); + const [note, setNote] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const handleScan = async () => { + try { + const res = await Taro.scanCode({ scanType: ['qrCode'] }); + if (!res.result) throw new Error('未识别到二维码'); + const data = await resolveQR(res.result); + setEntity(data); + Taro.showToast({ title: '扫码成功', icon: 'success' }); + } catch (err) { + Taro.showToast({ + title: err instanceof Error ? err.message : '扫码失败', + icon: 'none', + }); + } + }; + + const handleSubmit = async () => { + if (!entity) { + Taro.showToast({ title: '请先扫码', icon: 'none' }); + return; + } + if (!agent || !concentration) { + Taro.showToast({ title: '请填写药剂和浓度', icon: 'none' }); + return; + } + setSubmitting(true); + try { + await createDisinfectionRecord({ + kind: 'execution', + roomId: entity.entity.roomId, + batchId: entity.entityType === 'batch' ? entity.entity.id : entity.entity.batchId, + agent, + concentration, + amount: amount || undefined, + note: note || undefined, + }); + Taro.showToast({ title: '消毒记录已提交', icon: 'success' }); + setAgent(''); + setConcentration(''); + setAmount(''); + setNote(''); + } catch (err) { + Taro.showToast({ + title: err instanceof Error ? err.message : '提交失败', + icon: 'none', + }); + } finally { + setSubmitting(false); + } + }; + + return ( + + + 二维码身份 + + {entity ? ( + + 类型:{entity.entityType} + + {entity.entity.name || entity.entity.sampleNo || entity.entity.id} + + + ) : null} + + + {entity ? ( + + 现场消毒执行 + 消毒药剂 + setAgent(e.detail.value)} + /> + 浓度 + setConcentration(e.detail.value)} + /> + 用量 + setAmount(e.detail.value)} + /> + 备注 + setNote(e.detail.value)} + /> + + + ) : null} + + ); +}; + +export default BiosecurityPage; diff --git a/miniapp/src/pages/settings/index.tsx b/miniapp/src/pages/settings/index.tsx index b585a3e..2970277 100644 --- a/miniapp/src/pages/settings/index.tsx +++ b/miniapp/src/pages/settings/index.tsx @@ -59,6 +59,9 @@ const SettingsPage: React.FC = () => { case 'lamp': Taro.navigateTo({ url: '/pages/lamp/index' }); break; + case 'biosecurity': + Taro.navigateTo({ url: '/pages/biosecurity/index' }); + break; case 'about': Taro.showModal({ title: '关于', @@ -190,6 +193,11 @@ const SettingsPage: React.FC = () => { LAMP 检测 + handleMenuTap('biosecurity')}> + 🛡️ + 生物安全 + + handleMenuTap('wsStatus')}> 🔗 连接状态 diff --git a/server-go/cmd/server/main.go b/server-go/cmd/server/main.go index cfac303..78878f7 100644 --- a/server-go/cmd/server/main.go +++ b/server-go/cmd/server/main.go @@ -133,6 +133,7 @@ func main() { handler.RegisterLampRoutes(api, db, s3Svc, cfg.S3BucketImages) handler.RegisterDetectionTaskRoutes(api, db) handler.RegisterConsumableRoutes(api, db) + handler.RegisterBiosecurityRoutes(api, db) handler.RegisterConsultationRoutes(api, db) handler.RegisterDetectionMethodRoutes(api, db) handler.RegisterTraceRoutes(api, db) diff --git a/server-go/internal/database/db.go b/server-go/internal/database/db.go index d8f3903..c2f0720 100644 --- a/server-go/internal/database/db.go +++ b/server-go/internal/database/db.go @@ -41,6 +41,9 @@ func Init(cfg *config.Config) error { &model.DetectionTask{}, &model.Sample{}, &model.DiseaseEvent{}, + &model.SeedSource{}, + &model.DisinfectionRecord{}, + &model.IdentityLink{}, &model.Tray{}, &model.Batch{}, &model.RearingRecord{}, &model.WechatBinding{}, &model.WeatherAlert{}, diff --git a/server-go/internal/database/migrate.go b/server-go/internal/database/migrate.go index dbc69d2..e03fa38 100644 --- a/server-go/internal/database/migrate.go +++ b/server-go/internal/database/migrate.go @@ -13,7 +13,7 @@ import ( ) // CurrentSchemaVersion 是当前后端代码期望的迁移版本。 -const CurrentSchemaVersion = "5" +const CurrentSchemaVersion = "6" // 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 febbc5b..44d0a26 100644 --- a/server-go/internal/database/migrate_test.go +++ b/server-go/internal/database/migrate_test.go @@ -112,4 +112,8 @@ func TestEmbeddedMigrationsIncludeBaseline(t *testing.T) { if err != nil || next != 5 { t.Fatalf("expected detection/disease migration version 5, got %d (err %v)", next, err) } + next, err = driver.Next(next) + if err != nil || next != 6 { + t.Fatalf("expected biosecurity migration version 6, got %d (err %v)", next, err) + } } diff --git a/server-go/internal/handler/biosecurity.go b/server-go/internal/handler/biosecurity.go new file mode 100644 index 0000000..2aa6560 --- /dev/null +++ b/server-go/internal/handler/biosecurity.go @@ -0,0 +1,413 @@ +package handler + +import ( + "crypto/rand" + "encoding/hex" + "net/http" + "time" + + "silk-server-go/internal/middleware" + "silk-server-go/internal/model" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// RegisterBiosecurityRoutes 注册种源、消毒和二维码身份路由。 +func RegisterBiosecurityRoutes(rg *gin.RouterGroup, db *gorm.DB) { + read := middleware.RequirePermission(db, "biosecurity:read") + write := middleware.RequirePermission(db, "biosecurity:write") + rg.GET("/biosecurity/seed-sources", read, listSeedSources(db)) + rg.POST("/biosecurity/seed-sources", write, createSeedSource(db)) + rg.PATCH("/biosecurity/seed-sources/:id", write, updateSeedSource(db)) + rg.GET("/biosecurity/disinfection-records", read, listDisinfectionRecords(db)) + rg.POST("/biosecurity/disinfection-records", write, createDisinfectionRecord(db)) + rg.PATCH("/biosecurity/disinfection-records/:id", write, updateDisinfectionRecord(db)) + rg.POST("/biosecurity/qr", write, issueQR(db)) + rg.POST("/biosecurity/qr/resolve", read, resolveQR(db)) +} + +func listSeedSources(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + q := db.Model(&model.SeedSource{}) + if batch := c.Query("batchId"); batch != "" { + q = q.Where("batch_id = ?", batch) + } + var list []model.SeedSource + q.Order("created_at DESC").Limit(200).Find(&list) + c.JSON(http.StatusOK, list) + } +} + +func createSeedSource(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + var body struct { + BatchID *string `json:"batchId"` + ParentID *string `json:"parentId"` + Supplier string `json:"supplier"` + SeedBatchNo string `json:"seedBatchNo"` + QuarantineNo *string `json:"quarantineNo"` + Variety *string `json:"variety"` + CertificateURL *string `json:"certificateUrl"` + EntryAt *time.Time `json:"entryAt"` + Note *string `json:"note"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + for _, id := range []*string{body.BatchID, body.ParentID} { + if id != nil && !isUUID(*id) { + c.JSON(http.StatusBadRequest, gin.H{"error": "关联 ID 不是合法的 UUID"}) + return + } + } + source := model.SeedSource{ + PublicID: randomPublicID(), + BatchID: body.BatchID, + ParentID: body.ParentID, + Supplier: body.Supplier, + SeedBatchNo: body.SeedBatchNo, + QuarantineNo: body.QuarantineNo, + Variety: body.Variety, + CertificateURL: body.CertificateURL, + EntryAt: body.EntryAt, + Note: body.Note, + CreatedBy: currentUserID(c), + } + if err := model.ValidateSeedSource(source); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if source.ParentID != nil { + if err := model.SeedSourceCycleError(seedParentChain(db), source.ID, *source.ParentID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + } + if err := db.Create(&source).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "创建种源失败"}) + return + } + linkSeedSourceToBatch(db, source) + c.JSON(http.StatusCreated, source) + } +} + +func updateSeedSource(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + 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"}) + return + } + var body struct { + BatchID *string `json:"batchId"` + ParentID *string `json:"parentId"` + Supplier *string `json:"supplier"` + SeedBatchNo *string `json:"seedBatchNo"` + QuarantineNo *string `json:"quarantineNo"` + Variety *string `json:"variety"` + CertificateURL *string `json:"certificateUrl"` + EntryAt *time.Time `json:"entryAt"` + 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.BatchID != nil { + updates["batch_id"] = *body.BatchID + } + if body.ParentID != nil { + if *body.ParentID != "" { + if err := model.SeedSourceCycleError(seedParentChain(db), source.ID, *body.ParentID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + updates["parent_id"] = *body.ParentID + } else { + updates["parent_id"] = nil + } + } + if body.Supplier != nil { + updates["supplier"] = *body.Supplier + } + if body.SeedBatchNo != nil { + updates["seed_batch_no"] = *body.SeedBatchNo + } + if body.QuarantineNo != nil { + updates["quarantine_no"] = *body.QuarantineNo + } + if body.Variety != nil { + updates["variety"] = *body.Variety + } + if body.CertificateURL != nil { + updates["certificate_url"] = *body.CertificateURL + } + if body.EntryAt != nil { + updates["entry_at"] = *body.EntryAt + } + if body.Note != nil { + updates["note"] = *body.Note + } + if len(updates) > 0 { + if err := db.Model(&model.SeedSource{}).Where("id = ?", source.ID).Updates(updates).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "更新种源失败"}) + return + } + } + db.Where("id = ?", source.ID).First(&source) + if body.BatchID != nil { + linkSeedSourceToBatch(db, source) + } + c.JSON(http.StatusOK, source) + } +} + +func listDisinfectionRecords(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + q := db.Model(&model.DisinfectionRecord{}) + if room := c.Query("roomId"); room != "" { + q = q.Where("room_id = ?", room) + } + if batch := c.Query("batchId"); batch != "" { + q = q.Where("batch_id = ?", batch) + } + if kind := c.Query("kind"); kind != "" { + q = q.Where("kind = ?", kind) + } + var list []model.DisinfectionRecord + q.Order("created_at DESC").Limit(200).Find(&list) + c.JSON(http.StatusOK, list) + } +} + +func createDisinfectionRecord(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + var body model.DisinfectionRecord + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + body.ID = "" + body.CreatedBy = currentUserID(c) + if err := model.ValidateDisinfectionRecord(body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + now := time.Now() + if body.Kind == "plan" && body.PlannedAt == nil { + body.PlannedAt = &now + } + if body.Kind == "execution" { + body.ExecutorID = currentUserID(c) + if body.ExecutedAt == nil { + body.ExecutedAt = &now + } + } + if body.RoomID != nil && !isUUID(*body.RoomID) { + c.JSON(http.StatusBadRequest, gin.H{"error": "roomId 不是合法的 UUID"}) + return + } + if body.BatchID != nil && !isUUID(*body.BatchID) { + c.JSON(http.StatusBadRequest, gin.H{"error": "batchId 不是合法的 UUID"}) + return + } + if err := db.Create(&body).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "创建消毒记录失败"}) + return + } + c.JSON(http.StatusCreated, body) + } +} + +func updateDisinfectionRecord(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + 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"}) + return + } + var body struct { + ExecutedAt *time.Time `json:"executedAt"` + ReviewedAt *time.Time `json:"reviewedAt"` + ReviewerID *string `json:"reviewerId"` + PhotoURL *string `json:"photoUrl"` + 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{}{} + now := time.Now() + if body.ExecutedAt != nil { + updates["executed_at"] = now + updates["executor_id"] = currentUserID(c) + } + if body.ReviewedAt != nil { + updates["reviewed_at"] = *body.ReviewedAt + } + if body.ReviewerID != nil { + updates["reviewer_id"] = *body.ReviewerID + } + if body.PhotoURL != nil { + updates["photo_url"] = *body.PhotoURL + } + if body.Note != nil { + updates["note"] = *body.Note + } + if len(updates) > 0 { + if err := db.Model(&model.DisinfectionRecord{}).Where("id = ?", record.ID).Updates(updates).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "更新消毒记录失败"}) + return + } + } + db.Where("id = ?", record.ID).First(&record) + c.JSON(http.StatusOK, record) + } +} + +func issueQR(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + var body struct { + EntityType string `json:"entityType"` + EntityID string `json:"entityId"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if !model.ValidQRIdentityType(body.EntityType) || !isUUID(body.EntityID) { + c.JSON(http.StatusBadRequest, gin.H{"error": "entityType/entityId 无效"}) + return + } + if !entityExists(db, body.EntityType, body.EntityID) { + c.JSON(http.StatusNotFound, gin.H{"error": "二维码关联实体不存在"}) + return + } + var link model.IdentityLink + if db.Where("entity_type = ? AND entity_id = ?", body.EntityType, body.EntityID).First(&link).Error != nil { + link = model.IdentityLink{PublicID: randomPublicID(), EntityType: body.EntityType, EntityID: body.EntityID, Version: model.QRVersion} + if err := db.Create(&link).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "创建二维码失败"}) + return + } + } + c.JSON(http.StatusOK, gin.H{ + "publicId": link.PublicID, + "entityType": link.EntityType, + "entityId": link.EntityID, + "payload": model.EncodeQRPayload(link.EntityType, link.PublicID, link.Version), + }) + } +} + +func resolveQR(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + var body struct { + Payload string `json:"payload"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + entityType, publicID, version, err := model.ParseQRPayload(body.Payload) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var link model.IdentityLink + if db.Where("public_id = ? AND entity_type = ?", publicID, entityType).First(&link).Error != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "二维码身份不存在或已失效"}) + return + } + if link.Version != version { + c.JSON(http.StatusConflict, gin.H{"error": "二维码版本不匹配"}) + return + } + summary, ok := entitySummary(db, link.EntityType, link.EntityID) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "二维码关联实体不存在"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "entityType": link.EntityType, + "publicId": link.PublicID, + "entity": summary, + }) + } +} + +func seedParentChain(db *gorm.DB) map[string]string { + var rows []struct { + ID string `gorm:"column:id"` + ParentID *string `gorm:"column:parent_id"` + } + db.Table("seed_sources").Select("id, parent_id").Scan(&rows) + chain := make(map[string]string, len(rows)) + for _, row := range rows { + if row.ParentID != nil { + chain[row.ID] = *row.ParentID + } + } + return chain +} + +func linkSeedSourceToBatch(db *gorm.DB, source model.SeedSource) { + if source.BatchID == nil { + return + } + _ = db.Model(&model.Batch{}).Where("id = ?", *source.BatchID).Update("seed_source_id", source.ID).Error +} + +func entityExists(db *gorm.DB, entityType, entityID string) bool { + switch entityType { + case "batch": + var batch model.Batch + return db.Where("id = ?", entityID).First(&batch).Error == nil + case "tray": + var tray model.Tray + return db.Where("id = ?", entityID).First(&tray).Error == nil + case "sample": + var sample model.Sample + return db.Where("id = ?", entityID).First(&sample).Error == nil + default: + return false + } +} + +func entitySummary(db *gorm.DB, entityType, entityID string) (map[string]interface{}, bool) { + switch entityType { + case "batch": + var batch model.Batch + if db.Where("id = ?", entityID).First(&batch).Error != nil { + return nil, false + } + return gin.H{"id": batch.ID, "name": batch.Name, "roomId": batch.RoomID}, true + case "tray": + var tray model.Tray + if db.Where("id = ?", entityID).First(&tray).Error != nil { + return nil, false + } + return gin.H{"id": tray.ID, "name": tray.Name, "roomId": tray.RoomID}, true + case "sample": + var sample model.Sample + if db.Where("id = ?", entityID).First(&sample).Error != nil { + return nil, false + } + return gin.H{"id": sample.ID, "sampleNo": sample.SampleNo, "detectionTaskId": sample.DetectionTaskID, "state": sample.State}, true + default: + return nil, false + } +} + +func randomPublicID() string { + b := make([]byte, 24) + if _, err := rand.Read(b); err != nil { + return hex.EncodeToString([]byte(time.Now().Format(time.RFC3339Nano))) + } + return hex.EncodeToString(b) +} diff --git a/server-go/internal/handler/biosecurity_test.go b/server-go/internal/handler/biosecurity_test.go new file mode 100644 index 0000000..135c77a --- /dev/null +++ b/server-go/internal/handler/biosecurity_test.go @@ -0,0 +1,66 @@ +package handler + +import ( + "testing" + + "silk-server-go/internal/model" +) + +func TestQRPayloadRoundTripAndTamper(t *testing.T) { + payload := model.EncodeQRPayload("batch", "public-abc", model.QRVersion) + entityType, publicID, version, err := model.ParseQRPayload(payload) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + if entityType != "batch" || publicID != "public-abc" || version != model.QRVersion { + t.Fatalf("payload = %s/%s/%d", entityType, publicID, version) + } + if _, _, _, err := model.ParseQRPayload("batch:public-abc:1"); err == nil { + t.Fatal("tampered format should fail") + } + if _, _, _, err := model.ParseQRPayload(model.EncodeQRPayload("batch", "public-abc", 2)); err != nil { + t.Fatal("different version should parse but must be rejected by resolver") + } +} + +func TestSeedSourceCycleRejected(t *testing.T) { + chain := map[string]string{ + "a": "b", + "b": "c", + "c": "a", + } + if err := model.SeedSourceCycleError(chain, "d", "a"); err == nil { + t.Fatal("cycle should be rejected") + } + if err := model.SeedSourceCycleError(chain, "a", "a"); err == nil { + t.Fatal("self reference should be rejected") + } +} + +func TestSeedSourceCycleAllowsAcyclic(t *testing.T) { + chain := map[string]string{"a": "b", "b": "c"} + if err := model.SeedSourceCycleError(chain, "d", "a"); err != nil { + t.Fatalf("acyclic chain should pass: %v", err) + } +} + +func TestDisinfectionRequiredFields(t *testing.T) { + record := model.DisinfectionRecord{Kind: "execution", Agent: "漂白粉", Concentration: "1%"} + if err := model.ValidateDisinfectionRecord(record); err != nil { + t.Fatalf("valid record should pass: %v", err) + } + record.Agent = "" + if err := model.ValidateDisinfectionRecord(record); err == nil { + t.Fatal("missing agent should fail") + } + record.Agent = "漂白粉" + record.Concentration = "" + if err := model.ValidateDisinfectionRecord(record); err == nil { + t.Fatal("missing concentration should fail") + } + record.Concentration = "1%" + record.Kind = "unknown" + if err := model.ValidateDisinfectionRecord(record); err == nil { + t.Fatal("invalid kind should fail") + } +} diff --git a/server-go/internal/handler/trace.go b/server-go/internal/handler/trace.go index 7633b65..7724a73 100644 --- a/server-go/internal/handler/trace.go +++ b/server-go/internal/handler/trace.go @@ -262,6 +262,35 @@ func autoTrace(db *gorm.DB) gin.HandlerFunc { "pastCount": pastCount, } + // 生物安全证据:种源与消毒记录 + bio := map[string]interface{}{ + "seedSourceCount": int64(0), + "disinfectionCount": int64(0), + "evidenceSufficient": false, + "missing": []string{"种源", "消毒"}, + } + if t.RoomID != nil { + var seedCount, disinfectionCount int64 + db.Table("seed_sources"). + Joins("JOIN batches ON batches.id = seed_sources.batch_id AND batches.room_id = ?", *t.RoomID). + Count(&seedCount) + db.Table("disinfection_records"). + Joins("JOIN batches ON batches.id = disinfection_records.batch_id AND batches.room_id = ?", *t.RoomID). + Count(&disinfectionCount) + missing := []string{} + if seedCount == 0 { + missing = append(missing, "种源") + } + if disinfectionCount == 0 { + missing = append(missing, "消毒") + } + bio["seedSourceCount"] = seedCount + bio["disinfectionCount"] = disinfectionCount + bio["evidenceSufficient"] = seedCount > 0 && disinfectionCount > 0 + bio["missing"] = missing + } + report["biosecurity"] = bio + // 传播途径推断 mode, source := service.TransmissionInference(t.Disease) report["transmission"] = map[string]interface{}{"mode": mode, "source": source} diff --git a/server-go/internal/model/biosecurity.go b/server-go/internal/model/biosecurity.go new file mode 100644 index 0000000..fe9a819 --- /dev/null +++ b/server-go/internal/model/biosecurity.go @@ -0,0 +1,144 @@ +package model + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +const QRVersion = 1 + +// IdentityLink 不透明二维码映射,避免把内部 ID 或个人信息放进二维码。 +type IdentityLink struct { + ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + PublicID string `gorm:"column:public_id;size:64;uniqueIndex" json:"publicId"` + EntityType string `gorm:"column:entity_type;size:16;index" json:"entityType"` + EntityID string `gorm:"column:entity_id;size:128;uniqueIndex" json:"entityId"` + Version int `gorm:"default:1" json:"version"` + CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` + UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"` +} + +func (IdentityLink) TableName() string { return "identity_links" } + +// SeedSource 蚕种来源与检疫链。 +type SeedSource struct { + ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + PublicID string `gorm:"column:public_id;size:64;uniqueIndex" json:"publicId"` + BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"` + ParentID *string `gorm:"column:parent_id;type:uuid;index" json:"parentId,omitempty"` + Supplier string `gorm:"size:128" json:"supplier"` + SeedBatchNo string `gorm:"column:seed_batch_no;size:64" json:"seedBatchNo"` + QuarantineNo *string `gorm:"column:quarantine_no;size:64" json:"quarantineNo,omitempty"` + Variety *string `gorm:"size:64" json:"variety,omitempty"` + CertificateURL *string `gorm:"column:certificate_url;size:512" json:"certificateUrl,omitempty"` + EntryAt *time.Time `gorm:"column:entry_at;type:timestamptz" json:"entryAt,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 (SeedSource) TableName() string { return "seed_sources" } + +// DisinfectionRecord 消毒计划与执行记录。 +type DisinfectionRecord 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"` + Kind string `gorm:"size:16;default:plan" json:"kind"` // plan/execution + PlanID *string `gorm:"column:plan_id;type:uuid;index" json:"planId,omitempty"` + Agent string `gorm:"size:128" json:"agent"` + Concentration string `gorm:"size:64" json:"concentration"` + Amount *string `gorm:"size:64" json:"amount,omitempty"` + PlannedAt *time.Time `gorm:"column:planned_at;type:timestamptz" json:"plannedAt,omitempty"` + ExecutedAt *time.Time `gorm:"column:executed_at;type:timestamptz" json:"executedAt,omitempty"` + ExecutorID *string `gorm:"column:executor_id;type:uuid" json:"executorId,omitempty"` + ReviewedAt *time.Time `gorm:"column:reviewed_at;type:timestamptz" json:"reviewedAt,omitempty"` + ReviewerID *string `gorm:"column:reviewer_id;type:uuid" json:"reviewerId,omitempty"` + PhotoURL *string `gorm:"column:photo_url;size:512" json:"photoUrl,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 (DisinfectionRecord) TableName() string { return "disinfection_records" } + +// EncodeQRPayload 生成不透明二维码载荷。 +func EncodeQRPayload(entityType, publicID string, version int) string { + return fmt.Sprintf("silk:v1:%s:%s:%d", entityType, publicID, version) +} + +// ParseQRPayload 解析并校验二维码载荷。 +func ParseQRPayload(payload string) (string, string, int, error) { + parts := strings.Split(strings.TrimSpace(payload), ":") + if len(parts) != 5 || parts[0] != "silk" || parts[1] != "v1" { + return "", "", 0, errors.New("二维码格式不正确") + } + entityType := parts[2] + publicID := parts[3] + version, err := strconv.Atoi(parts[4]) + if err != nil || version <= 0 { + return "", "", 0, errors.New("二维码版本不正确") + } + if !ValidQRIdentityType(entityType) || publicID == "" { + return "", "", 0, errors.New("二维码身份无效") + } + return entityType, publicID, version, nil +} + +// ValidQRIdentityType 当前二维码支持的实体类型。 +func ValidQRIdentityType(entityType string) bool { + switch entityType { + case "batch", "tray", "sample": + return true + default: + return false + } +} + +// ValidateSeedSource 种源必填项。 +func ValidateSeedSource(source SeedSource) error { + if strings.TrimSpace(source.Supplier) == "" { + return errors.New("供应商不能为空") + } + if strings.TrimSpace(source.SeedBatchNo) == "" { + return errors.New("蚕种批号不能为空") + } + return nil +} + +// ValidateDisinfectionRecord 消毒必填项。 +func ValidateDisinfectionRecord(record DisinfectionRecord) error { + if record.Kind != "plan" && record.Kind != "execution" { + return errors.New("kind 仅支持 plan/execution") + } + if strings.TrimSpace(record.Agent) == "" { + return errors.New("消毒药剂不能为空") + } + if strings.TrimSpace(record.Concentration) == "" { + return errors.New("消毒浓度不能为空") + } + return nil +} + +// SeedSourceCycleError 检测种源链是否形成循环。 +func SeedSourceCycleError(chain map[string]string, id, parentID string) error { + seen := map[string]bool{id: true} + current := parentID + for current != "" { + if seen[current] { + return errors.New("种源链不能形成循环") + } + seen[current] = true + next := chain[current] + if next == current { + return errors.New("种源链不能自引用") + } + current = next + } + return nil +} diff --git a/server-go/internal/model/permission_seed.go b/server-go/internal/model/permission_seed.go index 2da896c..d4656f0 100644 --- a/server-go/internal/model/permission_seed.go +++ b/server-go/internal/model/permission_seed.go @@ -43,6 +43,8 @@ var AllPermissions = []PermissionDef{ {"consultation:write", "会诊管理", "发起会诊、出具意见与防控方案"}, {"trace:read", "溯源查看", "查看疫病溯源记录与报告"}, {"trace:write", "溯源管理", "发起溯源、执行排查清单与报告"}, + {"biosecurity:read", "生物安全查看", "查看种源、消毒和二维码身份"}, + {"biosecurity:write", "生物安全管理", "维护种源、消毒记录并签发二维码"}, {"user:manage", "用户管理", "管理用户、角色和权限"}, {"audit:read", "审计查看", "查看审计日志"}, } @@ -62,6 +64,7 @@ var RolePermissionMap = map[string][]string{ "consumable:read", "consumable:write", "consultation:read", "consultation:write", "trace:read", "trace:write", + "biosecurity:read", "biosecurity:write", "user:manage", "audit:read", }, RoleOperator: { @@ -77,6 +80,7 @@ var RolePermissionMap = map[string][]string{ "consumable:read", "consumable:write", "consultation:read", "consultation:write", "trace:read", "trace:write", + "biosecurity:read", "biosecurity:write", }, RoleViewer: { "dashboard:view", "room:read", "device:read", @@ -90,6 +94,7 @@ var RolePermissionMap = map[string][]string{ "consumable:read", "consultation:read", "trace:read", + "biosecurity:read", }, RoleFarmer: { "dashboard:view", "room:read", "device:read", "device:control", @@ -103,5 +108,6 @@ var RolePermissionMap = map[string][]string{ "consumable:read", "consultation:read", "trace:read", + "biosecurity:read", "biosecurity:write", }, } diff --git a/server-go/internal/model/tray_batch.go b/server-go/internal/model/tray_batch.go index 11c550d..6d61e75 100644 --- a/server-go/internal/model/tray_batch.go +++ b/server-go/internal/model/tray_batch.go @@ -21,14 +21,15 @@ type Batch struct { ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` RoomID string `gorm:"column:room_id;type:uuid;index" json:"roomId"` Name string `gorm:"size:64" json:"name"` - Variety *string `gorm:"size:64" json:"variety,omitempty"` // 品种 - Source *string `gorm:"size:128" json:"source,omitempty"` // 蚕种来源/供应商 - SeedBatchNo *string `gorm:"column:seed_batch_no;size:64" json:"seedBatchNo,omitempty"` // 蚕种批次号 - QuarantineNo *string `gorm:"column:quarantine_no;size:64" json:"quarantineNo,omitempty"` // 检疫证明编号 - Instar *int `gorm:"type:int" json:"instar,omitempty"` // 龄期 1-5 + Variety *string `gorm:"size:64" json:"variety,omitempty"` // 品种 + Source *string `gorm:"size:128" json:"source,omitempty"` // 蚕种来源/供应商 + SeedSourceID *string `gorm:"column:seed_source_id;type:uuid;index" json:"seedSourceId,omitempty"` + SeedBatchNo *string `gorm:"column:seed_batch_no;size:64" json:"seedBatchNo,omitempty"` // 蚕种批次号 + QuarantineNo *string `gorm:"column:quarantine_no;size:64" json:"quarantineNo,omitempty"` // 检疫证明编号 + Instar *int `gorm:"type:int" json:"instar,omitempty"` // 龄期 1-5 EnteredAt *time.Time `gorm:"column:entered_at;type:timestamptz" json:"enteredAt,omitempty"` // 入房时间 MountAt *time.Time `gorm:"column:mount_at;type:timestamptz" json:"mountAt,omitempty"` // 上蔟时间 - Status string `gorm:"size:16;default:rearing" json:"status"` // rearing/mounted/finished + Status string `gorm:"size:16;default:rearing" json:"status"` // rearing/mounted/finished 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/migrations/000006_biosecurity.down.sql b/server-go/migrations/000006_biosecurity.down.sql new file mode 100644 index 0000000..f048859 --- /dev/null +++ b/server-go/migrations/000006_biosecurity.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE batches DROP COLUMN IF EXISTS seed_source_id; +DROP TABLE IF EXISTS identity_links CASCADE; +DROP TABLE IF EXISTS disinfection_records CASCADE; +DROP TABLE IF EXISTS seed_sources CASCADE; diff --git a/server-go/migrations/000006_biosecurity.up.sql b/server-go/migrations/000006_biosecurity.up.sql new file mode 100644 index 0000000..df57583 --- /dev/null +++ b/server-go/migrations/000006_biosecurity.up.sql @@ -0,0 +1,64 @@ +CREATE TABLE IF NOT EXISTS seed_sources ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + public_id varchar(64) NOT NULL, + batch_id uuid, + parent_id uuid, + supplier varchar(128) NOT NULL, + seed_batch_no varchar(64) NOT NULL, + quarantine_no varchar(64), + variety varchar(64), + certificate_url varchar(512), + entry_at timestamptz, + note text, + created_by uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_seed_sources_public_id ON seed_sources (public_id); +CREATE INDEX IF NOT EXISTS idx_seed_sources_batch_id ON seed_sources (batch_id); +CREATE INDEX IF NOT EXISTS idx_seed_sources_parent_id ON seed_sources (parent_id); + +CREATE TABLE IF NOT EXISTS disinfection_records ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + room_id uuid, + batch_id uuid, + kind varchar(16) NOT NULL DEFAULT 'plan', + plan_id uuid, + agent varchar(128) NOT NULL, + concentration varchar(64) NOT NULL, + amount varchar(64), + planned_at timestamptz, + executed_at timestamptz, + executor_id uuid, + reviewed_at timestamptz, + reviewer_id uuid, + photo_url varchar(512), + 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_disinfection_records_room_id ON disinfection_records (room_id); +CREATE INDEX IF NOT EXISTS idx_disinfection_records_batch_id ON disinfection_records (batch_id); +CREATE INDEX IF NOT EXISTS idx_disinfection_records_plan_id ON disinfection_records (plan_id); + +CREATE TABLE IF NOT EXISTS identity_links ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + public_id varchar(64) NOT NULL, + entity_type varchar(16) NOT NULL, + entity_id varchar(128) NOT NULL, + version integer NOT NULL DEFAULT 1, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_identity_links_public_id ON identity_links (public_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_identity_links_entity ON identity_links (entity_type, entity_id); +CREATE INDEX IF NOT EXISTS idx_identity_links_entity_type ON identity_links (entity_type); + +ALTER TABLE batches + ADD COLUMN IF NOT EXISTS seed_source_id uuid; + +CREATE INDEX IF NOT EXISTS idx_batches_seed_source_id ON batches (seed_source_id); diff --git a/web/src/dal/biosecurity.ts b/web/src/dal/biosecurity.ts new file mode 100644 index 0000000..83cc2c8 --- /dev/null +++ b/web/src/dal/biosecurity.ts @@ -0,0 +1,61 @@ +import { get, post, patch } from '../api/http'; + +export interface SeedSource { + id: string; + publicId?: string; + batchId?: string; + parentId?: string; + supplier: string; + seedBatchNo: string; + quarantineNo?: string; + variety?: string; + certificateUrl?: string; + entryAt?: string; + note?: string; + createdAt?: string; +} + +export interface DisinfectionRecord { + id: string; + roomId?: string; + batchId?: string; + kind: 'plan' | 'execution'; + planId?: string; + agent: string; + concentration: string; + amount?: string; + plannedAt?: string; + executedAt?: string; + executorId?: string; + reviewedAt?: string; + reviewerId?: string; + photoUrl?: string; + note?: string; + createdAt?: string; +} + +export interface QRIdentity { + publicId: string; + entityType: string; + entityId: string; + payload: string; +} + +export const listSeedSources = (params?: any) => + get('/biosecurity/seed-sources', { params }); +export const createSeedSource = (data: Partial) => + post('/biosecurity/seed-sources', data); +export const updateSeedSource = (id: string, data: Partial) => + patch(`/biosecurity/seed-sources/${id}`, data); + +export const listDisinfectionRecords = (params?: any) => + get('/biosecurity/disinfection-records', { params }); +export const createDisinfectionRecord = (data: Partial) => + post('/biosecurity/disinfection-records', data); +export const updateDisinfectionRecord = (id: string, data: Partial) => + patch(`/biosecurity/disinfection-records/${id}`, data); + +export const issueQR = (entityType: string, entityId: string) => + post('/biosecurity/qr', { entityType, entityId }); +export const resolveQR = (payload: string) => + post<{ entityType: string; publicId: string; entity: any }>('/biosecurity/qr/resolve', { payload }); diff --git a/web/src/layout/BasicLayout.tsx b/web/src/layout/BasicLayout.tsx index 8a7eec4..1b538e2 100644 --- a/web/src/layout/BasicLayout.tsx +++ b/web/src/layout/BasicLayout.tsx @@ -14,6 +14,7 @@ import { CameraOutlined, TeamOutlined, DeploymentUnitOutlined, + SafetyCertificateOutlined, SettingOutlined, LogoutOutlined, UserOutlined, @@ -42,6 +43,7 @@ const menuData = [ { path: '/lamp-tests', name: 'LAMP 检测', icon: , permission: 'lamp:read' }, { path: '/detection-tasks', name: '检测任务', icon: , permission: 'lamp:read' }, { path: '/consumables', name: '耗材管理', icon: , permission: 'consumable:read' }, + { path: '/biosecurity', name: '生物安全', icon: , permission: 'biosecurity:read' }, { path: '/inspections', name: '巡检记录', icon: , permission: 'inspection:read' }, { path: '/consultations', name: '专家会诊', icon: , permission: 'consultation:read' }, { path: '/traces', name: '疫病溯源', icon: , permission: 'trace:read' }, diff --git a/web/src/pages/Biosecurity.tsx b/web/src/pages/Biosecurity.tsx new file mode 100644 index 0000000..36ccaf3 --- /dev/null +++ b/web/src/pages/Biosecurity.tsx @@ -0,0 +1,272 @@ +import { useEffect, useRef, useState } from 'react'; +import { + Button, DatePicker, Form, Input, Modal, 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 { + createDisinfectionRecord, + createSeedSource, + issueQR, + listDisinfectionRecords, + listSeedSources, + resolveQR, + updateDisinfectionRecord, + type DisinfectionRecord, + type SeedSource, +} from '../dal/biosecurity'; +import { listHouses, type SilkwormHouse } from '../dal/silkworm'; +import { listBatches, type Batch } from '../dal/trayBatch'; +import { authService } from '../services/auth'; + +const KIND_LABELS: Record = { + plan: '计划', + execution: '执行', +}; + +const canWrite = () => authService.hasPermission('biosecurity:write'); + +export default function BiosecurityPage() { + const [rooms, setRooms] = useState([]); + const [batches, setBatches] = useState([]); + const [seedSources, setSeedSources] = useState([]); + const seedAction = useRef(); + const disinfectionAction = useRef(); + const [seedOpen, setSeedOpen] = useState(false); + const [seedForm] = Form.useForm(); + const [disinfectionOpen, setDisinfectionOpen] = useState(false); + const [disinfectionForm] = Form.useForm(); + const [qrIssued, setQrIssued] = useState(null); + const [qrResolved, setQrResolved] = useState(null); + const [qrForm] = Form.useForm(); + const [resolveForm] = Form.useForm(); + + useEffect(() => { + Promise.all([ + listHouses().catch(() => ({ items: [] as SilkwormHouse[] })), + listBatches().catch(() => [] as Batch[]), + listSeedSources().catch(() => [] as SeedSource[]), + ]).then(([houseRes, batchRes, seedRes]) => { + setRooms(houseRes.items); + setBatches(batchRes); + setSeedSources(seedRes); + }); + }, []); + + 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 seedColumns: ProColumns[] = [ + { title: '供应商', dataIndex: 'supplier' }, + { title: '批号', dataIndex: 'seedBatchNo' }, + { title: '检疫证', dataIndex: 'quarantineNo', search: false, render: (_, r) => r.quarantineNo || '-' }, + { title: '批次', dataIndex: 'batchId', search: false, render: (_, r) => batchName(r.batchId) }, + { title: '上链来源', dataIndex: 'parentId', search: false, render: (_, r) => r.parentId || '-' }, + { title: '创建时间', dataIndex: 'createdAt', search: false, render: (_, r) => (r.createdAt ? dayjs(r.createdAt).format('YYYY-MM-DD HH:mm') : '-') }, + ]; + + const disinfectionColumns: ProColumns[] = [ + { title: '类型', dataIndex: 'kind', valueEnum: Object.fromEntries(Object.entries(KIND_LABELS).map(([k, v]) => [k, { text: v }])) }, + { title: '蚕房', dataIndex: 'roomId', search: false, render: (_, r) => roomName(r.roomId) }, + { title: '批次', dataIndex: 'batchId', search: false, render: (_, r) => batchName(r.batchId) }, + { title: '药剂', dataIndex: 'agent' }, + { title: '浓度', dataIndex: 'concentration' }, + { title: '计划时间', dataIndex: 'plannedAt', search: false, render: (_, r) => (r.plannedAt ? dayjs(r.plannedAt).format('YYYY-MM-DD HH:mm') : '-') }, + { title: '执行时间', dataIndex: 'executedAt', search: false, render: (_, r) => (r.executedAt ? dayjs(r.executedAt).format('YYYY-MM-DD HH:mm') : '-') }, + { + title: '操作', + valueType: 'option', + render: (_, r) => [ + ...(canWrite() && !r.executedAt + ? [ + { + await updateDisinfectionRecord(r.id, { executedAt: new Date().toISOString() }); + message.success('已记录执行'); + disinfectionAction.current?.reload(); + }}>标记执行, + ] + : []), + ], + }, + ]; + + return ( + <> + + actionRef={seedAction} + rowKey="id" + columns={seedColumns} + search={false} + request={async () => { + const res = await listSeedSources(); + setSeedSources(res); + return { data: res, total: res.length, success: true }; + }} + toolBarRender={() => + canWrite() + ? [ + , + ] + : [] + } + /> + ), + }, + { + key: 'disinfection', + label: '消毒记录', + children: ( + + actionRef={disinfectionAction} + rowKey="id" + columns={disinfectionColumns} + search={false} + request={async (params) => { + const res = await listDisinfectionRecords({ kind: params.kind }); + return { data: res, total: res.length, success: true }; + }} + toolBarRender={() => + canWrite() + ? [ + , + ] + : [] + } + /> + ), + }, + { + key: 'qr', + label: '二维码身份', + children: ( + +
{ + setQrIssued(await issueQR(v.entityType, v.entityId)); + }}> + + + + +
+ {qrIssued ? ( +
+ 已签发 +
{qrIssued.payload}
+
+ ) : null} +
{ + setQrResolved(await resolveQR(v.payload)); + }}> + + + + +
+ {qrResolved ? ( +
+ 解析结果 +
{JSON.stringify(qrResolved, null, 2)}
+
+ ) : null} +
+ ), + }, + ]} + /> + + setSeedOpen(false)} + onOk={async () => { + const v = await seedForm.validateFields(); + await createSeedSource({ ...v, entryAt: v.entryAt?.toISOString() }); + message.success('种源已创建'); + setSeedOpen(false); + seedAction.current?.reload(); + }} + > +
+ + + + + + + + ({ value: s.id, label: `${s.supplier} ${s.seedBatchNo}` }))} /> + + + + + + + + + + + + + + + + +
+
+ + setDisinfectionOpen(false)} + onOk={async () => { + const v = await disinfectionForm.validateFields(); + await createDisinfectionRecord(v); + message.success('消毒记录已创建'); + setDisinfectionOpen(false); + disinfectionAction.current?.reload(); + }} + > +
+ + ({ value: r.id, label: r.name }))} /> + + + + + + + + + + + + + +
+
+ + ); +} diff --git a/web/src/router.tsx b/web/src/router.tsx index ebc0fb0..8bf4bf2 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -15,6 +15,7 @@ import Batches from './pages/Batches'; import LampTests from './pages/LampTests'; import DetectionTasks from './pages/DetectionTasks'; import Consumables from './pages/Consumables'; +import Biosecurity from './pages/Biosecurity'; import Inspections from './pages/Inspections'; import Consultations from './pages/Consultations'; import Traces from './pages/Traces'; @@ -59,6 +60,7 @@ export const router = createBrowserRouter([ { path: 'lamp-tests', element: }, { path: 'detection-tasks', element: }, { path: 'consumables', element: }, + { path: 'biosecurity', element: }, { path: 'inspections', element: }, { path: 'consultations', element: }, { path: 'traces', element: }, diff --git a/后续工作计划.md b/后续工作计划.md index 76a7b4b..06a919e 100644 --- a/后续工作计划.md +++ b/后续工作计划.md @@ -1,6 +1,6 @@ # 后续工作计划 -> **完成状态(2026-08-14 更新)**:#5-#24、#27 已完成,Task 0/1/2/4/5/6/8/9 整改代码完成(详见 `开发交接记录.md`);#1-4 因物理机问题挂起;#23/#26 骨架完成;Task 3/7 延后到最后处理;微信/天气真实数据待凭证。 +> **完成状态(2026-08-14 更新)**:#5-#24、#27 已完成,Task 0/1/2/4/5/6/8/9/10 整改代码完成(详见 `开发交接记录.md`);#1-4 因物理机问题挂起;#23/#26 骨架完成;Task 3/7 延后到最后处理;微信/天气真实数据待凭证。 ## 整改实施计划 Wave 0-4(2026-08-13 启动) @@ -18,7 +18,7 @@ | Wave 1 | P0 安全与正确性 | Task 7 修订 qPCR 判读与检测质控 | 延后到最后(跳过) | 用户 2026-08-14 明确要求跳过并留到最后;恢复前需领域专家确认 | | Wave 2 | 工程可靠性 | Task 8 建立可靠通知、吊销与跨实例状态 | 部分可用 | 待开发服务器迁移部署与 Redis/微信真实联调 | | Wave 2 | 工程可靠性 | Task 9 建立统一检测任务、样本链与发病事件 | 部分可用 | 待开发服务器迁移部署与端到端联调 | -| Wave 3 | 业务闭环 | Task 10 补齐消毒、种源与二维码身份链 | 未开始 | 无 | +| Wave 3 | 业务闭环 | Task 10 补齐消毒、种源与二维码身份链 | 部分可用 | 待开发服务器迁移部署与真实二维码/现场扫码联调 | | Wave 3 | 业务闭环 | Task 11 实现小程序离线巡检与可靠同步 | 未开始 | 无 | | Wave 3 | 业务闭环 | Task 12 完善环境规则、会诊治理、知识审核与效果评估 | 未开始 | 无 | | Wave 4 | 验收与发布 | Task 13 建立可观测性、容量与恢复验证 | 未开始 | 无 | diff --git a/开发交接记录.md b/开发交接记录.md index 0d9cb66..d55a4a9 100644 --- a/开发交接记录.md +++ b/开发交接记录.md @@ -1027,3 +1027,34 @@ MVP 沿用 IoTDB(现状);TDengine 作为生产规模化候选(先基准 - 本任务前分支提交为 `a25bc7a`;回滚可还原 Task 9 提交; - 数据库回滚执行 `000005_detection_disease_events.down.sql`,可删除新表和关联列;已建立 `DiseaseEvent -> TraceRecord` 关联的数据会随回滚断开,回滚前需先备份; - Web 新页面回滚只需还原路由/菜单/页面文件,旧 LAMP 页面不受影响。 + +## 2026-08-14 整改 Task 10:补齐消毒、种源与二维码身份链 + +### 做了什么 + +- 新增 `seed_sources`、`disinfection_records`、`identity_links` 表及 `000006_biosecurity` 迁移;`batches` 增加 `seed_source_id`; +- 新增 `biosecurity:read/write` 权限并接入 admin/operator/viewer/farmer 角色; +- 种源链支持供应商、蚕种批号、检疫证号、入场时间、凭证 URL 和上链来源,创建/更新时检测循环引用; +- 消毒记录支持计划/执行两类,必填药剂和浓度,记录计划时间、执行时间、执行人、复核人和照片 URL; +- 二维码载荷只包含 `silk:v1:entityType:publicId:version`,通过 `identity_links` 映射到批次/蚕匾/样本;解析时校验实体存在、版本和权限; +- 溯源初报新增生物安全证据汇总,种源或消毒缺失时返回 `missing` 和 `evidenceSufficient=false`; +- Web 新增「生物安全」页面;小程序新增扫码与现场消毒执行页。 + +### 设计思路与决策依据 + +- 二维码不包含内部 ID、密码或个人信息,使用随机 `publicId` 和数据库映射,避免可猜测身份; +- 种源链采用 `parent_id` 表达跨批次关系,并用循环检测阻止错误引用; +- 消毒记录使用同一表承载计划和执行,执行记录可关联计划,便于现场录入和复核; +- 当前项目还没有组织级 ACL,二维码解析先按实体存在性和 `biosecurity:read` RBAC 控制,组织/房间级对象授权仍待后续任务。 + +### 验证结果 + +- `scripts/verify.ps1` exit 0:Go test/vet/build、Web test/lint/build、小程序 typecheck/build、APP typecheck/lint、AI pytest 15/15 均通过; +- 新增测试覆盖二维码篡改/版本解析、种源链循环/无环、消毒必填项; +- 未部署开发服务器,未执行 `000006` 迁移;未做真实二维码打印与小程序扫码联调。 + +### 回滚点 + +- 本任务前分支提交为 `cc93c9a`;回滚可还原 Task 10 提交; +- 数据库回滚执行 `000006_biosecurity.down.sql`,可删除种源、消毒、二维码映射表和 `batches.seed_source_id`; +- 页面回滚需同时还原 Web 路由/菜单、小程序页面与设置入口,并移除 `biosecurity` 权限种子。