feat(server-go): AI 巡检闭环后端(inspection_records + /inspections 幂等接口)
This commit is contained in:
@@ -72,6 +72,7 @@ func main() {
|
|||||||
s3Svc := service.NewS3Service(cfg)
|
s3Svc := service.NewS3Service(cfg)
|
||||||
mediaSvc := service.NewMediaService(cfg)
|
mediaSvc := service.NewMediaService(cfg)
|
||||||
transcodeSvc := service.NewTranscodeService(db, s3Svc)
|
transcodeSvc := service.NewTranscodeService(db, s3Svc)
|
||||||
|
aiSvc := service.NewAIClient(cfg.AIServiceBase)
|
||||||
|
|
||||||
// 9. 创建 Gin 引擎
|
// 9. 创建 Gin 引擎
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
@@ -107,6 +108,7 @@ func main() {
|
|||||||
handler.RegisterVideoRecordRoutes(api, db, mediaSvc, cfg)
|
handler.RegisterVideoRecordRoutes(api, db, mediaSvc, cfg)
|
||||||
handler.RegisterStorageRoutes(api, db)
|
handler.RegisterStorageRoutes(api, db)
|
||||||
handler.RegisterKnowledgeRoutes(api, db, s3Svc, cfg.S3BucketImages)
|
handler.RegisterKnowledgeRoutes(api, db, s3Svc, cfg.S3BucketImages)
|
||||||
|
handler.RegisterInspectionRoutes(api, db, s3Svc, aiSvc, cfg.S3BucketImages)
|
||||||
|
|
||||||
// 启动后台设备状态同步(每 30 秒查询 WVP 设备在线状态)
|
// 启动后台设备状态同步(每 30 秒查询 WVP 设备在线状态)
|
||||||
go startDeviceStatusSync(db, mediaSvc)
|
go startDeviceStatusSync(db, mediaSvc)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type Config struct {
|
|||||||
ZLMAPIBase string `env:"ZLM_API_BASE" envDefault:"http://100.83.103.1:8081"`
|
ZLMAPIBase string `env:"ZLM_API_BASE" envDefault:"http://100.83.103.1:8081"`
|
||||||
ZLMSecret string `env:"ZLM_SECRET" envDefault:"su6TiedN2rVAmBbIDX0aa0QTiBJLBdcf"`
|
ZLMSecret string `env:"ZLM_SECRET" envDefault:"su6TiedN2rVAmBbIDX0aa0QTiBJLBdcf"`
|
||||||
RecorderAPIBase string `env:"RECORDER_API_BASE" envDefault:"http://localhost:9090"`
|
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"`
|
InternalAPIKey string `env:"INTERNAL_API_KEY" envDefault:"silk-internal-2026"`
|
||||||
Port int `env:"PORT" envDefault:"3000"`
|
Port int `env:"PORT" envDefault:"3000"`
|
||||||
DefaultAdminUsername string `env:"DEFAULT_ADMIN_USERNAME" envDefault:"admin"`
|
DefaultAdminUsername string `env:"DEFAULT_ADMIN_USERNAME" envDefault:"admin"`
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ func Init(cfg *config.Config) error {
|
|||||||
&model.AuditLog{}, &model.Telemetry{},
|
&model.AuditLog{}, &model.Telemetry{},
|
||||||
&model.Permission{}, &model.RolePermission{},
|
&model.Permission{}, &model.RolePermission{},
|
||||||
&model.Disease{}, &model.KnowledgeArticle{},
|
&model.Disease{}, &model.KnowledgeArticle{},
|
||||||
|
&model.InspectionRecord{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
slog.Warn("自动迁移有警告(可忽略)", "err", err)
|
slog.Warn("自动迁移有警告(可忽略)", "err", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildImageKey 生成 knowledge/<日期>/<随机>.ext 的对象键
|
// buildObjectKey 生成 <prefix>/<日期>/<随机>.ext 的对象键
|
||||||
func buildImageKey(filename string) (string, string, error) {
|
func buildObjectKey(prefix, filename string) (string, string, error) {
|
||||||
ext := strings.ToLower(filepath.Ext(filename))
|
ext := strings.ToLower(filepath.Ext(filename))
|
||||||
if _, ok := imageContentType(ext); !ok {
|
if _, ok := imageContentType(ext); !ok {
|
||||||
return "", "", errors.New("仅支持 jpg/jpeg/png/webp 图片")
|
return "", "", errors.New("仅支持 jpg/jpeg/png/webp 图片")
|
||||||
@@ -56,10 +56,15 @@ func buildImageKey(filename string) (string, string, error) {
|
|||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
date := time.Now().Format("20060102")
|
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
|
return key, ext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildImageKey 生成 knowledge/<日期>/<随机>.ext 的对象键
|
||||||
|
func buildImageKey(filename string) (string, string, error) {
|
||||||
|
return buildObjectKey("knowledge", filename)
|
||||||
|
}
|
||||||
|
|
||||||
// uploadKnowledgeImage 上传知识库图片(multipart 字段名 file)
|
// uploadKnowledgeImage 上传知识库图片(multipart 字段名 file)
|
||||||
func uploadKnowledgeImage(s3 *service.S3Service, bucket string) gin.HandlerFunc {
|
func uploadKnowledgeImage(s3 *service.S3Service, bucket string) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
|||||||
@@ -64,3 +64,16 @@ func TestBuildImageKey(t *testing.T) {
|
|||||||
t.Errorf("key 应包含日期与随机部分: %s", key)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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" }
|
||||||
@@ -24,6 +24,8 @@ var AllPermissions = []PermissionDef{
|
|||||||
{"log:read", "日志查看", "查看控制日志"},
|
{"log:read", "日志查看", "查看控制日志"},
|
||||||
{"knowledge:read", "知识库查看", "查看蚕病百科和知识文章"},
|
{"knowledge:read", "知识库查看", "查看蚕病百科和知识文章"},
|
||||||
{"knowledge:write", "知识库管理", "新增、编辑、删除知识库内容"},
|
{"knowledge:write", "知识库管理", "新增、编辑、删除知识库内容"},
|
||||||
|
{"inspection:create", "巡检创建", "发起 AI 拍照巡检"},
|
||||||
|
{"inspection:read", "巡检查看", "查看 AI 巡检记录"},
|
||||||
{"user:manage", "用户管理", "管理用户、角色和权限"},
|
{"user:manage", "用户管理", "管理用户、角色和权限"},
|
||||||
{"audit:read", "审计查看", "查看审计日志"},
|
{"audit:read", "审计查看", "查看审计日志"},
|
||||||
}
|
}
|
||||||
@@ -35,6 +37,7 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"threshold:read", "threshold:write", "alarm:read", "alarm:ack",
|
"threshold:read", "threshold:write", "alarm:read", "alarm:ack",
|
||||||
"video:read", "video:record", "energy:view", "log:read",
|
"video:read", "video:record", "energy:view", "log:read",
|
||||||
"knowledge:read", "knowledge:write",
|
"knowledge:read", "knowledge:write",
|
||||||
|
"inspection:create", "inspection:read",
|
||||||
"user:manage", "audit:read",
|
"user:manage", "audit:read",
|
||||||
},
|
},
|
||||||
RoleOperator: {
|
RoleOperator: {
|
||||||
@@ -42,15 +45,18 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"threshold:read", "threshold:write", "alarm:read", "alarm:ack",
|
"threshold:read", "threshold:write", "alarm:read", "alarm:ack",
|
||||||
"video:read", "video:record", "energy:view", "log:read",
|
"video:read", "video:record", "energy:view", "log:read",
|
||||||
"knowledge:read", "knowledge:write",
|
"knowledge:read", "knowledge:write",
|
||||||
|
"inspection:create", "inspection:read",
|
||||||
},
|
},
|
||||||
RoleViewer: {
|
RoleViewer: {
|
||||||
"dashboard:view", "room:read", "device:read",
|
"dashboard:view", "room:read", "device:read",
|
||||||
"threshold:read", "alarm:read", "video:read", "energy:view",
|
"threshold:read", "alarm:read", "video:read", "energy:view",
|
||||||
"knowledge:read",
|
"knowledge:read",
|
||||||
|
"inspection:read",
|
||||||
},
|
},
|
||||||
RoleFarmer: {
|
RoleFarmer: {
|
||||||
"dashboard:view", "room:read", "device:read", "device:control",
|
"dashboard:view", "room:read", "device:read", "device:control",
|
||||||
"alarm:read", "alarm:ack", "video:read", "energy:view",
|
"alarm:read", "alarm:ack", "video:read", "energy:view",
|
||||||
"knowledge:read",
|
"knowledge:read",
|
||||||
|
"inspection:create", "inspection:read",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 应返回错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user