diff --git a/server/admin_internal_config.go b/server/admin_internal_config.go index b5d1506..1cb9d82 100644 --- a/server/admin_internal_config.go +++ b/server/admin_internal_config.go @@ -168,6 +168,19 @@ func (c *adminInternalClient) ThirdPartyConfig(group string, keys ...string) (ma 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 { diff --git a/server/im_http.go b/server/im_http.go index 75b98aa..7253aca 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -1514,6 +1514,39 @@ func serveIMAbuseReports(wrt http.ResponseWriter, req *http.Request) { writeIMSuccess(wrt, req, abuseReportPayload(report)) } +// imContactUsDictType is the admin dictionary type backing the mobile +// "contact us" entries. +const imContactUsDictType = "im_contact_us" + +func serveIMContactUs(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + if _, ok := authenticateIMHTTPBearer(wrt, req); !ok { + return + } + items, err := store.DictionaryConfig.Get(imContactUsDictType) + if err != nil { + if errors.Is(err, types.ErrUnsupported) || errors.Is(err, types.ErrNotFound) { + items = nil + } else { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + } + payload := make([]map[string]any, 0, len(items)) + for _, item := range items { + label := strings.TrimSpace(item.Label) + value := strings.TrimSpace(item.Value) + if label == "" || value == "" { + continue + } + payload = append(payload, map[string]any{"label": label, "value": value}) + } + writeIMSuccess(wrt, req, map[string]any{"items": payload}) +} + func serveIMFeedbackConfig(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) diff --git a/server/im_http_test.go b/server/im_http_test.go index c1850b1..5cf4aad 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -8266,3 +8266,69 @@ func TestIMMessageTypeDraftyEntities(t *testing.T) { } } } + +type testDictionaryStore struct { + items []types.DictionaryItem + err error +} + +func (s *testDictionaryStore) Get(_ string) ([]types.DictionaryItem, error) { + if s.err != nil { + return nil, s.err + } + return s.items, nil +} + +func TestServeIMContactUs(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldDict := store.DictionaryConfig + defer func() { store.DictionaryConfig = oldDict }() + + store.DictionaryConfig = &testDictionaryStore{items: []types.DictionaryItem{ + {Label: "客服邮箱", Value: "support@example.com", Sort: 10}, + {Label: "客服电话", Value: "+86 400-000-0000", Sort: 20}, + }} + req := httptest.NewRequest(http.MethodGet, "/v1/im/contact-us", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMContactUs(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String()) + } + resp := decodeIMHTTPResponse(t, rec) + items, ok := resp.Data.(map[string]any)["items"].([]any) + if !ok || len(items) != 2 { + t.Fatalf("unexpected items: %#v", resp.Data) + } + if items[0].(map[string]any)["label"] != "客服邮箱" || items[1].(map[string]any)["value"] != "+86 400-000-0000" { + t.Fatalf("item mismatch: %#v", items) + } + + store.DictionaryConfig = &testDictionaryStore{err: types.ErrUnsupported} + rec = httptest.NewRecorder() + serveIMContactUs(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("unsupported status: got %d body=%s", rec.Code, rec.Body.String()) + } + resp = decodeIMHTTPResponse(t, rec) + if listed, ok := resp.Data.(map[string]any)["items"].([]any); !ok || len(listed) != 0 { + t.Fatalf("expected empty items, got %#v", resp.Data) + } + + store.DictionaryConfig = &testDictionaryStore{err: types.ErrInternal} + rec = httptest.NewRecorder() + serveIMContactUs(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("error status: got %d body=%s", rec.Code, rec.Body.String()) + } + + req = httptest.NewRequest(http.MethodPost, "/v1/im/contact-us", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec = httptest.NewRecorder() + serveIMContactUs(rec, req) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("method status: got %d body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/server/kafka_config_events.go b/server/kafka_config_events.go index 6ac7fa3..22715ac 100644 --- a/server/kafka_config_events.go +++ b/server/kafka_config_events.go @@ -92,6 +92,9 @@ func executeKafkaConfigEvent(envelope kafkaEnvelope) error { } case configEventDictionaryUpdated, configEventThirdPartyUpdated: store.InvalidateThirdPartyConfigCache(payload.Group) + if strings.TrimSpace(envelope.EventType) == configEventDictionaryUpdated { + store.InvalidateDictionaryConfigCache(payload.Group) + } if payload.Group == "moderation" { store.InvalidateModerationConfigCache(tenantCode, true, false, false) } diff --git a/server/main.go b/server/main.go index dfa9c37..d50107f 100644 --- a/server/main.go +++ b/server/main.go @@ -889,6 +889,7 @@ func main() { mux.HandleFunc(config.ApiPath+"v1/im/reports/abuse", serveIMAbuseReports) mux.HandleFunc(config.ApiPath+"v1/im/feedback/config", serveIMFeedbackConfig) mux.HandleFunc(config.ApiPath+"v1/im/feedback", serveIMFeedback) + mux.HandleFunc(config.ApiPath+"v1/im/contact-us", serveIMContactUs) mux.HandleFunc(config.ApiPath+"v1/im/app/releases/latest", serveIMAppReleaseLatest) mux.HandleFunc(config.ApiPath+"v1/im/me", serveIMMe) mux.HandleFunc(config.ApiPath+"v1/im/me/profile", serveIMMeProfile) diff --git a/server/store/config_cache.go b/server/store/config_cache.go index 24aae3f..2f5933c 100644 --- a/server/store/config_cache.go +++ b/server/store/config_cache.go @@ -60,6 +60,7 @@ var ( tenantByIDCache = newConfigCache[*types.Tenant]() registrationSMSCache = newConfigCache[bool]() thirdPartyConfigCache = newConfigCache[map[string]string]() + dictConfigCache = newConfigCache[[]types.DictionaryItem]() appReleaseConfigCache = newConfigCache[*types.AppRelease]() moderationProviderCache = newConfigCache[*types.ModerationProviderConfig]() moderationPolicyCache = newConfigCache[*types.ModerationPolicy]() @@ -174,6 +175,29 @@ func InvalidateThirdPartyConfigCache(group string) { thirdPartyConfigCache.mu.Unlock() } +func dictConfigCacheKey(dictType string) string { + return strings.TrimSpace(dictType) +} + +func cloneDictionaryItems(src []types.DictionaryItem) []types.DictionaryItem { + if src == nil { + return nil + } + dst := make([]types.DictionaryItem, len(src)) + copy(dst, src) + return dst +} + +// InvalidateDictionaryConfigCache clears cached dictionary config. An empty +// type clears all dictionary entries. +func InvalidateDictionaryConfigCache(dictType string) { + if strings.TrimSpace(dictType) == "" { + dictConfigCache.clear() + return + } + dictConfigCache.delete(strings.TrimSpace(dictType)) +} + func formatTenantIDForCache(id types.TenantID) string { return strconv.FormatInt(int64(id), 10) } diff --git a/server/store/store.go b/server/store/store.go index 3d08565..63053d0 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -181,8 +181,21 @@ type AdminInternalConfigProvider interface { var adminInternalConfigProvider AdminInternalConfigProvider +// DictionaryConfigProvider is an optional capability of the admin internal +// config provider that resolves admin-side dictionary config. +type DictionaryConfigProvider interface { + DictionaryConfig(dictType string) ([]types.DictionaryItem, error) +} + +var adminInternalDictionaryProvider DictionaryConfigProvider + func SetAdminInternalConfigProvider(provider AdminInternalConfigProvider) { adminInternalConfigProvider = provider + if dp, ok := provider.(DictionaryConfigProvider); ok { + adminInternalDictionaryProvider = dp + } else { + adminInternalDictionaryProvider = nil + } } type ModerationConfigPersistenceInterface interface { @@ -322,6 +335,41 @@ func (thirdPartyConfigMapper) Get(group string, keys ...string) (map[string]stri return result, err } +// DictionaryConfigPersistenceInterface exposes admin-side dictionary config, +// such as the "contact us" entries shown in mobile clients. +type DictionaryConfigPersistenceInterface interface { + Get(dictType string) ([]types.DictionaryItem, error) +} + +type dictionaryConfigMapper struct{} + +// DictionaryConfig reads dictionary config from the Admin service. +var DictionaryConfig DictionaryConfigPersistenceInterface = dictionaryConfigMapper{} + +func (dictionaryConfigMapper) Get(dictType string) ([]types.DictionaryItem, error) { + dictType = strings.TrimSpace(dictType) + if dictType == "" { + return nil, types.ErrMalformed + } + cacheKey := dictConfigCacheKey(dictType) + if cached, ok := dictConfigCache.get(cacheKey); ok { + return cloneDictionaryItems(cached), nil + } + if adminInternalDictionaryProvider != nil { + result, err := adminInternalDictionaryProvider.DictionaryConfig(dictType) + if err == nil { + items := cloneDictionaryItems(result) + if items == nil { + items = []types.DictionaryItem{} + } + dictConfigCache.set(cacheKey, items) + return cloneDictionaryItems(items), nil + } + return nil, err + } + return nil, types.ErrUnsupported +} + type RealNameConfigPersistenceInterface interface { Policy(tenantID types.TenantID) (*types.RealNamePolicy, error) } diff --git a/server/store/types/profile.go b/server/store/types/profile.go index dda22bf..a502af1 100644 --- a/server/store/types/profile.go +++ b/server/store/types/profile.go @@ -71,6 +71,14 @@ type AppRelease struct { UpdatedAt time.Time } +// DictionaryItem is a single entry of an admin-side dictionary config, such as +// the "contact us" email/phone entries shown in mobile clients. +type DictionaryItem struct { + Label string + Value string + Sort int +} + type YouthModeSettings struct { UserID Uid Enabled bool