feat: 建立统一检测任务、样本链与发病事件

This commit is contained in:
weijuesen
2026-08-14 03:31:02 +08:00
parent a25bc7abc6
commit cc93c9a053
26 changed files with 1716 additions and 32 deletions
+2 -1
View File
@@ -87,7 +87,7 @@ func main() {
wechatSvc := service.NewWechatService(cfg.WechatAppID, cfg.WechatSecret)
weatherSvc := service.NewWeatherService(cfg.QWeatherAPIKey, cfg.QWeatherLocation)
outbox := service.NewOutbox(db)
outbox.SetHandler(service.NewWechatOutboxHandler(db, wechatSvc))
outbox.SetHandler(service.NewOutboxHandler(db, wechatSvc))
outboxCtx, cancelOutbox := context.WithCancel(context.Background())
defer cancelOutbox()
outbox.Start(outboxCtx)
@@ -131,6 +131,7 @@ func main() {
handler.RegisterWechatRoutes(api, db, wechatSvc)
handler.RegisterWeatherRoutes(api, db, weatherSvc)
handler.RegisterLampRoutes(api, db, s3Svc, cfg.S3BucketImages)
handler.RegisterDetectionTaskRoutes(api, db)
handler.RegisterConsumableRoutes(api, db)
handler.RegisterConsultationRoutes(api, db)
handler.RegisterDetectionMethodRoutes(api, db)
+3
View File
@@ -38,6 +38,9 @@ func Init(cfg *config.Config) error {
&model.InspectionRecord{},
&model.OutboxEvent{},
&model.Notification{},
&model.DetectionTask{},
&model.Sample{},
&model.DiseaseEvent{},
&model.Tray{}, &model.Batch{}, &model.RearingRecord{},
&model.WechatBinding{},
&model.WeatherAlert{},
+1 -1
View File
@@ -13,7 +13,7 @@ import (
)
// CurrentSchemaVersion 是当前后端代码期望的迁移版本。
const CurrentSchemaVersion = "4"
const CurrentSchemaVersion = "5"
// RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。
func RunMigrations(db *gorm.DB) error {
@@ -108,4 +108,8 @@ func TestEmbeddedMigrationsIncludeBaseline(t *testing.T) {
if err != nil || next != 4 {
t.Fatalf("expected notifications/outbox migration version 4, got %d (err %v)", next, err)
}
next, err = driver.Next(next)
if err != nil || next != 5 {
t.Fatalf("expected detection/disease migration version 5, got %d (err %v)", next, err)
}
}
@@ -0,0 +1,579 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"silk-server-go/internal/middleware"
"silk-server-go/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// RegisterDetectionTaskRoutes 注册统一检测任务、样本和发病事件路由。
func RegisterDetectionTaskRoutes(rg *gin.RouterGroup, db *gorm.DB) {
lampRead := middleware.RequirePermission(db, "lamp:read")
lampWrite := middleware.RequirePermission(db, "lamp:write")
traceRead := middleware.RequirePermission(db, "trace:read")
traceWrite := middleware.RequirePermission(db, "trace:write")
rg.GET("/detection-tasks", lampRead, listDetectionTasks(db))
rg.POST("/detection-tasks", lampWrite, createDetectionTask(db))
rg.GET("/detection-tasks/:id", lampRead, getDetectionTask(db))
rg.PATCH("/detection-tasks/:id", lampWrite, updateDetectionTask(db))
rg.GET("/detection-tasks/:id/samples", lampRead, listSamples(db))
rg.POST("/detection-tasks/:id/samples", lampWrite, createSample(db))
rg.PATCH("/samples/:id", lampWrite, updateSample(db))
rg.GET("/disease-events", traceRead, listDiseaseEvents(db))
rg.POST("/disease-events", traceWrite, createDiseaseEvent(db))
rg.PATCH("/disease-events/:id", traceWrite, updateDiseaseEvent(db))
}
func listDetectionTasks(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
q := db.Model(&model.DetectionTask{})
if status := c.Query("status"); status != "" {
q = q.Where("status = ?", status)
}
if room := c.Query("roomId"); room != "" {
q = q.Where("room_id = ?", room)
}
if sourceType := c.Query("sourceType"); sourceType != "" {
q = q.Where("source_type = ?", sourceType)
}
var list []model.DetectionTask
q.Order("created_at DESC").Limit(200).Find(&list)
fillDetectionTaskRoomNames(db, list)
c.JSON(http.StatusOK, list)
}
}
func getDetectionTask(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var task model.DetectionTask
if db.Where("id = ?", c.Param("id")).First(&task).Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "detection task not found"})
return
}
fillDetectionTaskRoomNames(db, []model.DetectionTask{task})
c.JSON(http.StatusOK, task)
}
}
func createDetectionTask(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var body struct {
SourceKey string `json:"sourceKey"`
SourceType string `json:"sourceType"`
SourceID string `json:"sourceId"`
RoomID *string `json:"roomId"`
BatchID *string `json:"batchId"`
InspectionID *string `json:"inspectionId"`
Disease string `json:"disease"`
RecommendedMethod string `json:"recommendedMethod"`
Method *string `json:"method"`
Priority string `json:"priority"`
Note *string `json:"note"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if body.Disease == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "病种不能为空"})
return
}
if body.Method != nil && !model.ValidDetectionMethod(*body.Method) {
c.JSON(http.StatusBadRequest, gin.H{"error": "method 仅支持 lamp/qpcr/sers/hyperspectral"})
return
}
for _, id := range []*string{body.RoomID, body.BatchID, body.InspectionID} {
if id != nil && *id != "" && !isUUID(*id) {
c.JSON(http.StatusBadRequest, gin.H{"error": "关联 ID 不是合法的 UUID"})
return
}
}
sourceKey := body.SourceKey
if sourceKey == "" {
sourceKey = fmt.Sprintf("manual-%d", time.Now().UnixNano())
}
sourceType := body.SourceType
if sourceType == "" {
sourceType = "manual"
}
priority := body.Priority
if priority == "" {
priority = "routine"
}
task := model.DetectionTask{
SourceKey: sourceKey,
SourceType: sourceType,
SourceID: body.SourceID,
RoomID: body.RoomID,
BatchID: body.BatchID,
InspectionID: body.InspectionID,
Disease: body.Disease,
RecommendedMethod: body.RecommendedMethod,
Method: body.Method,
Priority: priority,
Status: "pending",
CreatedBy: currentUserID(c),
Note: body.Note,
}
if err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "source_key"}},
DoNothing: true,
}).Create(&task).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "创建检测任务失败"})
return
}
if task.ID == "" {
_ = db.Where("source_key = ?", sourceKey).First(&task).Error
}
c.JSON(http.StatusCreated, task)
}
}
func updateDetectionTask(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var task model.DetectionTask
if db.Where("id = ?", c.Param("id")).First(&task).Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "detection task not found"})
return
}
var body struct {
AssigneeID *string `json:"assigneeId"`
Method *string `json:"method"`
Status *string `json:"status"`
Result *string `json:"result"`
CancelledReason *string `json:"cancelledReason"`
Note *string `json:"note"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if body.Method != nil && !model.ValidDetectionMethod(*body.Method) {
c.JSON(http.StatusBadRequest, gin.H{"error": "method 仅支持 lamp/qpcr/sers/hyperspectral"})
return
}
if body.Status != nil && !model.ValidDetectionTaskTransition(task.Status, *body.Status) {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("检测任务状态不能从 %s 转到 %s", task.Status, *body.Status)})
return
}
if body.Status != nil && *body.Status == "assigned" && body.AssigneeID == nil && task.AssigneeID == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "指派状态必须先分配负责人"})
return
}
if body.Status != nil && *body.Status == "completed" && body.Result == nil && task.Result == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "完成检测任务前必须录入结果"})
return
}
if body.Status != nil && *body.Status == "cancelled" && (body.CancelledReason == nil || strings.TrimSpace(*body.CancelledReason) == "") {
c.JSON(http.StatusBadRequest, gin.H{"error": "取消任务必须填写原因"})
return
}
updates := map[string]interface{}{}
if body.AssigneeID != nil {
updates["assignee_id"] = *body.AssigneeID
updates["assigned_at"] = time.Now()
}
if body.Method != nil {
updates["method"] = *body.Method
}
if body.Note != nil {
updates["note"] = *body.Note
}
if body.Result != nil {
if task.Status != "review" {
c.JSON(http.StatusBadRequest, gin.H{"error": "结果需在复核阶段录入"})
return
}
updates["result"] = *body.Result
updates["resulted_at"] = time.Now()
}
if body.Status != nil {
updates["status"] = *body.Status
if *body.Status == "cancelled" {
updates["cancelled_reason"] = *body.CancelledReason
}
}
if len(updates) > 0 {
if err := db.Model(&model.DetectionTask{}).Where("id = ?", task.ID).Updates(updates).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "更新检测任务失败"})
return
}
}
db.Where("id = ?", task.ID).First(&task)
if task.Result != nil && *task.Result == "positive" {
_ = ensureDiseaseEventFromDetectionTask(db, task)
}
fillDetectionTaskRoomNames(db, []model.DetectionTask{task})
c.JSON(http.StatusOK, task)
}
}
func listSamples(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var samples []model.Sample
db.Where("detection_task_id = ?", c.Param("id")).Order("created_at ASC").Find(&samples)
c.JSON(http.StatusOK, samples)
}
}
func createSample(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var task model.DetectionTask
if db.Where("id = ?", c.Param("id")).First(&task).Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "detection task not found"})
return
}
var existing model.Sample
if db.Where("detection_task_id = ?", task.ID).First(&existing).Error == nil {
c.JSON(http.StatusOK, existing)
return
}
var body struct {
SampleNo string `json:"sampleNo"`
RoomID *string `json:"roomId"`
BatchID *string `json:"batchId"`
TrayID *string `json:"trayId"`
Note *string `json:"note"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
sampleNo := body.SampleNo
if sampleNo == "" {
sampleNo = fmt.Sprintf("S-%s", strings.ToUpper(task.ID[:8]))
}
now := time.Now()
sample := model.Sample{
DetectionTaskID: task.ID,
SampleNo: sampleNo,
RoomID: body.RoomID,
BatchID: body.BatchID,
TrayID: body.TrayID,
SampledBy: currentUserID(c),
SampledAt: &now,
State: "created",
Note: body.Note,
}
if sample.RoomID == nil {
sample.RoomID = task.RoomID
}
if sample.BatchID == nil {
sample.BatchID = task.BatchID
}
if err := db.Create(&sample).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "创建样本失败"})
return
}
c.JSON(http.StatusCreated, sample)
}
}
func updateSample(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var sample model.Sample
if db.Where("id = ?", c.Param("id")).First(&sample).Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "sample not found"})
return
}
var body struct {
State *string `json:"state"`
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.Note != nil {
updates["note"] = *body.Note
}
if body.State != nil {
if !model.ValidSampleTransition(sample.State, *body.State) {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("样本状态不能从 %s 转到 %s", sample.State, *body.State)})
return
}
updates["state"] = *body.State
now := time.Now()
switch *body.State {
case "collected":
updates["collected_at"] = now
case "handed_over":
updates["handed_over_at"] = now
updates["handed_over_by"] = currentUserID(c)
case "received":
updates["received_at"] = now
updates["received_by"] = currentUserID(c)
case "testing":
updates["testing_started_at"] = now
case "consumed":
updates["consumed_at"] = now
case "disposed":
updates["disposed_at"] = now
}
}
if len(updates) > 0 {
if err := db.Model(&model.Sample{}).Where("id = ?", sample.ID).Updates(updates).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "更新样本失败"})
return
}
}
db.Where("id = ?", sample.ID).First(&sample)
c.JSON(http.StatusOK, sample)
}
}
func listDiseaseEvents(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
q := db.Model(&model.DiseaseEvent{})
if status := c.Query("status"); status != "" {
q = q.Where("status = ?", status)
}
if room := c.Query("roomId"); room != "" {
q = q.Where("room_id = ?", room)
}
var list []model.DiseaseEvent
q.Order("created_at DESC").Limit(200).Find(&list)
fillDiseaseEventRoomNames(db, list)
c.JSON(http.StatusOK, list)
}
}
func createDiseaseEvent(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var body struct {
SourceKey string `json:"sourceKey"`
RoomID *string `json:"roomId"`
BatchID *string `json:"batchId"`
DetectionTaskID *string `json:"detectionTaskId"`
LampTestID *string `json:"lampTestId"`
ConsultationID *string `json:"consultationId"`
InspectionID *string `json:"inspectionId"`
Disease string `json:"disease"`
Status string `json:"status"`
Evidence json.RawMessage `json:"evidence"`
LossSummary *string `json:"lossSummary"`
Measure *string `json:"measure"`
Note *string `json:"note"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if body.Disease == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "病种不能为空"})
return
}
for _, id := range []*string{body.RoomID, body.BatchID, body.DetectionTaskID, body.LampTestID, body.ConsultationID, body.InspectionID} {
if id != nil && *id != "" && !isUUID(*id) {
c.JSON(http.StatusBadRequest, gin.H{"error": "关联 ID 不是合法的 UUID"})
return
}
}
if body.Status == "" {
body.Status = "suspected"
}
event := model.DiseaseEvent{
SourceKey: body.SourceKey,
RoomID: body.RoomID,
BatchID: body.BatchID,
DetectionTaskID: body.DetectionTaskID,
LampTestID: body.LampTestID,
ConsultationID: body.ConsultationID,
InspectionID: body.InspectionID,
Disease: body.Disease,
Status: body.Status,
Evidence: body.Evidence,
LossSummary: body.LossSummary,
Measure: body.Measure,
Note: body.Note,
}
if event.SourceKey == "" {
event.SourceKey = fmt.Sprintf("manual-%d", time.Now().UnixNano())
}
if err := model.ValidateDiseaseEventEvidence(event); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if event.Status == "confirmed" {
now := time.Now()
event.ConfirmedAt = &now
event.ConfirmedBy = currentUserID(c)
}
if err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "source_key"}},
DoNothing: true,
}).Create(&event).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "创建发病事件失败"})
return
}
if event.ID == "" {
_ = db.Where("source_key = ?", event.SourceKey).First(&event).Error
}
if event.Status == "confirmed" {
_ = ensureTraceForDiseaseEvent(db, event)
}
c.JSON(http.StatusCreated, event)
}
}
func updateDiseaseEvent(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var event model.DiseaseEvent
if db.Where("id = ?", c.Param("id")).First(&event).Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "disease event not found"})
return
}
var body struct {
Status *string `json:"status"`
Evidence json.RawMessage `json:"evidence"`
LossSummary *string `json:"lossSummary"`
Measure *string `json:"measure"`
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.Status != nil {
if !model.ValidDiseaseEventTransition(event.Status, *body.Status) {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("发病事件状态不能从 %s 转到 %s", event.Status, *body.Status)})
return
}
updates["status"] = *body.Status
if *body.Status == "confirmed" {
if len(body.Evidence) == 0 && len(event.Evidence) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "确诊发病事件必须提供证据"})
return
}
now := time.Now()
updates["confirmed_at"] = now
updates["confirmed_by"] = currentUserID(c)
}
}
if len(body.Evidence) > 0 {
updates["evidence"] = body.Evidence
}
if body.LossSummary != nil {
updates["loss_summary"] = *body.LossSummary
}
if body.Measure != nil {
updates["measure"] = *body.Measure
}
if body.Note != nil {
updates["note"] = *body.Note
}
if len(updates) > 0 {
if err := db.Model(&model.DiseaseEvent{}).Where("id = ?", event.ID).Updates(updates).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "更新发病事件失败"})
return
}
}
db.Where("id = ?", event.ID).First(&event)
if event.Status == "confirmed" {
_ = ensureTraceForDiseaseEvent(db, event)
}
c.JSON(http.StatusOK, event)
}
}
func fillDetectionTaskRoomNames(db *gorm.DB, list []model.DetectionTask) {
var rooms []model.Room
db.Select("id", "name").Find(&rooms)
names := make(map[string]string, len(rooms))
for _, r := range rooms {
names[r.ID] = r.Name
}
for i := range list {
if list[i].RoomID != nil {
if name, ok := names[*list[i].RoomID]; ok {
list[i].RoomName = &name
}
}
}
}
func fillDiseaseEventRoomNames(db *gorm.DB, list []model.DiseaseEvent) {
var rooms []model.Room
db.Select("id", "name").Find(&rooms)
names := make(map[string]string, len(rooms))
for _, r := range rooms {
names[r.ID] = r.Name
}
for i := range list {
if list[i].RoomID != nil {
if name, ok := names[*list[i].RoomID]; ok {
list[i].RoomName = &name
}
}
}
}
// ensureTraceForDiseaseEvent 确诊后自动创建关联溯源记录,重复调用不重复创建。
func ensureTraceForDiseaseEvent(db *gorm.DB, event model.DiseaseEvent) error {
if event.Status != "confirmed" {
return nil
}
var count int64
if err := db.Model(&model.TraceRecord{}).Where("disease_event_id = ?", event.ID).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
trace := model.TraceRecord{
DiseaseEventID: &event.ID,
RoomID: event.RoomID,
LampTestID: event.LampTestID,
Disease: event.Disease,
Status: "pending",
}
return db.Create(&trace).Error
}
func ensureDiseaseEventFromDetectionTask(db *gorm.DB, task model.DetectionTask) error {
if task.Result == nil || *task.Result != "positive" {
return nil
}
now := time.Now()
evidence, _ := json.Marshal(map[string]interface{}{
"result": *task.Result,
"method": task.Method,
"detectionTaskId": task.ID,
"resultedAt": task.ResultedAt,
})
sourceKey := "detection-task-" + task.ID
event := model.DiseaseEvent{
SourceKey: sourceKey,
RoomID: task.RoomID,
BatchID: task.BatchID,
DetectionTaskID: &task.ID,
InspectionID: task.InspectionID,
Disease: task.Disease,
Status: "confirmed",
Evidence: evidence,
ConfirmedAt: &now,
ConfirmedBy: task.AssigneeID,
}
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)
}
+32 -1
View File
@@ -183,7 +183,38 @@ func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient,
AggregateID: rec.ID,
Payload: payload,
}
return outbox.PublishTx(tx, event)
if err := outbox.PublishTx(tx, event); err != nil {
return err
}
}
}
if rec.IsMock != nil && !*rec.IsMock && (*rec.RiskLevel == "orange" || *rec.RiskLevel == "red") {
priority := "routine"
if *rec.RiskLevel == "red" {
priority = "urgent"
}
roomID := ""
if rec.RoomID != nil {
roomID = *rec.RoomID
}
payload, _ := json.Marshal(service.DetectionTaskCreatePayload{
SourceKey: "inspection-" + rec.ID,
SourceType: "inspection",
SourceID: rec.ID,
RoomID: roomID,
InspectionID: rec.ID,
Disease: "待确认",
Priority: priority,
})
event := service.Event{
ID: "detection-task-" + rec.ID,
Type: service.OutboxEventDetectionTaskCreate,
AggregateType: "inspection",
AggregateID: rec.ID,
Payload: payload,
}
if err := outbox.PublishTx(tx, event); err != nil {
return err
}
}
return nil
+78 -2
View File
@@ -14,6 +14,7 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// RegisterLampRoutes 注册 LAMP 检测路由
@@ -79,6 +80,10 @@ func createLampTest(db *gorm.DB) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "batchId 不是合法的 UUID"})
return
}
if t.DetectionTaskID != nil && !isUUID(*t.DetectionTaskID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "detectionTaskId 不是合法的 UUID"})
return
}
if t.Status == "" {
t.Status = "pending"
}
@@ -127,6 +132,8 @@ func judgeQPCR(db *gorm.DB) gin.HandlerFunc {
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)
@@ -312,12 +319,81 @@ func updateLampTest(db *gorm.DB) gin.HandlerFunc {
// 交叉验证:结果落库后与同房间最近巡检比对
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
@@ -376,8 +452,8 @@ func getLampCrossValidation(db *gorm.DB) gin.HandlerFunc {
}
}
c.JSON(http.StatusOK, gin.H{
"crossStatus": t.CrossStatus,
"crossReason": t.CrossReason,
"crossStatus": t.CrossStatus,
"crossReason": t.CrossReason,
"relatedInspection": related,
})
}
+5 -3
View File
@@ -130,6 +130,7 @@ func createTraceRecord(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var body struct {
RoomID *string `json:"roomId"`
DiseaseEventID *string `json:"diseaseEventId"`
LampTestID *string `json:"lampTestId"`
ConsultationID *string `json:"consultationId"`
Disease string `json:"disease"`
@@ -138,7 +139,7 @@ func createTraceRecord(db *gorm.DB) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
for _, id := range []*string{body.RoomID, body.LampTestID, body.ConsultationID} {
for _, id := range []*string{body.RoomID, body.DiseaseEventID, body.LampTestID, body.ConsultationID} {
if id != nil && *id != "" && !isUUID(*id) {
c.JSON(http.StatusBadRequest, gin.H{"error": "关联 ID 不是合法的 UUID"})
return
@@ -149,7 +150,8 @@ func createTraceRecord(db *gorm.DB) gin.HandlerFunc {
return
}
rec := model.TraceRecord{
RoomID: body.RoomID, LampTestID: body.LampTestID,
RoomID: body.RoomID, DiseaseEventID: body.DiseaseEventID,
LampTestID: body.LampTestID,
ConsultationID: body.ConsultationID, Disease: body.Disease, Status: "pending",
}
if err := db.Create(&rec).Error; err != nil {
@@ -204,7 +206,7 @@ func autoTrace(db *gorm.DB) gin.HandlerFunc {
return
}
report := map[string]interface{}{
"disease": t.Disease,
"disease": t.Disease,
"generatedAt": time.Now(),
}
origin := "unknown"
@@ -0,0 +1,97 @@
package model
import "time"
// DetectionTask 统一检测任务,覆盖 LAMP/qPCR/SERS/高光谱。
type DetectionTask struct {
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
SourceKey string `gorm:"column:source_key;size:128;uniqueIndex" json:"sourceKey,omitempty"`
SourceType string `gorm:"column:source_type;size:32" json:"sourceType"`
SourceID string `gorm:"column:source_id;size:128" json:"sourceId,omitempty"`
RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"`
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
InspectionID *string `gorm:"column:inspection_id;type:uuid;index" json:"inspectionId,omitempty"`
Disease string `gorm:"size:64" json:"disease"`
RecommendedMethod string `gorm:"column:recommended_method;size:32" json:"recommendedMethod,omitempty"`
Method *string `gorm:"size:32" json:"method,omitempty"`
Priority string `gorm:"size:16;default:routine" json:"priority"`
Status string `gorm:"size:16;default:pending;index" json:"status"`
AssigneeID *string `gorm:"column:assignee_id;type:uuid" json:"assigneeId,omitempty"`
AssignedAt *time.Time `gorm:"column:assigned_at;type:timestamptz" json:"assignedAt,omitempty"`
Result *string `gorm:"size:16" json:"result,omitempty"`
ResultedAt *time.Time `gorm:"column:resulted_at;type:timestamptz" json:"resultedAt,omitempty"`
CancelledReason *string `gorm:"column:cancelled_reason;type:text" json:"cancelledReason,omitempty"`
CreatedBy *string `gorm:"column:created_by;type:uuid" json:"createdBy,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"`
RoomName *string `gorm:"-" json:"roomName,omitempty"`
}
func (DetectionTask) TableName() string { return "detection_tasks" }
// ValidDetectionTaskTransition 状态机:draft/pending/assigned/sampling/testing/review/completed/cancelled。
func ValidDetectionTaskTransition(from, to string) bool {
switch from {
case "draft":
return to == "pending" || to == "cancelled"
case "pending":
return to == "assigned" || to == "cancelled"
case "assigned":
return to == "sampling" || to == "cancelled"
case "sampling":
return to == "testing" || to == "cancelled"
case "testing":
return to == "review" || to == "cancelled"
case "review":
return to == "completed" || to == "cancelled"
default:
return false
}
}
// Sample 检测样本链路。
type Sample struct {
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
DetectionTaskID string `gorm:"column:detection_task_id;type:uuid;uniqueIndex" json:"detectionTaskId"`
SampleNo string `gorm:"column:sample_no;size:64;uniqueIndex" json:"sampleNo"`
RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"`
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
TrayID *string `gorm:"column:tray_id;type:uuid" json:"trayId,omitempty"`
SampledBy *string `gorm:"column:sampled_by;type:uuid" json:"sampledBy,omitempty"`
SampledAt *time.Time `gorm:"column:sampled_at;type:timestamptz" json:"sampledAt,omitempty"`
CollectedAt *time.Time `gorm:"column:collected_at;type:timestamptz" json:"collectedAt,omitempty"`
HandedOverAt *time.Time `gorm:"column:handed_over_at;type:timestamptz" json:"handedOverAt,omitempty"`
HandedOverBy *string `gorm:"column:handed_over_by;type:uuid" json:"handedOverBy,omitempty"`
ReceivedAt *time.Time `gorm:"column:received_at;type:timestamptz" json:"receivedAt,omitempty"`
ReceivedBy *string `gorm:"column:received_by;type:uuid" json:"receivedBy,omitempty"`
TestingStartedAt *time.Time `gorm:"column:testing_started_at;type:timestamptz" json:"testingStartedAt,omitempty"`
ConsumedAt *time.Time `gorm:"column:consumed_at;type:timestamptz" json:"consumedAt,omitempty"`
DisposedAt *time.Time `gorm:"column:disposed_at;type:timestamptz" json:"disposedAt,omitempty"`
State string `gorm:"size:16;default:created" json:"state"`
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 (Sample) TableName() string { return "samples" }
// ValidSampleTransition 样本状态机:created/collected/handed_over/received/testing/consumed/disposed。
func ValidSampleTransition(from, to string) bool {
switch from {
case "created":
return to == "collected" || to == "disposed"
case "collected":
return to == "handed_over" || to == "disposed"
case "handed_over":
return to == "received" || to == "disposed"
case "received":
return to == "testing" || to == "disposed"
case "testing":
return to == "consumed" || to == "disposed"
case "consumed":
return to == "disposed"
default:
return false
}
}
@@ -0,0 +1,51 @@
package model
import "testing"
func TestDetectionTaskRejectsInvalidTransition(t *testing.T) {
if ValidDetectionTaskTransition("pending", "testing") {
t.Error("pending 不能直接跳 testing")
}
if ValidDetectionTaskTransition("draft", "completed") {
t.Error("draft 不能直接 completed")
}
if ValidDetectionTaskTransition("completed", "review") {
t.Error("completed 不能回退")
}
}
func TestDetectionTaskAllowsRequiredTransitions(t *testing.T) {
transitions := [][2]string{
{"draft", "pending"},
{"pending", "assigned"},
{"assigned", "sampling"},
{"sampling", "testing"},
{"testing", "review"},
{"review", "completed"},
{"pending", "cancelled"},
}
for _, tr := range transitions {
if !ValidDetectionTaskTransition(tr[0], tr[1]) {
t.Errorf("expected %s -> %s", tr[0], tr[1])
}
}
}
func TestSampleTransition(t *testing.T) {
transitions := [][2]string{
{"created", "collected"},
{"collected", "handed_over"},
{"handed_over", "received"},
{"received", "testing"},
{"testing", "consumed"},
{"consumed", "disposed"},
}
for _, tr := range transitions {
if !ValidSampleTransition(tr[0], tr[1]) {
t.Errorf("expected %s -> %s", tr[0], tr[1])
}
}
if ValidSampleTransition("created", "testing") {
t.Error("created 不能直接 testing")
}
}
+58
View File
@@ -0,0 +1,58 @@
package model
import (
"encoding/json"
"errors"
"time"
)
// DiseaseEvent 独立发病事件,作为处置、会诊、溯源和效果评估主线。
type DiseaseEvent struct {
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
SourceKey string `gorm:"column:source_key;size:128;uniqueIndex" json:"sourceKey,omitempty"`
RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"`
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
DetectionTaskID *string `gorm:"column:detection_task_id;type:uuid;index" json:"detectionTaskId,omitempty"`
LampTestID *string `gorm:"column:lamp_test_id;type:uuid;index" json:"lampTestId,omitempty"`
ConsultationID *string `gorm:"column:consultation_id;type:uuid;index" json:"consultationId,omitempty"`
InspectionID *string `gorm:"column:inspection_id;type:uuid;index" json:"inspectionId,omitempty"`
Disease string `gorm:"size:64" json:"disease"`
Status string `gorm:"size:16;default:suspected;index" json:"status"`
Evidence json.RawMessage `gorm:"type:jsonb" json:"evidence,omitempty"`
ConfirmedAt *time.Time `gorm:"column:confirmed_at;type:timestamptz" json:"confirmedAt,omitempty"`
ConfirmedBy *string `gorm:"column:confirmed_by;type:uuid" json:"confirmedBy,omitempty"`
LossSummary *string `gorm:"column:loss_summary;type:text" json:"lossSummary,omitempty"`
Measure *string `gorm:"type:text" json:"measure,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"`
RoomName *string `gorm:"-" json:"roomName,omitempty"`
}
func (DiseaseEvent) TableName() string { return "disease_events" }
// ValidDiseaseEventTransition 状态机:suspected/confirmed/controlled/closed/reopened。
func ValidDiseaseEventTransition(from, to string) bool {
switch from {
case "suspected":
return to == "confirmed" || to == "closed"
case "confirmed":
return to == "controlled" || to == "closed"
case "controlled":
return to == "closed" || to == "reopened"
case "closed":
return to == "reopened"
case "reopened":
return to == "confirmed" || to == "controlled" || to == "closed"
default:
return false
}
}
// ValidateDiseaseEventEvidence 确诊必须有证据,不能仅凭状态字段确认。
func ValidateDiseaseEventEvidence(event DiseaseEvent) error {
if event.Status == "confirmed" && len(event.Evidence) == 0 {
return errors.New("确诊发病事件必须提供证据")
}
return nil
}
@@ -0,0 +1,35 @@
package model
import (
"encoding/json"
"testing"
)
func TestDiseaseEventRequiresEvidenceToConfirm(t *testing.T) {
event := DiseaseEvent{Status: "confirmed"}
if err := ValidateDiseaseEventEvidence(event); err == nil {
t.Fatal("confirmed 无证据应返回错误")
}
event.Evidence = json.RawMessage(`{"result":"positive"}`)
if err := ValidateDiseaseEventEvidence(event); err != nil {
t.Fatalf("confirmed 有证据应通过: %v", err)
}
}
func TestDiseaseEventTransition(t *testing.T) {
transitions := [][2]string{
{"suspected", "confirmed"},
{"confirmed", "controlled"},
{"controlled", "closed"},
{"closed", "reopened"},
{"reopened", "confirmed"},
}
for _, tr := range transitions {
if !ValidDiseaseEventTransition(tr[0], tr[1]) {
t.Errorf("expected %s -> %s", tr[0], tr[1])
}
}
if ValidDiseaseEventTransition("suspected", "controlled") {
t.Error("suspected 不能直接 controlled")
}
}
+18 -17
View File
@@ -7,23 +7,24 @@ 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"`
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"`
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"`
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
DetectionTaskID *string `gorm:"column:detection_task_id;type:uuid;index" json:"detectionTaskId,omitempty"`
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"`
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"`
}
func (LampTest) TableName() string { return "lamp_tests" }
+5 -4
View File
@@ -9,17 +9,18 @@ import (
type TraceRecord 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"`
DiseaseEventID *string `gorm:"column:disease_event_id;type:uuid;index" json:"diseaseEventId,omitempty"`
LampTestID *string `gorm:"column:lamp_test_id;type:uuid;index" json:"lampTestId,omitempty"`
ConsultationID *string `gorm:"column:consultation_id;type:uuid;index" json:"consultationId,omitempty"`
Disease string `gorm:"size:64" json:"disease"`
Status string `gorm:"size:16;default:pending" json:"status"` // pending/reported/analysis/archived
Origin *string `gorm:"size:32" json:"origin,omitempty"` // internal/external/unknown
Confidence *float64 `gorm:"type:float" json:"confidence,omitempty"`
AutoReport json.RawMessage `gorm:"column:auto_report;type:jsonb" json:"autoReport,omitempty"` // 一级初报
Checklist json.RawMessage `gorm:"type:jsonb" json:"checklist,omitempty"` // 二级排查清单
AutoReport json.RawMessage `gorm:"column:auto_report;type:jsonb" json:"autoReport,omitempty"` // 一级初报
Checklist json.RawMessage `gorm:"type:jsonb" json:"checklist,omitempty"` // 二级排查清单
AnalysisReport json.RawMessage `gorm:"column:analysis_report;type:jsonb" json:"analysisReport,omitempty"` // 二级报告
ExpertNote *string `gorm:"column:expert_note;type:text" json:"expertNote,omitempty"` // 三级-专家
LabNote *string `gorm:"column:lab_note;type:text" json:"labNote,omitempty"` // 三级-实验室
ExpertNote *string `gorm:"column:expert_note;type:text" json:"expertNote,omitempty"` // 三级-专家
LabNote *string `gorm:"column:lab_note;type:text" json:"labNote,omitempty"` // 三级-实验室
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
RoomName *string `gorm:"-" json:"roomName,omitempty"`
@@ -0,0 +1,82 @@
package service
import (
"context"
"encoding/json"
"errors"
"silk-server-go/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// DetectionTaskCreatePayload 创建待确认检测任务的 outbox 载荷。
type DetectionTaskCreatePayload struct {
SourceKey string `json:"sourceKey"`
SourceType string `json:"sourceType"`
SourceID string `json:"sourceId"`
RoomID string `json:"roomId"`
BatchID string `json:"batchId"`
InspectionID string `json:"inspectionId"`
Disease string `json:"disease"`
Priority string `json:"priority"`
}
// NewDetectionTaskOutboxHandler 创建检测任务事件处理器。
func NewDetectionTaskOutboxHandler(db *gorm.DB) EventHandler {
return func(ctx context.Context, event model.OutboxEvent) error {
if event.EventType != OutboxEventDetectionTaskCreate {
return nil
}
var payload DetectionTaskCreatePayload
if err := json.Unmarshal(event.Payload, &payload); err != nil {
return err
}
if payload.SourceKey == "" {
return errors.New("detection task sourceKey is required")
}
disease := payload.Disease
if disease == "" {
disease = "待确认"
}
priority := payload.Priority
if priority == "" {
priority = "routine"
}
task := model.DetectionTask{
SourceKey: payload.SourceKey,
SourceType: payload.SourceType,
SourceID: payload.SourceID,
RoomID: strPtrOrNil(payload.RoomID),
BatchID: strPtrOrNil(payload.BatchID),
InspectionID: strPtrOrNil(payload.InspectionID),
Disease: disease,
Priority: priority,
Status: "pending",
}
return db.WithContext(ctx).
Session(&gorm.Session{SkipDefaultTransaction: true}).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "source_key"}},
DoNothing: true,
}).
Create(&task).Error
}
}
// NewOutboxHandler 分发当前已支持的业务事件。
func NewOutboxHandler(db *gorm.DB, wechat *WechatService) EventHandler {
wechatHandler := NewWechatOutboxHandler(db, wechat)
detectionHandler := NewDetectionTaskOutboxHandler(db)
return func(ctx context.Context, event model.OutboxEvent) error {
switch event.EventType {
case OutboxEventWechatSubscribe:
return wechatHandler(ctx, event)
case OutboxEventDetectionTaskCreate:
return detectionHandler(ctx, event)
default:
return nil
}
}
}
@@ -0,0 +1,44 @@
package service
import (
"context"
"database/sql/driver"
"encoding/json"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"silk-server-go/internal/model"
)
func TestDetectionTaskCreateOutboxIsIdempotent(t *testing.T) {
o, mock := newOutboxMock(t)
handler := NewDetectionTaskOutboxHandler(o.db)
payload, _ := json.Marshal(DetectionTaskCreatePayload{
SourceKey: "inspection-inspection-1",
SourceType: "inspection",
SourceID: "inspection-1",
InspectionID: "inspection-1",
Disease: "待确认",
})
event := model.OutboxEvent{
EventType: OutboxEventDetectionTaskCreate,
Payload: payload,
}
args := make([]driver.Value, 0, 20)
for i := 0; i < 20; i++ {
args = append(args, sqlmock.AnyArg())
}
mock.ExpectQuery(`INSERT INTO "detection_tasks".*ON CONFLICT \("source_key"\) DO NOTHING`).
WithArgs(args...).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("task-1"))
if err := handler(context.Background(), event); err != nil {
t.Fatalf("first create failed: %v", err)
}
mock.ExpectQuery(`INSERT INTO "detection_tasks".*ON CONFLICT \("source_key"\) DO NOTHING`).
WithArgs(args...).
WillReturnRows(sqlmock.NewRows([]string{"id"}))
if err := handler(context.Background(), event); err != nil {
t.Fatalf("duplicate create failed: %v", err)
}
}
+2 -1
View File
@@ -23,7 +23,8 @@ const (
OutboxStatusFailed = "failed"
OutboxStatusCancelled = "cancelled"
OutboxEventWechatSubscribe = "wechat.subscribe"
OutboxEventWechatSubscribe = "wechat.subscribe"
OutboxEventDetectionTaskCreate = "detection.task.create"
)
// Event 写入 outbox 的领域事件。
@@ -0,0 +1,5 @@
ALTER TABLE lamp_tests DROP COLUMN IF EXISTS detection_task_id;
ALTER TABLE trace_records DROP COLUMN IF EXISTS disease_event_id;
DROP TABLE IF EXISTS disease_events CASCADE;
DROP TABLE IF EXISTS samples CASCADE;
DROP TABLE IF EXISTS detection_tasks CASCADE;
@@ -0,0 +1,89 @@
CREATE TABLE IF NOT EXISTS detection_tasks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
source_key varchar(128) NOT NULL,
source_type varchar(32) NOT NULL,
source_id varchar(128),
room_id uuid,
batch_id uuid,
inspection_id uuid,
disease varchar(64) NOT NULL,
recommended_method varchar(32),
method varchar(32),
priority varchar(16) NOT NULL DEFAULT 'routine',
status varchar(16) NOT NULL DEFAULT 'pending',
assignee_id uuid,
assigned_at timestamptz,
result varchar(16),
resulted_at timestamptz,
cancelled_reason text,
created_by uuid,
note text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_detection_tasks_source_key ON detection_tasks (source_key);
CREATE INDEX IF NOT EXISTS idx_detection_tasks_status ON detection_tasks (status);
CREATE INDEX IF NOT EXISTS idx_detection_tasks_room_id ON detection_tasks (room_id);
CREATE INDEX IF NOT EXISTS idx_detection_tasks_inspection_id ON detection_tasks (inspection_id);
CREATE TABLE IF NOT EXISTS samples (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
detection_task_id uuid NOT NULL,
sample_no varchar(64) NOT NULL,
room_id uuid,
batch_id uuid,
tray_id uuid,
sampled_by uuid,
sampled_at timestamptz,
collected_at timestamptz,
handed_over_at timestamptz,
handed_over_by uuid,
received_at timestamptz,
received_by uuid,
testing_started_at timestamptz,
consumed_at timestamptz,
disposed_at timestamptz,
state varchar(16) NOT NULL DEFAULT 'created',
note text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_samples_detection_task_id ON samples (detection_task_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_samples_sample_no ON samples (sample_no);
CREATE TABLE IF NOT EXISTS disease_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
source_key varchar(128) NOT NULL,
room_id uuid,
batch_id uuid,
detection_task_id uuid,
lamp_test_id uuid,
consultation_id uuid,
inspection_id uuid,
disease varchar(64) NOT NULL,
status varchar(16) NOT NULL DEFAULT 'suspected',
evidence jsonb,
confirmed_at timestamptz,
confirmed_by uuid,
loss_summary text,
measure text,
note text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_disease_events_source_key ON disease_events (source_key);
CREATE INDEX IF NOT EXISTS idx_disease_events_status ON disease_events (status);
CREATE INDEX IF NOT EXISTS idx_disease_events_room_id ON disease_events (room_id);
ALTER TABLE trace_records
ADD COLUMN IF NOT EXISTS disease_event_id uuid;
CREATE INDEX IF NOT EXISTS idx_trace_records_disease_event_id ON trace_records (disease_event_id);
ALTER TABLE lamp_tests
ADD COLUMN IF NOT EXISTS detection_task_id uuid;
CREATE INDEX IF NOT EXISTS idx_lamp_tests_detection_task_id ON lamp_tests (detection_task_id);