feat:增加在线更新功能&&增加全自动部署脚本

This commit is contained in:
amos
2025-12-19 16:21:32 +08:00
parent 9d580f5a18
commit 60d3a96c2a
17 changed files with 730 additions and 69 deletions

View File

@@ -13,10 +13,11 @@ android {
applicationId = "com.memory.app"
minSdk = 26
targetSdk = 35
versionCode = 3
versionName = "1.1.1"
versionCode = 9
versionName = "1.2.5"
buildConfigField("String", "API_BASE_URL", "\"https://x.amos.us.kg/api/\"")
buildConfigField("int", "VERSION_CODE", "9")
}
signingConfigs {

View File

@@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:allowBackup="true"
@@ -23,6 +24,16 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -17,8 +17,11 @@ import coil.memory.MemoryCache
import com.memory.app.data.CredentialsManager
import com.memory.app.data.ThemeManager
import com.memory.app.data.ThemeMode
import com.memory.app.data.UpdateManager
import com.memory.app.data.model.User
import com.memory.app.data.model.VersionInfo
import com.memory.app.data.repository.AuthRepository
import com.memory.app.ui.components.UpdateDialog
import com.memory.app.ui.navigation.MainNavigation
import com.memory.app.ui.screen.LoginScreen
import com.memory.app.ui.theme.MemoryTheme
@@ -30,6 +33,7 @@ class MainActivity : ComponentActivity() {
private lateinit var authRepository: AuthRepository
private lateinit var themeManager: ThemeManager
private lateinit var credentialsManager: CredentialsManager
private lateinit var updateManager: UpdateManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -37,6 +41,7 @@ class MainActivity : ComponentActivity() {
themeManager = ThemeManager(this)
credentialsManager = CredentialsManager(this)
updateManager = UpdateManager(this)
// 配置 Coil 图片加载器
val imageLoader = ImageLoader.Builder(this)
@@ -75,6 +80,7 @@ class MainActivity : ComponentActivity() {
var currentUser by remember { mutableStateOf<User?>(null) }
var isLoading by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var updateInfo by remember { mutableStateOf<VersionInfo?>(null) }
// Check login state
LaunchedEffect(Unit) {
@@ -85,6 +91,27 @@ class MainActivity : ComponentActivity() {
isLoggedIn = restored
}
// Check for updates
LaunchedEffect(Unit) {
updateInfo = updateManager.checkUpdate()
}
// Update dialog
updateInfo?.let { info ->
UpdateDialog(
versionInfo = info,
onUpdate = {
updateManager.downloadAndInstall(info)
if (!info.forceUpdate) {
updateInfo = null
}
},
onDismiss = {
updateInfo = null
}
)
}
// Observe user changes
LaunchedEffect(Unit) {
authRepository.currentUser.collect { user ->
@@ -153,6 +180,21 @@ class MainActivity : ComponentActivity() {
},
onToggleTheme = {
themeManager.toggleTheme()
},
onCheckUpdate = {
lifecycleScope.launch {
val info = updateManager.checkUpdate()
if (info != null) {
updateInfo = info
} else {
// 已是最新版本,可以显示 Toast
android.widget.Toast.makeText(
this@MainActivity,
"已是最新版本",
android.widget.Toast.LENGTH_SHORT
).show()
}
}
}
)
}

View File

@@ -0,0 +1,93 @@
package com.memory.app.data
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Build
import android.os.Environment
import androidx.core.content.FileProvider
import com.memory.app.BuildConfig
import com.memory.app.data.api.ApiClient
import com.memory.app.data.model.VersionInfo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
class UpdateManager(private val context: Context) {
suspend fun checkUpdate(): VersionInfo? {
return withContext(Dispatchers.IO) {
try {
val response = ApiClient.apiNoAuth.getLatestVersion()
if (response.isSuccessful) {
val versionInfo = response.body()
if (versionInfo != null && versionInfo.versionCode > BuildConfig.VERSION_CODE) {
versionInfo
} else {
null
}
} else {
null
}
} catch (e: Exception) {
e.printStackTrace()
null
}
}
}
fun downloadAndInstall(versionInfo: VersionInfo) {
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
// 删除旧的 APK 文件
val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "memory_update.apk")
if (apkFile.exists()) {
apkFile.delete()
}
val request = DownloadManager.Request(Uri.parse(versionInfo.downloadUrl))
.setTitle("Memory 更新")
.setDescription("正在下载 v${versionInfo.versionName}")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, "memory_update.apk")
.setAllowedOverMetered(true)
.setAllowedOverRoaming(true)
val downloadId = downloadManager.enqueue(request)
// 注册下载完成广播
val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
if (id == downloadId) {
installApk(apkFile)
context.unregisterReceiver(this)
}
}
}
context.registerReceiver(
receiver,
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
Context.RECEIVER_NOT_EXPORTED
)
}
private fun installApk(apkFile: File) {
val intent = Intent(Intent.ACTION_VIEW)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
val apkUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apkFile)
} else {
Uri.fromFile(apkFile)
}
intent.setDataAndType(apkUri, "application/vnd.android.package-archive")
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
context.startActivity(intent)
}
}

View File

@@ -51,6 +51,26 @@ object ApiClient {
retrofit.create(ApiService::class.java)
}
// 不需要认证的 API 客户端(用于版本检查等)
private val okHttpClientNoAuth by lazy {
OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()
}
private val retrofitNoAuth by lazy {
Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(okHttpClientNoAuth)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
}
val apiNoAuth: ApiService by lazy {
retrofitNoAuth.create(ApiService::class.java)
}
fun setToken(newToken: String?) {
token = newToken
}

View File

@@ -110,4 +110,8 @@ interface ApiService {
@PUT("admin/settings")
suspend fun updateSettings(@Body settings: Map<String, String>): Response<MessageResponse>
// Version
@GET("version")
suspend fun getLatestVersion(): Response<VersionInfo>
}

View File

@@ -172,3 +172,12 @@ data class MessageResponse(
data class ErrorResponse(
val error: String
)
@Serializable
data class VersionInfo(
@SerialName("version_code") val versionCode: Int,
@SerialName("version_name") val versionName: String,
@SerialName("download_url") val downloadUrl: String,
@SerialName("update_log") val updateLog: String,
@SerialName("force_update") val forceUpdate: Boolean
)

View File

@@ -0,0 +1,104 @@
package com.memory.app.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.memory.app.data.model.VersionInfo
import com.memory.app.ui.theme.Brand500
@Composable
fun UpdateDialog(
versionInfo: VersionInfo,
onUpdate: () -> Unit,
onDismiss: () -> Unit
) {
Dialog(onDismissRequest = { if (!versionInfo.forceUpdate) onDismiss() }) {
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 8.dp,
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "🎉",
fontSize = 48.sp
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "发现新版本",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "v${versionInfo.versionName}",
fontSize = 16.sp,
color = Brand500,
fontWeight = FontWeight.Medium
)
if (versionInfo.updateLog.isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp))
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = versionInfo.updateLog,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(12.dp),
lineHeight = 20.sp
)
}
}
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = onUpdate,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = Brand500),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = "立即更新",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(vertical = 4.dp)
)
}
if (!versionInfo.forceUpdate) {
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = onDismiss) {
Text(
text = "稍后再说",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}

View File

@@ -49,7 +49,8 @@ fun MainNavigation(
likeCount: Int,
isDarkTheme: Boolean = false,
onLogout: () -> Unit,
onToggleTheme: () -> Unit = {}
onToggleTheme: () -> Unit = {},
onCheckUpdate: () -> Unit = {}
) {
val navController = rememberNavController()
var showCreatePost by remember { mutableStateOf(false) }
@@ -125,7 +126,8 @@ fun MainNavigation(
onUpdateAvatar = { uri ->
profileViewModel.updateAvatar(context, uri)
},
onToggleTheme = onToggleTheme
onToggleTheme = onToggleTheme,
onCheckUpdate = onCheckUpdate
)
}

View File

@@ -60,40 +60,13 @@ fun HomeScreen(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
shadowElevation = 0.dp
) {
Row(
Box(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 20.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
contentAlignment = Alignment.Center
) {
// Avatar with ring
Box(
modifier = Modifier
.size(36.dp)
.shadow(2.dp, CircleShape)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
if (user?.avatarUrl?.isNotEmpty() == true) {
AsyncImage(
model = user?.avatarUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Text(
text = user?.nickname?.firstOrNull()?.toString() ?: "?",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold,
fontSize = 14.sp
)
}
}
// Title
Text(
text = "时间轴",
@@ -101,19 +74,6 @@ fun HomeScreen(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
// Bell Icon
IconButton(
onClick = { },
modifier = Modifier.size(36.dp)
) {
Icon(
Icons.Outlined.Notifications,
contentDescription = "通知",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
}
}
}

View File

@@ -17,6 +17,7 @@ import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.outlined.DarkMode
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.LightMode
import androidx.compose.material.icons.outlined.SystemUpdate
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -46,7 +47,8 @@ fun ProfileScreen(
onLogout: () -> Unit,
onUpdateNickname: ((String) -> Unit)? = null,
onUpdateAvatar: ((Uri) -> Unit)? = null,
onToggleTheme: (() -> Unit)? = null
onToggleTheme: (() -> Unit)? = null,
onCheckUpdate: (() -> Unit)? = null
) {
var isEditing by remember { mutableStateOf(false) }
var editedNickname by remember(user) { mutableStateOf(user?.nickname ?: "") }
@@ -76,29 +78,57 @@ fun ProfileScreen(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Theme Toggle Button
Surface(
onClick = { onToggleTheme?.invoke() },
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.surfaceVariant
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// Theme Toggle Button
Surface(
onClick = { onToggleTheme?.invoke() },
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.surfaceVariant
) {
Icon(
imageVector = if (isDarkTheme) Icons.Outlined.DarkMode else Icons.Outlined.LightMode,
contentDescription = "切换主题",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = if (isDarkTheme) "深色" else "浅色",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 14.sp,
fontWeight = FontWeight.Medium
)
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (isDarkTheme) Icons.Outlined.DarkMode else Icons.Outlined.LightMode,
contentDescription = "切换主题",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = if (isDarkTheme) "深色" else "浅色",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 14.sp,
fontWeight = FontWeight.Medium
)
}
}
// Check Update Button
Surface(
onClick = { onCheckUpdate?.invoke() },
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.surfaceVariant
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.SystemUpdate,
contentDescription = "检查更新",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "更新",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 14.sp,
fontWeight = FontWeight.Medium
)
}
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path
name="downloads"
path="Download/" />
</paths>

271
release.sh Executable file
View File

@@ -0,0 +1,271 @@
#!/bin/bash
# Memory App 发布脚本
# 功能:自动递增版本号、编译 APK、上传到 R2、更新服务器版本信息
set -e
# ============ 配置区域 ============
# R2 配置
R2_ACCOUNT_ID="ebf33b5ee4eb26f32af0c6e06102e000"
R2_ACCESS_KEY_ID="8acbc8a9386d60d0e8dac6bd8165c618"
R2_ACCESS_KEY_SECRET="72935e23b5b4be8fda99008e75285e8ac778f8926656c42780b25785bb149443"
R2_BUCKET_NAME="memory"
R2_PUBLIC_URL="https://cdn.amos.us.kg"
# API 配置
API_BASE_URL="https://x.amos.us.kg/api"
# 服务器配置
SERVER_HOST="95.181.190.226"
SERVER_PORT="33"
SERVER_USER="root"
SERVER_PASSWORD="xiaobiao."
SERVER_PROJECT_PATH="/amos/memory"
# 签名配置
KEYSTORE_FILE="release.keystore"
KEYSTORE_PASSWORD="memory123"
KEY_ALIAS="memory"
KEY_PASSWORD="memory123"
# ============ 函数定义 ============
# 获取当前版本号
get_current_version() {
grep "versionName" android/app/build.gradle.kts | head -1 | sed 's/.*"\(.*\)".*/\1/'
}
# 获取当前版本代码
get_current_version_code() {
grep "versionCode" android/app/build.gradle.kts | head -1 | sed 's/[^0-9]*//g'
}
# 递增版本号 (1.1.9 -> 1.2.0, 1.1.0 -> 1.1.1)
increment_version() {
local version=$1
local major=$(echo $version | cut -d. -f1)
local minor=$(echo $version | cut -d. -f2)
local patch=$(echo $version | cut -d. -f3)
patch=$((patch + 1))
if [ $patch -ge 10 ]; then
patch=0
minor=$((minor + 1))
if [ $minor -ge 10 ]; then
minor=0
major=$((major + 1))
fi
fi
echo "${major}.${minor}.${patch}"
}
# 更新 build.gradle.kts 中的版本号
update_version_in_gradle() {
local new_version=$1
local new_code=$2
sed -i '' "s/versionCode = [0-9]*/versionCode = ${new_code}/" android/app/build.gradle.kts
sed -i '' "s/versionName = \"[^\"]*\"/versionName = \"${new_version}\"/" android/app/build.gradle.kts
sed -i '' "s/VERSION_CODE\", \"[0-9]*\"/VERSION_CODE\", \"${new_code}\"/" android/app/build.gradle.kts
}
# 编译 Docker 镜像并导出
build_docker() {
echo "🐳 编译 Docker 镜像..."
cd server
docker build --platform linux/amd64 -t memory-server:latest .
docker save memory-server:latest -o memory-server.tar
cd ..
echo "✅ Docker 镜像已导出到 server/memory-server.tar"
}
# 部署 Docker 镜像到服务器
deploy_docker() {
echo "🚀 部署 Docker 镜像到服务器..."
# 上传镜像到服务器
echo "📤 上传镜像文件..."
sshpass -p "${SERVER_PASSWORD}" scp -P ${SERVER_PORT} -o StrictHostKeyChecking=no server/memory-server.tar ${SERVER_USER}@${SERVER_HOST}:${SERVER_PROJECT_PATH}/
# 在服务器上加载镜像并重启
echo "🔄 加载镜像并重启服务..."
sshpass -p "${SERVER_PASSWORD}" ssh -p ${SERVER_PORT} -o StrictHostKeyChecking=no ${SERVER_USER}@${SERVER_HOST} << EOF
cd ${SERVER_PROJECT_PATH}
docker load -i memory-server.tar
docker compose down
docker compose up -d
# 清理旧镜像
echo "🧹 清理旧镜像..."
docker image prune -f
# 删除镜像文件
rm -f memory-server.tar
echo "✅ 服务已重启"
EOF
echo "✅ 部署完成!"
}
# 编译 APK
build_apk() {
echo "📦 编译 APK..."
cd android
KEYSTORE_FILE=$KEYSTORE_FILE \
KEYSTORE_PASSWORD=$KEYSTORE_PASSWORD \
KEY_ALIAS=$KEY_ALIAS \
KEY_PASSWORD=$KEY_PASSWORD \
gradle assembleRelease --quiet
cd ..
}
# 上传到 R2
upload_to_r2() {
local version=$1
local apk_path="android/app/build/outputs/apk/release/app-release.apk"
local remote_path="releases/memory-${version}.apk"
echo "☁️ 上传到 R2..."
# 使用 AWS CLI 上传 (需要配置 AWS CLI)
AWS_ACCESS_KEY_ID=$R2_ACCESS_KEY_ID \
AWS_SECRET_ACCESS_KEY=$R2_ACCESS_KEY_SECRET \
aws s3 cp "$apk_path" "s3://${R2_BUCKET_NAME}/${remote_path}" \
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \
--content-type "application/vnd.android.package-archive"
echo "${R2_PUBLIC_URL}/${remote_path}"
}
# 更新服务器版本信息
update_server_version() {
local version_code=$1
local version_name=$2
local download_url=$3
local update_log=$4
echo "🔄 更新服务器版本信息..."
curl -s -X POST "${API_BASE_URL}/admin/version" \
-H "Content-Type: application/json" \
-d "{
\"version_code\": ${version_code},
\"version_name\": \"${version_name}\",
\"download_url\": \"${download_url}\",
\"update_log\": \"${update_log}\",
\"force_update\": false
}"
}
# ============ 主流程 ============
echo "🚀 Memory App 发布脚本"
echo "========================"
# 选择发布内容
echo ""
echo "请选择要发布的内容:"
echo " 1) 只发布 APK"
echo " 2) 只部署后端"
echo " 3) 发布 APK + 部署后端"
echo ""
read -p "请选择 (1/2/3): " choice
BUILD_APK=false
DEPLOY_BACKEND=false
case $choice in
1)
BUILD_APK=true
;;
2)
DEPLOY_BACKEND=true
;;
3)
BUILD_APK=true
DEPLOY_BACKEND=true
;;
*)
echo "❌ 无效选择"
exit 1
;;
esac
# APK 发布流程
if [ "$BUILD_APK" = true ]; then
# 获取当前版本
current_version=$(get_current_version)
current_code=$(get_current_version_code)
echo ""
echo "📌 当前版本: v${current_version} (code: ${current_code})"
# 计算新版本
new_version=$(increment_version $current_version)
new_code=$((current_code + 1))
echo "📌 新版本: v${new_version} (code: ${new_code})"
# 获取更新日志
echo ""
echo "📝 请输入更新日志 (输入空行结束):"
update_log=""
while IFS= read -r line; do
[ -z "$line" ] && break
if [ -z "$update_log" ]; then
update_log="$line"
else
update_log="${update_log}\\n${line}"
fi
done
if [ -z "$update_log" ]; then
update_log="Bug 修复和性能优化"
fi
echo ""
echo "📋 发布信息:"
echo " 版本: v${new_version} (code: ${new_code})"
echo " 更新日志: ${update_log}"
echo ""
read -p "确认发布 APK? (y/n) " confirm
if [ "$confirm" != "y" ]; then
echo "❌ APK 发布已取消"
BUILD_APK=false
else
# 更新版本号
echo ""
echo "📝 更新版本号..."
update_version_in_gradle $new_version $new_code
# 编译
build_apk
# 上传
download_url=$(upload_to_r2 $new_version)
echo "✅ 上传完成: $download_url"
# 更新服务器
update_server_version $new_code "$new_version" "$download_url" "$update_log"
echo ""
echo "🎉 APK 发布完成!"
echo " 版本: v${new_version}"
echo " 下载: ${download_url}"
fi
fi
# 后端部署流程
if [ "$DEPLOY_BACKEND" = true ]; then
echo ""
read -p "确认部署后端? (y/n) " deploy_confirm
if [ "$deploy_confirm" = "y" ]; then
build_docker
deploy_docker
echo ""
echo "🎉 后端部署完成!"
else
echo "❌ 后端部署已取消"
fi
fi
echo ""
echo "✨ 全部完成!"

View File

@@ -127,5 +127,18 @@ func migrate(db *sql.DB) error {
// 设置 amos 为超管
db.Exec("UPDATE users SET is_superadmin = 1 WHERE username = 'amos'")
// 迁移:创建版本表
db.Exec(`
CREATE TABLE IF NOT EXISTS app_version (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version_code INTEGER NOT NULL,
version_name TEXT NOT NULL,
download_url TEXT NOT NULL,
update_log TEXT DEFAULT '',
force_update INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
return nil
}

View File

@@ -0,0 +1,90 @@
package handler
import (
"database/sql"
"net/http"
"memory/internal/config"
"github.com/gin-gonic/gin"
)
type VersionHandler struct {
db *sql.DB
cfg *config.Config
}
func NewVersionHandler(db *sql.DB, cfg *config.Config) *VersionHandler {
return &VersionHandler{db: db, cfg: cfg}
}
type VersionInfo struct {
VersionCode int `json:"version_code"`
VersionName string `json:"version_name"`
DownloadURL string `json:"download_url"`
UpdateLog string `json:"update_log"`
ForceUpdate bool `json:"force_update"`
}
// 获取最新版本信息
func (h *VersionHandler) GetLatestVersion(c *gin.Context) {
var versionCode int
var versionName, downloadURL, updateLog string
var forceUpdate int
err := h.db.QueryRow(`
SELECT version_code, version_name, download_url, update_log, force_update
FROM app_version ORDER BY version_code DESC LIMIT 1
`).Scan(&versionCode, &versionName, &downloadURL, &updateLog, &forceUpdate)
if err == sql.ErrNoRows {
// 没有版本信息,返回默认值
c.JSON(http.StatusOK, VersionInfo{
VersionCode: 1,
VersionName: "1.0.0",
DownloadURL: "",
UpdateLog: "",
ForceUpdate: false,
})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
return
}
c.JSON(http.StatusOK, VersionInfo{
VersionCode: versionCode,
VersionName: versionName,
DownloadURL: downloadURL,
UpdateLog: updateLog,
ForceUpdate: forceUpdate == 1,
})
}
// 设置版本信息(管理员接口)
func (h *VersionHandler) SetVersion(c *gin.Context) {
var req VersionInfo
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
forceUpdate := 0
if req.ForceUpdate {
forceUpdate = 1
}
_, err := h.db.Exec(`
INSERT INTO app_version (version_code, version_name, download_url, update_log, force_update)
VALUES (?, ?, ?, ?, ?)
`, req.VersionCode, req.VersionName, req.DownloadURL, req.UpdateLog, forceUpdate)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set version"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "version updated"})
}

View File

@@ -43,9 +43,13 @@ func Setup(db *sql.DB, cfg *config.Config) *gin.Engine {
// R2 文件代理 (公开访问)
r.GET("/files/*filepath", uploadHandler.GetFile)
// Version handler
versionHandler := handler.NewVersionHandler(db, cfg)
// 公开接口
r.POST("/api/auth/register", authHandler.Register)
r.POST("/api/auth/login", authHandler.Login)
r.GET("/api/version", versionHandler.GetLatestVersion)
// 需要认证的接口
auth := r.Group("/api")
@@ -86,6 +90,7 @@ func Setup(db *sql.DB, cfg *config.Config) *gin.Engine {
{
admin.GET("/settings", userHandler.GetSettings)
admin.PUT("/settings", userHandler.UpdateSettings)
admin.POST("/version", versionHandler.SetVersion)
}
}

Binary file not shown.