Files
bj_power/bj_power_wms/internal/agv/client.go
T
SunYF bf3a408839 refactor: 统一术语BOM为物料清单并补充工位扩展能力
主要变更:
1.  后端:重构工位管理逻辑,新增工位自定义扩展能力,将工位号上限调整为999并支持14+虚拟工位
2.  前后端:全量替换"BOM"术语为"物料清单",包括提示语、注释和文档
3.  WMS模块:新增AGV任务取消接口与前端页面,对接海康RCS取消任务逻辑
4.  权限与种子数据:新增工位新增权限并更新权限配置
5.  修复错误校验逻辑,优化AGV客户端签名与请求处理
2026-09-17 19:41:00 +08:00

276 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package agv 海康 AGV 调度系统(RCS-2000 V4.2) 对接客户端。
// 能力:下发搬运任务(task/submit)、查询任务状态(task/query)、任务取消(task/cancel)。
// 签名协议详见《AGV海康.md》:HMAC-SHA256 加盐 + MD5 二次摘要,sign 放 URL query。
// 完整请求路径 = 服务前缀 /rcs/rtas + 业务路径(文档 §2 开头)。
// 未配置真实 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"
)
// ServicePrefix RCS-2000 统一服务前缀(文档 §2:「服务前缀统一:/rcs/rtas」)
const ServicePrefix = "/rcs/rtas"
// 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)
// CancelTask 取消任务(软取消 CANCEL / 硬取消 DROP)。
CancelTask(ctx context.Context, robotTaskCode, cancelType, reason string) error
// Disable 是否处于 Mock(未接真实 RCS)模式。
Disable() bool
}
// 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,
}
}
// url 拼接 RCS 服务前缀 + 业务路径(文档 §2:完整请求路径 = 服务前缀 + 路径)。
// 兼容用户把 BaseURL 直接填成已含 /rcs/rtas 的情况,避免重复前缀。
func (c *realClient) url(path string) string {
base := strings.TrimRight(c.cfg.BaseURL, "/")
if strings.HasSuffix(base, ServicePrefix) {
return base + path
}
return base + ServicePrefix + path
}
// post 任务下发实现的实际请求
// 海康路径:/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" && envelope.Code != "0" {
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)
// X-lr-request-id 是文档标注的唯一「必需」首部字段(1.1 节 是否必需=是)
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")
req.Header.Set("Content-Type", "application/json;charset=UTF-8")
}
// SubmitTask 实现 Client:下发搬运任务。
// 海康路径 /rcs/rtas/api/robot/controller/task/submit
func (c *realClient) SubmitTask(ctx context.Context, fromDock, toDock, carrierCode string) (string, error) {
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,避免 RCS 按空载具校验失败
carrier := carrierCode
if carrier == "" {
carrier = c.carrier
}
if carrier != "" {
payload["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
}
// CancelTask 实现 Client:取消任务(文档 2.1.4 任务取消接口)。
// cancelTypeCANCEL 软取消(可下发回库)/ DROP 人工介入硬取消。
func (c *realClient) CancelTask(ctx context.Context, robotTaskCode, cancelType, reason string) error {
if robotTaskCode == "" {
return fmt.Errorf("缺少任务号")
}
if cancelType == "" {
cancelType = "CANCEL"
}
payload := map[string]any{
"robotTaskCode": robotTaskCode,
"cancelType": cancelType,
}
if reason != "" {
payload["reason"] = reason
}
return c.post(ctx, "/api/robot/controller/task/cancel", payload, nil)
}
// Disable 真实客户端非 Mock 模式
func (c *realClient) Disable() bool { return false }
// ---------- 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 (m *mockClient) CancelTask(ctx context.Context, robotTaskCode, cancelType, reason string) error {
return nil
}
func (m *mockClient) Disable() bool { return true }
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)
}