feat(server-go): AI 巡检闭环后端(inspection_records + /inspections 幂等接口)

This commit is contained in:
weijuesen
2026-08-12 16:24:55 +08:00
parent b89ad5e865
commit 210bd6cefb
10 changed files with 354 additions and 3 deletions
+136
View File
@@ -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)
}
}
@@ -45,8 +45,8 @@ func validateImageFile(filename string, size int64) error {
return nil
}
// buildImageKey 生成 knowledge/<日期>/<随机>.ext 的对象键
func buildImageKey(filename string) (string, string, error) {
// buildObjectKey 生成 <prefix>/<日期>/<随机>.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) {
@@ -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)
}
}