feat: 补齐消毒、种源与二维码身份链

This commit is contained in:
weijuesen
2026-08-14 08:39:56 +08:00
parent cc93c9a053
commit 6b222abb7b
24 changed files with 1344 additions and 9 deletions
+3
View File
@@ -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{},
+1 -1
View File
@@ -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)
}
}
+413
View File
@@ -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")
}
}
+29
View File
@@ -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}
+144
View File
@@ -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",
},
}
+7 -6
View File
@@ -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"`