package handler import ( "bytes" "encoding/json" "fmt" "io" "log/slog" "net/http" "regexp" "strconv" "strings" "silk-server-go/internal/middleware" "silk-server-go/internal/model" "silk-server-go/internal/service" "github.com/gin-gonic/gin" "gorm.io/gorm" ) var uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) // isUUID 判断字符串是否为标准 UUID 格式(roomId 等外键校验用) func isUUID(s string) bool { return uuidPattern.MatchString(s) } // RegisterInspectionRoutes 注册 AI 巡检路由 func RegisterInspectionRoutes(rg *gin.RouterGroup, db *gorm.DB, s3 *service.S3Service, ai *service.AIClient, imageBucket string, outbox *service.Outbox, inspectionTemplateID string, appEnv string) { rg.POST("/inspections", middleware.RequirePermission(db, "inspection:create"), createInspection(db, s3, ai, imageBucket, outbox, inspectionTemplateID, appEnv)) rg.GET("/inspections", middleware.RequirePermission(db, "inspection:read"), listInspections(db)) } // currentUserID 从 JWT 上下文取用户 ID func currentUserID(c *gin.Context) *string { if user, ok := c.Get("user"); ok { if m, ok := user.(map[string]interface{}); ok { if s, ok := m["sub"].(string); ok && s != "" { return &s } } } return nil } func buildRiskInput(detRes *service.AIDetectResponse) service.RiskInput { status := detRes.Status if status == "" { status = service.AIDetectionStatus(detRes.Detections) } modelVersion := detRes.ModelVersion if modelVersion == "" { modelVersion = "unknown" } in := service.RiskInput{ModelVersion: modelVersion} if status != "unknown" { aiProb := detRes.AbnormalProbability if len(detRes.Detections) > 0 && aiProb == 0 { aiProb = service.AbnormalProbability(detRes.Detections) } in.AI = &aiProb } return in } // createInspection 拍照巡检:图片存 S3 → 调 AI /detect → 写记录。 // 幂等:客户端传 Idempotency-Key 头时,重复请求返回已有记录。 func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient, bucket string, outbox *service.Outbox, inspectionTemplateID string, appEnv string) gin.HandlerFunc { return func(c *gin.Context) { idemKey := strings.TrimSpace(c.GetHeader("Idempotency-Key")) roomID := strings.TrimSpace(c.PostForm("roomId")) if idemKey != "" { var exist model.InspectionRecord if db.Where("idempotency_key = ?", idemKey).First(&exist).Error == nil { c.JSON(http.StatusOK, exist) 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("inspections", 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 } imageURL := s3.Endpoint() + "/" + bucket + "/" + key rec := model.InspectionRecord{ UserID: currentUserID(c), ImageURL: &imageURL, AIStatus: "done", } if roomID != "" { if !isUUID(roomID) { c.JSON(http.StatusBadRequest, gin.H{"error": "roomId 不是合法的 UUID"}) return } rec.RoomID = &roomID } if idemKey != "" { rec.IdempotencyKey = &idemKey } detRes, aiErr := ai.Detect(c.Request.Context(), imageBytes, header.Filename) if aiErr != nil { rec.AIStatus = "failed" } else { raw, _ := json.Marshal(detRes.Detections) rec.Detections = raw isMock := detRes.IsMock rec.IsMock = &isMock modelVersion := detRes.ModelVersion if modelVersion == "" { modelVersion = "unknown" } rec.ModelVersion = &modelVersion if appEnv == "production" && isMock { slog.Error("生产环境收到 Mock AI 检测结果,按失败记录", "modelVersion", modelVersion, "roomId", roomID) rec.AIStatus = "failed" } else { // 风险评分(#9 V2):只消费 AI 异常概率;缺失环境/阶段不填 0 riskInput := buildRiskInput(detRes) riskInput.Env, riskInput.Stage = loadRoomRisk(db, roomID) assessment := service.ComputeRiskScore(riskInput) rec.RiskScore = &assessment.Score rec.RiskLevel = &assessment.Level rawRisk, _ := json.Marshal(assessment) rec.RiskAssessment = rawRisk } } // 微信订阅消息(#11 骨架):Mock 不进入告警;业务事务内写 outbox,重启后仍可重试 txErr := db.Transaction(func(tx *gorm.DB) error { if err := tx.Create(&rec).Error; err != nil { return err } if rec.AIStatus == "done" && rec.UserID != nil && rec.IsMock != nil && !*rec.IsMock { if key := service.WechatTemplateKey(*rec.RiskLevel); key != "" { payload, _ := json.Marshal(service.WechatSubscribePayload{ UserID: *rec.UserID, TemplateKey: key, TemplateID: inspectionTemplateID, Page: "pages/inspection/index", Title: "巡检风险提醒", Body: fmt.Sprintf("风险等级 %s,风险分 %.0f", *rec.RiskLevel, *rec.RiskScore), Data: service.BuildSubscribeData(*rec.RiskLevel, *rec.RiskScore), BusinessType: "inspection", BusinessID: rec.ID, }) event := service.Event{ ID: "inspection-" + rec.ID + "-" + *rec.RiskLevel, Type: service.OutboxEventWechatSubscribe, AggregateType: "inspection", AggregateID: rec.ID, Payload: payload, } return outbox.PublishTx(tx, event) } } return nil }) if txErr != nil { // 并发幂等:唯一索引冲突时返回已有记录 if idemKey != "" { var exist model.InspectionRecord if db.Where("idempotency_key = ?", idemKey).First(&exist).Error == nil { c.JSON(http.StatusOK, exist) return } } c.JSON(http.StatusInternalServerError, gin.H{"error": "创建巡检记录失败"}) return } c.JSON(http.StatusCreated, rec) } } // loadRoomRisk 加载房间阶段系数与环境系数(无房间/无数据时返回 nil) func loadRoomRisk(db *gorm.DB, roomID string) (*float64, *float64) { if roomID == "" { return nil, nil } var room model.Room if db.Where("id = ?", roomID).First(&room).Error != nil { return nil, nil } var stageCoef *float64 if room.Stage != nil { value := service.StageCoefficient(*room.Stage) stageCoef = &value } var humidity, temperature *float64 var h model.Telemetry if err := db.Table("telemetry"). Select("telemetry.value"). Joins("JOIN devices ON devices.device_key = telemetry.device_key AND devices.room_id = ?", roomID). Where("telemetry.metric = ?", "humidity"). Order("telemetry.timestamp DESC"). Limit(1). First(&h).Error; err == nil { humidity = &h.Value } var t model.Telemetry if err := db.Table("telemetry"). Select("telemetry.value"). Joins("JOIN devices ON devices.device_key = telemetry.device_key AND devices.room_id = ?", roomID). Where("telemetry.metric = ?", "temperature"). Order("telemetry.timestamp DESC"). Limit(1). First(&t).Error; err == nil { temperature = &t.Value } var envCoef *float64 if humidity != nil || temperature != nil { value := service.EnvCoefficient(temperature, humidity) envCoef = &value } return stageCoef, envCoef } // listInspections 巡检记录列表(roomId/limit 过滤) func listInspections(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { q := db.Model(&model.InspectionRecord{}) if room := c.Query("roomId"); room != "" { q = q.Where("room_id = ?", room) } limit := 50 if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 && l <= 200 { limit = l } var list []model.InspectionRecord q.Order("created_at DESC").Limit(limit).Find(&list) // 联查蚕房名(非持久化字段) var rooms []model.Room db.Select("id", "name").Find(&rooms) roomNames := make(map[string]string, len(rooms)) for _, r := range rooms { roomNames[r.ID] = r.Name } for i := range list { if list[i].RoomID != nil { if n, ok := roomNames[*list[i].RoomID]; ok { list[i].RoomName = &n } } } c.JSON(http.StatusOK, list) } }