// 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 }