From 210bd6cefb76a8355ba971c79caac8a35cd660e1 Mon Sep 17 00:00:00 2001 From: weijuesen Date: Wed, 12 Aug 2026 16:24:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(server-go):=20AI=20=E5=B7=A1=E6=A3=80?= =?UTF-8?q?=E9=97=AD=E7=8E=AF=E5=90=8E=E7=AB=AF=EF=BC=88inspection=5Frecor?= =?UTF-8?q?ds=20+=20/inspections=20=E5=B9=82=E7=AD=89=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server-go/cmd/server/main.go | 2 + server-go/internal/config/config.go | 1 + server-go/internal/database/db.go | 1 + server-go/internal/handler/inspection.go | 136 ++++++++++++++++++ server-go/internal/handler/knowledge_image.go | 11 +- .../internal/handler/knowledge_image_test.go | 13 ++ server-go/internal/model/inspection.go | 23 +++ server-go/internal/model/permission_seed.go | 6 + server-go/internal/service/ai_client.go | 90 ++++++++++++ server-go/internal/service/ai_client_test.go | 74 ++++++++++ 10 files changed, 354 insertions(+), 3 deletions(-) create mode 100644 server-go/internal/handler/inspection.go create mode 100644 server-go/internal/model/inspection.go create mode 100644 server-go/internal/service/ai_client.go create mode 100644 server-go/internal/service/ai_client_test.go diff --git a/server-go/cmd/server/main.go b/server-go/cmd/server/main.go index ed1e23b..97f453d 100644 --- a/server-go/cmd/server/main.go +++ b/server-go/cmd/server/main.go @@ -72,6 +72,7 @@ func main() { s3Svc := service.NewS3Service(cfg) mediaSvc := service.NewMediaService(cfg) transcodeSvc := service.NewTranscodeService(db, s3Svc) + aiSvc := service.NewAIClient(cfg.AIServiceBase) // 9. 创建 Gin 引擎 gin.SetMode(gin.ReleaseMode) @@ -107,6 +108,7 @@ func main() { handler.RegisterVideoRecordRoutes(api, db, mediaSvc, cfg) handler.RegisterStorageRoutes(api, db) handler.RegisterKnowledgeRoutes(api, db, s3Svc, cfg.S3BucketImages) + handler.RegisterInspectionRoutes(api, db, s3Svc, aiSvc, cfg.S3BucketImages) // 启动后台设备状态同步(每 30 秒查询 WVP 设备在线状态) go startDeviceStatusSync(db, mediaSvc) diff --git a/server-go/internal/config/config.go b/server-go/internal/config/config.go index a6fee71..0f6a376 100644 --- a/server-go/internal/config/config.go +++ b/server-go/internal/config/config.go @@ -24,6 +24,7 @@ type Config struct { ZLMAPIBase string `env:"ZLM_API_BASE" envDefault:"http://100.83.103.1:8081"` ZLMSecret string `env:"ZLM_SECRET" envDefault:"su6TiedN2rVAmBbIDX0aa0QTiBJLBdcf"` RecorderAPIBase string `env:"RECORDER_API_BASE" envDefault:"http://localhost:9090"` + AIServiceBase string `env:"AI_SERVICE_BASE" envDefault:"http://localhost:8000"` InternalAPIKey string `env:"INTERNAL_API_KEY" envDefault:"silk-internal-2026"` Port int `env:"PORT" envDefault:"3000"` DefaultAdminUsername string `env:"DEFAULT_ADMIN_USERNAME" envDefault:"admin"` diff --git a/server-go/internal/database/db.go b/server-go/internal/database/db.go index 8e7fc94..6b09c76 100644 --- a/server-go/internal/database/db.go +++ b/server-go/internal/database/db.go @@ -28,6 +28,7 @@ func Init(cfg *config.Config) error { &model.AuditLog{}, &model.Telemetry{}, &model.Permission{}, &model.RolePermission{}, &model.Disease{}, &model.KnowledgeArticle{}, + &model.InspectionRecord{}, ); err != nil { slog.Warn("自动迁移有警告(可忽略)", "err", err) } diff --git a/server-go/internal/handler/inspection.go b/server-go/internal/handler/inspection.go new file mode 100644 index 0000000..cb7fe58 --- /dev/null +++ b/server-go/internal/handler/inspection.go @@ -0,0 +1,136 @@ +package handler + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "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" +) + +// RegisterInspectionRoutes 注册 AI 巡检路由 +func RegisterInspectionRoutes(rg *gin.RouterGroup, db *gorm.DB, s3 *service.S3Service, ai *service.AIClient, imageBucket string) { + rg.POST("/inspections", middleware.RequirePermission(db, "inspection:create"), createInspection(db, s3, ai, imageBucket)) + 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) 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 != "" { + 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 + } + + 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) + } +} + +// 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) + c.JSON(http.StatusOK, list) + } +} diff --git a/server-go/internal/handler/knowledge_image.go b/server-go/internal/handler/knowledge_image.go index e103d2a..2e2ea14 100644 --- a/server-go/internal/handler/knowledge_image.go +++ b/server-go/internal/handler/knowledge_image.go @@ -45,8 +45,8 @@ func validateImageFile(filename string, size int64) error { return nil } -// buildImageKey 生成 knowledge/<日期>/<随机>.ext 的对象键 -func buildImageKey(filename string) (string, string, error) { +// buildObjectKey 生成 /<日期>/<随机>.ext 的对象键 +func buildObjectKey(prefix, filename string) (string, string, error) { ext := strings.ToLower(filepath.Ext(filename)) if _, ok := imageContentType(ext); !ok { return "", "", errors.New("仅支持 jpg/jpeg/png/webp 图片") @@ -56,10 +56,15 @@ func buildImageKey(filename string) (string, string, error) { return "", "", err } date := time.Now().Format("20060102") - key := fmt.Sprintf("knowledge/%s/%s%s", date, hex.EncodeToString(buf), ext) + key := fmt.Sprintf("%s/%s/%s%s", prefix, date, hex.EncodeToString(buf), ext) return key, ext, nil } +// buildImageKey 生成 knowledge/<日期>/<随机>.ext 的对象键 +func buildImageKey(filename string) (string, string, error) { + return buildObjectKey("knowledge", filename) +} + // uploadKnowledgeImage 上传知识库图片(multipart 字段名 file) func uploadKnowledgeImage(s3 *service.S3Service, bucket string) gin.HandlerFunc { return func(c *gin.Context) { diff --git a/server-go/internal/handler/knowledge_image_test.go b/server-go/internal/handler/knowledge_image_test.go index 1417a89..eb7fad3 100644 --- a/server-go/internal/handler/knowledge_image_test.go +++ b/server-go/internal/handler/knowledge_image_test.go @@ -64,3 +64,16 @@ func TestBuildImageKey(t *testing.T) { t.Errorf("key 应包含日期与随机部分: %s", key) } } + +func TestBuildObjectKeyPrefix(t *testing.T) { + key, ext, err := buildObjectKey("inspections", "a.jpg") + if err != nil { + t.Fatalf("buildObjectKey 失败: %v", err) + } + if !strings.HasPrefix(key, "inspections/") { + t.Errorf("key 应以 inspections/ 开头: %s", key) + } + if !strings.HasSuffix(key, ".jpg") || ext != ".jpg" { + t.Errorf("key/ext 应以 .jpg 结尾: %s / %s", key, ext) + } +} diff --git a/server-go/internal/model/inspection.go b/server-go/internal/model/inspection.go new file mode 100644 index 0000000..42a47c3 --- /dev/null +++ b/server-go/internal/model/inspection.go @@ -0,0 +1,23 @@ +package model + +import ( + "encoding/json" + "time" +) + +// InspectionRecord AI 巡检记录 +type InspectionRecord struct { + ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + UserID *string `gorm:"column:user_id;type:uuid;index" json:"userId,omitempty"` + RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"` + ImageURL *string `gorm:"column:image_url;size:512" json:"imageUrl,omitempty"` + Detections json.RawMessage `gorm:"column:detections;type:jsonb" json:"detections,omitempty"` + RiskScore *float64 `gorm:"column:risk_score;type:float" json:"riskScore,omitempty"` + RiskLevel *string `gorm:"column:risk_level;size:16" json:"riskLevel,omitempty"` + AIStatus string `gorm:"column:ai_status;size:16;default:done" json:"aiStatus"` + IdempotencyKey *string `gorm:"column:idempotency_key;size:128;uniqueIndex" json:"idempotencyKey,omitempty"` + CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` + UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"` +} + +func (InspectionRecord) TableName() string { return "inspection_records" } diff --git a/server-go/internal/model/permission_seed.go b/server-go/internal/model/permission_seed.go index 7595955..de27531 100644 --- a/server-go/internal/model/permission_seed.go +++ b/server-go/internal/model/permission_seed.go @@ -24,6 +24,8 @@ var AllPermissions = []PermissionDef{ {"log:read", "日志查看", "查看控制日志"}, {"knowledge:read", "知识库查看", "查看蚕病百科和知识文章"}, {"knowledge:write", "知识库管理", "新增、编辑、删除知识库内容"}, + {"inspection:create", "巡检创建", "发起 AI 拍照巡检"}, + {"inspection:read", "巡检查看", "查看 AI 巡检记录"}, {"user:manage", "用户管理", "管理用户、角色和权限"}, {"audit:read", "审计查看", "查看审计日志"}, } @@ -35,6 +37,7 @@ var RolePermissionMap = map[string][]string{ "threshold:read", "threshold:write", "alarm:read", "alarm:ack", "video:read", "video:record", "energy:view", "log:read", "knowledge:read", "knowledge:write", + "inspection:create", "inspection:read", "user:manage", "audit:read", }, RoleOperator: { @@ -42,15 +45,18 @@ var RolePermissionMap = map[string][]string{ "threshold:read", "threshold:write", "alarm:read", "alarm:ack", "video:read", "video:record", "energy:view", "log:read", "knowledge:read", "knowledge:write", + "inspection:create", "inspection:read", }, RoleViewer: { "dashboard:view", "room:read", "device:read", "threshold:read", "alarm:read", "video:read", "energy:view", "knowledge:read", + "inspection:read", }, RoleFarmer: { "dashboard:view", "room:read", "device:read", "device:control", "alarm:read", "alarm:ack", "video:read", "energy:view", "knowledge:read", + "inspection:create", "inspection:read", }, } diff --git a/server-go/internal/service/ai_client.go b/server-go/internal/service/ai_client.go new file mode 100644 index 0000000..4c4a8b8 --- /dev/null +++ b/server-go/internal/service/ai_client.go @@ -0,0 +1,90 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "strings" + "time" +) + +// AIBBox 检测框 +type AIBBox struct { + X float64 `json:"x"` + Y float64 `json:"y"` + W float64 `json:"w"` + H float64 `json:"h"` +} + +// AIDetection 单条检测结果 +type AIDetection struct { + BBox AIBBox `json:"bbox"` + ClassName string `json:"class"` + Confidence float64 `json:"confidence"` +} + +// AIDetectResponse /detect 响应 +type AIDetectResponse struct { + Model string `json:"model"` + Detections []AIDetection `json:"detections"` +} + +// AIClient ai-service HTTP 客户端 +type AIClient struct { + baseURL string + client *http.Client +} + +// NewAIClient 创建 AI 客户端 +func NewAIClient(baseURL string) *AIClient { + return &AIClient{ + baseURL: strings.TrimSuffix(baseURL, "/"), + client: &http.Client{Timeout: 30 * time.Second}, + } +} + +// Detect 上传图片到 ai-service /detect,返回检测结果(无状态接口,天然幂等) +func (c *AIClient) Detect(ctx context.Context, imageBytes []byte, filename string) (*AIDetectResponse, error) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", filename) + if err != nil { + return nil, err + } + if _, err := part.Write(imageBytes); err != nil { + return nil, err + } + if err := writer.Close(); err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/detect", &body) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ai-service /detect 返回 %d: %s", resp.StatusCode, string(data)) + } + + var out AIDetectResponse + if err := json.Unmarshal(data, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/server-go/internal/service/ai_client_test.go b/server-go/internal/service/ai_client_test.go new file mode 100644 index 0000000..b80c949 --- /dev/null +++ b/server-go/internal/service/ai_client_test.go @@ -0,0 +1,74 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestAIClientDetectParsesResult(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + if r.URL.Path != "/detect" { + t.Errorf("path = %s, want /detect", r.URL.Path) + } + if err := r.ParseMultipartForm(10 << 20); err != nil { + t.Errorf("multipart 解析失败: %v", err) + } + if _, _, err := r.FormFile("file"); err != nil { + t.Errorf("缺少 file 字段: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "model": "mock", + "detections": []map[string]any{ + {"bbox": map[string]float64{"x": 1, "y": 2, "w": 3, "h": 4}, "class": "sick", "confidence": 0.93}, + }, + }) + })) + defer srv.Close() + + client := NewAIClient(srv.URL) + res, err := client.Detect(context.Background(), []byte("image-bytes"), "a.png") + if err != nil { + t.Fatalf("Detect 返回错误: %v", err) + } + if res.Model != "mock" { + t.Errorf("model = %s, want mock", res.Model) + } + if len(res.Detections) != 1 { + t.Fatalf("detections 数量 = %d, want 1", len(res.Detections)) + } + d := res.Detections[0] + if d.ClassName != "sick" || d.Confidence != 0.93 || d.BBox.W != 3 { + t.Errorf("解析结果不正确: %+v", d) + } +} + +func TestAIClientDetectServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer srv.Close() + + _, err := NewAIClient(srv.URL).Detect(context.Background(), []byte("x"), "a.png") + if err == nil { + t.Error("服务端 500 应返回错误") + } +} + +func TestAIClientDetectInvalidJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("{bad")) + })) + defer srv.Close() + + _, err := NewAIClient(srv.URL).Detect(context.Background(), []byte("x"), "a.png") + if err == nil { + t.Error("非法 JSON 应返回错误") + } +}