From c6e5a76e71fbf64d10573e2de2d754e66093f840 Mon Sep 17 00:00:00 2001 From: weijuesen Date: Wed, 12 Aug 2026 17:48:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(server-go):=20LAMP=20=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E7=AE=A1=E7=90=86=EF=BC=88=E4=BB=BB=E5=8A=A1=E5=8D=95/?= =?UTF-8?q?=E6=AD=A5=E9=AA=A4/=E7=BB=93=E6=9E=9C=E5=BD=95=E5=85=A5/?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E5=9B=BE=E7=89=87=EF=BC=8C#14=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server-go/cmd/server/main.go | 1 + server-go/internal/database/db.go | 1 + server-go/internal/handler/lamp.go | 238 ++++++++++++++++++++ server-go/internal/model/lamp.go | 55 +++++ server-go/internal/model/lamp_test_test.go | 30 +++ server-go/internal/model/permission_seed.go | 6 + 6 files changed, 331 insertions(+) create mode 100644 server-go/internal/handler/lamp.go create mode 100644 server-go/internal/model/lamp.go create mode 100644 server-go/internal/model/lamp_test_test.go diff --git a/server-go/cmd/server/main.go b/server-go/cmd/server/main.go index a5df681..42ea147 100644 --- a/server-go/cmd/server/main.go +++ b/server-go/cmd/server/main.go @@ -114,6 +114,7 @@ func main() { handler.RegisterTrayBatchRoutes(api, db) handler.RegisterWechatRoutes(api, db, wechatSvc) handler.RegisterWeatherRoutes(api, db, weatherSvc) + handler.RegisterLampRoutes(api, db, s3Svc, cfg.S3BucketImages) // 启动高发病天气预警定时任务(未配置时跳过) go startWeatherAlertLoop(db, weatherSvc, time.Duration(cfg.QWeatherIntervalMin)*time.Minute) diff --git a/server-go/internal/database/db.go b/server-go/internal/database/db.go index 003de70..1d097af 100644 --- a/server-go/internal/database/db.go +++ b/server-go/internal/database/db.go @@ -32,6 +32,7 @@ func Init(cfg *config.Config) error { &model.Tray{}, &model.Batch{}, &model.RearingRecord{}, &model.WechatBinding{}, &model.WeatherAlert{}, + &model.LampTest{}, &model.LampTestStep{}, ); err != nil { slog.Warn("自动迁移有警告(可忽略)", "err", err) } diff --git a/server-go/internal/handler/lamp.go b/server-go/internal/handler/lamp.go new file mode 100644 index 0000000..2a4cb9d --- /dev/null +++ b/server-go/internal/handler/lamp.go @@ -0,0 +1,238 @@ +package handler + +import ( + "bytes" + "io" + "net/http" + "strconv" + "time" + + "silk-server-go/internal/middleware" + "silk-server-go/internal/model" + "silk-server-go/internal/service" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// RegisterLampRoutes 注册 LAMP 检测路由 +func RegisterLampRoutes(rg *gin.RouterGroup, db *gorm.DB, s3 *service.S3Service, imageBucket string) { + read := middleware.RequirePermission(db, "lamp:read") + write := middleware.RequirePermission(db, "lamp:write") + rg.GET("/lamp-tests", read, listLampTests(db)) + rg.POST("/lamp-tests", write, createLampTest(db)) + rg.PATCH("/lamp-tests/:id", write, updateLampTest(db)) + rg.DELETE("/lamp-tests/:id", write, deleteLampTest(db)) + rg.GET("/lamp-tests/:id/steps", read, listLampTestSteps(db)) + rg.PATCH("/lamp-tests/:id/steps/:stepNo", write, updateLampTestStep(db)) + rg.POST("/lamp-tests/:id/result-image", write, uploadLampResultImage(db, s3, imageBucket)) +} + +// listLampTests 检测任务单列表(roomId/batchId/status 过滤) +func listLampTests(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + q := db.Model(&model.LampTest{}) + 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 status := c.Query("status"); status != "" { + q = q.Where("status = ?", status) + } + var list []model.LampTest + q.Order("created_at DESC").Find(&list) + c.JSON(http.StatusOK, list) + } +} + +// createLampTest 新建任务单并自动生成标准 5 步 +func createLampTest(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + var t model.LampTest + if err := c.ShouldBindJSON(&t); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + t.ID = "" + if t.RoomID != nil && !isUUID(*t.RoomID) { + c.JSON(http.StatusBadRequest, gin.H{"error": "roomId 不是合法的 UUID"}) + return + } + if t.BatchID != nil && !isUUID(*t.BatchID) { + c.JSON(http.StatusBadRequest, gin.H{"error": "batchId 不是合法的 UUID"}) + return + } + if t.Status == "" { + t.Status = "pending" + } + t.OperatorID = currentUserID(c) + if err := db.Create(&t).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"}) + return + } + for i, name := range model.DefaultLampSteps() { + step := model.LampTestStep{LampTestID: t.ID, StepNo: i + 1, Name: name} + if err := db.Create(&step).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "创建步骤失败"}) + return + } + } + c.JSON(http.StatusCreated, t) + } +} + +// updateLampTest 更新任务单;result 合法时自动置状态 resulted +func updateLampTest(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + id := c.Param("id") + var t model.LampTest + if db.Where("id = ?", id).First(&t).Error != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "lamp test not found"}) + return + } + updates, err := bindUpdates(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if r, ok := updates["result"]; ok { + result, _ := r.(string) + if result != "" && !model.ValidLampResult(result) { + c.JSON(http.StatusBadRequest, gin.H{"error": "result 仅支持 positive/negative/invalid"}) + return + } + if result != "" { + updates["status"] = "resulted" + updates["resulted_at"] = time.Now() + } else { + updates["status"] = "testing" + } + } + if len(updates) > 0 { + db.Model(&model.LampTest{}).Where("id = ?", id).Updates(updates) + } + db.Where("id = ?", id).First(&t) + c.JSON(http.StatusOK, t) + } +} + +// deleteLampTest 删除任务单(级联删除步骤) +func deleteLampTest(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + id := c.Param("id") + var t model.LampTest + if db.Where("id = ?", id).First(&t).Error != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "lamp test not found"}) + return + } + db.Where("lamp_test_id = ?", id).Delete(&model.LampTestStep{}) + db.Where("id = ?", id).Delete(&model.LampTest{}) + c.JSON(http.StatusOK, gin.H{"id": id}) + } +} + +// listLampTestSteps 任务单步骤 +func listLampTestSteps(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + id := c.Param("id") + var t model.LampTest + if db.Where("id = ?", id).First(&t).Error != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "lamp test not found"}) + return + } + var steps []model.LampTestStep + db.Where("lamp_test_id = ?", id).Order("step_no ASC").Find(&steps) + c.JSON(http.StatusOK, steps) + } +} + +// updateLampTestStep 更新步骤完成状态 +func updateLampTestStep(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + id := c.Param("id") + stepNo, err := strconv.Atoi(c.Param("stepNo")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "stepNo 不合法"}) + return + } + var step model.LampTestStep + if db.Where("lamp_test_id = ? AND step_no = ?", id, stepNo).First(&step).Error != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "step not found"}) + return + } + var body struct { + Done *bool `json:"done"` + 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.Done != nil { + updates["done"] = *body.Done + if *body.Done { + updates["done_at"] = time.Now() + } else { + updates["done_at"] = nil + } + if stepNo == 5 && *body.Done { + db.Model(&model.LampTest{}).Where("id = ?", id).Update("status", "testing") + } + } + if body.Note != nil { + updates["note"] = *body.Note + } + if len(updates) > 0 { + db.Model(&model.LampTestStep{}).Where("id = ?", step.ID).Updates(updates) + } + db.Where("id = ?", step.ID).First(&step) + c.JSON(http.StatusOK, step) + } +} + +// uploadLampResultImage 结果照片上传(S3 lamp/ 前缀) +func uploadLampResultImage(db *gorm.DB, s3 *service.S3Service, bucket string) gin.HandlerFunc { + return func(c *gin.Context) { + id := c.Param("id") + var t model.LampTest + if db.Where("id = ?", id).First(&t).Error != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "lamp test not found"}) + return + } + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请选择图片文件(字段名 file)"}) + return + } + defer file.Close() + if err := validateImageFile(header.Filename, header.Size); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + imageBytes, err := io.ReadAll(file) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "读取图片失败"}) + return + } + key, ext, err := buildObjectKey("lamp", header.Filename) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + contentType, _ := imageContentType(ext) + if err := s3.EnsureBucket(bucket); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "图片存储不可用: " + err.Error()}) + return + } + if err := s3.UploadImage(bucket, key, bytes.NewReader(imageBytes), contentType); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "图片上传失败: " + err.Error()}) + return + } + url := s3.Endpoint() + "/" + bucket + "/" + key + db.Model(&model.LampTest{}).Where("id = ?", id).Update("result_image_url", url) + c.JSON(http.StatusOK, gin.H{"url": url}) + } +} diff --git a/server-go/internal/model/lamp.go b/server-go/internal/model/lamp.go new file mode 100644 index 0000000..f8fb4a2 --- /dev/null +++ b/server-go/internal/model/lamp.go @@ -0,0 +1,55 @@ +package model + +import ( + "encoding/json" + "time" +) + +// LampTest LAMP 检测任务单 +type LampTest 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"` + Diseases json.RawMessage `gorm:"type:jsonb" json:"diseases,omitempty"` + Status string `gorm:"size:16;default:pending" json:"status"` // pending/testing/resulted + SampleInfo *string `gorm:"column:sample_info;size:255" json:"sampleInfo,omitempty"` + Result *string `gorm:"size:16" json:"result,omitempty"` // positive/negative/invalid + ResultImageURL *string `gorm:"column:result_image_url;size:512" json:"resultImageUrl,omitempty"` + OperatorID *string `gorm:"column:operator_id;type:uuid" json:"operatorId,omitempty"` + ResultedAt *time.Time `gorm:"column:resulted_at;type:timestamptz" json:"resultedAt,omitempty"` + Note *string `gorm:"type:text" json:"note,omitempty"` + CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` + UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"` +} + +func (LampTest) TableName() string { return "lamp_tests" } + +// LampTestStep LAMP 检测步骤 +type LampTestStep struct { + ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + LampTestID string `gorm:"column:lamp_test_id;type:uuid;index" json:"lampTestId"` + StepNo int `gorm:"column:step_no;type:int" json:"stepNo"` + Name string `gorm:"size:64" json:"name"` + Done bool `gorm:"default:false" json:"done"` + DoneAt *time.Time `gorm:"column:done_at;type:timestamptz" json:"doneAt,omitempty"` + Note *string `gorm:"type:text" json:"note,omitempty"` + CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` + UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"` +} + +func (LampTestStep) TableName() string { return "lamp_test_steps" } + +// DefaultLampSteps LAMP 标准 5 步流程(规格书 3.3.2.1) +func DefaultLampSteps() []string { + return []string{"采样", "DNA提取", "反应体系配制", "恒温反应", "结果判读"} +} + +// ValidLampResult 校验 LAMP 结果枚举 +func ValidLampResult(result string) bool { + switch result { + case "positive", "negative", "invalid": + return true + default: + return false + } +} diff --git a/server-go/internal/model/lamp_test_test.go b/server-go/internal/model/lamp_test_test.go new file mode 100644 index 0000000..7072a0e --- /dev/null +++ b/server-go/internal/model/lamp_test_test.go @@ -0,0 +1,30 @@ +package model + +import "testing" + +func TestDefaultLampSteps(t *testing.T) { + steps := DefaultLampSteps() + want := []string{"采样", "DNA提取", "反应体系配制", "恒温反应", "结果判读"} + if len(steps) != len(want) { + t.Fatalf("步骤数 = %d, want %d", len(steps), len(want)) + } + for i, name := range want { + if steps[i] != name { + t.Errorf("步骤 %d = %s, want %s", i+1, steps[i], name) + } + } +} + +func TestValidLampResult(t *testing.T) { + for _, r := range []string{"positive", "negative", "invalid"} { + if !ValidLampResult(r) { + t.Errorf("%s 应为合法结果", r) + } + } + if ValidLampResult("unknown") { + t.Error("unknown 不应为合法结果") + } + if ValidLampResult("") { + t.Error("空结果不应为合法结果") + } +} diff --git a/server-go/internal/model/permission_seed.go b/server-go/internal/model/permission_seed.go index 81df015..04e0827 100644 --- a/server-go/internal/model/permission_seed.go +++ b/server-go/internal/model/permission_seed.go @@ -35,6 +35,8 @@ var AllPermissions = []PermissionDef{ {"notification:read", "订阅查看", "查看微信订阅绑定状态"}, {"notification:write", "订阅管理", "绑定微信并管理订阅授权"}, {"weather:read", "天气查看", "查看天气与高发病天气预警"}, + {"lamp:read", "LAMP 检测查看", "查看 LAMP 检测任务单与结果"}, + {"lamp:write", "LAMP 检测管理", "新建、编辑、录入 LAMP 检测结果"}, {"user:manage", "用户管理", "管理用户、角色和权限"}, {"audit:read", "审计查看", "查看审计日志"}, } @@ -50,6 +52,7 @@ var RolePermissionMap = map[string][]string{ "tray:read", "tray:write", "batch:read", "batch:write", "rearing:read", "rearing:write", "notification:read", "notification:write", "weather:read", + "lamp:read", "lamp:write", "user:manage", "audit:read", }, RoleOperator: { @@ -61,6 +64,7 @@ var RolePermissionMap = map[string][]string{ "tray:read", "tray:write", "batch:read", "batch:write", "rearing:read", "rearing:write", "notification:read", "notification:write", "weather:read", + "lamp:read", "lamp:write", }, RoleViewer: { "dashboard:view", "room:read", "device:read", @@ -70,6 +74,7 @@ var RolePermissionMap = map[string][]string{ "tray:read", "batch:read", "rearing:read", "notification:read", "weather:read", + "lamp:read", }, RoleFarmer: { "dashboard:view", "room:read", "device:read", "device:control", @@ -79,5 +84,6 @@ var RolePermissionMap = map[string][]string{ "tray:read", "batch:read", "rearing:read", "notification:read", "notification:write", "weather:read", + "lamp:read", }, }