feat(server-go): 分子检测扩展(qPCR Ct 判读/SERS 光谱上传/光谱库,#19)
This commit is contained in:
@@ -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提取", "反应体系配制", "恒温反应", "结果判读"}
|
||||
|
||||
@@ -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 光谱数据文件")
|
||||
}
|
||||
}
|
||||
@@ -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 应被拒绝")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user