package handler import ( "bytes" "context" "encoding/json" "io" "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, wechat *service.WechatService, inspectionTemplateID string) { rg.POST("/inspections", middleware.RequirePermission(db, "inspection:create"), createInspection(db, s3, ai, imageBucket, wechat, inspectionTemplateID)) 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 } // createInspection 拍照巡检:图片存 S3 → 调 AI /detect → 写记录。 // 幂等:客户端传 Idempotency-Key 头时,重复请求返回已有记录。 func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient, bucket string, wechat *service.WechatService, inspectionTemplateID 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 // 风险评分(#9):AI 置信度取检测结果最大值;环境/阶段系数在有 roomId 时按房间数据计算 aiConf := 0.0 for _, d := range detRes.Detections { if d.Confidence > aiConf { aiConf = d.Confidence } } stageCoef, envCoef := loadRoomRisk(db, roomID) score := service.ComputeRiskScore(service.RiskInput{ AI: aiConf, Env: envCoef, Stage: stageCoef, }) rec.RiskScore = &score level := service.RiskLevel(score) rec.RiskLevel = &level // 微信订阅消息(#11 骨架):风险非绿且用户已授权时异步推送 if key := service.WechatTemplateKey(level); key != "" { go func(uid *string, lv string, sc float64) { if uid == nil || !wechat.Configured() || inspectionTemplateID == "" { return } var binding model.WechatBinding if db.Where("user_id = ?", *uid).First(&binding).Error != nil { return } var authorized []string if len(binding.AuthorizedTemplates) > 0 { _ = json.Unmarshal(binding.AuthorizedTemplates, &authorized) } if !service.IsAuthorized(authorized, key) { return } _ = wechat.SendSubscribe( context.Background(), binding.OpenID, inspectionTemplateID, service.BuildSubscribeData(lv, sc), "pages/inspection/index", ) }(rec.UserID, level, score) } } if err := db.Create(&rec).Error; err != 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 加载房间阶段系数与环境系数(无房间/无数据时返回 0) func loadRoomRisk(db *gorm.DB, roomID string) (stageCoef, envCoef float64) { if roomID == "" { return 0, 0 } var room model.Room if db.Where("id = ?", roomID).First(&room).Error != nil { return 0, 0 } if room.Stage != nil { stageCoef = service.StageCoefficient(*room.Stage) } 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 } envCoef = service.EnvCoefficient(temperature, humidity) 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) } }