feat: 补齐消毒、种源与二维码身份链
This commit is contained in:
@@ -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{},
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
// CurrentSchemaVersion 是当前后端代码期望的迁移版本。
|
||||
const CurrentSchemaVersion = "5"
|
||||
const CurrentSchemaVersion = "6"
|
||||
|
||||
// RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。
|
||||
func RunMigrations(db *gorm.DB) error {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user