feat: add IM admin management
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package imadmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type testInviteListRequest struct {
|
||||
Page int `json:"page,omitempty"`
|
||||
PerPage int `json:"per_page,omitempty"`
|
||||
InviteCode string `json:"invite_code,omitempty"`
|
||||
BatchID int64 `json:"batch_id,omitempty"`
|
||||
}
|
||||
|
||||
func TestListCodesUsesSignedGetWithSnakeQuery(t *testing.T) {
|
||||
server := signedTestServer(t, http.MethodGet, "/admin/v1/im/registration-invite-codes", func(t *testing.T, r *http.Request, body []byte) {
|
||||
if len(body) != 0 {
|
||||
t.Fatalf("GET list should not send body, got %q", string(body))
|
||||
}
|
||||
query := r.URL.Query()
|
||||
if query.Get("page") != "2" || query.Get("per_page") != "20" || query.Get("batch_id") != "123" {
|
||||
t.Fatalf("unexpected query: %s", r.URL.RawQuery)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Config{BaseUrl: server.URL, ServiceId: "hotgo-admin", ServiceSecret: "secret", Timeout: time.Second})
|
||||
var out Page[struct{}]
|
||||
err := client.ListCodes(context.Background(), AdminScope{TenantId: 9, OperatorId: 7, RequestId: "req-1"}, testInviteListRequest{Page: 2, PerPage: 20, BatchID: 123}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCodes returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCodesWithInviteCodeUsesSignedSearchPost(t *testing.T) {
|
||||
server := signedTestServer(t, http.MethodPost, "/admin/v1/im/registration-invite-codes/search", func(t *testing.T, r *http.Request, body []byte) {
|
||||
if !strings.Contains(string(body), `"invite_code":"Ab123"`) {
|
||||
t.Fatalf("POST search should send snake_case invite code, got %s", string(body))
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Config{BaseUrl: server.URL, ServiceId: "hotgo-admin", ServiceSecret: "secret", Timeout: time.Second})
|
||||
var out Page[struct{}]
|
||||
err := client.ListCodes(context.Background(), AdminScope{TenantId: 9, OperatorId: 7, RequestId: "req-2"}, testInviteListRequest{Page: 1, PerPage: 20, InviteCode: "Ab123"}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCodes returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func signedTestServer(t *testing.T, method string, path string, inspect func(t *testing.T, r *http.Request, body []byte)) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != method {
|
||||
t.Fatalf("method = %s, want %s", r.Method, method)
|
||||
}
|
||||
if r.URL.Path != path {
|
||||
t.Fatalf("path = %s, want %s", r.URL.Path, path)
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
inspect(t, r, body)
|
||||
assertSignedRequest(t, r, body, "secret")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":{"items":[],"page":1,"per_page":20,"total":0}}`))
|
||||
}))
|
||||
}
|
||||
|
||||
func assertSignedRequest(t *testing.T, r *http.Request, body []byte, secret string) {
|
||||
t.Helper()
|
||||
for _, name := range []string{"Authorization", "X-IM-Service-ID", "X-IM-Tenant-ID", "X-IM-Operator-ID", "X-IM-Timestamp", "X-IM-Nonce", "X-Request-ID"} {
|
||||
if r.Header.Get(name) == "" {
|
||||
t.Fatalf("missing header %s", name)
|
||||
}
|
||||
}
|
||||
if _, err := strconv.ParseInt(r.Header.Get("X-IM-Timestamp"), 10, 64); err != nil {
|
||||
t.Fatalf("timestamp is not unix seconds: %v", err)
|
||||
}
|
||||
bodyHash := sha256.Sum256(body)
|
||||
canonical := strings.Join([]string{
|
||||
r.Method,
|
||||
r.URL.RequestURI(),
|
||||
r.Header.Get("X-IM-Service-ID"),
|
||||
r.Header.Get("X-IM-Tenant-ID"),
|
||||
r.Header.Get("X-IM-Operator-ID"),
|
||||
r.Header.Get("X-IM-Timestamp"),
|
||||
r.Header.Get("X-IM-Nonce"),
|
||||
r.Header.Get("X-Request-ID"),
|
||||
hex.EncodeToString(bodyHash[:]),
|
||||
}, "\n")
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(canonical))
|
||||
expected := fmt.Sprintf("IM-HMAC %s", hex.EncodeToString(mac.Sum(nil)))
|
||||
if r.Header.Get("Authorization") != expected {
|
||||
t.Fatalf("bad signature\nwant: %s\n got: %s", expected, r.Header.Get("Authorization"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package imadmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
BaseUrl string `json:"baseUrl"`
|
||||
ServiceId string `json:"serviceId"`
|
||||
ServiceSecret string `json:"serviceSecret"`
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
}
|
||||
|
||||
func LoadConfig(ctx context.Context) Config {
|
||||
timeout := g.Cfg().MustGet(ctx, "imAdminApi.timeout", "5s").Duration()
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
serviceSecret := g.Cfg().MustGet(ctx, "imAdminApi.serviceSecret").String()
|
||||
if strings.HasPrefix(serviceSecret, "${") && strings.HasSuffix(serviceSecret, "}") {
|
||||
serviceSecret = os.Getenv(strings.TrimSuffix(strings.TrimPrefix(serviceSecret, "${"), "}"))
|
||||
}
|
||||
if serviceSecret == "" {
|
||||
serviceSecret = os.Getenv("IM_ADMIN_API_SECRET")
|
||||
}
|
||||
return Config{
|
||||
BaseUrl: g.Cfg().MustGet(ctx, "imAdminApi.baseUrl").String(),
|
||||
ServiceId: g.Cfg().MustGet(ctx, "imAdminApi.serviceId", "hotgo-admin").String(),
|
||||
ServiceSecret: serviceSecret,
|
||||
Timeout: timeout,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user