// Package agv 海康 AGV 调度系统(RCS-2000 V4.2) 对接客户端。 // 能力:下发搬运任务(task/submit)、查询任务状态(task/query)。 // 签名协议详见《AGV海康.md》:HMAC-SHA256 加盐 + MD5 二次摘要,sign 放 URL query。 // 未配置真实 RCS(appKey/appSecret/BaseURL) 或 EnableMock=true 时走 Mock 客户端。 package agv import ( "bytes" "context" "crypto/hmac" "crypto/md5" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "sort" "strings" "time" "bj_power_wms/internal/config" ) // Client AGV 调度客户端抽象 type Client interface { // SubmitTask 下发搬运任务 fromDock -> toDock,返回海康任务号 robotTaskCode。 SubmitTask(ctx context.Context, fromDock, toDock, carrierCode string) (string, error) // QueryTask 查询任务状态,返回海康 taskStatus 枚举。 QueryTask(ctx context.Context, robotTaskCode string) (string, error) } // New 依据配置返回真实或 Mock 客户端 func New(cfg config.AgvConfig) Client { if cfg.EnableMock || cfg.BaseURL == "" { return newMockClient() } return newRealClient(cfg) } // ---------- 签名与请求(真实海康) ---------- type realClient struct { cfg config.AgvConfig hc *http.Client carrier string } func newRealClient(cfg config.AgvConfig) *realClient { return &realClient{ cfg: cfg, hc: &http.Client{Timeout: 15 * time.Second}, carrier: cfg.CarrierCode, } } // buildPath 拼接服务前缀 + 业务路径,system func (c *realClient) url(path string) string { base := strings.TrimRight(c.cfg.BaseURL, "/") return base + path } // submit 任务下发实现的实际请求 // 海康路径:/rcs/rtas/api/robot/controller/task/submit func (c *realClient) post(ctx context.Context, path string, payload map[string]any, out any) error { body, err := json.Marshal(payload) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url(path), bytes.NewReader(body)) if err != nil { return err } c.sign(req, body, path) resp, err := c.hc.Do(req) if err != nil { return err } defer resp.Body.Close() raw, err := io.ReadAll(resp.Body) if err != nil { return err } if resp.StatusCode >= 400 { return fmt.Errorf("agv http %d: %s", resp.StatusCode, string(raw)) } var envelope struct { Code string `json:"code"` Message string `json:"message"` Data json.RawMessage `json:"data"` } if err := json.Unmarshal(raw, &envelope); err != nil { return fmt.Errorf("agv parse envelope: %w", err) } if envelope.Code != "" && envelope.Code != "SUCCESS" { return fmt.Errorf("agv code=%s message=%s", envelope.Code, envelope.Message) } if out != nil && len(envelope.Data) > 0 { return json.Unmarshal(envelope.Data, out) } return nil } // sign 按协议 1.3 节生成鉴权头与 sign 签名。 // 参与签名字段(排序):AUTHORIZATION、HOST、X-LR-APPKEY、X-LR-REQUEST-ID、X-LR-SOURCE、X-LR-TRACE-ID、X-LR-VERSION // 签名 = MD5( HMAC-SHA256(appSecret, 拼接原串) ),sign 作为 URL query 参数。 func (c *realClient) sign(req *http.Request, body []byte, path string) { nonce := randHex(8) timestamp := time.Now().Format(time.RFC3339) requestID := randHex(16) traceID := randHex(16) authHeader := fmt.Sprintf("nonce=%q,method=\"HMAC-SHA256\",timestamp=%q", nonce, timestamp) signKV := map[string]string{ "AUTHORIZATION": authHeader, "HOST": req.URL.Host, "X-LR-APPKEY": c.cfg.AppKey, "X-LR-REQUEST-ID": requestID, "X-LR-SOURCE": "bj_power_wms", "X-LR-TRACE-ID": traceID, "X-LR-VERSION": "v1.0", } keys := make([]string, 0, len(signKV)) for k := range signKV { keys = append(keys, k) } sort.Strings(keys) var sb strings.Builder for _, k := range keys { sb.WriteString(k) sb.WriteString(":") sb.WriteString(signKV[k]) sb.WriteString("\n") } // 请求报文拼接后的原串 = 头拼接串 + 空行 + 消息体 raw := sb.String() + "\n" + string(body) mac := hmac.New(sha256.New, []byte(c.cfg.AppSecret)) mac.Write([]byte(raw)) h := md5.Sum(mac.Sum(nil)) sign := strings.ToUpper(hex.EncodeToString(h[:])) q := req.URL.Query() q.Set("sign", sign) req.URL.RawQuery = q.Encode() req.Header.Set("Authorization", authHeader) req.Header.Set("X-lr-appkey", c.cfg.AppKey) req.Header.Set("X-lr-request-id", requestID) req.Header.Set("X-lr-version", "v1.0") req.Header.Set("X-lr-trace-id", traceID) req.Header.Set("X-lr-source", "bj_power_wms") } // SubmitTask 实现 Client:下发搬运任务。 // 海康路径 /rcs/rtas/api/robot/controller/task/submit func (c *realClient) SubmitTask(ctx context.Context, fromDock, toDock, carrierCode string) (string, error) { carrier := carrierCode if carrier == "" { carrier = c.carrier } payload := map[string]any{ "taskType": "PF-LMR-COMMON", "targetRoute": []map[string]any{ {"type": "SITE", "code": fromDock, "operation": "COLLECT"}, {"type": "SITE", "code": toDock, "operation": "DELIVERY"}, }, "carrierInfo": []map[string]any{ {"carrierType": "1", "carrierCode": carrier}, }, } var data struct { RobotTaskCode string `json:"robotTaskCode"` Code string `json:"code"` } if err := c.post(ctx, "/api/robot/controller/task/submit", payload, &data); err != nil { return "", err } if data.RobotTaskCode == "" { // 部分版本把任务号放 data.code if data.Code == "" { return "", fmt.Errorf("agv submit 未返回任务号") } return data.Code, nil } return data.RobotTaskCode, nil } // QueryTask 实现 Client:查询任务状态。 // 海康路径 /rcs/rtas/api/robot/controller/task/query func (c *realClient) QueryTask(ctx context.Context, robotTaskCode string) (string, error) { payload := map[string]any{"robotTaskCode": robotTaskCode} var data map[string]any if err := c.post(ctx, "/api/robot/controller/task/query", payload, &data); err != nil { return "", err } st, _ := data["taskStatus"].(string) if st == "" { if v, ok := data["status"].(string); ok { st = v } } if st == "" { return "QUEUE", nil } return st, nil } // ---------- Mock 客户端(未接真实 RCS 时用) ---------- type mockClient struct{} func newMockClient() *mockClient { return &mockClient{} } func (m *mockClient) SubmitTask(ctx context.Context, fromDock, toDock, carrierCode string) (string, error) { // 模拟立即成功下发,返回一个可追溯的假任务号(前缀 MOCK-) return "MOCK-" + randHex(8), nil } func (m *mockClient) QueryTask(ctx context.Context, robotTaskCode string) (string, error) { // 模拟:MOCK 任务一律视为已完成,便于离线联调观察"待搬运→已到位"。 return "FINISHED", nil } func randHex(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return fmt.Sprintf("%02d", time.Now().UnixNano()%100) } return hex.EncodeToString(b) }