269 lines
7.2 KiB
Go
269 lines
7.2 KiB
Go
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/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)
|
|
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 == "<nil>" {
|
|
continue
|
|
}
|
|
values.Set(key, text)
|
|
}
|
|
return values.Encode()
|
|
}
|