feat(server-go): LAMP 检测管理(任务单/步骤/结果录入/结果图片,#14)
This commit is contained in:
@@ -114,6 +114,7 @@ func main() {
|
|||||||
handler.RegisterTrayBatchRoutes(api, db)
|
handler.RegisterTrayBatchRoutes(api, db)
|
||||||
handler.RegisterWechatRoutes(api, db, wechatSvc)
|
handler.RegisterWechatRoutes(api, db, wechatSvc)
|
||||||
handler.RegisterWeatherRoutes(api, db, weatherSvc)
|
handler.RegisterWeatherRoutes(api, db, weatherSvc)
|
||||||
|
handler.RegisterLampRoutes(api, db, s3Svc, cfg.S3BucketImages)
|
||||||
|
|
||||||
// 启动高发病天气预警定时任务(未配置时跳过)
|
// 启动高发病天气预警定时任务(未配置时跳过)
|
||||||
go startWeatherAlertLoop(db, weatherSvc, time.Duration(cfg.QWeatherIntervalMin)*time.Minute)
|
go startWeatherAlertLoop(db, weatherSvc, time.Duration(cfg.QWeatherIntervalMin)*time.Minute)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ func Init(cfg *config.Config) error {
|
|||||||
&model.Tray{}, &model.Batch{}, &model.RearingRecord{},
|
&model.Tray{}, &model.Batch{}, &model.RearingRecord{},
|
||||||
&model.WechatBinding{},
|
&model.WechatBinding{},
|
||||||
&model.WeatherAlert{},
|
&model.WeatherAlert{},
|
||||||
|
&model.LampTest{}, &model.LampTestStep{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
slog.Warn("自动迁移有警告(可忽略)", "err", err)
|
slog.Warn("自动迁移有警告(可忽略)", "err", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("空结果不应为合法结果")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,8 @@ var AllPermissions = []PermissionDef{
|
|||||||
{"notification:read", "订阅查看", "查看微信订阅绑定状态"},
|
{"notification:read", "订阅查看", "查看微信订阅绑定状态"},
|
||||||
{"notification:write", "订阅管理", "绑定微信并管理订阅授权"},
|
{"notification:write", "订阅管理", "绑定微信并管理订阅授权"},
|
||||||
{"weather:read", "天气查看", "查看天气与高发病天气预警"},
|
{"weather:read", "天气查看", "查看天气与高发病天气预警"},
|
||||||
|
{"lamp:read", "LAMP 检测查看", "查看 LAMP 检测任务单与结果"},
|
||||||
|
{"lamp:write", "LAMP 检测管理", "新建、编辑、录入 LAMP 检测结果"},
|
||||||
{"user:manage", "用户管理", "管理用户、角色和权限"},
|
{"user:manage", "用户管理", "管理用户、角色和权限"},
|
||||||
{"audit:read", "审计查看", "查看审计日志"},
|
{"audit:read", "审计查看", "查看审计日志"},
|
||||||
}
|
}
|
||||||
@@ -50,6 +52,7 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"tray:read", "tray:write", "batch:read", "batch:write", "rearing:read", "rearing:write",
|
"tray:read", "tray:write", "batch:read", "batch:write", "rearing:read", "rearing:write",
|
||||||
"notification:read", "notification:write",
|
"notification:read", "notification:write",
|
||||||
"weather:read",
|
"weather:read",
|
||||||
|
"lamp:read", "lamp:write",
|
||||||
"user:manage", "audit:read",
|
"user:manage", "audit:read",
|
||||||
},
|
},
|
||||||
RoleOperator: {
|
RoleOperator: {
|
||||||
@@ -61,6 +64,7 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"tray:read", "tray:write", "batch:read", "batch:write", "rearing:read", "rearing:write",
|
"tray:read", "tray:write", "batch:read", "batch:write", "rearing:read", "rearing:write",
|
||||||
"notification:read", "notification:write",
|
"notification:read", "notification:write",
|
||||||
"weather:read",
|
"weather:read",
|
||||||
|
"lamp:read", "lamp:write",
|
||||||
},
|
},
|
||||||
RoleViewer: {
|
RoleViewer: {
|
||||||
"dashboard:view", "room:read", "device:read",
|
"dashboard:view", "room:read", "device:read",
|
||||||
@@ -70,6 +74,7 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"tray:read", "batch:read", "rearing:read",
|
"tray:read", "batch:read", "rearing:read",
|
||||||
"notification:read",
|
"notification:read",
|
||||||
"weather:read",
|
"weather:read",
|
||||||
|
"lamp:read",
|
||||||
},
|
},
|
||||||
RoleFarmer: {
|
RoleFarmer: {
|
||||||
"dashboard:view", "room:read", "device:read", "device:control",
|
"dashboard:view", "room:read", "device:read", "device:control",
|
||||||
@@ -79,5 +84,6 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"tray:read", "batch:read", "rearing:read",
|
"tray:read", "batch:read", "rearing:read",
|
||||||
"notification:read", "notification:write",
|
"notification:read", "notification:write",
|
||||||
"weather:read",
|
"weather:read",
|
||||||
|
"lamp:read",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user