package imadmin import ( "bytes" "context" "crypto/hmac" cryptorand "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "net/url" "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 AdminScope struct { TenantId int64 OperatorId int64 RoleKey string RequestId string } type Page[T any] struct { Items []T `json:"items"` Page int `json:"page"` PerPage int `json:"per_page"` Total int `json:"total"` } type Client struct { conf Config http *http.Client } func NewClient(conf Config) *Client { return &Client{ conf: conf, http: &http.Client{Timeout: conf.Timeout}, } } func DefaultClient(ctx context.Context) *Client { conf := LoadConfig(ctx) return NewClient(conf) } func (c *Client) CreateCode(ctx context.Context, scope AdminScope, in any, out any) error { return c.doJSON(ctx, http.MethodPost, "/admin/v1/im/registration-invite-codes", scope, in, out) } func (c *Client) CreateBatch(ctx context.Context, scope AdminScope, in any, out any) error { return c.doJSON(ctx, http.MethodPost, "/admin/v1/im/registration-invite-code-batches", scope, in, out) } func (c *Client) ListCodes(ctx context.Context, scope AdminScope, in any, out any) error { if hasInviteCode(in) { return c.doJSON(ctx, http.MethodPost, "/admin/v1/im/registration-invite-codes/search", scope, in, out) } path := "/admin/v1/im/registration-invite-codes" if query := toQuery(in); query != "" { path += "?" + query } return c.doJSON(ctx, http.MethodGet, path, scope, nil, out) } func (c *Client) GetBatch(ctx context.Context, scope AdminScope, batchId int64, out any) error { return c.doJSON(ctx, http.MethodGet, fmt.Sprintf("/admin/v1/im/registration-invite-code-batches/%d", batchId), scope, nil, out) } func (c *Client) RevokeCode(ctx context.Context, scope AdminScope, codeId int64) error { return c.doJSON(ctx, http.MethodPost, fmt.Sprintf("/admin/v1/im/registration-invite-codes/%d/revoke", codeId), scope, nil, nil) } func (c *Client) RevokeBatch(ctx context.Context, scope AdminScope, batchId int64, out any) error { return c.doJSON(ctx, http.MethodPost, fmt.Sprintf("/admin/v1/im/registration-invite-code-batches/%d/revoke", batchId), scope, nil, out) } func (c *Client) ExportCodes(ctx context.Context, scope AdminScope, in any) (*http.Response, error) { req, err := c.newRequest(ctx, http.MethodPost, "/admin/v1/im/registration-invite-codes/export", scope, in) if err != nil { return nil, err } resp, err := c.http.Do(req) if err != nil { return nil, gerror.New("IM服务暂不可用") } if resp.StatusCode < 200 || resp.StatusCode >= 300 { defer resp.Body.Close() return nil, c.parseError(resp) } return resp, nil } func (c *Client) doJSON(ctx context.Context, method string, path string, scope AdminScope, in any, out any) error { req, err := c.newRequest(ctx, method, path, scope, in) if err != nil { return err } resp, err := c.http.Do(req) if err != nil { return gerror.New("IM服务暂不可用") } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return c.parseError(resp) } if out == nil { return nil } body, 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(body, &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(body, out) } func (c *Client) newRequest(ctx context.Context, method string, path string, scope AdminScope, in any) (*http.Request, error) { if c.conf.BaseUrl == "" { return nil, gerror.New("IM管理API未配置") } if c.conf.ServiceSecret == "" { return nil, gerror.New("IM管理API密钥未配置") } var body io.Reader bodyBytes := []byte{} if in != nil { b, err := json.Marshal(in) if err != nil { return nil, gerror.Wrap(err, "序列化IM请求失败") } bodyBytes = b body = bytes.NewReader(bodyBytes) } baseUrl := strings.TrimRight(c.conf.BaseUrl, "/") req, err := http.NewRequestWithContext(ctx, method, baseUrl+path, body) if err != nil { return nil, gerror.Wrap(err, "创建IM请求失败") } if scope.RequestId == "" { scope.RequestId = gctx.CtxId(ctx) } timestamp := strconv.FormatInt(time.Now().UTC().Unix(), 10) nonce, err := randomNonce() if err != nil { return nil, err } bodyHash := sha256.Sum256(bodyBytes) canonical := strings.Join([]string{ method, requestTarget(req), c.conf.ServiceId, fmt.Sprintf("%d", scope.TenantId), fmt.Sprintf("%d", scope.OperatorId), timestamp, nonce, scope.RequestId, hex.EncodeToString(bodyHash[:]), }, "\n") mac := hmac.New(sha256.New, []byte(c.conf.ServiceSecret)) _, _ = mac.Write([]byte(canonical)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "IM-HMAC "+hex.EncodeToString(mac.Sum(nil))) req.Header.Set("X-IM-Service-ID", c.conf.ServiceId) req.Header.Set("X-IM-Tenant-ID", fmt.Sprintf("%d", scope.TenantId)) req.Header.Set("X-IM-Operator-ID", fmt.Sprintf("%d", scope.OperatorId)) req.Header.Set("X-IM-Timestamp", timestamp) req.Header.Set("X-IM-Nonce", nonce) req.Header.Set("X-HotGo-Role-Key", scope.RoleKey) req.Header.Set("X-Request-ID", scope.RequestId) return req, nil } func (c *Client) 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) if envelope.Message == "" && envelope.Reason == "" { g.Log().Warningf(context.Background(), "IM API returned non-success status:%d url:%s body:%s", resp.StatusCode, resp.Request.URL.String(), string(body)) } 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 hasInviteCode(in any) bool { b, err := json.Marshal(in) if err != nil { return false } var m map[string]any if err = json.Unmarshal(b, &m); err != nil { return false } v, ok := m["inviteCode"] if ok && strings.TrimSpace(fmt.Sprint(v)) != "" { return true } v, ok = m["invite_code"] return ok && strings.TrimSpace(fmt.Sprint(v)) != "" } func requestTarget(req *http.Request) string { target := req.URL.EscapedPath() if req.URL.RawQuery != "" { target += "?" + req.URL.RawQuery } return target } func randomNonce() (string, error) { buf := make([]byte, 16) if _, err := cryptorand.Read(buf); err != nil { return "", gerror.Wrap(err, "生成IM请求随机数失败") } return hex.EncodeToString(buf), nil } func toQuery(in any) string { b, err := json.Marshal(in) if err != nil { return "" } var m map[string]any if err = json.Unmarshal(b, &m); err != nil { return "" } values := url.Values{} for key, value := range m { if value == nil { continue } text := strings.TrimSpace(fmt.Sprint(value)) if text == "" || text == "0" || text == "" { continue } values.Set(key, text) } return values.Encode() }