Files
im_manager/server/internal/library/cultivateadmin/client.go
T

305 lines
8.8 KiB
Go

package cultivateadmin
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://cultivate-server.cultivate-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, "cultivateAdminApi.timeout", "5s").Duration()
if timeout <= 0 {
timeout = 5 * time.Second
}
baseURL := resolveValue(g.Cfg().MustGet(ctx, "cultivateAdminApi.baseUrl", defaultBaseURL).String())
if baseURL == "" {
baseURL = defaultBaseURL
}
secret := firstValue(
g.Cfg().MustGet(ctx, "cultivateAdminApi.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, "cultivateAdminApi.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, "Cultivate 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, "Cultivate 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 cultivateError(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/cultivate/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 cultivateError(ctx context.Context, status int, reason, message, requestID string) error {
if status == http.StatusUnauthorized || status == http.StatusForbidden {
g.Log().Warningf(ctx, "Cultivate admin auth failed status:%d reason:%s requestId:%s", status, reason, requestID)
return gerror.New("养成服务内部鉴权失败")
}
if status >= http.StatusInternalServerError {
g.Log().Warningf(ctx, "Cultivate 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
}