feat: centralize app release configuration
This commit is contained in:
+5
-3
@@ -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
|
||||
|
||||
+3
-2
@@ -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)
|
||||
|
||||
+1
-1
@@ -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 凭据。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
+73
-8
@@ -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,
|
||||
|
||||
+63
-5
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+18
-4
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user