Files
im_manager/server/internal/library/maoyuadmin/client.go
T
Austin 8d6618dac1 feat: 新增毛隅手记服务内部管理接口客户端及配置
新增 maoyuAdminApi 配置段到模板和示例配置,实现 maoyuadmin 客户端,包含请求签名、响应解析等功能,配套单元测试
2026-10-12 17:40:59 +08:00

310 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package maoyuadmin 是 im_manager 调用毛隅手记服务(maoyu_server)内部管理接口的客户端。
//
// 与 cultivateadmin 相互独立:路径前缀固定为 /internal/admin/maoyu/tenants/{tenant_code},
// 配置段为 maoyuAdminApi,密钥优先使用 maoyuAdminApi.serviceSecret,
// 未配置时回落到与 imInternalApi 共享的密钥。
package maoyuadmin
import (
"bytes"
"context"
"crypto/hmac"
cryptorand "crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"unicode"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
)
const (
defaultBaseURL = "http://maoyu-server.maoyu-system.svc.cluster.local:8080"
maxBodySize = 10 << 20
)
type Config struct {
BaseURL string
ServiceName string
Secret string
Timeout time.Duration
}
type Scope struct {
TenantID int64
TenantCode string
OperatorID int64
RoleKey string
RequestID string
}
type Client struct {
conf Config
http *http.Client
}
type Page[T any] struct {
Items []T `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PerPage int `json:"perPage"`
}
func DefaultClient(ctx context.Context) *Client {
timeout := g.Cfg().MustGet(ctx, "maoyuAdminApi.timeout", "5s").Duration()
if timeout <= 0 {
timeout = 5 * time.Second
}
baseURL := resolveValue(g.Cfg().MustGet(ctx, "maoyuAdminApi.baseUrl", defaultBaseURL).String())
if baseURL == "" {
baseURL = defaultBaseURL
}
secret := firstValue(
g.Cfg().MustGet(ctx, "maoyuAdminApi.serviceSecret").String(),
g.Cfg().MustGet(ctx, "imInternalApi.serviceSecret").String(),
os.Getenv("IM_INTERNAL_API_SECRET"),
g.Cfg().MustGet(ctx, "imAdminApi.serviceSecret").String(),
os.Getenv("IM_ADMIN_API_SECRET"),
)
return NewClient(Config{
BaseURL: baseURL,
ServiceName: firstValue(g.Cfg().MustGet(ctx, "maoyuAdminApi.serviceName", "im-manager").String(), "im-manager"),
Secret: secret,
Timeout: timeout,
})
}
func NewClient(conf Config) *Client {
return &Client{conf: conf, http: &http.Client{Timeout: conf.Timeout}}
}
func (c *Client) Get(ctx context.Context, scope Scope, path string, values url.Values, out any) error {
if values != nil {
if query := values.Encode(); query != "" {
path += "?" + query
}
}
return c.doJSON(ctx, http.MethodGet, scope, path, nil, out)
}
func (c *Client) Post(ctx context.Context, scope Scope, path string, in any, out any) error {
return c.write(ctx, http.MethodPost, scope, path, in, out)
}
func (c *Client) Put(ctx context.Context, scope Scope, path string, in any, out any) error {
return c.write(ctx, http.MethodPut, scope, path, in, out)
}
func (c *Client) write(ctx context.Context, method string, scope Scope, path string, in any, out any) error {
body, err := json.Marshal(in)
if err != nil {
return gerror.Wrap(err, "序列化毛隅手记服务请求失败")
}
return c.doJSON(ctx, method, scope, path, body, out)
}
func (c *Client) doJSON(ctx context.Context, method string, scope Scope, path string, body []byte, out any) error {
req, err := c.newRequest(ctx, method, scope, path, body)
if err != nil {
return err
}
resp, err := c.http.Do(req)
if err != nil {
g.Log().Warningf(ctx, "Maoyu admin request failed url:%s err:%+v", req.URL.String(), err)
return gerror.New("毛隅手记服务暂不可用")
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize))
if err != nil {
return gerror.Wrap(err, "读取毛隅手记服务响应失败")
}
var envelope struct {
Code int `json:"code"`
Reason string `json:"reason"`
Message string `json:"message"`
RequestID string `json:"request_id"`
Data json.RawMessage `json:"data"`
}
if err = json.Unmarshal(raw, &envelope); err != nil {
g.Log().Warningf(ctx, "Maoyu admin invalid response status:%d requestId:%s", resp.StatusCode, scope.RequestID)
return gerror.New("毛隅手记服务返回异常")
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices || envelope.Code != 0 {
return maoyuError(ctx, resp.StatusCode, envelope.Reason, envelope.Message, envelope.RequestID)
}
if out == nil || len(envelope.Data) == 0 || bytes.Equal(envelope.Data, []byte("null")) {
return nil
}
if err = unmarshalSnakeCase(envelope.Data, out); err != nil {
return gerror.Wrap(err, "解析毛隅手记服务响应失败")
}
return nil
}
func (c *Client) newRequest(ctx context.Context, method string, scope Scope, path string, body []byte) (*http.Request, error) {
baseURL := strings.TrimRight(resolveValue(c.conf.BaseURL), "/")
secret := resolveValue(c.conf.Secret)
if baseURL == "" {
return nil, gerror.New("毛隅手记服务地址未配置")
}
if secret == "" {
return nil, gerror.New("毛隅手记服务内部鉴权密钥未配置")
}
if scope.TenantID <= 0 || strings.TrimSpace(scope.TenantCode) == "" || scope.OperatorID <= 0 {
return nil, gerror.New("毛隅手记服务管理范围不正确")
}
requestID := strings.TrimSpace(scope.RequestID)
if requestID == "" {
requestID = strings.TrimSpace(gctx.CtxId(ctx))
}
nonce, err := randomNonce()
if err != nil {
return nil, err
}
if requestID == "" {
requestID = "manager-" + nonce
}
prefix := "/internal/admin/maoyu/tenants/" + url.PathEscape(strings.TrimSpace(scope.TenantCode))
req, err := http.NewRequestWithContext(ctx, method, baseURL+prefix+path, bytes.NewReader(body))
if err != nil {
return nil, gerror.Wrap(err, "创建毛隅手记服务请求失败")
}
timestamp := strconv.FormatInt(time.Now().UTC().Unix(), 10)
tenantID := strconv.FormatInt(scope.TenantID, 10)
operatorID := strconv.FormatInt(scope.OperatorID, 10)
bodyHash := sha256.Sum256(body)
canonical := strings.Join([]string{
method,
req.URL.RequestURI(),
hex.EncodeToString(bodyHash[:]),
timestamp,
nonce,
tenantID,
operatorID,
scope.RoleKey,
requestID,
}, "\n")
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(canonical))
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Service-Name", firstValue(c.conf.ServiceName, "im-manager"))
req.Header.Set("X-Timestamp", timestamp)
req.Header.Set("X-Nonce", nonce)
req.Header.Set("X-Signature", hex.EncodeToString(mac.Sum(nil)))
req.Header.Set("X-Tenant-ID", tenantID)
req.Header.Set("X-Operator-ID", operatorID)
req.Header.Set("X-Role-Key", scope.RoleKey)
req.Header.Set("X-Request-ID", requestID)
return req, nil
}
func maoyuError(ctx context.Context, status int, reason, message, requestID string) error {
if status == http.StatusUnauthorized || status == http.StatusForbidden {
g.Log().Warningf(ctx, "Maoyu admin auth failed status:%d reason:%s requestId:%s", status, reason, requestID)
return gerror.New("毛隅手记服务内部鉴权失败")
}
if status >= http.StatusInternalServerError {
g.Log().Warningf(ctx, "Maoyu admin failed status:%d reason:%s requestId:%s", status, reason, requestID)
if status == http.StatusServiceUnavailable && message != "" {
return gerror.New(message)
}
return gerror.New("毛隅手记服务暂不可用")
}
if strings.TrimSpace(message) != "" {
return gerror.New(message)
}
if strings.TrimSpace(reason) != "" {
return gerror.New(reason)
}
return gerror.New("毛隅手记服务请求失败")
}
func unmarshalSnakeCase(raw []byte, out any) error {
var value any
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
if err := decoder.Decode(&value); err != nil {
return err
}
converted, err := json.Marshal(convertKeys(value))
if err != nil {
return err
}
return json.Unmarshal(converted, out)
}
func convertKeys(value any) any {
switch typed := value.(type) {
case map[string]any:
result := make(map[string]any, len(typed))
for key, item := range typed {
result[snakeToLowerCamel(key)] = convertKeys(item)
}
return result
case []any:
result := make([]any, len(typed))
for index, item := range typed {
result[index] = convertKeys(item)
}
return result
default:
return value
}
}
func snakeToLowerCamel(value string) string {
parts := strings.Split(value, "_")
if len(parts) == 1 {
return value
}
var builder strings.Builder
builder.WriteString(parts[0])
for _, part := range parts[1:] {
if part == "" {
continue
}
runes := []rune(part)
builder.WriteRune(unicode.ToUpper(runes[0]))
builder.WriteString(string(runes[1:]))
}
return builder.String()
}
func randomNonce() (string, error) {
var value [18]byte
if _, err := cryptorand.Read(value[:]); err != nil {
return "", gerror.Wrap(err, "生成毛隅手记服务请求随机数失败")
}
return hex.EncodeToString(value[:]), nil
}
func firstValue(values ...string) string {
for _, value := range values {
if resolved := resolveValue(value); resolved != "" {
return resolved
}
}
return ""
}
func resolveValue(value string) string {
value = strings.TrimSpace(value)
if strings.HasPrefix(value, "${") && strings.HasSuffix(value, "}") {
return strings.TrimSpace(os.Getenv(strings.TrimSuffix(strings.TrimPrefix(value, "${"), "}")))
}
if strings.HasPrefix(value, "$") && !strings.Contains(value, "://") {
return strings.TrimSpace(os.Getenv(strings.TrimPrefix(value, "$")))
}
return value
}