feat: 添加联系我们配置接口和字典配置缓存
1. 新增DictionaryItem字典配置结构体 2. 添加字典配置缓存及失效逻辑 3. 实现联系我们接口的业务逻辑和测试用例 4. 注册联系我们API路由 5. 集成字典配置到admin内部客户端 6. 处理字典配置更新的kafka事件
This commit is contained in:
@@ -168,6 +168,19 @@ func (c *adminInternalClient) ThirdPartyConfig(group string, keys ...string) (ma
|
|||||||
return wrapper.Values, nil
|
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) {
|
func (c *adminInternalClient) ModerationProvider() (*types.ModerationProviderConfig, error) {
|
||||||
var result types.ModerationProviderConfig
|
var result types.ModerationProviderConfig
|
||||||
if err := c.getJSON("/internal/im-config/moderation/provider", nil, &result); err != nil {
|
if err := c.getJSON("/internal/im-config/moderation/provider", nil, &result); err != nil {
|
||||||
|
|||||||
@@ -1514,6 +1514,39 @@ func serveIMAbuseReports(wrt http.ResponseWriter, req *http.Request) {
|
|||||||
writeIMSuccess(wrt, req, abuseReportPayload(report))
|
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) {
|
func serveIMFeedbackConfig(wrt http.ResponseWriter, req *http.Request) {
|
||||||
if req.Method != http.MethodGet {
|
if req.Method != http.MethodGet {
|
||||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ func executeKafkaConfigEvent(envelope kafkaEnvelope) error {
|
|||||||
}
|
}
|
||||||
case configEventDictionaryUpdated, configEventThirdPartyUpdated:
|
case configEventDictionaryUpdated, configEventThirdPartyUpdated:
|
||||||
store.InvalidateThirdPartyConfigCache(payload.Group)
|
store.InvalidateThirdPartyConfigCache(payload.Group)
|
||||||
|
if strings.TrimSpace(envelope.EventType) == configEventDictionaryUpdated {
|
||||||
|
store.InvalidateDictionaryConfigCache(payload.Group)
|
||||||
|
}
|
||||||
if payload.Group == "moderation" {
|
if payload.Group == "moderation" {
|
||||||
store.InvalidateModerationConfigCache(tenantCode, true, false, false)
|
store.InvalidateModerationConfigCache(tenantCode, true, false, false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -889,6 +889,7 @@ func main() {
|
|||||||
mux.HandleFunc(config.ApiPath+"v1/im/reports/abuse", serveIMAbuseReports)
|
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/config", serveIMFeedbackConfig)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/feedback", serveIMFeedback)
|
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/app/releases/latest", serveIMAppReleaseLatest)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/me", serveIMMe)
|
mux.HandleFunc(config.ApiPath+"v1/im/me", serveIMMe)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/me/profile", serveIMMeProfile)
|
mux.HandleFunc(config.ApiPath+"v1/im/me/profile", serveIMMeProfile)
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ var (
|
|||||||
tenantByIDCache = newConfigCache[*types.Tenant]()
|
tenantByIDCache = newConfigCache[*types.Tenant]()
|
||||||
registrationSMSCache = newConfigCache[bool]()
|
registrationSMSCache = newConfigCache[bool]()
|
||||||
thirdPartyConfigCache = newConfigCache[map[string]string]()
|
thirdPartyConfigCache = newConfigCache[map[string]string]()
|
||||||
|
dictConfigCache = newConfigCache[[]types.DictionaryItem]()
|
||||||
appReleaseConfigCache = newConfigCache[*types.AppRelease]()
|
appReleaseConfigCache = newConfigCache[*types.AppRelease]()
|
||||||
moderationProviderCache = newConfigCache[*types.ModerationProviderConfig]()
|
moderationProviderCache = newConfigCache[*types.ModerationProviderConfig]()
|
||||||
moderationPolicyCache = newConfigCache[*types.ModerationPolicy]()
|
moderationPolicyCache = newConfigCache[*types.ModerationPolicy]()
|
||||||
@@ -174,6 +175,29 @@ func InvalidateThirdPartyConfigCache(group string) {
|
|||||||
thirdPartyConfigCache.mu.Unlock()
|
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 {
|
func formatTenantIDForCache(id types.TenantID) string {
|
||||||
return strconv.FormatInt(int64(id), 10)
|
return strconv.FormatInt(int64(id), 10)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,8 +181,21 @@ type AdminInternalConfigProvider interface {
|
|||||||
|
|
||||||
var adminInternalConfigProvider AdminInternalConfigProvider
|
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) {
|
func SetAdminInternalConfigProvider(provider AdminInternalConfigProvider) {
|
||||||
adminInternalConfigProvider = provider
|
adminInternalConfigProvider = provider
|
||||||
|
if dp, ok := provider.(DictionaryConfigProvider); ok {
|
||||||
|
adminInternalDictionaryProvider = dp
|
||||||
|
} else {
|
||||||
|
adminInternalDictionaryProvider = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModerationConfigPersistenceInterface interface {
|
type ModerationConfigPersistenceInterface interface {
|
||||||
@@ -322,6 +335,41 @@ func (thirdPartyConfigMapper) Get(group string, keys ...string) (map[string]stri
|
|||||||
return result, err
|
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 {
|
type RealNameConfigPersistenceInterface interface {
|
||||||
Policy(tenantID types.TenantID) (*types.RealNamePolicy, error)
|
Policy(tenantID types.TenantID) (*types.RealNamePolicy, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,14 @@ type AppRelease struct {
|
|||||||
UpdatedAt time.Time
|
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 {
|
type YouthModeSettings struct {
|
||||||
UserID Uid
|
UserID Uid
|
||||||
Enabled bool
|
Enabled bool
|
||||||
|
|||||||
Reference in New Issue
Block a user