feat: add IM admin management

This commit is contained in:
richjohnrambo
2026-08-21 01:06:19 +08:00
parent 331a55751d
commit a2abaef3a6
290 changed files with 19917 additions and 337 deletions
+268
View File
@@ -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"))
}
}
+37
View File
@@ -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,
}
}