feat: 新增毛隅手记服务内部管理接口客户端及配置
新增 maoyuAdminApi 配置段到模板和示例配置,实现 maoyuadmin 客户端,包含请求签名、响应解析等功能,配套单元测试
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -324,6 +324,13 @@ imInternalApi:
|
|||||||
serviceName: "hotgo-admin"
|
serviceName: "hotgo-admin"
|
||||||
serviceSecret: "${IM_INTERNAL_API_SECRET}"
|
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:
|
imWorkbenchApi:
|
||||||
baseUrl: "http://im-workbench.workbench-system.svc.cluster.local:6061"
|
baseUrl: "http://im-workbench.workbench-system.svc.cluster.local:6061"
|
||||||
serviceName: "hotgo-admin"
|
serviceName: "hotgo-admin"
|
||||||
|
|||||||
@@ -321,6 +321,13 @@ imInternalApi:
|
|||||||
serviceName: "$IM_INTERNAL_API_SERVICE_NAME"
|
serviceName: "$IM_INTERNAL_API_SERVICE_NAME"
|
||||||
serviceSecret: "$IM_INTERNAL_API_SECRET"
|
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:
|
imWorkbenchApi:
|
||||||
baseUrl: "$IM_WORKBENCH_API_BASE_URL"
|
baseUrl: "$IM_WORKBENCH_API_BASE_URL"
|
||||||
serviceName: "$IM_WORKBENCH_API_SERVICE_NAME"
|
serviceName: "$IM_WORKBENCH_API_SERVICE_NAME"
|
||||||
|
|||||||
Reference in New Issue
Block a user