Files
silk/server-go/internal/handler/lamp.go
T
2026-08-17 21:43:26 +08:00

629 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"bytes"
"encoding/json"
"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"
"gorm.io/gorm/clause"
)
// 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))
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 过滤)
func listLampTests(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
q := applyRoomScope(db.Model(&model.LampTest{}), c, "room_id")
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.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
}
if t.RoomID != nil && !canAccessRoom(db, c, t.RoomID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权在该蚕房下创建检测任务"})
return
}
if t.BatchID != nil && !isUUID(*t.BatchID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "batchId 不是合法的 UUID"})
return
}
if t.BatchID != nil && !requireObjectAccess(c, db, "batch", *t.BatchID) {
return
}
if t.DetectionTaskID != nil && !isUUID(*t.DetectionTaskID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "detectionTaskId 不是合法的 UUID"})
return
}
if t.DetectionTaskID != nil && !requireObjectAccess(c, db, "detection_task", *t.DetectionTaskID) {
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)
}
}
// judgeQPCR qPCR Ct 值录入并自动判读(#19)
func judgeQPCR(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
if !requireObjectAccess(c, db, "lamp_test", id) {
return
}
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,
})
_ = syncDetectionTaskFromLamp(db, id)
_ = ensureDiseaseEventForLamp(db, id)
_ = 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")
if !requireObjectAccess(c, db, "lamp_test", id) {
return
}
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) {
id := c.Param("id")
if !requireObjectAccess(c, db, "lamp_test", id) {
return
}
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 roomID, ok := updates["room_id"].(string); ok && !canAccessRoom(db, c, &roomID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权迁移检测任务到指定蚕房"})
return
}
for _, ref := range []struct {
key string
kind string
}{
{key: "batch_id", kind: "batch"},
{key: "detection_task_id", kind: "detection_task"},
} {
if id, ok := updates[ref.key].(string); ok && !requireObjectAccess(c, db, ref.kind, id) {
return
}
}
resultSet := false
resultValue := ""
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"
}
resultSet = true
resultValue = result
}
if len(updates) > 0 {
db.Model(&model.LampTest{}).Where("id = ?", id).Updates(updates)
}
// 交叉验证:结果落库后与同房间最近巡检比对
if resultSet && resultValue != "" {
_ = runCrossValidation(db, id, resultValue)
_ = syncDetectionTaskFromLamp(db, id)
_ = ensureDiseaseEventForLamp(db, id)
}
db.Where("id = ?", id).First(&t)
c.JSON(http.StatusOK, t)
}
}
// syncDetectionTaskFromLamp LAMP 结果同步到统一检测任务。
func syncDetectionTaskFromLamp(db *gorm.DB, lampID string) error {
var t model.LampTest
if err := db.Where("id = ?", lampID).First(&t).Error; err != nil {
return err
}
if t.DetectionTaskID == nil || t.Result == nil {
return nil
}
return db.Model(&model.DetectionTask{}).Where("id = ?", *t.DetectionTaskID).
Updates(map[string]interface{}{
"result": *t.Result,
"status": "completed",
"resulted_at": t.ResultedAt,
}).Error
}
// ensureDiseaseEventForLamp 有效阳性结果创建发病事件,重复结果不重复建单。
func ensureDiseaseEventForLamp(db *gorm.DB, lampID string) error {
var t model.LampTest
if err := db.Where("id = ?", lampID).First(&t).Error; err != nil {
return err
}
if t.Result == nil || *t.Result != "positive" {
return nil
}
disease := "待确认"
var diseases []string
if len(t.Diseases) > 0 {
_ = json.Unmarshal(t.Diseases, &diseases)
if len(diseases) > 0 {
disease = diseases[0]
}
}
now := time.Now()
evidence, _ := json.Marshal(map[string]interface{}{
"result": *t.Result,
"method": t.Method,
"resultedAt": t.ResultedAt,
"lampTestId": t.ID,
"operatorId": t.OperatorID,
})
sourceKey := "lamp-" + t.ID
event := model.DiseaseEvent{
SourceKey: sourceKey,
RoomID: t.RoomID,
BatchID: t.BatchID,
DetectionTaskID: t.DetectionTaskID,
LampTestID: &t.ID,
Disease: disease,
Status: "confirmed",
Evidence: evidence,
ConfirmedAt: &now,
ConfirmedBy: t.OperatorID,
}
if err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "source_key"}},
DoNothing: true,
}).Create(&event).Error; err != nil {
return err
}
if event.ID == "" {
_ = db.Where("source_key = ?", sourceKey).First(&event).Error
}
return ensureTraceForDiseaseEvent(db, event)
}
// runCrossValidation 用同房间最近的巡检记录做交叉验证,并回写 cross_status/cross_reason
func runCrossValidation(db *gorm.DB, lampTestID, lampResult string) error {
var t model.LampTest
if err := db.Where("id = ?", lampTestID).First(&t).Error; err != nil {
return err
}
if t.RoomID == nil {
return nil
}
var inspection model.InspectionRecord
if err := db.Where("room_id = ?", *t.RoomID).Order("created_at DESC").First(&inspection).Error; err != nil {
return nil // 无关联巡检,保持 pending
}
var detections []service.AIDetection
if len(inspection.Detections) > 0 {
_ = json.Unmarshal(inspection.Detections, &detections)
}
aiClass := service.AIClassFromDetections(detections)
var diseases []string
if len(t.Diseases) > 0 {
_ = json.Unmarshal(t.Diseases, &diseases)
}
ok, reason := service.CrossValidate(aiClass, lampResult, diseases)
status := "inconsistent"
if ok {
status = "consistent"
}
return db.Model(&model.LampTest{}).Where("id = ?", lampTestID).
Updates(map[string]interface{}{"cross_status": status, "cross_reason": reason}).Error
}
// getLampCrossValidation 交叉验证详情(含关联巡检摘要)
func getLampCrossValidation(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
if !requireObjectAccess(c, db, "lamp_test", id) {
return
}
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 related map[string]interface{}
if t.RoomID != nil {
var inspection model.InspectionRecord
if db.Where("room_id = ?", *t.RoomID).Order("created_at DESC").First(&inspection).Error == nil {
var detections []service.AIDetection
if len(inspection.Detections) > 0 {
_ = json.Unmarshal(inspection.Detections, &detections)
}
related = map[string]interface{}{
"id": inspection.ID,
"imageUrl": inspection.ImageURL,
"aiClass": service.AIClassFromDetections(detections),
"riskLevel": inspection.RiskLevel,
"createdAt": inspection.CreatedAt,
}
}
}
c.JSON(http.StatusOK, gin.H{
"crossStatus": t.CrossStatus,
"crossReason": t.CrossReason,
"relatedInspection": related,
})
}
}
// deleteLampTest 删除任务单(级联删除步骤)
func deleteLampTest(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
if !requireObjectAccess(c, db, "lamp_test", id) {
return
}
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")
if !requireObjectAccess(c, db, "lamp_test", id) {
return
}
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")
if !requireObjectAccess(c, db, "lamp_test", id) {
return
}
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")
if !requireObjectAccess(c, db, "lamp_test", id) {
return
}
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})
}
}