diff --git a/server/internal/library/maoyuadmin/client.go b/server/internal/library/maoyuadmin/client.go new file mode 100644 index 0000000..97b6775 --- /dev/null +++ b/server/internal/library/maoyuadmin/client.go @@ -0,0 +1,309 @@ +// 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 +} diff --git a/server/internal/library/maoyuadmin/client_test.go b/server/internal/library/maoyuadmin/client_test.go new file mode 100644 index 0000000..7474e7a --- /dev/null +++ b/server/internal/library/maoyuadmin/client_test.go @@ -0,0 +1,84 @@ +package maoyuadmin + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func TestClientSignsScopeAndTargetsMaoyuPrefix(t *testing.T) { + const secret = "test-secret" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + if r.URL.RequestURI() != "/internal/admin/maoyu/tenants/default/content-reviews?status=pending" { + t.Fatalf("request URI = %q", r.URL.RequestURI()) + } + if r.Header.Get("X-Tenant-ID") != "1" || r.Header.Get("X-Operator-ID") != "14" { + t.Fatalf("unexpected scope headers") + } + hash := sha256.Sum256(body) + canonical := strings.Join([]string{ + r.Method, + r.URL.RequestURI(), + hex.EncodeToString(hash[:]), + r.Header.Get("X-Timestamp"), + r.Header.Get("X-Nonce"), + "1", + "14", + "imManager", + "req-test", + }, "\n") + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(canonical)) + if !hmac.Equal([]byte(r.Header.Get("X-Signature")), []byte(hex.EncodeToString(mac.Sum(nil)))) { + t.Fatal("signature mismatch") + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, + "data": map[string]any{"post_count": 7, "pending_audit_count": 3}, + }) + })) + defer server.Close() + + client := NewClient(Config{BaseURL: server.URL, ServiceName: "im-manager", Secret: secret, Timeout: time.Second}) + client.http = server.Client() + var out struct { + PostCount int64 `json:"postCount"` + PendingAuditCount int64 `json:"pendingAuditCount"` + } + err := client.Get(context.Background(), Scope{TenantID: 1, TenantCode: "default", OperatorID: 14, RoleKey: "imManager", RequestID: "req-test"}, "/content-reviews", url.Values{"status": []string{"pending"}}, &out) + if err != nil { + t.Fatal(err) + } + if out.PostCount != 7 || out.PendingAuditCount != 3 { + t.Fatalf("unexpected response: %+v", out) + } +} + +func TestClientReturnsBusinessMessage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"code":40901,"reason":"STATE_CONFLICT","message":"当前状态不允许操作"}`)) + })) + defer server.Close() + + client := NewClient(Config{BaseURL: server.URL, ServiceName: "im-manager", Secret: "secret", Timeout: time.Second}) + client.http = server.Client() + err := client.Post(context.Background(), Scope{TenantID: 1, TenantCode: "default", OperatorID: 14, RequestID: "req-test"}, "/content-reviews/feed_post/1/actions", map[string]any{"action": "approve"}, nil) + if err == nil || err.Error() != "当前状态不允许操作" { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/server/manifest/config/config.example.yaml b/server/manifest/config/config.example.yaml index 582cd16..ea59534 100644 --- a/server/manifest/config/config.example.yaml +++ b/server/manifest/config/config.example.yaml @@ -324,6 +324,13 @@ imInternalApi: serviceName: "hotgo-admin" serviceSecret: "${IM_INTERNAL_API_SECRET}" +# 毛隅手记服务(maoyu_server)内部管理接口 +maoyuAdminApi: + baseUrl: "http://maoyu-server.maoyu-system.svc.cluster.local:8080" + serviceName: "hotgo-admin" + serviceSecret: "${MAOYU_INTERNAL_API_SECRET}" + timeout: "5s" + imWorkbenchApi: baseUrl: "http://im-workbench.workbench-system.svc.cluster.local:6061" serviceName: "hotgo-admin" diff --git a/server/manifest/config/config.template b/server/manifest/config/config.template index 7e35a1b..96ab376 100644 --- a/server/manifest/config/config.template +++ b/server/manifest/config/config.template @@ -321,6 +321,13 @@ imInternalApi: serviceName: "$IM_INTERNAL_API_SERVICE_NAME" serviceSecret: "$IM_INTERNAL_API_SECRET" +# 毛隅手记服务(maoyu_server)内部管理接口 +maoyuAdminApi: + baseUrl: "$MAOYU_ADMIN_API_BASE_URL" + serviceName: "$MAOYU_ADMIN_API_SERVICE_NAME" + serviceSecret: "$MAOYU_INTERNAL_API_SECRET" + timeout: "5s" + imWorkbenchApi: baseUrl: "$IM_WORKBENCH_API_BASE_URL" serviceName: "$IM_WORKBENCH_API_SERVICE_NAME"