From 7bb87dd89c42ca6b5bdd8c524e1e7a9d9b1706ce Mon Sep 17 00:00:00 2001 From: weijuesen Date: Wed, 12 Aug 2026 19:33:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(server-go):=20=E5=88=86=E5=AD=90=E6=A3=80?= =?UTF-8?q?=E6=B5=8B=E6=89=A9=E5=B1=95=EF=BC=88qPCR=20Ct=20=E5=88=A4?= =?UTF-8?q?=E8=AF=BB/SERS=20=E5=85=89=E8=B0=B1=E4=B8=8A=E4=BC=A0/=E5=85=89?= =?UTF-8?q?=E8=B0=B1=E5=BA=93=EF=BC=8C#19=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server-go/internal/database/db.go | 1 + server-go/internal/handler/knowledge_image.go | 11 + .../internal/handler/knowledge_image_test.go | 13 ++ server-go/internal/handler/lamp.go | 190 ++++++++++++++++++ server-go/internal/model/lamp.go | 14 ++ server-go/internal/model/molecular.go | 49 +++++ server-go/internal/model/molecular_test.go | 45 +++++ 7 files changed, 323 insertions(+) create mode 100644 server-go/internal/model/molecular.go create mode 100644 server-go/internal/model/molecular_test.go diff --git a/server-go/internal/database/db.go b/server-go/internal/database/db.go index 7b87e73..e34879e 100644 --- a/server-go/internal/database/db.go +++ b/server-go/internal/database/db.go @@ -35,6 +35,7 @@ func Init(cfg *config.Config) error { &model.LampTest{}, &model.LampTestStep{}, &model.Consumable{}, &model.Consultation{}, + &model.SpectrumEntry{}, ); err != nil { slog.Warn("自动迁移有警告(可忽略)", "err", err) } diff --git a/server-go/internal/handler/knowledge_image.go b/server-go/internal/handler/knowledge_image.go index 2e2ea14..2d30fde 100644 --- a/server-go/internal/handler/knowledge_image.go +++ b/server-go/internal/handler/knowledge_image.go @@ -65,6 +65,17 @@ func buildImageKey(filename string) (string, string, error) { return buildObjectKey("knowledge", filename) } +// buildDataKey 生成 /<日期>/<随机>(不校验扩展名,由调用方校验) +func buildDataKey(prefix, filename string) (string, string, error) { + ext := strings.ToLower(filepath.Ext(filename)) + buf := make([]byte, 8) + if _, err := rand.Read(buf); err != nil { + return "", "", err + } + date := time.Now().Format("20060102") + return fmt.Sprintf("%s/%s/%s%s", prefix, date, hex.EncodeToString(buf), ext), ext, nil +} + // uploadKnowledgeImage 上传知识库图片(multipart 字段名 file) func uploadKnowledgeImage(s3 *service.S3Service, bucket string) gin.HandlerFunc { return func(c *gin.Context) { diff --git a/server-go/internal/handler/knowledge_image_test.go b/server-go/internal/handler/knowledge_image_test.go index eb7fad3..59e2a38 100644 --- a/server-go/internal/handler/knowledge_image_test.go +++ b/server-go/internal/handler/knowledge_image_test.go @@ -77,3 +77,16 @@ func TestBuildObjectKeyPrefix(t *testing.T) { t.Errorf("key/ext 应以 .jpg 结尾: %s / %s", key, ext) } } + +func TestBuildDataKey(t *testing.T) { + key, ext, err := buildDataKey("spectrum", "a.CSV") + if err != nil { + t.Fatalf("buildDataKey 失败: %v", err) + } + if !strings.HasPrefix(key, "spectrum/") || !strings.HasSuffix(key, ".csv") { + t.Errorf("key 不正确: %s", key) + } + if ext != ".csv" { + t.Errorf("ext = %s, want .csv", ext) + } +} diff --git a/server-go/internal/handler/lamp.go b/server-go/internal/handler/lamp.go index 1a02641..4d82f91 100644 --- a/server-go/internal/handler/lamp.go +++ b/server-go/internal/handler/lamp.go @@ -28,6 +28,12 @@ func RegisterLampRoutes(rg *gin.RouterGroup, db *gorm.DB, s3 *service.S3Service, rg.PATCH("/lamp-tests/:id/steps/:stepNo", write, updateLampTestStep(db)) rg.POST("/lamp-tests/:id/result-image", write, uploadLampResultImage(db, s3, imageBucket)) rg.GET("/lamp-tests/:id/cross-validation", read, getLampCrossValidation(db)) + rg.POST("/lamp-tests/:id/judge-qpcr", write, judgeQPCR(db)) + rg.POST("/lamp-tests/:id/spectrum", write, uploadLampSpectrum(db, s3, imageBucket)) + rg.GET("/spectrum-entries", read, listSpectrumEntries(db)) + rg.POST("/spectrum-entries", write, createSpectrumEntry(db)) + rg.POST("/spectrum-entries/upload", write, uploadSpectrumEntryFile(s3, imageBucket)) + rg.DELETE("/spectrum-entries/:id", write, deleteSpectrumEntry(db)) } // listLampTests 检测任务单列表(roomId/batchId/status 过滤) @@ -58,6 +64,13 @@ func createLampTest(db *gorm.DB) gin.HandlerFunc { return } t.ID = "" + if t.Method == "" { + t.Method = "lamp" + } + if !model.ValidDetectionMethod(t.Method) { + c.JSON(http.StatusBadRequest, gin.H{"error": "method 仅支持 lamp/qpcr/sers/hyperspectral"}) + return + } if t.RoomID != nil && !isUUID(*t.RoomID) { c.JSON(http.StatusBadRequest, gin.H{"error": "roomId 不是合法的 UUID"}) return @@ -85,6 +98,183 @@ func createLampTest(db *gorm.DB) gin.HandlerFunc { } } +// judgeQPCR qPCR Ct 值录入并自动判读(#19) +func judgeQPCR(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 body struct { + CtValues []float64 `json:"ctValues"` + Threshold float64 `json:"threshold"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + result, reason := model.JudgeQPCR(body.CtValues, body.Threshold) + extra := map[string]interface{}{} + if len(t.ExtraData) > 0 { + _ = json.Unmarshal(t.ExtraData, &extra) + } + extra["ctValues"] = body.CtValues + extra["judgeReason"] = reason + raw, _ := json.Marshal(extra) + now := time.Now() + db.Model(&model.LampTest{}).Where("id = ?", id).Updates(map[string]interface{}{ + "extra_data": raw, "result": result, "status": "resulted", "resulted_at": now, + }) + _ = runCrossValidation(db, id, result) + db.Where("id = ?", id).First(&t) + c.JSON(http.StatusOK, t) + } +} + +// uploadLampSpectrum SERS 光谱数据上传(S3 spectrum/ 前缀) +func uploadLampSpectrum(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 := model.ValidateSpectrumFile(header.Filename, header.Size); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + data, err := io.ReadAll(file) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "读取文件失败"}) + return + } + key, ext, err := buildDataKey("spectrum", header.Filename) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + 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(data), spectrumContentType(ext)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "上传失败: " + err.Error()}) + return + } + url := s3.Endpoint() + "/" + bucket + "/" + key + extra := map[string]interface{}{} + if len(t.ExtraData) > 0 { + _ = json.Unmarshal(t.ExtraData, &extra) + } + extra["spectrumUrl"] = url + raw, _ := json.Marshal(extra) + db.Model(&model.LampTest{}).Where("id = ?", id).Update("extra_data", raw) + c.JSON(http.StatusOK, gin.H{"url": url}) + } +} + +// spectrumContentType 光谱文件 Content-Type +func spectrumContentType(ext string) string { + switch ext { + case ".json": + return "application/json" + case ".csv": + return "text/csv" + default: + return "text/plain" + } +} + +// ---------- 光谱库 ---------- + +func listSpectrumEntries(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + q := db.Model(&model.SpectrumEntry{}) + if disease := c.Query("disease"); disease != "" { + q = q.Where("disease = ?", disease) + } + var list []model.SpectrumEntry + q.Order("created_at DESC").Find(&list) + c.JSON(http.StatusOK, list) + } +} + +func createSpectrumEntry(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + var e model.SpectrumEntry + if err := c.ShouldBindJSON(&e); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + e.ID = "" + if e.Disease == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "病种不能为空"}) + return + } + if err := db.Create(&e).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"}) + return + } + c.JSON(http.StatusCreated, e) + } +} + +func uploadSpectrumEntryFile(s3 *service.S3Service, bucket string) gin.HandlerFunc { + return func(c *gin.Context) { + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请选择光谱文件(字段名 file)"}) + return + } + defer file.Close() + if err := model.ValidateSpectrumFile(header.Filename, header.Size); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + data, err := io.ReadAll(file) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "读取文件失败"}) + return + } + key, ext, err := buildDataKey("spectrum", header.Filename) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + 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(data), spectrumContentType(ext)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "上传失败: " + err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"url": s3.Endpoint() + "/" + bucket + "/" + key}) + } +} + +func deleteSpectrumEntry(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + id := c.Param("id") + var e model.SpectrumEntry + if db.Where("id = ?", id).First(&e).Error != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "spectrum entry not found"}) + return + } + db.Where("id = ?", id).Delete(&model.SpectrumEntry{}) + c.JSON(http.StatusOK, gin.H{"id": id}) + } +} + // updateLampTest 更新任务单;result 合法时自动置状态 resulted func updateLampTest(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { diff --git a/server-go/internal/model/lamp.go b/server-go/internal/model/lamp.go index 42163bc..d4c05a5 100644 --- a/server-go/internal/model/lamp.go +++ b/server-go/internal/model/lamp.go @@ -8,6 +8,7 @@ import ( // LampTest LAMP 检测任务单 type LampTest struct { ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + Method string `gorm:"size:32;default:lamp" json:"method"` // lamp/qpcr/sers/hyperspectral 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"` @@ -19,6 +20,7 @@ type LampTest struct { ResultedAt *time.Time `gorm:"column:resulted_at;type:timestamptz" json:"resultedAt,omitempty"` CrossStatus string `gorm:"column:cross_status;size:16;default:pending" json:"crossStatus"` // pending/consistent/inconsistent CrossReason *string `gorm:"column:cross_reason;type:text" json:"crossReason,omitempty"` + ExtraData json.RawMessage `gorm:"column:extra_data;type:jsonb" json:"extraData,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"` @@ -41,6 +43,18 @@ type LampTestStep struct { func (LampTestStep) TableName() string { return "lamp_test_steps" } +// SpectrumEntry 光谱库条目 +type SpectrumEntry struct { + ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + Disease string `gorm:"size:64" json:"disease"` + Source *string `gorm:"size:128" json:"source,omitempty"` + DataURL *string `gorm:"column:data_url;size:512" json:"dataUrl,omitempty"` + Note *string `gorm:"type:text" json:"note,omitempty"` + CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` +} + +func (SpectrumEntry) TableName() string { return "spectrum_entries" } + // DefaultLampSteps LAMP 标准 5 步流程(规格书 3.3.2.1) func DefaultLampSteps() []string { return []string{"采样", "DNA提取", "反应体系配制", "恒温反应", "结果判读"} diff --git a/server-go/internal/model/molecular.go b/server-go/internal/model/molecular.go new file mode 100644 index 0000000..c1c5a47 --- /dev/null +++ b/server-go/internal/model/molecular.go @@ -0,0 +1,49 @@ +package model + +import ( + "errors" + "path/filepath" + "strings" +) + +const maxSpectrumSize = 5 << 20 // 5MB + +// ValidDetectionMethod 检测方式枚举 +func ValidDetectionMethod(method string) bool { + switch method { + case "lamp", "qpcr", "sers", "hyperspectral": + return true + default: + return false + } +} + +// JudgeQPCR qPCR Ct 值自动判读:任一 Ct < threshold 判阳性,无 Ct 判阴性 +func JudgeQPCR(cts []float64, threshold float64) (string, string) { + if threshold <= 0 { + threshold = 35 + } + if len(cts) == 0 { + return "negative", "未检出 Ct 值,判为阴性" + } + for _, ct := range cts { + if ct < threshold { + return "positive", "存在 Ct 值低于阈值,判为阳性" + } + } + return "negative", "全部 Ct 值不低于阈值,判为阴性" +} + +// ValidateSpectrumFile 光谱数据文件校验(csv/txt/json,≤5MB) +func ValidateSpectrumFile(filename string, size int64) error { + if size <= 0 || size > maxSpectrumSize { + return errors.New("光谱文件大小需在 1B~5MB 之间") + } + ext := strings.ToLower(filepath.Ext(filename)) + switch ext { + case ".csv", ".txt", ".json": + return nil + default: + return errors.New("仅支持 csv/txt/json 光谱数据文件") + } +} diff --git a/server-go/internal/model/molecular_test.go b/server-go/internal/model/molecular_test.go new file mode 100644 index 0000000..85a6efd --- /dev/null +++ b/server-go/internal/model/molecular_test.go @@ -0,0 +1,45 @@ +package model + +import "testing" + +func TestJudgeQPCR(t *testing.T) { + cases := []struct { + name string + cts []float64 + threshold float64 + want string + }{ + {"Ct32 阳性", []float64{32}, 35, "positive"}, + {"Ct38 阴性", []float64{38}, 35, "negative"}, + {"无Ct 阴性", nil, 35, "negative"}, + {"多个Ct含阳性", []float64{33, 37}, 35, "positive"}, + {"自定义阈值40", []float64{38}, 40, "positive"}, + {"阈值为0用默认35", []float64{38}, 0, "negative"}, + } + for _, c := range cases { + got, reason := JudgeQPCR(c.cts, c.threshold) + if got != c.want { + t.Errorf("%s: result=%s, want %s(原因 %s)", c.name, got, c.want, reason) + } + if reason == "" { + t.Errorf("%s: 缺少判读说明", c.name) + } + } +} + +func TestValidateSpectrumFile(t *testing.T) { + for _, name := range []string{"a.csv", "b.txt", "c.json"} { + if err := ValidateSpectrumFile(name, 1024); err != nil { + t.Errorf("%s 应被允许: %v", name, err) + } + } + if err := ValidateSpectrumFile("a.jpg", 1024); err == nil { + t.Error("图片扩展名不应被允许") + } + if err := ValidateSpectrumFile("a.csv", 0); err == nil { + t.Error("空文件应被拒绝") + } + if err := ValidateSpectrumFile("a.csv", maxSpectrumSize+1); err == nil { + t.Error("超 5MB 应被拒绝") + } +}