feat: 添加 App 检查更新功能

- 前端:设置页面添加检查更新菜单项,从 pubspec.yaml 动态获取版本号
- 后端:新增 /api/v1/app/check-updates 接口,自动扫描 releases 目录对比版本
- 配置:新增 AppVersionSettings,支持通过环境变量配置版本和下载链接
- Docker:添加 releases 目录挂载
This commit is contained in:
qzl
2026-03-16 16:09:07 +08:00
parent dcceb48d84
commit ab073c88ed
11 changed files with 485 additions and 138 deletions
+12
View File
@@ -81,3 +81,15 @@ SOCIAL_LLM__PROVIDER_KEYS__MOONSHOT=
SOCIAL_LLM__PROVIDER_KEYS__DEEPSEEK= SOCIAL_LLM__PROVIDER_KEYS__DEEPSEEK=
SOCIAL_LLM__PROVIDER_KEYS__ARK= SOCIAL_LLM__PROVIDER_KEYS__ARK=
SOCIAL_LLM__PROVIDER_KEYS__ZAI= SOCIAL_LLM__PROVIDER_KEYS__ZAI=
############
# App 版本更新配置
############
# 安装包目录,相对于项目根目录下的 deploy/static/
SOCIAL_APP_VERSION__RELEASES_DIR=releases
# 当前版本号(语义化版本)
SOCIAL_APP_VERSION__CURRENT_VERSION=0.1.0
# 当前构建号(整数,每次打包递增)
SOCIAL_APP_VERSION__CURRENT_BUILD=1
# 下载链接基础域名(生产环境需配置)
SOCIAL_APP_VERSION__DOWNLOAD_BASE_URL=
@@ -0,0 +1,13 @@
import 'package:package_info_plus/package_info_plus.dart';
class AppConstants {
static String version = '0.1.0';
static int build = 1;
static Future<void> init() async {
final info = await PackageInfo.fromPlatform();
version = info.version;
final buildStr = info.buildNumber.isEmpty ? '1' : info.buildNumber;
build = int.tryParse(buildStr) ?? 1;
}
}
+4
View File
@@ -15,6 +15,7 @@ import '../../features/calendar/data/services/calendar_service.dart';
import '../../features/calendar/ui/calendar_state_manager.dart'; import '../../features/calendar/ui/calendar_state_manager.dart';
import '../../features/friends/data/friends_api.dart'; import '../../features/friends/data/friends_api.dart';
import '../../features/messages/data/inbox_api.dart'; import '../../features/messages/data/inbox_api.dart';
import '../../features/settings/data/settings_api.dart';
import '../../features/users/data/users_api.dart'; import '../../features/users/data/users_api.dart';
import '../../features/todo/data/todo_api.dart'; import '../../features/todo/data/todo_api.dart';
@@ -55,6 +56,9 @@ Future<void> configureDependencies() async {
final friendsApi = FriendsApi(apiClient); final friendsApi = FriendsApi(apiClient);
sl.registerSingleton<FriendsApi>(friendsApi); sl.registerSingleton<FriendsApi>(friendsApi);
final settingsApi = SettingsApi(apiClient);
sl.registerSingleton<SettingsApi>(settingsApi);
final inboxApi = InboxApi(apiClient); final inboxApi = InboxApi(apiClient);
sl.registerSingleton<InboxApi>(inboxApi); sl.registerSingleton<InboxApi>(inboxApi);
@@ -0,0 +1,59 @@
import 'package:social_app/core/api/i_api_client.dart';
class AppVersionResponse {
final bool hasUpdate;
final String latestVersion;
final int latestBuild;
final String minRequiredVersion;
final String updateType;
final String? downloadUrl;
final String? releaseNotes;
AppVersionResponse({
required this.hasUpdate,
required this.latestVersion,
required this.latestBuild,
required this.minRequiredVersion,
required this.updateType,
this.downloadUrl,
this.releaseNotes,
});
factory AppVersionResponse.fromJson(Map<String, dynamic> json) {
return AppVersionResponse(
hasUpdate: json['has_update'] as bool,
latestVersion: json['latest_version'] as String,
latestBuild: json['latest_build'] as int,
minRequiredVersion: json['min_required_version'] as String,
updateType: json['update_type'] as String,
downloadUrl: json['download_url'] as String?,
releaseNotes: json['release_notes'] as String?,
);
}
}
class SettingsApi {
final IApiClient _client;
static const _prefix = '/api/v1/app';
SettingsApi(this._client);
Future<AppVersionResponse> checkUpdates({
required int currentBuild,
String? currentVersion,
String platform = 'android',
}) async {
final params = <String, String>{
'platform': platform,
'current_build': currentBuild.toString(),
};
if (currentVersion != null) {
params['current_version'] = currentVersion;
}
final queryString = params.entries
.map((e) => '${e.key}=${e.value}')
.join('&');
final response = await _client.get('$_prefix/check-updates?$queryString');
return AppVersionResponse.fromJson(response.data);
}
}
@@ -1,11 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../../core/di/injection.dart'; import 'package:social_app/core/constants/app_constants.dart';
import '../../../../core/theme/design_tokens.dart'; import 'package:social_app/core/di/injection.dart';
import '../../../../shared/widgets/page_header.dart' as widgets; import 'package:social_app/core/theme/design_tokens.dart';
import '../../../friends/data/friends_api.dart'; import 'package:social_app/shared/widgets/app_loading_indicator.dart';
import '../../../users/data/models/user_response.dart'; import 'package:social_app/shared/widgets/page_header.dart' as widgets;
import '../../../users/data/users_api.dart'; import 'package:social_app/shared/widgets/toast/toast.dart';
import 'package:social_app/shared/widgets/toast/toast_type.dart';
import 'package:social_app/features/friends/data/friends_api.dart';
import 'package:social_app/features/settings/data/settings_api.dart';
import 'package:social_app/features/users/data/models/user_response.dart';
import 'package:social_app/features/users/data/users_api.dart';
class SettingsScreen extends StatefulWidget { class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@@ -68,7 +73,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
const widgets.PageHeader(leading: widgets.BackButton()), const widgets.PageHeader(leading: widgets.BackButton()),
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), padding: const EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.sm,
AppSpacing.xl,
AppSpacing.xl,
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -93,13 +103,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (_isLoading) { if (_isLoading) {
return Container( return Container(
width: double.infinity, width: double.infinity,
height: 100, height: 120,
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(AppSpacing.xl),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.white, color: AppColors.white,
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(24),
), ),
child: const Center(child: CircularProgressIndicator()), child: const Center(child: AppLoadingIndicator(size: 22)),
); );
} }
@@ -108,82 +118,101 @@ class _SettingsScreenState extends State<SettingsScreen> {
return Container( return Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(AppSpacing.xl),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient( gradient: const LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
stops: [0, 1], colors: [AppColors.white, Color(0xF8F9FCFF)],
colors: [Color(0xFFFFFFFF), AppColors.surfaceInfoLight],
transform: GradientRotation(35 * 3.14159 / 180),
), ),
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(24),
border: Border.all(color: const Color(0xFFE5ECF8)), border: Border.all(color: AppColors.borderSecondary),
boxShadow: const [ boxShadow: const [
BoxShadow( BoxShadow(
color: Color(0x1A0F172A), color: Color(0x05000000),
blurRadius: 14, blurRadius: 12,
offset: Offset(0, 4), offset: Offset(0, 3),
), ),
], ],
), ),
child: Row( child: Row(
children: [ children: [
Container( Container(
width: 72, width: 64,
height: 72, height: 64,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient( gradient: LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [Color(0xFFEAF1FF), Color(0xFFF8FBFF)], colors: [AppColors.blue100, AppColors.blue50],
), ),
borderRadius: BorderRadius.circular(36), borderRadius: BorderRadius.circular(32),
border: Border.all(color: const Color(0xFFD9E5FA)), boxShadow: [
BoxShadow(
color: Color.fromRGBO(
AppColors.blue400.r.toInt(),
AppColors.blue400.g.toInt(),
AppColors.blue400.b.toInt(),
0.2,
),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
), ),
child: const Icon(Icons.person, size: 30, color: AppColors.blue500), child: const Icon(Icons.person, size: 28, color: AppColors.blue600),
), ),
const SizedBox(width: 12), const SizedBox(width: AppSpacing.lg),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Text( Expanded(
username, child: Text(
style: const TextStyle( username,
fontSize: 17, style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 20,
color: AppColors.slate900, fontWeight: FontWeight.w700,
color: AppColors.slate900,
),
overflow: TextOverflow.ellipsis,
), ),
), ),
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 10, horizontal: 10,
vertical: 4, vertical: 5,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.surfaceTertiary, gradient: LinearGradient(
borderRadius: BorderRadius.circular(10), colors: [
border: Border.all(color: const Color(0xFFDEE7F6)), AppColors.blue50,
AppColors.surfaceInfoLight,
],
),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.borderQuaternary),
), ),
child: const Text( child: const Text(
'Free', 'Free',
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w600,
color: AppColors.slate500, color: AppColors.blue600,
), ),
), ),
), ),
], ],
), ),
const SizedBox(height: 4), const SizedBox(height: 6),
Text( Text(
email, email,
style: const TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: AppColors.slate500, color: AppColors.slate500,
@@ -208,49 +237,34 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
Widget _buildQuickActions(BuildContext context) { Widget _buildQuickActions(BuildContext context) {
return Container( return Row(
height: 120, children: [
padding: const EdgeInsets.all(10), Expanded(
decoration: BoxDecoration( child: _buildActionCard(
color: AppColors.white, icon: Icons.people,
borderRadius: BorderRadius.circular(18), iconColor: AppColors.blue500,
border: Border.all(color: const Color(0xFFE7EDF6)), title: '联系人',
), subtitle: _buildFriendsSubtitle(),
child: Row( onTap: () => context.push('/contacts'),
children: [
Expanded(
child: _buildQuickActionCard(
icon: Icons.people,
iconColor: AppColors.blue600,
iconBg: AppColors.surfaceTertiary,
iconBorder: const Color(0xFFE6ECF7),
title: '联系人',
subtitle: _buildFriendsSubtitle(),
onTap: () => context.push('/contacts'),
),
), ),
const SizedBox(width: 10), ),
Expanded( const SizedBox(width: AppSpacing.md),
child: _buildQuickActionCard( Expanded(
icon: Icons.auto_awesome, child: _buildActionCard(
iconColor: const Color(0xFF0EA5A4), icon: Icons.auto_awesome,
iconBg: const Color(0xFFF7FAFF), iconColor: const Color(0xFF8B5CF6),
iconBorder: const Color(0xFFE6ECF7), title: '常用功能',
title: '常用功能', subtitle: '已启用:会议提醒',
subtitle: '已启用:会议提醒', onTap: () => context.push('/settings/features'),
onTap: () => context.push('/settings/features'),
),
), ),
], ),
), ],
); );
} }
Widget _buildQuickActionCard({ Widget _buildActionCard({
required IconData icon, required IconData icon,
required Color iconColor, required Color iconColor,
required Color iconBg,
required Color iconBorder,
required String title, required String title,
required String subtitle, required String subtitle,
required VoidCallback onTap, required VoidCallback onTap,
@@ -258,47 +272,56 @@ class _SettingsScreenState extends State<SettingsScreen> {
return GestureDetector( return GestureDetector(
onTap: onTap, onTap: onTap,
child: Container( child: Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration( decoration: BoxDecoration(
color: iconBg, color: AppColors.white,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(20),
border: Border.all(color: iconBorder), border: Border.all(color: AppColors.borderSecondary),
boxShadow: const [
BoxShadow(
color: Color(0x04000000),
blurRadius: 6,
offset: Offset(0, 1),
),
],
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Row( Container(
children: [ width: 36,
Icon(icon, size: 18, color: iconColor), height: 36,
const SizedBox(width: 8), decoration: BoxDecoration(
Text( color: AppColors.surfaceTertiary,
title, borderRadius: BorderRadius.circular(10),
style: const TextStyle( ),
fontSize: 15, child: Icon(icon, size: 18, color: iconColor),
fontWeight: FontWeight.w600,
color: Color(0xFF1E293B),
),
),
],
),
const Icon(
Icons.chevron_right,
size: 16,
color: AppColors.slate400,
), ),
const Spacer(),
Icon(Icons.chevron_right, size: 16, color: AppColors.slate300),
], ],
), ),
const SizedBox(height: AppSpacing.md),
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
),
),
const SizedBox(height: 2),
Text( Text(
subtitle, subtitle,
style: const TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: AppColors.slate500, color: AppColors.slate500,
), ),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
], ],
), ),
@@ -308,65 +331,95 @@ class _SettingsScreenState extends State<SettingsScreen> {
Widget _buildSubscriptionCard() { Widget _buildSubscriptionCard() {
return Container( return Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.white, gradient: LinearGradient(
borderRadius: BorderRadius.circular(16), begin: Alignment.topLeft,
border: Border.all(color: const Color(0xFFE3EAF6)), end: Alignment.bottomRight,
colors: [AppColors.white, const Color(0xFFFAFBFF)],
),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.borderSecondary),
boxShadow: const [
BoxShadow(
color: Color(0x03000000),
blurRadius: 6,
offset: Offset(0, 1),
),
],
), ),
child: Row( child: Row(
children: [ children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.blue100, AppColors.blue50],
),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: AppColors.blue200.withValues(alpha: 0.45),
blurRadius: 6,
offset: const Offset(0, 1),
),
],
),
child: const Icon(
Icons.workspace_premium,
size: 22,
color: AppColors.blue600,
),
),
const SizedBox(width: AppSpacing.md),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( const Text(
'套餐等级 Free', '升级到 Pro',
style: TextStyle( style: TextStyle(
fontSize: 15, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.slate900, color: AppColors.slate900,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 2),
const Text( Text(
'∞ / ∞', '解锁更多高级功能',
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: AppColors.slate500, color: AppColors.slate500,
), ),
), ),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: 0,
backgroundColor: const Color(0xFFE8EEF8),
valueColor: const AlwaysStoppedAnimation(AppColors.blue400),
minHeight: 8,
),
),
], ],
), ),
), ),
const SizedBox(width: 12),
Container( Container(
width: 72, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
height: 32,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFE2E8F0), gradient: const LinearGradient(
borderRadius: BorderRadius.circular(16), colors: [AppColors.blue500, AppColors.blue600],
border: Border.all(color: const Color(0xFFCBD5E1)), ),
), borderRadius: BorderRadius.circular(20),
child: const Center( boxShadow: [
child: Text( BoxShadow(
'升级', color: const Color(0x4D60A5FA),
style: TextStyle( blurRadius: 8,
fontSize: 13, offset: const Offset(0, 2),
fontWeight: FontWeight.w600,
color: Color(0xFF94A3B8),
), ),
],
),
child: const Text(
'升级',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.white,
), ),
), ),
), ),
@@ -401,6 +454,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
title: '我的账户', title: '我的账户',
onTap: () => context.push('/settings/account'), onTap: () => context.push('/settings/account'),
), ),
_buildDivider(),
_buildMenuItem(
icon: Icons.system_update,
title: '检查更新',
trailing: 'v${AppConstants.version}',
onTap: () => _checkForUpdates(context),
),
], ],
), ),
); );
@@ -464,7 +524,59 @@ class _SettingsScreenState extends State<SettingsScreen> {
return Container( return Container(
height: 1, height: 1,
margin: const EdgeInsets.symmetric(horizontal: 14), margin: const EdgeInsets.symmetric(horizontal: 14),
color: const Color(0xFFEEF2F7), color: AppColors.slate100,
); );
} }
Future<void> _checkForUpdates(BuildContext context) async {
try {
final settingsApi = sl<SettingsApi>();
final result = await settingsApi.checkUpdates(
currentBuild: AppConstants.build,
currentVersion: AppConstants.version,
platform: 'android',
);
if (!mounted) return;
if (!result.hasUpdate) {
Toast.show(context, '当前已是最新版本', type: ToastType.success);
return;
}
final message = result.updateType == 'required'
? '有新版本可用 (${result.latestVersion}),请立即更新'
: '发现新版本 (${result.latestVersion}),是否更新?';
final shouldUpdate = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('检查更新'),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
if (result.downloadUrl != null)
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('更新'),
),
],
),
);
if (shouldUpdate == true && result.downloadUrl != null && mounted) {
Toast.show(
context,
'下载链接: ${result.downloadUrl}',
type: ToastType.info,
);
}
} catch (e) {
if (!mounted) return;
Toast.show(context, '检查更新失败', type: ToastType.error);
}
}
} }
+2
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'core/constants/app_constants.dart';
import 'core/di/injection.dart'; import 'core/di/injection.dart';
import 'core/notifications/local_notification_service.dart'; import 'core/notifications/local_notification_service.dart';
import 'core/router/app_router.dart'; import 'core/router/app_router.dart';
@@ -16,6 +17,7 @@ import 'features/calendar/data/services/calendar_service.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await configureDependencies(); await configureDependencies();
await AppConstants.init();
final authBloc = sl<AuthBloc>(); final authBloc = sl<AuthBloc>();
authBloc.add(AuthStarted()); authBloc.add(AuthStarted());
+2 -1
View File
@@ -1,7 +1,7 @@
name: social_app name: social_app
description: "Social App - A Flutter mobile application" description: "Social App - A Flutter mobile application"
publish_to: 'none' publish_to: 'none'
version: 1.0.0+1 version: 0.1.0+1
environment: environment:
sdk: ^3.10.7 sdk: ^3.10.7
@@ -25,6 +25,7 @@ dependencies:
flutter_local_notifications: ^17.2.4 flutter_local_notifications: ^17.2.4
timezone: ^0.9.4 timezone: ^0.9.4
image_picker: ^1.0.7 image_picker: ^1.0.7
package_info_plus: ^8.0.3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
+20
View File
@@ -198,6 +198,25 @@ class DatabaseSettings(BaseModel):
) )
class AppVersionSettings(BaseModel):
releases_dir: str = Field(
default="releases",
description="安装包目录,相对于项目根目录",
)
current_version: str = Field(
default="0.1.0",
description="当前版本号",
)
current_build: int = Field(
default=1,
description="当前构建号",
)
download_base_url: str = Field(
default="",
description="下载链接基础域名,如 https://your-domain.com",
)
def _resolve_env_file() -> str: def _resolve_env_file() -> str:
current = Path(__file__).resolve() current = Path(__file__).resolve()
for parent in [current, *current.parents]: for parent in [current, *current.parents]:
@@ -221,6 +240,7 @@ class Settings(BaseSettings):
agent_runtime: AgentRuntimeSettings = AgentRuntimeSettings() agent_runtime: AgentRuntimeSettings = AgentRuntimeSettings()
taskiq: TaskiqSettings = TaskiqSettings() taskiq: TaskiqSettings = TaskiqSettings()
database: DatabaseSettings = DatabaseSettings() database: DatabaseSettings = DatabaseSettings()
app_version: AppVersionSettings = AppVersionSettings()
@computed_field @computed_field
@property @property
+116
View File
@@ -0,0 +1,116 @@
from __future__ import annotations
import re
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, Query
from pydantic import BaseModel, Field
from core.config.settings import config
class AppVersionInfo(BaseModel):
has_update: bool = Field(description="是否有新版本可用")
latest_version: str = Field(description="最新版本号,如 0.1.0")
latest_build: int = Field(description="最新构建号")
min_required_version: str = Field(description="强制更新版本号")
update_type: Literal["none", "optional", "required"] = Field(
description="更新类型: none=无更新, optional=可选更新, required=必须更新"
)
download_url: str | None = Field(default=None, description="安装包下载链接")
release_notes: str | None = Field(default=None, description="版本更新说明")
router = APIRouter(prefix="/app", tags=["app"])
def _parse_version(filename: str) -> tuple[str, int] | None:
pattern = r"app[-_]v?(\d+\.\d+\.\d+)\+(\d+)\.(?:apk|ipa)"
match = re.search(pattern, filename, re.IGNORECASE)
if match:
version = match.group(1)
build = int(match.group(2))
return (version, build)
return None
def _get_latest_release(
platform: Literal["ios", "android"],
) -> tuple[str, int, str] | None:
releases_dir = config.app_version.releases_dir
base_path = Path.cwd().parent / "deploy" / "static" / releases_dir
if not base_path.exists():
return None
ext = "ipa" if platform == "ios" else "apk"
candidates = []
for f in base_path.iterdir():
if f.is_file() and f.suffix.lstrip(".").lower() == ext.lower():
parsed = _parse_version(f.name)
if parsed:
version, build = parsed
candidates.append((version, build, f.name))
if not candidates:
return None
candidates.sort(key=lambda x: (x[0], x[1]), reverse=True)
return candidates[0][0], candidates[0][1], candidates[0][2]
def _compare_versions(
current_version: str, current_build: int, latest_version: str, latest_build: int
) -> tuple[bool, Literal["none", "optional", "required"]]:
if current_build >= latest_build:
return False, "none"
if current_build < latest_build - 2:
return True, "required"
return True, "optional"
@router.get("/check-updates", response_model=AppVersionInfo)
async def check_updates(
current_version: str | None = Query(None, description="前端当前版本,如 0.1.0"),
current_build: int | None = Query(None, description="前端当前构建号,如 1"),
platform: Literal["ios", "android"] = Query("ios", description="平台类型"),
) -> AppVersionInfo:
current_build = current_build or 0
latest = _get_latest_release(platform)
if not latest:
return AppVersionInfo(
has_update=False,
latest_version=config.app_version.current_version,
latest_build=config.app_version.current_build,
min_required_version=config.app_version.current_version,
update_type="none",
download_url=None,
release_notes=None,
)
latest_version, latest_build, filename = latest
has_update, update_type = _compare_versions(
current_version or "0.0.0",
current_build,
latest_version,
latest_build,
)
download_url: str | None = None
if has_update and config.app_version.download_base_url:
download_url = f"{config.app_version.download_base_url.rstrip('/')}/{config.app_version.releases_dir}/{filename}"
return AppVersionInfo(
has_update=has_update,
latest_version=latest_version,
latest_build=latest_build,
min_required_version=latest_version,
update_type=update_type,
download_url=download_url,
release_notes="问题修复和体验优化",
)
+3
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from fastapi import APIRouter from fastapi import APIRouter
from v1.agent.router import router as agent_router from v1.agent.router import router as agent_router
from v1.app.router import router as app_router
from v1.auth.router import router as auth_router from v1.auth.router import router as auth_router
from v1.friendships.router import router as friendships_router from v1.friendships.router import router as friendships_router
from v1.inbox_messages.router import router as inbox_messages_router from v1.inbox_messages.router import router as inbox_messages_router
@@ -12,8 +13,10 @@ from v1.users.router import router as users_router
router = APIRouter(prefix="/api/v1") router = APIRouter(prefix="/api/v1")
router.include_router(app_router)
router.include_router(auth_router) router.include_router(auth_router)
router.include_router(agent_router) router.include_router(agent_router)
router.include_router(agent_router)
router.include_router(friendships_router) router.include_router(friendships_router)
router.include_router(users_router) router.include_router(users_router)
router.include_router(schedule_items_router) router.include_router(schedule_items_router)
+5
View File
@@ -68,6 +68,7 @@ services:
condition: service_healthy condition: service_healthy
volumes: volumes:
- ../logs:/app/logs - ../logs:/app/logs
- ./static/releases:/app/static/releases:ro
healthcheck: healthcheck:
test: test:
[ [
@@ -104,6 +105,7 @@ services:
condition: service_healthy condition: service_healthy
volumes: volumes:
- ../logs:/app/logs - ../logs:/app/logs
- ./static/releases:/app/static/releases:ro
worker-default: worker-default:
image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod}
@@ -129,6 +131,7 @@ services:
condition: service_healthy condition: service_healthy
volumes: volumes:
- ../logs:/app/logs - ../logs:/app/logs
- ./static/releases:/app/static/releases:ro
worker-bulk: worker-bulk:
image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod}
@@ -154,6 +157,7 @@ services:
condition: service_healthy condition: service_healthy
volumes: volumes:
- ../logs:/app/logs - ../logs:/app/logs
- ./static/releases:/app/static/releases:ro
init-job: init-job:
image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod}
@@ -178,6 +182,7 @@ services:
condition: service_healthy condition: service_healthy
volumes: volumes:
- ../logs:/app/logs - ../logs:/app/logs
- ./static/releases:/app/static/releases:ro
profiles: profiles:
- job - job