feat: add im kafka admin integration

This commit is contained in:
richjohnrambo
2026-08-28 00:05:54 +08:00
parent d53b0c1b62
commit 4779214591
75 changed files with 9053 additions and 185 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"))
}
}
+55
View File
@@ -0,0 +1,55 @@
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"`
InternalBaseUrl string `json:"internalBaseUrl"`
InternalServiceName string `json:"internalServiceName"`
InternalServiceSecret string `json:"internalServiceSecret"`
}
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")
}
internalSecret := g.Cfg().MustGet(ctx, "imInternalApi.serviceSecret").String()
if strings.HasPrefix(internalSecret, "${") && strings.HasSuffix(internalSecret, "}") {
internalSecret = os.Getenv(strings.TrimSuffix(strings.TrimPrefix(internalSecret, "${"), "}"))
}
if internalSecret == "" {
internalSecret = os.Getenv("IM_INTERNAL_API_SECRET")
}
if internalSecret == "" {
internalSecret = serviceSecret
}
baseUrl := g.Cfg().MustGet(ctx, "imAdminApi.baseUrl").String()
serviceId := g.Cfg().MustGet(ctx, "imAdminApi.serviceId", "hotgo-admin").String()
return Config{
BaseUrl: baseUrl,
ServiceId: serviceId,
ServiceSecret: serviceSecret,
Timeout: timeout,
InternalBaseUrl: g.Cfg().MustGet(ctx, "imInternalApi.baseUrl", baseUrl).String(),
InternalServiceName: g.Cfg().MustGet(ctx, "imInternalApi.serviceName", serviceId).String(),
InternalServiceSecret: internalSecret,
}
}
+101
View File
@@ -0,0 +1,101 @@
package imadmin
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/gogf/gf/v2/errors/gerror"
)
func (c *Client) InternalGet(ctx context.Context, path string, out any) error {
req, err := c.newInternalRequest(ctx, http.MethodGet, path, nil)
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) newInternalRequest(ctx context.Context, method string, path string, bodyBytes []byte) (*http.Request, error) {
baseUrl := strings.TrimRight(c.conf.InternalBaseUrl, "/")
if baseUrl == "" {
baseUrl = strings.TrimRight(c.conf.BaseUrl, "/")
}
if baseUrl == "" {
return nil, gerror.New("IM内部查询API未配置")
}
serviceName := strings.TrimSpace(c.conf.InternalServiceName)
if serviceName == "" {
serviceName = strings.TrimSpace(c.conf.ServiceId)
}
if serviceName == "" {
return nil, gerror.New("IM内部查询服务名未配置")
}
secret := c.conf.InternalServiceSecret
if secret == "" {
secret = c.conf.ServiceSecret
}
if secret == "" {
return nil, gerror.New("IM内部查询API密钥未配置")
}
req, err := http.NewRequestWithContext(ctx, method, baseUrl+path, bytes.NewReader(bodyBytes))
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(bodyBytes)
signingText := strings.Join([]string{
method,
requestTarget(req),
hex.EncodeToString(bodyHash[:]),
timestamp,
nonce,
}, "\n")
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(signingText))
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Service-Name", serviceName)
req.Header.Set("X-Timestamp", timestamp)
req.Header.Set("X-Nonce", nonce)
req.Header.Set("X-Signature", hex.EncodeToString(mac.Sum(nil)))
return req, nil
}
+135
View File
@@ -0,0 +1,135 @@
package iminternal
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
const (
headerService = "X-Service-Name"
headerTimestamp = "X-Timestamp"
headerNonce = "X-Nonce"
headerSignature = "X-Signature"
)
var nonces sync.Map
func Middleware(r *ghttp.Request) {
if !Authorize(r) {
return
}
r.Middleware.Next()
}
func Authorize(r *ghttp.Request) bool {
ctx := r.GetCtx()
serviceName := strings.TrimSpace(r.Header.Get(headerService))
timestampRaw := strings.TrimSpace(r.Header.Get(headerTimestamp))
nonce := strings.TrimSpace(r.Header.Get(headerNonce))
signature := strings.TrimSpace(r.Header.Get(headerSignature))
expectedService := g.Cfg().MustGet(ctx, "imConfigApi.serviceName", "im-server").String()
secret := configuredSecret(r)
if serviceName == "" || timestampRaw == "" || nonce == "" || signature == "" || serviceName != expectedService || secret == "" {
writeAuthError(r, http.StatusUnauthorized, "internal signature required")
return false
}
timestamp, err := strconv.ParseInt(timestampRaw, 10, 64)
if err != nil {
writeAuthError(r, http.StatusUnauthorized, "invalid internal timestamp")
return false
}
window := g.Cfg().MustGet(ctx, "imConfigApi.timeWindow", "300s").Duration()
if window <= 0 {
window = 5 * time.Minute
}
delta := time.Since(time.Unix(timestamp, 0))
if delta > window || delta < -window {
writeAuthError(r, http.StatusUnauthorized, "internal signature expired")
return false
}
body, err := readBody(r)
if err != nil {
writeAuthError(r, http.StatusInternalServerError, "read internal body failed")
return false
}
bodyHash := sha256.Sum256(body)
signingText := strings.Join([]string{
r.Method,
r.URL.RequestURI(),
hex.EncodeToString(bodyHash[:]),
timestampRaw,
nonce,
}, "\n")
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(signingText))
if !hmac.Equal([]byte(strings.ToLower(signature)), []byte(hex.EncodeToString(mac.Sum(nil)))) {
writeAuthError(r, http.StatusUnauthorized, "invalid internal signature")
return false
}
if !claimNonce(serviceName, nonce, time.Now().Add(window)) {
writeAuthError(r, http.StatusUnauthorized, "internal nonce replayed")
return false
}
return true
}
func configuredSecret(r *ghttp.Request) string {
ctx := r.GetCtx()
secret := g.Cfg().MustGet(ctx, "imConfigApi.serviceSecret").String()
if strings.HasPrefix(secret, "${") && strings.HasSuffix(secret, "}") {
secret = os.Getenv(strings.TrimSuffix(strings.TrimPrefix(secret, "${"), "}"))
}
if secret == "" {
secret = os.Getenv("IM_CONFIG_API_SECRET")
}
if secret == "" {
secret = g.Cfg().MustGet(ctx, "imInternalApi.serviceSecret").String()
}
return secret
}
func readBody(r *ghttp.Request) ([]byte, error) {
if r.Body == nil {
return nil, nil
}
body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
if err != nil {
return nil, err
}
r.Body = io.NopCloser(bytes.NewReader(body))
return body, nil
}
func claimNonce(serviceName string, nonce string, expiresAt time.Time) bool {
now := time.Now()
nonces.Range(func(key, value any) bool {
if expires, ok := value.(time.Time); ok && expires.Before(now) {
nonces.Delete(key)
}
return true
})
key := serviceName + ":" + nonce
_, loaded := nonces.LoadOrStore(key, expiresAt)
return !loaded
}
func writeAuthError(r *ghttp.Request, status int, message string) {
r.Response.WriteStatus(status)
r.Response.WriteJson(g.Map{
"code": status,
"message": message,
})
r.Exit()
}
+378
View File
@@ -0,0 +1,378 @@
package imkafka
import (
"context"
"encoding/json"
"hotgo/internal/consts"
"hotgo/internal/library/queue"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/gconv"
"github.com/gogf/gf/v2/util/guid"
)
const (
adminCommandTable = "im_admin_commands"
adminOutboxTable = "im_admin_outbox"
)
func PublishAdminCommand(ctx context.Context, in AdminCommandInput) (res *AdminCommandModel, err error) {
if in.TenantID <= 0 || in.TenantCode == "" {
return nil, gerror.New("IM租户信息不完整")
}
if in.EventType == "" {
return nil, gerror.New("IM命令类型不能为空")
}
if in.IdempotencyKey == "" {
in.IdempotencyKey = "cmd_" + guid.S()
}
commandID := "cmd_" + guid.S()
eventID := "evt_" + guid.S()
now := time.Now().UTC().Round(time.Millisecond)
payload := in.Payload
if payload == nil {
payload = map[string]any{}
}
payload["command_id"] = commandID
payloadBytes, err := json.Marshal(payload)
if err != nil {
return nil, gerror.Wrap(err, "序列化IM命令Payload失败")
}
envelope := Envelope{
EventID: eventID,
TraceID: gctx.CtxId(ctx),
TenantCode: in.TenantCode,
SourceService: consts.ImKafkaSourceService,
TargetService: consts.ImKafkaTargetService,
EventType: in.EventType,
BizType: in.BizType,
BizID: in.BizID,
IdempotencyKey: in.IdempotencyKey,
Version: in.Version,
OccurredAt: now,
Actor: in.Actor,
Payload: payloadBytes,
}
envelopeBytes, err := json.Marshal(envelope)
if err != nil {
return nil, gerror.Wrap(err, "序列化IM命令Envelope失败")
}
db := g.DB()
err = db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
data := g.Map{
"command_id": commandID,
"tenant_id": in.TenantID,
"tenant_code": in.TenantCode,
"event_id": eventID,
"event_type": in.EventType,
"biz_type": in.BizType,
"biz_id": in.BizID,
"idempotency_key": in.IdempotencyKey,
"status": consts.ImAdminCommandStatusPending,
"payload": string(payloadBytes),
"envelope": string(envelopeBytes),
"created_by": in.CreatedBy,
"created_at": gtime.Now(),
"updated_at": gtime.Now(),
}
if _, err := tx.Model(adminCommandTable).Safe().Ctx(ctx).Data(data).Insert(); err != nil {
return gerror.Wrap(err, "创建IM后台命令失败")
}
outbox := g.Map{
"event_id": eventID,
"command_id": commandID,
"topic": consts.QueueImAdminCommandTopic,
"event_type": in.EventType,
"biz_type": in.BizType,
"biz_id": in.BizID,
"key_value": in.BizID,
"envelope": string(envelopeBytes),
"status": consts.ImAdminOutboxStatusPending,
"created_at": gtime.Now(),
"updated_at": gtime.Now(),
}
if _, err := tx.Model(adminOutboxTable).Safe().Ctx(ctx).Data(outbox).Insert(); err != nil {
return gerror.Wrap(err, "创建IM后台命令Outbox失败")
}
return nil
})
if err != nil {
return nil, err
}
res = &AdminCommandModel{
CommandID: commandID,
EventID: eventID,
Status: consts.ImAdminCommandStatusPending,
TenantID: in.TenantID,
TenantCode: in.TenantCode,
EventType: in.EventType,
IdempotencyKey: in.IdempotencyKey,
}
if err = DispatchOutboxByEvent(ctx, eventID); err != nil {
return res, err
}
res.Status = consts.ImAdminCommandStatusRunning
return res, nil
}
func DispatchOutboxByEvent(ctx context.Context, eventID string) error {
var row gdb.Record
if err := g.DB().Model(adminOutboxTable).Safe().Ctx(ctx).
Where("event_id", eventID).
Where("status", consts.ImAdminOutboxStatusPending).
Scan(&row); err != nil {
return gerror.Wrap(err, "读取IM命令Outbox失败")
}
if row.IsEmpty() {
return nil
}
return dispatchOutboxRecord(ctx, row)
}
func PublishConfigEvent(ctx context.Context, in ConfigEventInput) error {
if in.EventType == "" {
return gerror.New("IM配置事件类型不能为空")
}
eventID := "evt_" + guid.S()
now := time.Now().UTC().Round(time.Millisecond)
payload := map[string]any{
"tenant_code": in.TenantCode,
"tenant_id": in.TenantID,
"group": in.Group,
"keys": in.Keys,
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return gerror.Wrap(err, "序列化IM配置事件Payload失败")
}
envelope := Envelope{
EventID: eventID,
TraceID: gctx.CtxId(ctx),
TenantCode: in.TenantCode,
SourceService: consts.ImKafkaSourceService,
TargetService: consts.ImKafkaTargetService,
EventType: in.EventType,
BizType: "config",
BizID: in.BizID,
OccurredAt: now,
Actor: in.Actor,
Payload: payloadBytes,
}
envelopeBytes, err := json.Marshal(envelope)
if err != nil {
return gerror.Wrap(err, "序列化IM配置事件Envelope失败")
}
_, err = g.DB().Model(adminOutboxTable).Safe().Ctx(ctx).Data(g.Map{
"event_id": eventID,
"command_id": "",
"topic": consts.QueueImConfigEventTopic,
"event_type": in.EventType,
"biz_type": "config",
"biz_id": in.BizID,
"key_value": in.TenantCode,
"envelope": string(envelopeBytes),
"status": consts.ImAdminOutboxStatusPending,
"created_at": gtime.Now(),
"updated_at": gtime.Now(),
}).Insert()
if err != nil {
return gerror.Wrap(err, "创建IM配置事件Outbox失败")
}
return DispatchOutboxByEvent(ctx, eventID)
}
func PublishEvent(ctx context.Context, in EventInput) (eventID string, err error) {
if in.Topic == "" {
return "", gerror.New("IM Kafka Topic不能为空")
}
if in.EventType == "" {
return "", gerror.New("IM Kafka事件类型不能为空")
}
eventID = "evt_" + guid.S()
now := time.Now().UTC().Round(time.Millisecond)
payload := in.Payload
if payload == nil {
payload = map[string]any{}
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return "", gerror.Wrap(err, "序列化IM Kafka事件Payload失败")
}
targetService := in.TargetService
if targetService == "" {
targetService = consts.ImKafkaTargetService
}
envelope := Envelope{
EventID: eventID,
TraceID: gctx.CtxId(ctx),
TenantCode: in.TenantCode,
SourceService: consts.ImKafkaSourceService,
TargetService: targetService,
EventType: in.EventType,
BizType: in.BizType,
BizID: in.BizID,
IdempotencyKey: in.IdempotencyKey,
Version: in.Version,
OccurredAt: now,
Actor: in.Actor,
Payload: payloadBytes,
}
envelopeBytes, err := json.Marshal(envelope)
if err != nil {
return "", gerror.Wrap(err, "序列化IM Kafka事件Envelope失败")
}
_, err = g.DB().Model(adminOutboxTable).Safe().Ctx(ctx).Data(g.Map{
"event_id": eventID,
"command_id": "",
"topic": in.Topic,
"event_type": in.EventType,
"biz_type": in.BizType,
"biz_id": in.BizID,
"key_value": in.BizID,
"envelope": string(envelopeBytes),
"status": consts.ImAdminOutboxStatusPending,
"created_at": gtime.Now(),
"updated_at": gtime.Now(),
}).Insert()
if err != nil {
return "", gerror.Wrap(err, "创建IM Kafka事件Outbox失败")
}
if err = DispatchOutboxByEvent(ctx, eventID); err != nil {
return eventID, err
}
return eventID, nil
}
func DispatchPendingOutbox(ctx context.Context, limit int) (sent int, err error) {
if limit <= 0 {
limit = 100
}
var rows []gdb.Record
err = g.DB().Model(adminOutboxTable).Safe().Ctx(ctx).
WhereIn("status", []string{consts.ImAdminOutboxStatusPending, consts.ImAdminOutboxStatusFailed}).
Where("next_retry_at IS NULL OR next_retry_at <= NOW()").
OrderAsc("id").
Limit(limit).
Scan(&rows)
if err != nil {
return 0, gerror.Wrap(err, "读取待投递IM命令Outbox失败")
}
for _, row := range rows {
if err = dispatchOutboxRecord(ctx, row); err != nil {
g.Log().Warningf(ctx, "IM命令Outbox投递失败 event:%s err:%+v", row["event_id"].String(), err)
continue
}
sent++
}
return sent, nil
}
func dispatchOutboxRecord(ctx context.Context, row gdb.Record) error {
eventID := row["event_id"].String()
commandID := row["command_id"].String()
topic := row["topic"].String()
body := row["envelope"].String()
if topic == "" || body == "" {
return markOutboxFailed(ctx, eventID, "MALFORMED_OUTBOX", "topic or envelope is empty")
}
if g.Cfg().MustGet(ctx, "queue.driver").String() != "kafka" {
err := gerror.New("IM Kafka未启用,请将queue.driver配置为kafka")
_ = markOutboxFailed(ctx, eventID, "KAFKA_DISABLED", err.Error())
return err
}
if err := queue.Push(topic, body); err != nil {
_ = markOutboxFailed(ctx, eventID, "KAFKA_SEND_FAILED", err.Error())
return err
}
now := gtime.Now()
_, err := g.DB().Model(adminOutboxTable).Safe().Ctx(ctx).
Where("event_id", eventID).
Data(g.Map{
"status": consts.ImAdminOutboxStatusSent,
"attempts": gdb.Raw("attempts + 1"),
"sent_at": now,
"last_reason": "",
"last_error": "",
"updated_at": now,
}).Update()
if err != nil {
return gerror.Wrap(err, "更新IM命令Outbox投递状态失败")
}
if commandID != "" {
_, err = g.DB().Model(adminCommandTable).Safe().Ctx(ctx).
Where("command_id", commandID).
Data(g.Map{
"status": consts.ImAdminCommandStatusRunning,
"sent_at": now,
"updated_at": now,
}).Update()
if err != nil {
return gerror.Wrap(err, "更新IM后台命令投递状态失败")
}
}
return nil
}
func markOutboxFailed(ctx context.Context, eventID string, reason string, message string) error {
now := gtime.Now()
_, err := g.DB().Model(adminOutboxTable).Safe().Ctx(ctx).
Where("event_id", eventID).
Data(g.Map{
"status": consts.ImAdminOutboxStatusFailed,
"attempts": gdb.Raw("attempts + 1"),
"next_retry_at": now.Add(30 * time.Second),
"last_reason": reason,
"last_error": message,
"updated_at": now,
}).Update()
if err != nil {
return gerror.Wrap(err, "更新IM命令Outbox失败状态失败")
}
return nil
}
func ApplyCommandResult(ctx context.Context, result CommandResult) error {
commandID := result.CommandID
if commandID == "" {
commandID = result.CommandEventID
}
if commandID == "" && result.CommandEventID == "" {
return gerror.New("IM命令结果缺少command_id")
}
status := gconv.String(result.Status)
switch status {
case consts.ImAdminCommandStatusSucceeded, consts.ImAdminCommandStatusFailed, consts.ImAdminCommandStatusDead:
default:
status = consts.ImAdminCommandStatusFailed
}
data := g.Map{
"status": status,
"reason": result.Reason,
"message": result.Message,
"completed_at": gtime.Now(),
"updated_at": gtime.Now(),
}
if len(result.Result) > 0 {
data["result"] = string(result.Result)
}
mod := g.DB().Model(adminCommandTable).Safe().Ctx(ctx)
if result.CommandID != "" {
mod = mod.Where("command_id", result.CommandID)
} else {
mod = mod.Where("event_id", result.CommandEventID)
}
_, err := mod.Data(data).Update()
if err != nil {
return gerror.Wrap(err, "更新IM后台命令结果失败")
}
return nil
}
@@ -0,0 +1,90 @@
package imkafka
import (
"encoding/json"
"time"
)
type Actor struct {
Type string `json:"type,omitempty"`
ID string `json:"id,omitempty"`
DisplayName string `json:"display_name,omitempty"`
}
type Envelope struct {
EventID string `json:"event_id"`
TraceID string `json:"trace_id,omitempty"`
TenantCode string `json:"tenant_code,omitempty"`
SourceService string `json:"source_service,omitempty"`
TargetService string `json:"target_service,omitempty"`
EventType string `json:"event_type"`
BizType string `json:"biz_type,omitempty"`
BizID string `json:"biz_id,omitempty"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
Version int64 `json:"version,omitempty"`
OccurredAt time.Time `json:"occurred_at"`
Actor *Actor `json:"actor,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
ParentEventID string `json:"parent_event_id,omitempty"`
}
type CommandResult struct {
EventID string `json:"event_id"`
TraceID string `json:"trace_id,omitempty"`
TenantCode string `json:"tenant_code,omitempty"`
CommandID string `json:"command_id"`
CommandEventID string `json:"command_event_id"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
OccurredAt time.Time `json:"occurred_at"`
}
type AdminCommandInput struct {
TenantID int64
TenantCode string
EventType string
BizType string
BizID string
IdempotencyKey string
Version int64
Actor *Actor
Payload map[string]any
CreatedBy int64
}
type ConfigEventInput struct {
TenantID int64
TenantCode string
EventType string
BizID string
Group string
Keys []string
Actor *Actor
CreatedBy int64
}
type EventInput struct {
Topic string
TenantCode string
TargetService string
EventType string
BizType string
BizID string
IdempotencyKey string
Version int64
Actor *Actor
Payload map[string]any
CreatedBy int64
}
type AdminCommandModel struct {
CommandID string `json:"commandId" dc:"命令ID"`
EventID string `json:"eventId" dc:"Kafka事件ID"`
Status string `json:"status" dc:"状态"`
TenantID int64 `json:"tenantId" dc:"租户ID"`
TenantCode string `json:"tenantCode" dc:"租户编码"`
EventType string `json:"eventType" dc:"事件类型"`
IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"`
}
+24
View File
@@ -21,6 +21,8 @@ var tenantDbGroups sync.Map
const registryDbGroup = "imRegistry"
var registryDbGroupOnce sync.Once
// DB resolves a tenant registry record and returns the corresponding business DB.
func DB(ctx context.Context, tenantId int64) (db gdb.DB, tenant *entity.ImTenant, err error) {
if tenantId <= 0 {
@@ -60,12 +62,34 @@ func DB(ctx context.Context, tenantId int64) (db gdb.DB, tenant *entity.ImTenant
// RegistryDB returns the dedicated tenant registry connection, or the default
// HotGo database when a separate registry group is not configured.
func RegistryDB() gdb.DB {
registryDbGroupOnce.Do(func() {
ensureRegistryDBGroup()
})
if configs, err := gdb.GetConfigGroup(registryDbGroup); err == nil && len(configs) > 0 {
return g.DB(registryDbGroup)
}
return g.DB()
}
func ensureRegistryDBGroup() {
if configs, err := gdb.GetConfigGroup(registryDbGroup); err == nil && len(configs) > 0 {
configs[0].Prefix = ""
_ = gdb.SetConfigGroup(registryDbGroup, configs)
return
}
configs, err := gdb.GetConfigGroup("default")
if err != nil || len(configs) == 0 {
return
}
cloned := make(gdb.ConfigGroup, len(configs))
copy(cloned, configs)
for i := range cloned {
cloned[i].Prefix = ""
}
_ = gdb.SetConfigGroup(registryDbGroup, cloned)
}
func tenantLink(ctx context.Context, tenant *entity.ImTenant) (string, error) {
if tenant.DbDsn != "" {
return normalizeMySQLTimeOptions(tenant.DbDsn)