package imworkbench import ( "bytes" "context" "crypto/hmac" cryptorand "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "io" "net/http" "net/url" "os" "strconv" "strings" "time" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gctx" ) const maxResponseBody = 10 << 20 type Config struct { BaseURL string ServiceName string Secret string Timeout time.Duration } type Client struct { conf Config http *http.Client } type Page[T any] struct { Items []T `json:"items"` Total int `json:"total"` Page int `json:"page"` PerPage int `json:"per_page"` } func DefaultClient(ctx context.Context) *Client { timeout := g.Cfg().MustGet(ctx, "imWorkbenchApi.timeout", "5s").Duration() if timeout <= 0 { timeout = 5 * time.Second } secret := firstSecret( g.Cfg().MustGet(ctx, "imWorkbenchApi.serviceSecret").String(), os.Getenv("IM_WORKBENCH_API_SECRET"), g.Cfg().MustGet(ctx, "imInternalApi.serviceSecret").String(), os.Getenv("IM_INTERNAL_API_SECRET"), g.Cfg().MustGet(ctx, "imAdminApi.serviceSecret").String(), os.Getenv("IM_ADMIN_API_SECRET"), ) conf := Config{ BaseURL: g.Cfg().MustGet(ctx, "imWorkbenchApi.baseUrl").String(), ServiceName: g.Cfg().MustGet(ctx, "imWorkbenchApi.serviceName", "hotgo-admin").String(), Secret: secret, Timeout: timeout, } return &Client{conf: conf, http: &http.Client{Timeout: timeout}} } func (c *Client) Get(ctx context.Context, path string, values url.Values, out any) error { return c.GetForTenant(ctx, path, values, 0, "", out) } func (c *Client) GetForTenant(ctx context.Context, path string, values url.Values, tenantID int64, tenantCode string, out any) error { if values != nil { if query := values.Encode(); query != "" { path += "?" + query } } return c.doJSON(ctx, http.MethodGet, path, nil, tenantID, tenantCode, out) } func (c *Client) Post(ctx context.Context, path string, in any, out any) error { return c.PostForTenant(ctx, path, in, 0, "", out) } func (c *Client) PostForTenant(ctx context.Context, path string, in any, tenantID int64, tenantCode string, out any) error { body, err := json.Marshal(in) if err != nil { return gerror.Wrap(err, "序列化工作台请求失败") } return c.doJSON(ctx, http.MethodPost, path, body, tenantID, tenantCode, out) } func (c *Client) doJSON(ctx context.Context, method string, path string, body []byte, tenantID int64, tenantCode string, out any) error { req, err := c.newRequest(ctx, method, path, body) if err != nil { return err } if tenantID > 0 { req.Header.Set("X-Tenant-ID", strconv.FormatInt(tenantID, 10)) } if tenantCode = strings.TrimSpace(tenantCode); tenantCode != "" { req.Header.Set("X-Tenant-Code", tenantCode) } resp, err := c.http.Do(req) if err != nil { g.Log().Warningf(ctx, "IM workbench request failed url:%s err:%+v", req.URL.String(), err) return gerror.New("IM工作台服务暂不可用") } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return parseError(resp) } if out == nil { return nil } responseBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) if err != nil { return gerror.Wrap(err, "读取IM工作台响应失败") } var envelope struct { Code int `json:"code"` Message string `json:"message"` Reason string `json:"reason"` Data json.RawMessage `json:"data"` } if err = json.Unmarshal(responseBody, &envelope); err == nil && len(envelope.Data) > 0 { if envelope.Code != 0 { return stableError(envelope.Message, envelope.Reason) } return json.Unmarshal(envelope.Data, out) } return json.Unmarshal(responseBody, out) } func (c *Client) newRequest(ctx context.Context, method string, path string, body []byte) (*http.Request, error) { baseURL := strings.TrimRight(c.conf.BaseURL, "/") if baseURL == "" { return nil, gerror.New("IM工作台API未配置") } if c.conf.Secret == "" { return nil, gerror.New("IM工作台API密钥未配置") } req, err := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(body)) if err != nil { return nil, gerror.Wrap(err, "创建IM工作台请求失败") } timestamp := strconv.FormatInt(time.Now().UTC().Unix(), 10) nonce, err := randomNonce() if err != nil { return nil, err } bodyHash := sha256.Sum256(body) signingText := strings.Join([]string{ method, req.URL.RequestURI(), hex.EncodeToString(bodyHash[:]), timestamp, nonce, }, "\n") mac := hmac.New(sha256.New, []byte(c.conf.Secret)) _, _ = mac.Write([]byte(signingText)) req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Service-Name", c.conf.ServiceName) req.Header.Set("X-Timestamp", timestamp) req.Header.Set("X-Nonce", nonce) req.Header.Set("X-Signature", hex.EncodeToString(mac.Sum(nil))) req.Header.Set("X-Request-ID", gctx.CtxId(ctx)) return req, nil } func parseError(resp *http.Response) error { body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) var envelope struct { Message string `json:"message"` Reason string `json:"reason"` } _ = json.Unmarshal(body, &envelope) return stableError(envelope.Message, envelope.Reason) } func stableError(message string, reason string) error { if message != "" { return gerror.New(message) } if reason != "" { return gerror.New(reason) } return gerror.New("IM工作台服务暂不可用") } func randomNonce() (string, error) { var b [16]byte if _, err := cryptorand.Read(b[:]); err != nil { return "", gerror.Wrap(err, "生成IM工作台请求随机数失败") } return hex.EncodeToString(b[:]), nil } func firstSecret(values ...string) string { for _, value := range values { value = strings.TrimSpace(value) if value == "" { continue } if strings.HasPrefix(value, "${") && strings.HasSuffix(value, "}") { value = strings.TrimSpace(os.Getenv(strings.TrimSuffix(strings.TrimPrefix(value, "${"), "}"))) } if value != "" { return value } } return "" }