feat: 修复 AI 风险语义并隔离 Mock 数据

This commit is contained in:
weijuesen
2026-08-14 01:16:50 +08:00
parent 839ba91354
commit 74d1948d68
29 changed files with 650 additions and 113 deletions
+1 -1
View File
@@ -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{}
+82 -49
View File
@@ -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
}
+35 -1
View File
@@ -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)
}
}