diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index 61c795d..8d71d36 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -1006,7 +1006,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | `POST /v1/im/auth/password-reset/confirm` | `{"reset_token":"reset_token_123","new_password":"NewPass123"}` | `{"status":"ok"}` | | `POST /admin/v1/tenant/users/{user_id}/reset-password` 管理后台 | `{"new_password":"AdminReset123","reason":"用户无手机号无法自助找回"}` | `{"user_id":"usr...","status":"reset","auth_version":2}` | | `POST /v1/im/auth/logout` | `{}` | `{"status":"ok"}` | -| `GET /v1/im/app/releases/latest?platform=ios\|android¤t_version=1.0.0&channel=stable` | Query only | `{"platform":"ios","channel":"stable","latest_version":"1.0.0","min_supported_version":"1.0.0","update_type":"none","package_url":"","store_url":"","release_notes":"当前已是最新版本。","remind_after_seconds":0}` | +| `GET /v1/im/app/releases/latest?platform=ios\|android¤t_version=1.0.0&channel=stable` | Query only | `{"current_version":"1.0.0","has_update":true,"force_update":false,"platform":"ios","channel":"stable","latest_version":"1.1.0","min_supported_version":"1.0.0","update_type":"recommended","package_url":"","store_url":"https://apps.apple.com/app/id...","release_notes":"修复已知问题。","remind_after_seconds":86400}` | ## 2. 消息首页、全局导航与系统通知 @@ -2721,8 +2721,10 @@ X-MeetingIM-APIKey: <预置的普通 API Key> **状态:新增 App 配置接口。** -- `GET /v1/im/app/releases/latest?platform=ios|android¤t_version=1.0.0&channel=stable`,未登录可在企业解析后调用。 -- 返回 `latest_version,min_supported_version,update_type=none|optional|required,release_notes,store_url,package_url,checksum,published_at`。 +- `GET /v1/im/app/releases/latest?platform=ios|android¤t_version=1.0.0&channel=stable`,全局公共接口,不依赖租户解析。`current_version` 必传,使用数字点分版本号(如 `1.2.0`)。 +- 服务端返回 `has_update`、`force_update` 和 `update_type=none|recommended|force`;iOS 客户端使用 `store_url` 跳转 App Store。 +- 版本策略由 Admin 在 `hotgo.im_app_releases` 统一管理,IM Server 通过 HMAC 内部配置接口读取,不从租户库查询。 +- 返回 `current_version,has_update,force_update,latest_version,min_supported_version,update_type=none|recommended|force,release_notes,store_url,package_url,checksum,published_at`。 - 客户端按平台只打开受信任商店/签名下载地址;Android 包必须校验签名和 checksum,服务端 URL 使用允许域名。 ### 10.11 发现新版本.png diff --git a/docs/IM-数据库变更汇总.sql b/docs/IM-数据库变更汇总.sql index c0fbf70..9530529 100644 --- a/docs/IM-数据库变更汇总.sql +++ b/docs/IM-数据库变更汇总.sql @@ -740,7 +740,8 @@ CREATE TABLE IF NOT EXISTS tinode.im_user_privacy_settings ( UNIQUE INDEX uk_im_user_privacy_settings_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; -CREATE TABLE IF NOT EXISTS tinode.im_app_releases ( +-- 全局客户端版本策略属于 Admin 控制库,不按租户拆分。 +CREATE TABLE IF NOT EXISTS hotgo.im_app_releases ( id BIGINT NOT NULL AUTO_INCREMENT, platform VARCHAR(32) NOT NULL, channel VARCHAR(32) NOT NULL DEFAULT 'stable', @@ -1020,7 +1021,7 @@ INSERT INTO tinode.im_mailbox_config(id, enabled, allow_anonymous, required_fiel VALUES (1, 1, 1, '["subject","content"]', 5000, '{"max_files":5,"purpose":"executive_mailbox"}', '总经理信箱', '请如实填写诉求,匿名提交将按企业政策保护展示身份。') ON DUPLICATE KEY UPDATE updated_at = updated_at; -INSERT INTO tinode.im_app_releases(platform, channel, latest_version, min_supported_version, update_type, release_notes, remind_after_seconds) +INSERT INTO hotgo.im_app_releases(platform, channel, latest_version, min_supported_version, update_type, release_notes, remind_after_seconds) VALUES ('ios', 'stable', '1.0.0', '1.0.0', 'none', '当前已是最新版本。', 0), ('android', 'stable', '1.0.0', '1.0.0', 'none', '当前已是最新版本。', 0) diff --git a/docs/IM-未实现后续增强项.md b/docs/IM-未实现后续增强项.md index fbe20f2..26dba34 100644 --- a/docs/IM-未实现后续增强项.md +++ b/docs/IM-未实现后续增强项.md @@ -303,7 +303,7 @@ 1. 个人资料 `GET/PATCH /v1/im/me/profile`、个人页 `GET /v1/im/me` 已具备。 2. 隐私设置 `GET/PATCH /v1/im/me/privacy` 已具备,支持版本冲突控制。 -3. 最新版本检查 `GET /v1/im/app/releases/latest` 已具备,支持未登录按租户解析调用。 +3. 最新版本检查 `GET /v1/im/app/releases/latest` 已具备,版本策略为全局配置,不依赖租户解析。 4. 青少年模式查询、开启、关闭接口已具备,复用敏感操作 `action_token` 和独立密码校验链路。 5. 设备 Push Token 覆盖写入 `PUT /v1/im/devices/{device_id}/push-token` 已具备。 6. FCM 已优先使用产品 `im_device_push_tokens` 数据源,并支持通过环境变量注入 Firebase 凭据。 diff --git a/server/admin_internal_config.go b/server/admin_internal_config.go index 789555d..a5aed8f 100644 --- a/server/admin_internal_config.go +++ b/server/admin_internal_config.go @@ -186,6 +186,42 @@ func (c *adminInternalClient) TenantLoginIPWhitelist(id types.TenantID) (*tenant 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 { if c == nil || c.http == nil { return types.ErrUnsupported diff --git a/server/admin_internal_config_test.go b/server/admin_internal_config_test.go new file mode 100644 index 0000000..3748ae0 --- /dev/null +++ b/server/admin_internal_config_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestAdminInternalClientLatestAppRelease(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/internal/im-config/app/releases/latest" { + t.Errorf("unexpected path: %s", r.URL.Path) + return + } + if r.URL.Query().Get("platform") != "ios" || r.URL.Query().Get("channel") != "stable" { + t.Errorf("unexpected query: %s", r.URL.RawQuery) + return + } + if r.Header.Get(internalHeaderSignature) == "" { + t.Error("internal request must be signed") + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, + "data": map[string]any{ + "id": 1, "platform": "ios", "channel": "stable", + "latest_version": "2.0.0", "min_supported_version": "1.5.0", + "update_type": "recommended", "store_url": "https://apps.apple.com/app/id123", + "published_at": "2026-09-12T10:00:00Z", + }, + }) + })) + defer server.Close() + + client := &adminInternalClient{ + config: adminInternalAPIConfig{BaseURL: server.URL, ServiceName: "im-server", Secret: "test-secret"}, + http: server.Client(), + } + release, err := client.LatestAppRelease(" IOS ", "STABLE") + if err != nil { + t.Fatal(err) + } + if release.LatestVersion != "2.0.0" || release.StoreURL != "https://apps.apple.com/app/id123" || release.PublishedAt.IsZero() { + t.Fatalf("unexpected release: %#v", release) + } +} diff --git a/server/db/adapter.go b/server/db/adapter.go index b5317fb..92eb7c4 100644 --- a/server/db/adapter.go +++ b/server/db/adapter.go @@ -445,7 +445,6 @@ type TenantBusinessAdapter interface { TenantUserProfileUpdate(t.TenantID, t.Uid, t.UserProfilePatch) (*t.UserProfile, error) TenantUserPrivacyGet(t.TenantID, t.Uid) (*t.UserPrivacySettings, error) TenantUserPrivacyUpdate(t.TenantID, t.Uid, t.UserPrivacyPatch) (*t.UserPrivacySettings, error) - TenantAppReleaseLatest(t.TenantID, string, string) (*t.AppRelease, error) TenantDevicePushTokenUpsert(t.TenantID, *t.DevicePushToken) (*t.DevicePushToken, error) TenantRiskChallengeCreate(t.TenantID, *t.RiskChallenge) (*t.RiskChallenge, error) TenantRiskChallengeConfirm(t.TenantID, t.Uid, string, string, string, time.Time) (*t.RiskChallenge, error) diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index 28110f5..b63de30 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -1858,24 +1858,6 @@ func createIMUserProductTables(tx *sql.Tx) error { PRIMARY KEY(id), UNIQUE INDEX uk_im_user_privacy_settings_user(user_id) )`, - `CREATE TABLE im_app_releases( - id BIGINT NOT NULL AUTO_INCREMENT, - platform VARCHAR(32) NOT NULL, - channel VARCHAR(32) NOT NULL DEFAULT 'stable', - latest_version VARCHAR(64) NOT NULL, - min_supported_version VARCHAR(64) NOT NULL, - update_type VARCHAR(16) NOT NULL DEFAULT 'none', - release_notes TEXT NOT NULL, - store_url VARCHAR(512) NOT NULL DEFAULT '', - package_url VARCHAR(512) NOT NULL DEFAULT '', - checksum VARCHAR(128) NOT NULL DEFAULT '', - remind_after_seconds INT NOT NULL DEFAULT 0, - published_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(id), - UNIQUE INDEX uk_im_app_releases_platform_channel(platform, channel) - )`, `CREATE TABLE im_device_push_tokens( id BIGINT NOT NULL, device_id VARCHAR(128) NOT NULL, diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index d10c914..06347f7 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -6244,68 +6244,6 @@ func (a *adapter) TenantUserPrivacyUpdate(id t.TenantID, uid t.Uid, patch t.User return a.userPrivacyByUID(ctx, uid) } -func (a *adapter) TenantAppReleaseLatest(id t.TenantID, platform, channel string) (*t.AppRelease, error) { - if !a.tenantScopedDB { - tenantAdp, err := a.routedTenantAdapter(id) - if err != nil { - return nil, err - } - if tenantAdp != a { - return tenantAdp.TenantAppReleaseLatest(id, platform, channel) - } - } - platform = strings.ToLower(strings.TrimSpace(platform)) - channel = strings.ToLower(strings.TrimSpace(channel)) - if err := validTenant(id); err != nil || platform == "" || channel == "" { - return nil, t.ErrMalformed - } - ctx, cancel := a.getContext() - if cancel != nil { - defer cancel() - } - row := struct { - ID int64 `db:"id"` - Platform string `db:"platform"` - Channel string `db:"channel"` - LatestVersion string `db:"latest_version"` - MinSupportedVersion string `db:"min_supported_version"` - UpdateType string `db:"update_type"` - ReleaseNotes string `db:"release_notes"` - StoreURL string `db:"store_url"` - PackageURL string `db:"package_url"` - Checksum string `db:"checksum"` - RemindAfterSeconds int `db:"remind_after_seconds"` - PublishedAt time.Time `db:"published_at"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` - }{} - err := sqlx.GetContext(ctx, a.db, &row, - `SELECT id,platform,channel,latest_version,min_supported_version,update_type,release_notes,store_url,package_url,checksum,remind_after_seconds,published_at,created_at,updated_at - FROM im_app_releases WHERE platform=? AND channel=? LIMIT 1`, platform, channel) - if err == sql.ErrNoRows { - return nil, t.ErrNotFound - } - if err != nil { - return nil, err - } - return &t.AppRelease{ - ID: row.ID, - Platform: row.Platform, - Channel: row.Channel, - LatestVersion: row.LatestVersion, - MinSupportedVersion: row.MinSupportedVersion, - UpdateType: row.UpdateType, - ReleaseNotes: row.ReleaseNotes, - StoreURL: row.StoreURL, - PackageURL: row.PackageURL, - Checksum: row.Checksum, - RemindAfterSeconds: row.RemindAfterSeconds, - PublishedAt: row.PublishedAt, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - }, nil -} - func (a *adapter) TenantDevicePushTokenUpsert(id t.TenantID, token *t.DevicePushToken) (*t.DevicePushToken, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) diff --git a/server/im_http.go b/server/im_http.go index 1eb7c92..2e24568 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -3215,12 +3215,9 @@ func serveIMAppReleaseLatest(wrt http.ResponseWriter, req *http.Request) { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } - tenant, ok := resolveIMHTTPTenant(wrt, req, "default") - if !ok { - return - } platform := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("platform"))) channel := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("channel"))) + currentVersion := strings.TrimSpace(req.URL.Query().Get("current_version")) if channel == "" { channel = "stable" } @@ -3228,12 +3225,34 @@ func serveIMAppReleaseLatest(wrt http.ResponseWriter, req *http.Request) { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "platform"}) return } - release, err := store.AppReleases.Latest(tenant.ID, platform, channel) + currentParts, err := parseNumericAppVersion(currentVersion) + if err != nil { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "current_version"}) + return + } + release, err := store.AppReleases.Latest(platform, channel) if err != nil { writeIMRelationError(wrt, req, err) return } - writeIMSuccess(wrt, req, appReleasePayload(release)) + latestParts, latestErr := parseNumericAppVersion(release.LatestVersion) + minimumParts, minimumErr := parseNumericAppVersion(release.MinSupportedVersion) + if latestErr != nil || minimumErr != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 50000, imReasonInternal, "invalid app release configuration", nil) + return + } + hasUpdate := compareNumericAppVersion(currentParts, latestParts) < 0 + updateType := "none" + if hasUpdate { + updateType = strings.ToLower(strings.TrimSpace(release.UpdateType)) + if compareNumericAppVersion(currentParts, minimumParts) < 0 { + updateType = "force" + } + if updateType != "force" && updateType != "recommended" { + updateType = "none" + } + } + writeIMSuccess(wrt, req, appReleasePayload(release, currentVersion, hasUpdate, updateType)) } func serveIMYouthMode(wrt http.ResponseWriter, req *http.Request) { @@ -9315,13 +9334,16 @@ func imCanViewLastSeen(tenantID types.TenantID, viewer, target types.Uid) bool { } } -func appReleasePayload(release *types.AppRelease) map[string]any { +func appReleasePayload(release *types.AppRelease, currentVersion string, hasUpdate bool, updateType string) map[string]any { return map[string]any{ + "current_version": currentVersion, + "has_update": hasUpdate, + "force_update": hasUpdate && updateType == "force", "platform": release.Platform, "channel": release.Channel, "latest_version": release.LatestVersion, "min_supported_version": release.MinSupportedVersion, - "update_type": release.UpdateType, + "update_type": updateType, "release_notes": release.ReleaseNotes, "store_url": release.StoreURL, "package_url": release.PackageURL, @@ -9331,6 +9353,49 @@ func appReleasePayload(release *types.AppRelease) map[string]any { } } +func parseNumericAppVersion(raw string) ([]uint64, error) { + raw = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(raw)), "v") + parts := strings.Split(raw, ".") + if len(parts) == 0 || len(parts) > 8 { + return nil, types.ErrMalformed + } + parsed := make([]uint64, len(parts)) + for i, part := range parts { + if part == "" { + return nil, types.ErrMalformed + } + value, err := strconv.ParseUint(part, 10, 32) + if err != nil { + return nil, types.ErrMalformed + } + parsed[i] = value + } + return parsed, nil +} + +func compareNumericAppVersion(left, right []uint64) int { + length := len(left) + if len(right) > length { + length = len(right) + } + for i := 0; i < length; i++ { + var leftPart, rightPart uint64 + if i < len(left) { + leftPart = left[i] + } + if i < len(right) { + rightPart = right[i] + } + if leftPart < rightPart { + return -1 + } + if leftPart > rightPart { + return 1 + } + } + return 0 +} + func youthModePayload(settings *types.YouthModeSettings) map[string]any { data := map[string]any{ "enabled": settings.Enabled, diff --git a/server/im_http_test.go b/server/im_http_test.go index e02ca27..e9b6c45 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -1094,7 +1094,7 @@ func (s *testProfilesStore) UpdatePrivacy(_ types.TenantID, uid types.Uid, patch return privacy, nil } -func (s *testAppReleaseStore) Latest(_ types.TenantID, platform, channel string) (*types.AppRelease, error) { +func (s *testAppReleaseStore) Latest(platform, channel string) (*types.AppRelease, error) { if s.release == nil { now := time.Now().UTC().Round(time.Millisecond) s.release = &types.AppRelease{ @@ -1102,7 +1102,7 @@ func (s *testAppReleaseStore) Latest(_ types.TenantID, platform, channel string) Channel: channel, LatestVersion: "1.0.1", MinSupportedVersion: "1.0.0", - UpdateType: "optional", + UpdateType: "recommended", ReleaseNotes: "修复已知问题。", PublishedAt: now, CreatedAt: now, @@ -3787,12 +3787,9 @@ func TestIMCanViewLastSeen(t *testing.T) { } func TestServeIMAppReleaseLatest(t *testing.T) { - oldTenants := store.Tenants oldReleases := store.AppReleases - store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "default", Name: "Default", State: types.TenantStateActive}} store.AppReleases = &testAppReleaseStore{} defer func() { - store.Tenants = oldTenants store.AppReleases = oldReleases }() @@ -3807,6 +3804,67 @@ func TestServeIMAppReleaseLatest(t *testing.T) { if data["platform"] != "ios" || data["latest_version"] != "1.0.1" || data["channel"] != "stable" { t.Fatalf("unexpected release payload: %#v", data) } + if data["has_update"] != true || data["update_type"] != "recommended" || data["force_update"] != false { + t.Fatalf("unexpected update decision: %#v", data) + } +} + +func TestServeIMAppReleaseLatestForcesUnsupportedVersion(t *testing.T) { + oldReleases := store.AppReleases + store.AppReleases = &testAppReleaseStore{release: &types.AppRelease{ + Platform: "ios", Channel: "stable", LatestVersion: "2.0.0", MinSupportedVersion: "1.5.0", + UpdateType: "recommended", PublishedAt: time.Now().UTC(), + }} + defer func() { store.AppReleases = oldReleases }() + req := httptest.NewRequest(http.MethodGet, "/v1/im/app/releases/latest?platform=ios¤t_version=1.4.9", nil) + rec := httptest.NewRecorder() + serveIMAppReleaseLatest(rec, req) + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["has_update"] != true || data["update_type"] != "force" || data["force_update"] != true { + t.Fatalf("unexpected forced update decision: %#v", data) + } +} + +func TestServeIMAppReleaseLatestRequiresCurrentVersion(t *testing.T) { + oldReleases := store.AppReleases + store.AppReleases = &testAppReleaseStore{} + defer func() { store.AppReleases = oldReleases }() + req := httptest.NewRequest(http.MethodGet, "/v1/im/app/releases/latest?platform=ios", nil) + rec := httptest.NewRecorder() + serveIMAppReleaseLatest(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } +} + +func TestServeIMAppReleaseLatestReturnsNoUpdateForCurrentVersion(t *testing.T) { + oldReleases := store.AppReleases + store.AppReleases = &testAppReleaseStore{release: &types.AppRelease{ + Platform: "ios", Channel: "stable", LatestVersion: "1.2.0", MinSupportedVersion: "1.0.0", + UpdateType: "force", PublishedAt: time.Now().UTC(), + }} + defer func() { store.AppReleases = oldReleases }() + req := httptest.NewRequest(http.MethodGet, "/v1/im/app/releases/latest?platform=ios¤t_version=1.2.0", nil) + rec := httptest.NewRecorder() + serveIMAppReleaseLatest(rec, req) + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["has_update"] != false || data["update_type"] != "none" || data["force_update"] != false { + t.Fatalf("unexpected no-update decision: %#v", data) + } +} + +func TestNumericAppVersionComparison(t *testing.T) { + left, err := parseNumericAppVersion("v1.10.0") + if err != nil { + t.Fatal(err) + } + right, err := parseNumericAppVersion("1.9.9") + if err != nil { + t.Fatal(err) + } + if compareNumericAppVersion(left, right) <= 0 { + t.Fatal("expected 1.10.0 to be newer than 1.9.9") + } } func TestServeIMYouthModeEnableDisable(t *testing.T) { diff --git a/server/kafka_config_events.go b/server/kafka_config_events.go index 83677f1..12a5e63 100644 --- a/server/kafka_config_events.go +++ b/server/kafka_config_events.go @@ -20,6 +20,7 @@ const ( configEventDictionaryUpdated = "dictionary.updated" configEventThirdPartyUpdated = "third_party.updated" configEventRegistrationUpdated = "registration.config.updated" + configEventAppReleaseUpdated = "app.release.updated" ) type configEventPayload struct { @@ -86,10 +87,13 @@ func executeKafkaConfigEvent(envelope kafkaEnvelope) error { store.InvalidateThirdPartyConfigCache(payload.Group) case configEventRegistrationUpdated: store.InvalidateRegistrationConfigCache() + case configEventAppReleaseUpdated: + store.InvalidateAppReleaseConfigCache() default: store.InvalidateTenantConfigCache(tenantCode, tenantID) store.InvalidateThirdPartyConfigCache(payload.Group) store.InvalidateRegistrationConfigCache() + store.InvalidateAppReleaseConfigCache() } logs.Info.Printf("kafka config event applied: event=%s type=%s tenant=%s group=%s", envelope.EventID, envelope.EventType, tenantCode, payload.Group) return nil diff --git a/server/store/config_cache.go b/server/store/config_cache.go index 8a369c3..14bd88a 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]() + appReleaseConfigCache = newConfigCache[*types.AppRelease]() thirdPartyConfigAllKeys = "*" ) @@ -71,6 +72,11 @@ func cloneTenant(t *types.Tenant) *types.Tenant { return &cp } +// InvalidateAppReleaseConfigCache clears global client release policies. +func InvalidateAppReleaseConfigCache() { + appReleaseConfigCache.clear() +} + func cloneStringMap(src map[string]string) map[string]string { if src == nil { return nil diff --git a/server/store/store.go b/server/store/store.go index 1a516ca..c6ea0d3 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -169,6 +169,7 @@ type AdminInternalConfigProvider interface { ListActiveTenants(limit int) ([]types.Tenant, error) RegistrationSMSEnabled() (bool, error) ThirdPartyConfig(group string, keys ...string) (map[string]string, error) + LatestAppRelease(platform, channel string) (*types.AppRelease, error) } var adminInternalConfigProvider AdminInternalConfigProvider @@ -2728,24 +2729,37 @@ type profileMapper struct{} var Profiles ProfilePersistenceInterface type AppReleasePersistenceInterface interface { - Latest(tenantID types.TenantID, platform, channel string) (*types.AppRelease, error) + Latest(platform, channel string) (*types.AppRelease, error) } type appReleaseMapper struct{} var AppReleases AppReleasePersistenceInterface -func (appReleaseMapper) Latest(tenantID types.TenantID, platform, channel string) (*types.AppRelease, error) { +func (appReleaseMapper) Latest(platform, channel string) (*types.AppRelease, error) { platform = strings.ToLower(strings.TrimSpace(platform)) channel = strings.ToLower(strings.TrimSpace(channel)) if platform == "" || channel == "" { return nil, types.ErrMalformed } - tenantAdp, err := tenantBusinessAdapter(tenantID) + cacheKey := platform + ":" + channel + if cached, ok := appReleaseConfigCache.get(cacheKey); ok { + copy := *cached + return ©, nil + } + if adminInternalConfigProvider == nil { + return nil, types.ErrUnsupported + } + release, err := adminInternalConfigProvider.LatestAppRelease(platform, channel) if err != nil { return nil, err } - return tenantAdp.TenantAppReleaseLatest(tenantID, platform, channel) + if release == nil { + return nil, types.ErrNotFound + } + copy := *release + appReleaseConfigCache.set(cacheKey, ©) + return release, nil } type DevicePushTokenPersistenceInterface interface {