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 /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 /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"}` |
|
| `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. 消息首页、全局导航与系统通知
|
## 2. 消息首页、全局导航与系统通知
|
||||||
|
|
||||||
@@ -2721,8 +2721,10 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
|||||||
|
|
||||||
**状态:新增 App 配置接口。**
|
**状态:新增 App 配置接口。**
|
||||||
|
|
||||||
- `GET /v1/im/app/releases/latest?platform=ios|android¤t_version=1.0.0&channel=stable`,未登录可在企业解析后调用。
|
- `GET /v1/im/app/releases/latest?platform=ios|android¤t_version=1.0.0&channel=stable`,全局公共接口,不依赖租户解析。`current_version` 必传,使用数字点分版本号(如 `1.2.0`)。
|
||||||
- 返回 `latest_version,min_supported_version,update_type=none|optional|required,release_notes,store_url,package_url,checksum,published_at`。
|
- 服务端返回 `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 使用允许域名。
|
- 客户端按平台只打开受信任商店/签名下载地址;Android 包必须校验签名和 checksum,服务端 URL 使用允许域名。
|
||||||
|
|
||||||
### 10.11 发现新版本.png
|
### 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)
|
UNIQUE INDEX uk_im_user_privacy_settings_user (user_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
) 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,
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
platform VARCHAR(32) NOT NULL,
|
platform VARCHAR(32) NOT NULL,
|
||||||
channel VARCHAR(32) NOT NULL DEFAULT 'stable',
|
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"}', '总经理信箱', '请如实填写诉求,匿名提交将按企业政策保护展示身份。')
|
VALUES (1, 1, 1, '["subject","content"]', 5000, '{"max_files":5,"purpose":"executive_mailbox"}', '总经理信箱', '请如实填写诉求,匿名提交将按企业政策保护展示身份。')
|
||||||
ON DUPLICATE KEY UPDATE updated_at = updated_at;
|
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
|
VALUES
|
||||||
('ios', 'stable', '1.0.0', '1.0.0', 'none', '当前已是最新版本。', 0),
|
('ios', 'stable', '1.0.0', '1.0.0', 'none', '当前已是最新版本。', 0),
|
||||||
('android', '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` 已具备。
|
1. 个人资料 `GET/PATCH /v1/im/me/profile`、个人页 `GET /v1/im/me` 已具备。
|
||||||
2. 隐私设置 `GET/PATCH /v1/im/me/privacy` 已具备,支持版本冲突控制。
|
2. 隐私设置 `GET/PATCH /v1/im/me/privacy` 已具备,支持版本冲突控制。
|
||||||
3. 最新版本检查 `GET /v1/im/app/releases/latest` 已具备,支持未登录按租户解析调用。
|
3. 最新版本检查 `GET /v1/im/app/releases/latest` 已具备,版本策略为全局配置,不依赖租户解析。
|
||||||
4. 青少年模式查询、开启、关闭接口已具备,复用敏感操作 `action_token` 和独立密码校验链路。
|
4. 青少年模式查询、开启、关闭接口已具备,复用敏感操作 `action_token` 和独立密码校验链路。
|
||||||
5. 设备 Push Token 覆盖写入 `PUT /v1/im/devices/{device_id}/push-token` 已具备。
|
5. 设备 Push Token 覆盖写入 `PUT /v1/im/devices/{device_id}/push-token` 已具备。
|
||||||
6. FCM 已优先使用产品 `im_device_push_tokens` 数据源,并支持通过环境变量注入 Firebase 凭据。
|
6. FCM 已优先使用产品 `im_device_push_tokens` 数据源,并支持通过环境变量注入 Firebase 凭据。
|
||||||
|
|||||||
@@ -186,6 +186,42 @@ func (c *adminInternalClient) TenantLoginIPWhitelist(id types.TenantID) (*tenant
|
|||||||
return &result, nil
|
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 {
|
func (c *adminInternalClient) getJSON(path string, query url.Values, out any) error {
|
||||||
if c == nil || c.http == nil {
|
if c == nil || c.http == nil {
|
||||||
return types.ErrUnsupported
|
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)
|
TenantUserProfileUpdate(t.TenantID, t.Uid, t.UserProfilePatch) (*t.UserProfile, error)
|
||||||
TenantUserPrivacyGet(t.TenantID, t.Uid) (*t.UserPrivacySettings, error)
|
TenantUserPrivacyGet(t.TenantID, t.Uid) (*t.UserPrivacySettings, error)
|
||||||
TenantUserPrivacyUpdate(t.TenantID, t.Uid, t.UserPrivacyPatch) (*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)
|
TenantDevicePushTokenUpsert(t.TenantID, *t.DevicePushToken) (*t.DevicePushToken, error)
|
||||||
TenantRiskChallengeCreate(t.TenantID, *t.RiskChallenge) (*t.RiskChallenge, error)
|
TenantRiskChallengeCreate(t.TenantID, *t.RiskChallenge) (*t.RiskChallenge, error)
|
||||||
TenantRiskChallengeConfirm(t.TenantID, t.Uid, string, string, string, time.Time) (*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),
|
PRIMARY KEY(id),
|
||||||
UNIQUE INDEX uk_im_user_privacy_settings_user(user_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(
|
`CREATE TABLE im_device_push_tokens(
|
||||||
id BIGINT NOT NULL,
|
id BIGINT NOT NULL,
|
||||||
device_id VARCHAR(128) 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)
|
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) {
|
func (a *adapter) TenantDevicePushTokenUpsert(id t.TenantID, token *t.DevicePushToken) (*t.DevicePushToken, error) {
|
||||||
if !a.tenantScopedDB {
|
if !a.tenantScopedDB {
|
||||||
tenantAdp, err := a.routedTenantAdapter(id)
|
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)
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tenant, ok := resolveIMHTTPTenant(wrt, req, "default")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
platform := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("platform")))
|
platform := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("platform")))
|
||||||
channel := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("channel")))
|
channel := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("channel")))
|
||||||
|
currentVersion := strings.TrimSpace(req.URL.Query().Get("current_version"))
|
||||||
if channel == "" {
|
if channel == "" {
|
||||||
channel = "stable"
|
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"})
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "platform"})
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeIMRelationError(wrt, req, err)
|
writeIMRelationError(wrt, req, err)
|
||||||
return
|
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) {
|
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{
|
return map[string]any{
|
||||||
|
"current_version": currentVersion,
|
||||||
|
"has_update": hasUpdate,
|
||||||
|
"force_update": hasUpdate && updateType == "force",
|
||||||
"platform": release.Platform,
|
"platform": release.Platform,
|
||||||
"channel": release.Channel,
|
"channel": release.Channel,
|
||||||
"latest_version": release.LatestVersion,
|
"latest_version": release.LatestVersion,
|
||||||
"min_supported_version": release.MinSupportedVersion,
|
"min_supported_version": release.MinSupportedVersion,
|
||||||
"update_type": release.UpdateType,
|
"update_type": updateType,
|
||||||
"release_notes": release.ReleaseNotes,
|
"release_notes": release.ReleaseNotes,
|
||||||
"store_url": release.StoreURL,
|
"store_url": release.StoreURL,
|
||||||
"package_url": release.PackageURL,
|
"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 {
|
func youthModePayload(settings *types.YouthModeSettings) map[string]any {
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"enabled": settings.Enabled,
|
"enabled": settings.Enabled,
|
||||||
|
|||||||
+63
-5
@@ -1094,7 +1094,7 @@ func (s *testProfilesStore) UpdatePrivacy(_ types.TenantID, uid types.Uid, patch
|
|||||||
return privacy, nil
|
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 {
|
if s.release == nil {
|
||||||
now := time.Now().UTC().Round(time.Millisecond)
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
s.release = &types.AppRelease{
|
s.release = &types.AppRelease{
|
||||||
@@ -1102,7 +1102,7 @@ func (s *testAppReleaseStore) Latest(_ types.TenantID, platform, channel string)
|
|||||||
Channel: channel,
|
Channel: channel,
|
||||||
LatestVersion: "1.0.1",
|
LatestVersion: "1.0.1",
|
||||||
MinSupportedVersion: "1.0.0",
|
MinSupportedVersion: "1.0.0",
|
||||||
UpdateType: "optional",
|
UpdateType: "recommended",
|
||||||
ReleaseNotes: "修复已知问题。",
|
ReleaseNotes: "修复已知问题。",
|
||||||
PublishedAt: now,
|
PublishedAt: now,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
@@ -3787,12 +3787,9 @@ func TestIMCanViewLastSeen(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestServeIMAppReleaseLatest(t *testing.T) {
|
func TestServeIMAppReleaseLatest(t *testing.T) {
|
||||||
oldTenants := store.Tenants
|
|
||||||
oldReleases := store.AppReleases
|
oldReleases := store.AppReleases
|
||||||
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "default", Name: "Default", State: types.TenantStateActive}}
|
|
||||||
store.AppReleases = &testAppReleaseStore{}
|
store.AppReleases = &testAppReleaseStore{}
|
||||||
defer func() {
|
defer func() {
|
||||||
store.Tenants = oldTenants
|
|
||||||
store.AppReleases = oldReleases
|
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" {
|
if data["platform"] != "ios" || data["latest_version"] != "1.0.1" || data["channel"] != "stable" {
|
||||||
t.Fatalf("unexpected release payload: %#v", data)
|
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) {
|
func TestServeIMYouthModeEnableDisable(t *testing.T) {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const (
|
|||||||
configEventDictionaryUpdated = "dictionary.updated"
|
configEventDictionaryUpdated = "dictionary.updated"
|
||||||
configEventThirdPartyUpdated = "third_party.updated"
|
configEventThirdPartyUpdated = "third_party.updated"
|
||||||
configEventRegistrationUpdated = "registration.config.updated"
|
configEventRegistrationUpdated = "registration.config.updated"
|
||||||
|
configEventAppReleaseUpdated = "app.release.updated"
|
||||||
)
|
)
|
||||||
|
|
||||||
type configEventPayload struct {
|
type configEventPayload struct {
|
||||||
@@ -86,10 +87,13 @@ func executeKafkaConfigEvent(envelope kafkaEnvelope) error {
|
|||||||
store.InvalidateThirdPartyConfigCache(payload.Group)
|
store.InvalidateThirdPartyConfigCache(payload.Group)
|
||||||
case configEventRegistrationUpdated:
|
case configEventRegistrationUpdated:
|
||||||
store.InvalidateRegistrationConfigCache()
|
store.InvalidateRegistrationConfigCache()
|
||||||
|
case configEventAppReleaseUpdated:
|
||||||
|
store.InvalidateAppReleaseConfigCache()
|
||||||
default:
|
default:
|
||||||
store.InvalidateTenantConfigCache(tenantCode, tenantID)
|
store.InvalidateTenantConfigCache(tenantCode, tenantID)
|
||||||
store.InvalidateThirdPartyConfigCache(payload.Group)
|
store.InvalidateThirdPartyConfigCache(payload.Group)
|
||||||
store.InvalidateRegistrationConfigCache()
|
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)
|
logs.Info.Printf("kafka config event applied: event=%s type=%s tenant=%s group=%s", envelope.EventID, envelope.EventType, tenantCode, payload.Group)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -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]()
|
||||||
|
appReleaseConfigCache = newConfigCache[*types.AppRelease]()
|
||||||
thirdPartyConfigAllKeys = "*"
|
thirdPartyConfigAllKeys = "*"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,6 +72,11 @@ func cloneTenant(t *types.Tenant) *types.Tenant {
|
|||||||
return &cp
|
return &cp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InvalidateAppReleaseConfigCache clears global client release policies.
|
||||||
|
func InvalidateAppReleaseConfigCache() {
|
||||||
|
appReleaseConfigCache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
func cloneStringMap(src map[string]string) map[string]string {
|
func cloneStringMap(src map[string]string) map[string]string {
|
||||||
if src == nil {
|
if src == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+18
-4
@@ -169,6 +169,7 @@ type AdminInternalConfigProvider interface {
|
|||||||
ListActiveTenants(limit int) ([]types.Tenant, error)
|
ListActiveTenants(limit int) ([]types.Tenant, error)
|
||||||
RegistrationSMSEnabled() (bool, error)
|
RegistrationSMSEnabled() (bool, error)
|
||||||
ThirdPartyConfig(group string, keys ...string) (map[string]string, error)
|
ThirdPartyConfig(group string, keys ...string) (map[string]string, error)
|
||||||
|
LatestAppRelease(platform, channel string) (*types.AppRelease, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
var adminInternalConfigProvider AdminInternalConfigProvider
|
var adminInternalConfigProvider AdminInternalConfigProvider
|
||||||
@@ -2728,24 +2729,37 @@ type profileMapper struct{}
|
|||||||
var Profiles ProfilePersistenceInterface
|
var Profiles ProfilePersistenceInterface
|
||||||
|
|
||||||
type AppReleasePersistenceInterface interface {
|
type AppReleasePersistenceInterface interface {
|
||||||
Latest(tenantID types.TenantID, platform, channel string) (*types.AppRelease, error)
|
Latest(platform, channel string) (*types.AppRelease, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type appReleaseMapper struct{}
|
type appReleaseMapper struct{}
|
||||||
|
|
||||||
var AppReleases AppReleasePersistenceInterface
|
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))
|
platform = strings.ToLower(strings.TrimSpace(platform))
|
||||||
channel = strings.ToLower(strings.TrimSpace(channel))
|
channel = strings.ToLower(strings.TrimSpace(channel))
|
||||||
if platform == "" || channel == "" {
|
if platform == "" || channel == "" {
|
||||||
return nil, types.ErrMalformed
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
type DevicePushTokenPersistenceInterface interface {
|
||||||
|
|||||||
Reference in New Issue
Block a user