281 lines
8.6 KiB
Go
281 lines
8.6 KiB
Go
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)
|
||
}
|
||
}
|