Files
im_server/server/admin_internal_config.go
T
Austin 7829990180 feat: 添加联系我们配置接口和字典配置缓存
1. 新增DictionaryItem字典配置结构体
2. 添加字典配置缓存及失效逻辑
3. 实现联系我们接口的业务逻辑和测试用例
4. 注册联系我们API路由
5. 集成字典配置到admin内部客户端
6. 处理字典配置更新的kafka事件
2026-09-21 13:11:42 +08:00

434 lines
13 KiB
Go

package main
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
type adminInternalAPIConfig struct {
Enabled bool `json:"enabled"`
BaseURL string `json:"base_url"`
ServiceName string `json:"service_name"`
Secret string `json:"secret"`
TimeoutMS int `json:"timeout_ms"`
}
type adminInternalClient struct {
config adminInternalAPIConfig
http *http.Client
}
type tenantLoginIPWhitelistConfig struct {
TenantID types.TenantID `json:"tenant_id"`
AppList []string `json:"app_list"`
WebList []string `json:"web_list"`
}
type tenantLoginIPWhitelistProvider interface {
TenantLoginIPWhitelist(id types.TenantID) (*tenantLoginIPWhitelistConfig, error)
}
var loginIPWhitelistProvider tenantLoginIPWhitelistProvider
type adminInternalTenantDTO struct {
ID types.TenantID `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
TenantDesc *string `json:"tenant_desc,omitempty"`
DBName string `json:"db_name"`
DBDSN string `json:"db_dsn"`
State types.TenantState `json:"state"`
CreatedAt time.Time `json:"created_at"`
CreatedBy int64 `json:"created_by"`
UpdatedAt time.Time `json:"updated_at"`
UpdatedBy int64 `json:"updated_by"`
}
func initAdminInternalAPI(raw json.RawMessage) (*adminInternalClient, error) {
store.SetAdminInternalConfigProvider(nil)
loginIPWhitelistProvider = nil
if len(raw) == 0 || string(raw) == "null" {
return nil, nil
}
var cfg adminInternalAPIConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return nil, err
}
cfg.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
cfg.ServiceName = strings.TrimSpace(cfg.ServiceName)
if cfg.ServiceName == "" {
cfg.ServiceName = "im-server"
}
if cfg.TimeoutMS <= 0 {
cfg.TimeoutMS = 3000
}
if !cfg.Enabled {
return nil, nil
}
if cfg.BaseURL == "" || strings.TrimSpace(cfg.Secret) == "" {
return nil, errors.New("admin_internal_api enabled but base_url or secret is empty")
}
client := &adminInternalClient{
config: cfg,
http: &http.Client{Timeout: time.Duration(cfg.TimeoutMS) * time.Millisecond},
}
store.SetAdminInternalConfigProvider(client)
loginIPWhitelistProvider = client
go store.WarmMessageEncryptionKeys()
logs.Info.Printf("Admin internal config client enabled: base_url=%s service=%s", cfg.BaseURL, cfg.ServiceName)
return client, nil
}
func (c *adminInternalClient) GetTenantByCode(code string) (*types.Tenant, error) {
var tenant adminInternalTenantDTO
if err := c.getJSON("/internal/im-config/tenants/by-code/"+url.PathEscape(strings.TrimSpace(code)), nil, &tenant); err != nil {
return nil, err
}
return tenant.toStoreTenant(), nil
}
func (c *adminInternalClient) GetTenantByID(id types.TenantID) (*types.Tenant, error) {
if !id.IsValid() {
return nil, types.ErrMalformed
}
var tenant adminInternalTenantDTO
if err := c.getJSON("/internal/im-config/tenants/by-id/"+strconv.FormatInt(int64(id), 10), nil, &tenant); err != nil {
return nil, err
}
return tenant.toStoreTenant(), nil
}
func (c *adminInternalClient) ListActiveTenants(limit int) ([]types.Tenant, error) {
query := url.Values{}
query.Set("state", "active")
if limit > 0 {
query.Set("limit", strconv.Itoa(limit))
}
var wrapper struct {
Items []adminInternalTenantDTO `json:"items"`
}
if err := c.getJSON("/internal/im-config/tenants", query, &wrapper); err != nil {
return nil, err
}
tenants := make([]types.Tenant, 0, len(wrapper.Items))
for _, item := range wrapper.Items {
if tenant := item.toStoreTenant(); tenant != nil {
tenants = append(tenants, *tenant)
}
}
return tenants, nil
}
func (c *adminInternalClient) RegistrationSMSEnabled() (bool, error) {
var wrapper struct {
Enabled bool `json:"enabled"`
}
if err := c.getJSON("/internal/im-config/registration/sms", nil, &wrapper); err != nil {
return true, err
}
return wrapper.Enabled, nil
}
func (c *adminInternalClient) ThirdPartyConfig(group string, keys ...string) (map[string]string, error) {
query := url.Values{}
normalized := make([]string, 0, len(keys))
for _, key := range keys {
if key = strings.TrimSpace(key); key != "" {
normalized = append(normalized, key)
}
}
if len(normalized) > 0 {
query.Set("keys", strings.Join(normalized, ","))
}
var wrapper struct {
Values map[string]string `json:"values"`
}
if err := c.getJSON("/internal/im-config/third-party/"+url.PathEscape(strings.TrimSpace(group)), query, &wrapper); err != nil {
return nil, err
}
if wrapper.Values == nil {
wrapper.Values = map[string]string{}
}
return wrapper.Values, nil
}
func (c *adminInternalClient) DictionaryConfig(dictType string) ([]types.DictionaryItem, error) {
var wrapper struct {
Items []types.DictionaryItem `json:"items"`
}
if err := c.getJSON("/internal/im-config/dictionary/"+url.PathEscape(strings.TrimSpace(dictType)), nil, &wrapper); err != nil {
return nil, err
}
if wrapper.Items == nil {
wrapper.Items = []types.DictionaryItem{}
}
return wrapper.Items, nil
}
func (c *adminInternalClient) ModerationProvider() (*types.ModerationProviderConfig, error) {
var result types.ModerationProviderConfig
if err := c.getJSON("/internal/im-config/moderation/provider", nil, &result); err != nil {
return nil, err
}
return &result, nil
}
func (c *adminInternalClient) ModerationPolicy(tenantCode string) (*types.ModerationPolicy, error) {
var result types.ModerationPolicy
path := "/internal/im-config/tenants/" + url.PathEscape(strings.TrimSpace(tenantCode)) + "/moderation-policy"
if err := c.getJSON(path, nil, &result); err != nil {
return nil, err
}
return &result, nil
}
func (c *adminInternalClient) SensitiveWords(tenantCode string) (*types.SensitiveWordSet, error) {
var result types.SensitiveWordSet
path := "/internal/im-config/tenants/" + url.PathEscape(strings.TrimSpace(tenantCode)) + "/sensitive-words"
if err := c.getJSON(path, nil, &result); err != nil {
return nil, err
}
if result.Items == nil {
result.Items = []*types.SensitiveWordRule{}
}
return &result, nil
}
func (c *adminInternalClient) SecurityPolicy(tenantCode string) (*types.SecurityPolicy, error) {
var result types.SecurityPolicy
path := "/internal/im-config/tenants/" + url.PathEscape(strings.TrimSpace(tenantCode)) + "/security-policy"
if err := c.getJSON(path, nil, &result); err != nil {
return nil, err
}
if result.FriendDisabledPolicy == nil {
result.FriendDisabledPolicy = map[string]any{}
}
if result.CurfewRules == nil {
result.CurfewRules = []*types.MessageCurfewRule{}
}
return &result, nil
}
func (c *adminInternalClient) MessageKey(tenantCode string, version int) (*types.MessageKey, error) {
path := "/internal/im-config/tenants/" + url.PathEscape(strings.TrimSpace(tenantCode)) + "/message-keys/active"
if version > 0 {
path = "/internal/im-config/tenants/" + url.PathEscape(strings.TrimSpace(tenantCode)) + "/message-keys/" + strconv.Itoa(version)
}
var result types.MessageKey
if err := c.getJSON(path, nil, &result); err != nil {
return nil, err
}
return &result, nil
}
func (c *adminInternalClient) MessageKeyAck(tenantCode string, version int) error {
if version <= 0 {
return types.ErrMalformed
}
path := "/internal/im-config/tenants/" + url.PathEscape(strings.TrimSpace(tenantCode)) + "/message-keys/ack"
return c.doJSON(http.MethodPost, path, nil, map[string]int{"version": version}, nil)
}
func (c *adminInternalClient) RealNamePolicy(id types.TenantID) (*types.RealNamePolicy, error) {
if !id.IsValid() {
return nil, types.ErrMalformed
}
var result types.RealNamePolicy
path := "/internal/im-config/tenants/by-id/" + strconv.FormatInt(int64(id), 10) + "/realname-policy"
if err := c.getJSON(path, nil, &result); err != nil {
return nil, err
}
result.TenantID = id
result.Normalize()
return &result, nil
}
func (c *adminInternalClient) TenantLoginIPWhitelist(id types.TenantID) (*tenantLoginIPWhitelistConfig, error) {
if !id.IsValid() {
return nil, types.ErrMalformed
}
var result tenantLoginIPWhitelistConfig
path := "/internal/im-config/tenants/by-id/" + strconv.FormatInt(int64(id), 10) + "/login-ip-whitelist"
if err := c.getJSON(path, nil, &result); err != nil {
return nil, err
}
result.TenantID = id
if result.AppList == nil {
result.AppList = []string{}
}
if result.WebList == nil {
result.WebList = []string{}
}
return &result, nil
}
func (c *adminInternalClient) LatestAppRelease(platform, channel string) (*types.AppRelease, error) {
query := url.Values{}
query.Set("platform", strings.ToLower(strings.TrimSpace(platform)))
query.Set("channel", strings.ToLower(strings.TrimSpace(channel)))
var dto struct {
ID int64 `json:"id"`
Platform string `json:"platform"`
Channel string `json:"channel"`
LatestVersion string `json:"latest_version"`
MinSupportedVersion string `json:"min_supported_version"`
UpdateType string `json:"update_type"`
ReleaseNotes string `json:"release_notes"`
StoreURL string `json:"store_url"`
PackageURL string `json:"package_url"`
Checksum string `json:"checksum"`
RemindAfterSeconds int `json:"remind_after_seconds"`
PublishedAt string `json:"published_at"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
if err := c.getJSON("/internal/im-config/app/releases/latest", query, &dto); err != nil {
return nil, err
}
parseTime := func(raw string) time.Time {
parsed, _ := time.Parse(time.RFC3339Nano, raw)
return parsed
}
return &types.AppRelease{
ID: dto.ID, Platform: dto.Platform, Channel: dto.Channel,
LatestVersion: dto.LatestVersion, MinSupportedVersion: dto.MinSupportedVersion,
UpdateType: dto.UpdateType, ReleaseNotes: dto.ReleaseNotes, StoreURL: dto.StoreURL,
PackageURL: dto.PackageURL, Checksum: dto.Checksum, RemindAfterSeconds: dto.RemindAfterSeconds,
PublishedAt: parseTime(dto.PublishedAt), CreatedAt: parseTime(dto.CreatedAt), UpdatedAt: parseTime(dto.UpdatedAt),
}, nil
}
func (c *adminInternalClient) getJSON(path string, query url.Values, out any) error {
return c.doJSON(http.MethodGet, path, query, nil, out)
}
func (c *adminInternalClient) doJSON(method, path string, query url.Values, payload, out any) error {
if c == nil || c.http == nil {
return types.ErrUnsupported
}
path = "/" + strings.TrimLeft(path, "/")
u, err := url.Parse(c.config.BaseURL + path)
if err != nil {
return err
}
if len(query) > 0 {
u.RawQuery = query.Encode()
}
var requestBody []byte
if payload != nil {
requestBody, err = json.Marshal(payload)
if err != nil {
return err
}
}
req, err := http.NewRequest(method, u.String(), nil)
if err != nil {
return err
}
if err = c.signRequest(req, requestBody); err != nil {
return err
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("admin internal api status=%d body=%s", resp.StatusCode, string(body))
}
var parsed struct {
Code int `json:"code"`
Data json.RawMessage `json:"data"`
}
if err = json.Unmarshal(body, &parsed); err != nil {
return err
}
if parsed.Code != 0 {
return fmt.Errorf("admin internal api code=%d", parsed.Code)
}
if out == nil || len(parsed.Data) == 0 || string(parsed.Data) == "null" {
return nil
}
return json.Unmarshal(parsed.Data, out)
}
func (c *adminInternalClient) signRequest(req *http.Request, body []byte) error {
nonce, err := randomInternalNonce()
if err != nil {
return err
}
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
bodyHash := sha256.Sum256(body)
signingText := strings.Join([]string{
req.Method,
req.URL.RequestURI(),
hex.EncodeToString(bodyHash[:]),
timestamp,
nonce,
}, "\n")
req.Header.Set(internalHeaderService, c.config.ServiceName)
req.Header.Set(internalHeaderTimestamp, timestamp)
req.Header.Set(internalHeaderNonce, nonce)
req.Header.Set(internalHeaderSignature, signInternalRequest(c.config.Secret, signingText))
req.Header.Set("Accept", "application/json")
if len(body) > 0 {
req.Body = io.NopCloser(bytes.NewReader(body))
req.ContentLength = int64(len(body))
req.Header.Set("Content-Type", "application/json")
}
return nil
}
func randomInternalNonce() (string, error) {
var raw [16]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", err
}
return hex.EncodeToString(raw[:]), nil
}
func signInternalRequest(secret, signingText string) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(signingText))
return hex.EncodeToString(mac.Sum(nil))
}
func (t adminInternalTenantDTO) toStoreTenant() *types.Tenant {
if t.ID.IsZero() && strings.TrimSpace(t.Code) == "" {
return nil
}
return &types.Tenant{
ID: t.ID,
Code: strings.TrimSpace(t.Code),
Name: t.Name,
TenantDesc: t.TenantDesc,
DBName: t.DBName,
DBDSN: t.DBDSN,
State: t.State,
CreatedAt: t.CreatedAt,
CreatedBy: t.CreatedBy,
UpdatedAt: t.UpdatedAt,
UpdatedBy: t.UpdatedBy,
}
}