246 lines
7.4 KiB
Go
246 lines
7.4 KiB
Go
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)
|
||
}
|
||
}
|