feat: centralize app release configuration

This commit is contained in:
richjohnrambo
2026-09-12 17:07:49 +08:00
parent 2728f35069
commit e5fbca735d
13 changed files with 257 additions and 104 deletions
+36
View File
@@ -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
+48
View File
@@ -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)
}
}
-1
View File
@@ -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)
-18
View File
@@ -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,
-62
View File
@@ -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
View File
@@ -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
View File
@@ -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&current_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&current_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) {
+4
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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 &copy, 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, &copy)
return release, nil
}
type DevicePushTokenPersistenceInterface interface {