feat: 修复 AI 风险语义并隔离 Mock 数据
This commit is contained in:
@@ -44,7 +44,7 @@ func roomHealthProfile(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
db.Table("inspection_records").
|
||||
Select("risk_level, count(*) AS cnt").
|
||||
Where("room_id = ? AND ai_status = 'done' AND risk_level IS NOT NULL AND created_at >= ?", id, since).
|
||||
Where("room_id = ? AND ai_status = 'done' AND risk_level IS NOT NULL AND created_at >= ? AND COALESCE(is_mock, false) = false", id, since).
|
||||
Group("risk_level").
|
||||
Scan(&riskRows)
|
||||
riskCounts := map[string]int64{}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -26,8 +27,8 @@ func isUUID(s string) bool {
|
||||
}
|
||||
|
||||
// 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))
|
||||
func RegisterInspectionRoutes(rg *gin.RouterGroup, db *gorm.DB, s3 *service.S3Service, ai *service.AIClient, imageBucket string, wechat *service.WechatService, inspectionTemplateID string, appEnv string) {
|
||||
rg.POST("/inspections", middleware.RequirePermission(db, "inspection:create"), createInspection(db, s3, ai, imageBucket, wechat, inspectionTemplateID, appEnv))
|
||||
rg.GET("/inspections", middleware.RequirePermission(db, "inspection:read"), listInspections(db))
|
||||
}
|
||||
|
||||
@@ -43,9 +44,29 @@ func currentUserID(c *gin.Context) *string {
|
||||
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, wechat *service.WechatService, inspectionTemplateID string) gin.HandlerFunc {
|
||||
func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient, bucket string, wechat *service.WechatService, inspectionTemplateID string, appEnv string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
idemKey := strings.TrimSpace(c.GetHeader("Idempotency-Key"))
|
||||
roomID := strings.TrimSpace(c.PostForm("roomId"))
|
||||
@@ -113,49 +134,55 @@ func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient,
|
||||
} 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
|
||||
}
|
||||
isMock := detRes.IsMock
|
||||
rec.IsMock = &isMock
|
||||
modelVersion := detRes.ModelVersion
|
||||
if modelVersion == "" {
|
||||
modelVersion = "unknown"
|
||||
}
|
||||
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
|
||||
rec.ModelVersion = &modelVersion
|
||||
|
||||
// 微信订阅消息(#11 骨架):风险非绿且用户已授权时异步推送
|
||||
if key := service.WechatTemplateKey(level); key != "" {
|
||||
go func(uid *string, lv string, sc float64) {
|
||||
if uid == nil || !wechat.Configured() || inspectionTemplateID == "" {
|
||||
return
|
||||
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 结果不进入告警,风险非绿且用户已授权时异步推送
|
||||
if !isMock {
|
||||
if key := service.WechatTemplateKey(assessment.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, assessment.Level, assessment.Score)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,17 +202,19 @@ func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient,
|
||||
}
|
||||
}
|
||||
|
||||
// loadRoomRisk 加载房间阶段系数与环境系数(无房间/无数据时返回 0)
|
||||
func loadRoomRisk(db *gorm.DB, roomID string) (stageCoef, envCoef float64) {
|
||||
// loadRoomRisk 加载房间阶段系数与环境系数(无房间/无数据时返回 nil)
|
||||
func loadRoomRisk(db *gorm.DB, roomID string) (*float64, *float64) {
|
||||
if roomID == "" {
|
||||
return 0, 0
|
||||
return nil, nil
|
||||
}
|
||||
var room model.Room
|
||||
if db.Where("id = ?", roomID).First(&room).Error != nil {
|
||||
return 0, 0
|
||||
return nil, nil
|
||||
}
|
||||
var stageCoef *float64
|
||||
if room.Stage != nil {
|
||||
stageCoef = service.StageCoefficient(*room.Stage)
|
||||
value := service.StageCoefficient(*room.Stage)
|
||||
stageCoef = &value
|
||||
}
|
||||
|
||||
var humidity, temperature *float64
|
||||
@@ -209,7 +238,11 @@ func loadRoomRisk(db *gorm.DB, roomID string) (stageCoef, envCoef float64) {
|
||||
First(&t).Error; err == nil {
|
||||
temperature = &t.Value
|
||||
}
|
||||
envCoef = service.EnvCoefficient(temperature, humidity)
|
||||
var envCoef *float64
|
||||
if humidity != nil || temperature != nil {
|
||||
value := service.EnvCoefficient(temperature, humidity)
|
||||
envCoef = &value
|
||||
}
|
||||
return stageCoef, envCoef
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"silk-server-go/internal/service"
|
||||
)
|
||||
|
||||
func TestIsUUID(t *testing.T) {
|
||||
valid := []string{
|
||||
@@ -19,3 +23,33 @@ func TestIsUUID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRiskInputUsesAbnormalProbability(t *testing.T) {
|
||||
in := buildRiskInput(&service.AIDetectResponse{
|
||||
ModelVersion: "silk-yolo-2026.08.1",
|
||||
Status: "abnormal",
|
||||
AbnormalProbability: 0.93,
|
||||
})
|
||||
if in.AI == nil || *in.AI != 0.93 {
|
||||
t.Fatalf("AI 异常概率应进入风险输入,实际 %v", in.AI)
|
||||
}
|
||||
if in.ModelVersion != "silk-yolo-2026.08.1" {
|
||||
t.Errorf("modelVersion = %s", in.ModelVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRiskInputUnknownKeepsAIMissing(t *testing.T) {
|
||||
in := buildRiskInput(&service.AIDetectResponse{Status: "unknown"})
|
||||
if in.AI != nil {
|
||||
t.Fatalf("unknown 状态不应把 0 当作 AI 组件,实际 %v", in.AI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRiskInputFallsBackToDetections(t *testing.T) {
|
||||
in := buildRiskInput(&service.AIDetectResponse{
|
||||
Detections: []service.AIDetection{{ClassName: "sick", Confidence: 0.9}},
|
||||
})
|
||||
if in.AI == nil || *in.AI != 0.9 {
|
||||
t.Fatalf("旧 AI 响应应从检测类别计算异常概率,实际 %v", in.AI)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user