Files
silk/server-go/internal/handler/inspection.go
T

312 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
}
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
})
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)
}
}