健康日记项目初始化

This commit is contained in:
amos wong
2025-12-20 23:51:40 +08:00
commit 0c733f9a36
44 changed files with 2723 additions and 0 deletions

14
.env.production Normal file
View File

@@ -0,0 +1,14 @@
# 生产环境配置 - 复制到服务器的 /amos/healthFlow/.env
# JWT 密钥 (请修改为随机字符串)
JWT_SECRET=healthflow-app-jwt-secret-2025
# 服务器公网地址
BASE_URL=https://health.amos.us.kg
# Cloudflare R2
R2_ACCOUNT_ID=ebf33b5ee4eb26f32af0c6e06102e000
R2_ACCESS_KEY_ID=8acbc8a9386d60d0e8dac6bd8165c618
R2_ACCESS_KEY_SECRET=72935e23b5b4be8fda99008e75285e8ac778f8926656c42780b25785bb149443
R2_BUCKET_NAME=healthflow
R2_PUBLIC_URL=https://cdn-health.amos.us.kg

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Android
android/.gradle/
android/build/
android/app/build/
android/local.properties
android/*.iml
android/.idea/
*.apk
*.aab
# Go
server/healthflow-server
server/*.tar
server/data/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.local

41
README.md Normal file
View File

@@ -0,0 +1,41 @@
# HealthFlow
健康管理应用 - Android + Go 后端
## 技术栈
### Android
- Kotlin 2.0.21
- Jetpack Compose (BOM 2024.12.01)
- Material 3
- Retrofit + OkHttp
- Coil (图片加载)
- DataStore
### 后端
- Go 1.21
- Gin Web Framework
- SQLite
- JWT 认证
- Cloudflare R2 (文件存储)
## 项目结构
```
healthFlow/
├── android/ # Android 应用
├── server/ # Go 后端
├── docker-compose.yml
├── release.sh # 发布脚本
└── .env.production # 生产环境配置
```
## 部署
服务器端口: 6601
服务器目录: /amos/healthFlow
```bash
# 发布
./release.sh
```

View File

@@ -0,0 +1,90 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "com.healthflow.app"
compileSdk = 35
defaultConfig {
applicationId = "com.healthflow.app"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
buildConfigField("String", "API_BASE_URL", "\"https://health.amos.us.kg/api/\"")
buildConfigField("int", "VERSION_CODE", "1")
}
signingConfigs {
create("release") {
storeFile = file(System.getenv("KEYSTORE_FILE") ?: "release.keystore")
storePassword = System.getenv("KEYSTORE_PASSWORD") ?: ""
keyAlias = System.getenv("KEY_ALIAS") ?: "healthflow"
keyPassword = System.getenv("KEY_PASSWORD") ?: ""
}
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs.getByName("release")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
lint {
checkReleaseBuilds = false
abortOnError = false
}
}
dependencies {
// Compose
implementation(platform("androidx.compose:compose-bom:2024.12.01"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.navigation:navigation-compose:2.8.5")
// Lifecycle
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
// Network
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
// Image loading
implementation("io.coil-kt:coil-compose:2.7.0")
// DataStore
implementation("androidx.datastore:datastore-preferences:1.1.1")
// Debug
debugImplementation("androidx.compose.ui:ui-tooling")
}

33
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,33 @@
# Retrofit
-keepattributes Signature
-keepattributes *Annotation*
-keep class retrofit2.** { *; }
-keepclasseswithmembers class * {
@retrofit2.http.* <methods>;
}
# OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**
-keep class okhttp3.** { *; }
-keep class okio.** { *; }
# Kotlinx Serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclassmembers class kotlinx.serialization.json.** {
*** Companion;
}
-keepclasseswithmembers class kotlinx.serialization.json.** {
kotlinx.serialization.KSerializer serializer(...);
}
-keep,includedescriptorclasses class com.healthflow.app.**$$serializer { *; }
-keepclassmembers class com.healthflow.app.** {
*** Companion;
}
-keepclasseswithmembers class com.healthflow.app.** {
kotlinx.serialization.KSerializer serializer(...);
}
# Coil
-keep class coil.** { *; }

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".HealthFlowApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.HealthFlow"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.HealthFlow">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,38 @@
package com.healthflow.app
import android.app.Application
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.disk.DiskCache
import coil.memory.MemoryCache
import coil.request.CachePolicy
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
class HealthFlowApp : Application(), ImageLoaderFactory {
override fun newImageLoader(): ImageLoader {
val okHttpClient = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
return ImageLoader.Builder(this)
.okHttpClient(okHttpClient)
.memoryCache {
MemoryCache.Builder(this)
.maxSizePercent(0.30)
.build()
}
.diskCache {
DiskCache.Builder()
.directory(cacheDir.resolve("image_cache"))
.maxSizeBytes(100 * 1024 * 1024) // 100MB
.build()
}
.memoryCachePolicy(CachePolicy.ENABLED)
.diskCachePolicy(CachePolicy.ENABLED)
.crossfade(150)
.build()
}
}

View File

@@ -0,0 +1,64 @@
package com.healthflow.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import com.healthflow.app.data.api.ApiClient
import com.healthflow.app.data.local.TokenManager
import com.healthflow.app.ui.navigation.MainNavigation
import com.healthflow.app.ui.screen.LoginScreen
import com.healthflow.app.ui.theme.HealthFlowTheme
import com.healthflow.app.ui.viewmodel.AuthViewModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// 初始化 TokenManager
val tokenManager = TokenManager(this)
ApiClient.init(tokenManager)
// 检查是否已登录
val initialToken = runBlocking { tokenManager.token.first() }
setContent {
val authViewModel: AuthViewModel = viewModel()
val isLoggedIn by authViewModel.isLoggedIn.collectAsState()
val user by authViewModel.user.collectAsState()
LaunchedEffect(Unit) {
if (initialToken != null) {
authViewModel.checkAuth()
}
}
HealthFlowTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
if (isLoggedIn && user != null) {
MainNavigation(
user = user,
onLogout = { authViewModel.logout() }
)
} else {
LoginScreen(
onLoginSuccess = { authViewModel.checkAuth() }
)
}
}
}
}
}
}

View File

@@ -0,0 +1,67 @@
package com.healthflow.app.data.api
import com.healthflow.app.BuildConfig
import com.healthflow.app.data.local.TokenManager
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import java.util.concurrent.TimeUnit
object ApiClient {
private lateinit var tokenManager: TokenManager
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
}
private val authInterceptor = Interceptor { chain ->
val token = runBlocking { tokenManager.token.first() }
val request = chain.request().newBuilder().apply {
token?.let { addHeader("Authorization", "Bearer $it") }
}.build()
chain.proceed(request)
}
private val loggingInterceptor = HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}
private val okHttpClient by lazy {
OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
}
private val retrofit by lazy {
Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(okHttpClient)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
}
val api: ApiService by lazy {
retrofit.create(ApiService::class.java)
}
fun init(tokenManager: TokenManager) {
this.tokenManager = tokenManager
}
fun getTokenManager(): TokenManager = tokenManager
}

View File

@@ -0,0 +1,34 @@
package com.healthflow.app.data.api
import com.healthflow.app.data.model.*
import okhttp3.MultipartBody
import retrofit2.Response
import retrofit2.http.*
interface ApiService {
// Auth
@POST("auth/login")
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
@POST("auth/register")
suspend fun register(@Body request: RegisterRequest): Response<LoginResponse>
// User
@GET("user/profile")
suspend fun getProfile(): Response<ProfileResponse>
@PUT("user/profile")
suspend fun updateProfile(@Body request: UpdateProfileRequest): Response<MessageResponse>
@PUT("user/avatar")
suspend fun updateAvatar(@Body request: Map<String, String>): Response<MessageResponse>
// Upload
@Multipart
@POST("upload")
suspend fun upload(@Part file: MultipartBody.Part): Response<UploadResponse>
// Version
@GET("version")
suspend fun getVersion(): Response<VersionInfo>
}

View File

@@ -0,0 +1,34 @@
package com.healthflow.app.data.local
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "healthflow_prefs")
class TokenManager(private val context: Context) {
companion object {
private val TOKEN_KEY = stringPreferencesKey("auth_token")
}
val token: Flow<String?> = context.dataStore.data.map { preferences ->
preferences[TOKEN_KEY]
}
suspend fun saveToken(token: String) {
context.dataStore.edit { preferences ->
preferences[TOKEN_KEY] = token
}
}
suspend fun clearToken() {
context.dataStore.edit { preferences ->
preferences.remove(TOKEN_KEY)
}
}
}

View File

@@ -0,0 +1,70 @@
package com.healthflow.app.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class User(
val id: Long = 0,
val username: String = "",
val nickname: String = "",
@SerialName("avatar_url") val avatarUrl: String = "",
val bio: String = "",
@SerialName("is_admin") val isAdmin: Boolean = false,
@SerialName("created_at") val createdAt: String = ""
)
@Serializable
data class LoginRequest(
val username: String,
val password: String
)
@Serializable
data class RegisterRequest(
val username: String,
val password: String,
val nickname: String
)
@Serializable
data class LoginResponse(
val token: String,
val user: User
)
@Serializable
data class ProfileResponse(
val user: User,
@SerialName("created_at") val createdAt: String = ""
)
@Serializable
data class UpdateProfileRequest(
val nickname: String,
val bio: String = ""
)
@Serializable
data class UploadResponse(
val url: 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 = false
)
@Serializable
data class MessageResponse(
val message: String
)
@Serializable
data class ErrorResponse(
val error: String
)

View File

@@ -0,0 +1,108 @@
package com.healthflow.app.ui.navigation
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.healthflow.app.data.model.User
import com.healthflow.app.ui.screen.*
import com.healthflow.app.ui.theme.*
sealed class Screen(
val route: String,
val title: String,
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector
) {
data object Home : Screen("home", "首页", Icons.Filled.Home, Icons.Outlined.Home)
data object Profile : Screen("profile", "我的", Icons.Filled.Person, Icons.Outlined.Person)
}
val bottomNavItems = listOf(Screen.Home, Screen.Profile)
@Composable
fun MainNavigation(
user: User?,
onLogout: () -> Unit
) {
val navController = rememberNavController()
Box(modifier = Modifier.fillMaxSize()) {
NavHost(
navController = navController,
startDestination = Screen.Home.route,
modifier = Modifier.fillMaxSize()
) {
composable(Screen.Home.route) {
HomeScreen()
}
composable(Screen.Profile.route) {
ProfileScreen(
user = user,
onLogout = onLogout
)
}
}
// Bottom Navigation
val navBackStackEntry by navController.currentBackStackEntryAsState()
Surface(
modifier = Modifier
.fillMaxWidth()
.align(androidx.compose.ui.Alignment.BottomCenter),
color = MaterialTheme.colorScheme.surface
) {
Column {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.height(64.dp)
.padding(bottom = 8.dp),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
bottomNavItems.forEach { screen ->
val selected = navBackStackEntry?.destination?.hierarchy?.any {
it.route == screen.route
} == true
IconButton(
onClick = {
navController.navigate(screen.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = if (selected) screen.selectedIcon else screen.unselectedIcon,
contentDescription = screen.title,
tint = if (selected) Brand500 else Slate400,
modifier = Modifier.size(24.dp)
)
}
}
}
}
}
}
}

View File

@@ -0,0 +1,84 @@
package com.healthflow.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.healthflow.app.ui.theme.*
@Composable
fun HomeScreen() {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) {
// Header
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface
) {
Box(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 20.dp, vertical = 12.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "首页",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp)
// Content
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(32.dp)
) {
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(Brand100),
contentAlignment = Alignment.Center
) {
Text(text = "💚", fontSize = 36.sp)
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "欢迎使用 HealthFlow",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "健康功能即将上线",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}

View File

@@ -0,0 +1,230 @@
package com.healthflow.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
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.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.healthflow.app.data.api.ApiClient
import com.healthflow.app.data.model.LoginRequest
import com.healthflow.app.data.model.RegisterRequest
import com.healthflow.app.ui.theme.*
import kotlinx.coroutines.launch
@Composable
fun LoginScreen(onLoginSuccess: () -> Unit) {
var isLogin by remember { mutableStateOf(true) }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var nickname by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
var isLoading by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) {
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(80.dp))
// Logo
Text(
text = "💚",
fontSize = 64.sp
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "HealthFlow",
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
color = Brand500
)
Text(
text = "健康生活,从这里开始",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(48.dp))
// Form
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("用户名") },
leadingIcon = {
Icon(Icons.Outlined.Person, contentDescription = null, tint = Slate400)
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Brand500,
unfocusedBorderColor = Slate200
),
singleLine = true
)
Spacer(modifier = Modifier.height(16.dp))
if (!isLogin) {
OutlinedTextField(
value = nickname,
onValueChange = { nickname = it },
label = { Text("昵称") },
leadingIcon = {
Icon(Icons.Outlined.Person, contentDescription = null, tint = Slate400)
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Brand500,
unfocusedBorderColor = Slate200
),
singleLine = true
)
Spacer(modifier = Modifier.height(16.dp))
}
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("密码") },
leadingIcon = {
Icon(Icons.Outlined.Lock, contentDescription = null, tint = Slate400)
},
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription = null,
tint = Slate400
)
}
},
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Brand500,
unfocusedBorderColor = Slate200
),
singleLine = true
)
errorMessage?.let {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = it,
color = ErrorRed,
fontSize = 14.sp
)
}
Spacer(modifier = Modifier.height(24.dp))
// Submit Button
Surface(
onClick = {
if (username.isBlank() || password.isBlank()) {
errorMessage = "请填写完整信息"
return@Surface
}
if (!isLogin && nickname.isBlank()) {
errorMessage = "请填写昵称"
return@Surface
}
isLoading = true
errorMessage = null
scope.launch {
try {
val response = if (isLogin) {
ApiClient.api.login(LoginRequest(username, password))
} else {
ApiClient.api.register(RegisterRequest(username, password, nickname))
}
if (response.isSuccessful) {
response.body()?.let {
ApiClient.getTokenManager().saveToken(it.token)
onLoginSuccess()
}
} else {
errorMessage = if (isLogin) "用户名或密码错误" else "注册失败,用户名可能已存在"
}
} catch (e: Exception) {
errorMessage = "网络错误,请稍后重试"
} finally {
isLoading = false
}
}
},
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
shape = RoundedCornerShape(12.dp),
color = Brand500,
enabled = !isLoading
) {
Box(contentAlignment = Alignment.Center) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Text(
text = if (isLogin) "登录" else "注册",
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Toggle
TextButton(onClick = {
isLogin = !isLogin
errorMessage = null
}) {
Text(
text = if (isLogin) "没有账号?立即注册" else "已有账号?立即登录",
color = Brand500
)
}
}
}
}

View File

@@ -0,0 +1,150 @@
package com.healthflow.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Logout
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.healthflow.app.data.model.User
import com.healthflow.app.ui.theme.*
@Composable
fun ProfileScreen(
user: User?,
onLogout: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width(36.dp))
Text(
text = "我的",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
// Logout
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(ErrorRed.copy(alpha = 0.1f))
.clickable { onLogout() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Outlined.Logout,
contentDescription = "退出登录",
modifier = Modifier.size(18.dp),
tint = ErrorRed
)
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp)
// Profile Content
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(32.dp))
// Avatar
Box(
modifier = Modifier
.size(96.dp)
.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() ?: "?",
fontSize = 36.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Nickname
Text(
text = user?.nickname ?: "",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(4.dp))
// Username
Text(
text = "@${user?.username ?: ""}",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
// Info Card
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
) {
Column(
modifier = Modifier.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "💚",
fontSize = 32.sp
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "健康数据即将上线",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}

View File

@@ -0,0 +1,33 @@
package com.healthflow.app.ui.theme
import androidx.compose.ui.graphics.Color
// Brand Colors - 健康绿色主题
val Brand50 = Color(0xFFECFDF5)
val Brand100 = Color(0xFFD1FAE5)
val Brand200 = Color(0xFFA7F3D0)
val Brand300 = Color(0xFF6EE7B7)
val Brand400 = Color(0xFF34D399)
val Brand500 = Color(0xFF10B981) // 主色
val Brand600 = Color(0xFF059669)
val Brand700 = Color(0xFF047857)
val Brand800 = Color(0xFF065F46)
val Brand900 = Color(0xFF064E3B)
// Slate (灰色)
val Slate50 = Color(0xFFF8FAFC)
val Slate100 = Color(0xFFF1F5F9)
val Slate200 = Color(0xFFE2E8F0)
val Slate300 = Color(0xFFCBD5E1)
val Slate400 = Color(0xFF94A3B8)
val Slate500 = Color(0xFF64748B)
val Slate600 = Color(0xFF475569)
val Slate700 = Color(0xFF334155)
val Slate800 = Color(0xFF1E293B)
val Slate900 = Color(0xFF0F172A)
// Semantic Colors
val ErrorRed = Color(0xFFEF4444)
val SuccessGreen = Color(0xFF22C55E)
val WarningYellow = Color(0xFFF59E0B)
val InfoBlue = Color(0xFF3B82F6)

View File

@@ -0,0 +1,84 @@
package com.healthflow.app.ui.theme
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val LightColorScheme = lightColorScheme(
primary = Brand500,
onPrimary = Color.White,
primaryContainer = Brand100,
onPrimaryContainer = Brand900,
secondary = Brand400,
onSecondary = Color.White,
secondaryContainer = Brand50,
onSecondaryContainer = Brand800,
tertiary = Brand600,
onTertiary = Color.White,
background = Slate50,
onBackground = Slate900,
surface = Color.White,
onSurface = Slate900,
surfaceVariant = Slate100,
onSurfaceVariant = Slate600,
outline = Slate300,
outlineVariant = Slate200,
error = ErrorRed,
onError = Color.White
)
private val DarkColorScheme = darkColorScheme(
primary = Brand400,
onPrimary = Brand900,
primaryContainer = Brand800,
onPrimaryContainer = Brand100,
secondary = Brand300,
onSecondary = Brand900,
secondaryContainer = Brand700,
onSecondaryContainer = Brand100,
tertiary = Brand200,
onTertiary = Brand900,
background = Slate900,
onBackground = Slate100,
surface = Slate800,
onSurface = Slate100,
surfaceVariant = Slate700,
onSurfaceVariant = Slate300,
outline = Slate600,
outlineVariant = Slate700,
error = Color(0xFFF87171),
onError = Color.Black
)
@Composable
fun HealthFlowTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = Color.Transparent.toArgb()
window.navigationBarColor = Color.Transparent.toArgb()
WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightStatusBars = !darkTheme
isAppearanceLightNavigationBars = !darkTheme
}
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@@ -0,0 +1,93 @@
package com.healthflow.app.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
displayLarge = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp
),
displayMedium = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 45.sp,
lineHeight = 52.sp
),
displaySmall = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 36.sp,
lineHeight = 44.sp
),
headlineLarge = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 32.sp,
lineHeight = 40.sp
),
headlineMedium = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 28.sp,
lineHeight = 36.sp
),
headlineSmall = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 32.sp
),
titleLarge = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp
),
titleMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp
),
titleSmall = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
bodyLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
bodyMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
),
bodySmall = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp
),
labelLarge = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
labelMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
),
labelSmall = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
)

View File

@@ -0,0 +1,41 @@
package com.healthflow.app.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.healthflow.app.data.api.ApiClient
import com.healthflow.app.data.model.User
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class AuthViewModel : ViewModel() {
private val _isLoggedIn = MutableStateFlow(false)
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn
private val _user = MutableStateFlow<User?>(null)
val user: StateFlow<User?> = _user
fun checkAuth() {
viewModelScope.launch {
try {
val response = ApiClient.api.getProfile()
if (response.isSuccessful) {
_user.value = response.body()?.user
_isLoggedIn.value = true
} else {
logout()
}
} catch (e: Exception) {
logout()
}
}
}
fun logout() {
viewModelScope.launch {
ApiClient.getTokenManager().clearToken()
_user.value = null
_isLoggedIn.value = false
}
}
}

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- 心形图标 - 健康主题 -->
<path
android:fillColor="#FFFFFF"
android:pathData="M54,75
C54,75 30,55 30,42
C30,32 38,26 48,26
C52,26 54,28 54,28
C54,28 56,26 60,26
C70,26 78,32 78,42
C78,55 54,75 54,75 Z"/>
<!-- 心电图线条 -->
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="2.5"
android:fillColor="#00000000"
android:pathData="M32,50 L42,50 L46,40 L50,60 L54,45 L58,55 L62,50 L76,50"/>
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#10B981</color>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">HealthFlow</string>
</resources>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.HealthFlow" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</item>
</style>
</resources>

6
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,6 @@
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
}

12
android/gradle.properties Normal file
View File

@@ -0,0 +1,12 @@
# Project-wide Gradle settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# AndroidX
android.useAndroidX=true
android.enableJetifier=true
# Kotlin
kotlin.code.style=official
# Enable build cache
org.gradle.caching=true

View File

@@ -0,0 +1,24 @@
pluginManagement {
repositories {
maven { url = uri("https://maven.aliyun.com/repository/google") }
maven { url = uri("https://maven.aliyun.com/repository/central") }
maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin") }
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
maven { url = uri("https://maven.aliyun.com/repository/google") }
maven { url = uri("https://maven.aliyun.com/repository/central") }
maven { url = uri("https://maven.aliyun.com/repository/public") }
google()
mavenCentral()
}
}
rootProject.name = "HealthFlow"
include(":app")

22
docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
version: '3.8'
services:
healthflow:
image: healthflow-server:latest
container_name: healthflow-server
restart: unless-stopped
ports:
- "6601:8080"
volumes:
- ./data:/app/data
environment:
- SERVER_ADDR=:8080
- DB_PATH=/app/data/healthflow.db
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
- BASE_URL=${BASE_URL:-http://localhost:8080}
- LOCAL_UPLOAD_PATH=
- R2_ACCOUNT_ID=${R2_ACCOUNT_ID}
- R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID}
- R2_ACCESS_KEY_SECRET=${R2_ACCESS_KEY_SECRET}
- R2_BUCKET_NAME=${R2_BUCKET_NAME:-healthflow}
- R2_PUBLIC_URL=${R2_PUBLIC_URL}

280
release.sh Executable file
View File

@@ -0,0 +1,280 @@
#!/bin/bash
# HealthFlow App 发布脚本
# 功能:自动递增版本号、编译 APK、上传到 R2、更新服务器版本信息
set -e
# ============ 配置区域 ============
# R2 配置
R2_ACCOUNT_ID="ebf33b5ee4eb26f32af0c6e06102e000"
R2_ACCESS_KEY_ID="8acbc8a9386d60d0e8dac6bd8165c618"
R2_ACCESS_KEY_SECRET="72935e23b5b4be8fda99008e75285e8ac778f8926656c42780b25785bb149443"
R2_BUCKET_NAME="healthflow"
R2_PUBLIC_URL="https://cdn-health.amos.us.kg"
# API 配置
API_BASE_URL="https://health.amos.us.kg/api"
# 服务器配置
SERVER_HOST="95.181.190.226"
SERVER_PORT="33"
SERVER_USER="root"
SERVER_PASSWORD="xiaobiao."
SERVER_PROJECT_PATH="/amos/healthFlow"
# 签名配置
KEYSTORE_FILE="release.keystore"
KEYSTORE_PASSWORD="healthflow123"
KEY_ALIAS="healthflow"
KEY_PASSWORD="healthflow123"
# ============ 函数定义 ============
# 获取当前版本号
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 healthflow-server:latest .
docker save healthflow-server:latest -o healthflow-server.tar
cd ..
echo "✅ Docker 镜像已导出到 server/healthflow-server.tar"
}
# 部署 Docker 镜像到服务器
deploy_docker() {
echo "🚀 部署 Docker 镜像到服务器..."
# 上传镜像到服务器
echo "📤 上传镜像文件..."
sshpass -p "${SERVER_PASSWORD}" scp -P ${SERVER_PORT} -o StrictHostKeyChecking=no server/healthflow-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 healthflow-server.tar
docker compose down
docker compose up -d
# 清理旧镜像
echo "🧹 清理旧镜像..."
docker image prune -f
# 删除镜像文件
rm -f healthflow-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/healthflow-${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}"
}
# 更新服务器版本信息,返回 0 成功1 失败
update_server_version() {
local version_code=$1
local version_name=$2
local download_url=$3
local update_log=$4
echo "🔄 更新服务器版本信息..."
echo " version_code: ${version_code}"
echo " version_name: ${version_name}"
echo " download_url: ${download_url}"
# 使用 printf 构建正确的 JSON
local json_data=$(printf '{"version_code":%d,"version_name":"%s","download_url":"%s","update_log":"%s","force_update":false}' \
"$version_code" "$version_name" "$download_url" "$update_log")
local response=$(curl -s -w "\n%{http_code}" -X POST "${API_BASE_URL}/version" \
-H "Content-Type: application/json" \
-d "$json_data")
local http_code=$(echo "$response" | tail -n1)
local body=$(echo "$response" | sed '$d')
if [ "$http_code" = "200" ]; then
echo "✅ 服务器版本信息已更新"
return 0
else
echo "❌ 更新服务器版本失败 (HTTP $http_code)"
echo " 响应: $body"
return 1
fi
}
# ============ 主流程 ============
echo "🚀 HealthFlow 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 ""
read -p "📝 请输入更新日志: " update_log
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
# 先更新服务器版本信息,成功后再上传 APK
download_url="${R2_PUBLIC_URL}/releases/healthflow-${new_version}.apk"
if update_server_version $new_code "$new_version" "$download_url" "$update_log"; then
# 上传 APK
upload_to_r2 $new_version
echo "✅ 上传完成: $download_url"
echo ""
echo "🎉 APK 发布完成!"
echo " 版本: v${new_version}"
echo " 下载: ${download_url}"
else
echo ""
echo "❌ 服务器版本更新失败APK 未上传"
exit 1
fi
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 "✨ 全部完成!"

21
server/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM --platform=linux/amd64 golang:1.21-alpine AS builder
RUN apk add --no-cache gcc musl-dev sqlite-dev
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo -o healthflow-server .
FROM --platform=linux/amd64 alpine:latest
RUN apk --no-cache add ca-certificates sqlite-libs
WORKDIR /app
COPY --from=builder /app/healthflow-server .
RUN mkdir -p /app/data
EXPOSE 8080
CMD ["./healthflow-server"]

55
server/go.mod Normal file
View File

@@ -0,0 +1,55 @@
module healthflow
go 1.21
require (
github.com/aws/aws-sdk-go-v2 v1.24.0
github.com/aws/aws-sdk-go-v2/config v1.26.1
github.com/aws/aws-sdk-go-v2/credentials v1.16.12
github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.5.0
github.com/mattn/go-sqlite3 v1.14.19
golang.org/x/crypto v0.17.0
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 // indirect
github.com/aws/smithy-go v1.19.0 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

128
server/go.sum Normal file
View File

@@ -0,0 +1,128 @@
github.com/aws/aws-sdk-go-v2 v1.24.0 h1:890+mqQ+hTpNuw0gGP6/4akolQkSToDJgHfQE7AwGuk=
github.com/aws/aws-sdk-go-v2 v1.24.0/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 h1:OCs21ST2LrepDfD3lwlQiOqIGp6JiEUqG84GzTDoyJs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4/go.mod h1:usURWEKSNNAcAZuzRn/9ZYPT8aZQkR7xcCtunK/LkJo=
github.com/aws/aws-sdk-go-v2/config v1.26.1 h1:z6DqMxclFGL3Zfo+4Q0rLnAZ6yVkzCRxhRMsiRQnD1o=
github.com/aws/aws-sdk-go-v2/config v1.26.1/go.mod h1:ZB+CuKHRbb5v5F0oJtGdhFTelmrxd4iWO1lf0rQwSAg=
github.com/aws/aws-sdk-go-v2/credentials v1.16.12 h1:v/WgB8NxprNvr5inKIiVVrXPuuTegM+K8nncFkr1usU=
github.com/aws/aws-sdk-go-v2/credentials v1.16.12/go.mod h1:X21k0FjEJe+/pauud82HYiQbEr9jRKY3kXEIQ4hXeTQ=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 h1:w98BT5w+ao1/r5sUuiH6JkVzjowOKeOJRHERyy1vh58=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10/go.mod h1:K2WGI7vUvkIv1HoNbfBA1bvIZ+9kL3YVmWxeKuLQsiw=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 h1:v+HbZaCGmOwnTTVS86Fleq0vPzOd7tnJGbFhP0stNLs=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9/go.mod h1:Xjqy+Nyj7VDLBtCMkQYOw1QYfAEZCVLrfI0ezve8wd4=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 h1:N94sVhRACtXyVcjXxrwK1SKFIJrA9pOJ5yu2eSHnmls=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9/go.mod h1:hqamLz7g1/4EJP+GH5NBhcUMLjW+gKLQabgyz6/7WAU=
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 h1:GrSw8s0Gs/5zZ0SX+gX4zQjRnRsMJDJ2sLur1gRBhEM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 h1:ugD6qzjYtB7zM5PN/ZIeaAIyefPaD82G8+SJopgvUpw=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9/go.mod h1:YD0aYBWCrPENpHolhKw2XDlTIWae2GKXT1T4o6N6hiM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 h1:/90OR2XbSYfXucBMJ4U14wrjlfleq/0SB6dZDPncgmo=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9/go.mod h1:dN/Of9/fNZet7UrQQ6kTDo/VSwKPIq94vjlU16bRARc=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 h1:Nf2sHxjMJR8CSImIVCONRi4g0Su3J+TSTbS7G0pUeMU=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9/go.mod h1:idky4TER38YIjr2cADF1/ugFMKvZV7p//pVeV5LZbF0=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 h1:iEAeF6YC3l4FzlJPP9H3Ko1TXpdjdqWffxXjp8SY6uk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9/go.mod h1:kjsXoK23q9Z/tLBrckZLLyvjhZoS+AGrzqzUfEClvMM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5 h1:Keso8lIOS+IzI2MkPZyK6G0LYcK3My2LQ+T5bxghEAY=
github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5/go.mod h1:vADO6Jn+Rq4nDtfwNjhgR84qkZwiC6FqCaXdw/kYwjA=
github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 h1:ldSFWz9tEHAwHNmjx2Cvy1MjP5/L9kNoR0skc6wyOOM=
github.com/aws/aws-sdk-go-v2/service/sso v1.18.5/go.mod h1:CaFfXLYL376jgbP7VKC96uFcU8Rlavak0UlAwk1Dlhc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 h1:2k9KmFawS63euAkY4/ixVNsYYwrwnd5fIvgEKkfZFNM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5/go.mod h1:W+nd4wWDVkSUIox9bacmkBP5NMFQeTJ/xqNabpzSR38=
github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 h1:5UYvv8JUvllZsRnfrcMQ+hJ9jNICmcgKPAO1CER25Wg=
github.com/aws/aws-sdk-go-v2/service/sts v1.26.5/go.mod h1:XX5gh4CB7wAs4KhcF46G6C8a2i7eupU19dcAAE+EydU=
github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM=
github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI=
github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@@ -0,0 +1,44 @@
package config
import (
"os"
)
type Config struct {
ServerAddr string
DBPath string
JWTSecret string
BaseURL string
// 本地存储
LocalUploadPath string
// Cloudflare R2
R2AccountID string
R2AccessKeyID string
R2AccessKeySecret string
R2BucketName string
R2PublicURL string
}
func Load() *Config {
return &Config{
ServerAddr: getEnv("SERVER_ADDR", ":8080"),
DBPath: getEnv("DB_PATH", "./data/healthflow.db"),
JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
BaseURL: getEnv("BASE_URL", "http://localhost:8080"),
LocalUploadPath: getEnv("LOCAL_UPLOAD_PATH", "./uploads"),
R2AccountID: getEnv("R2_ACCOUNT_ID", ""),
R2AccessKeyID: getEnv("R2_ACCESS_KEY_ID", ""),
R2AccessKeySecret: getEnv("R2_ACCESS_KEY_SECRET", ""),
R2BucketName: getEnv("R2_BUCKET_NAME", "healthflow"),
R2PublicURL: getEnv("R2_PUBLIC_URL", ""),
}
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}

View File

@@ -0,0 +1,69 @@
package database
import (
"database/sql"
"os"
"path/filepath"
_ "github.com/mattn/go-sqlite3"
)
func Init(dbPath string) (*sql.DB, error) {
// 确保目录存在
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
}
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
if err != nil {
return nil, err
}
if err := migrate(db); err != nil {
return nil, err
}
return db, nil
}
func migrate(db *sql.DB) error {
schema := `
-- 用户表
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
nickname TEXT NOT NULL,
avatar_url TEXT DEFAULT '',
bio TEXT DEFAULT '',
is_admin INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 系统配置表
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- 版本表
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
);
-- 索引
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
-- 初始化默认设置
INSERT OR IGNORE INTO settings (key, value) VALUES ('allow_register', 'true');
`
_, err := db.Exec(schema)
return err
}

View File

@@ -0,0 +1,138 @@
package handler
import (
"database/sql"
"net/http"
"time"
"healthflow/internal/config"
"healthflow/internal/middleware"
"healthflow/internal/model"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
type AuthHandler struct {
db *sql.DB
cfg *config.Config
}
func NewAuthHandler(db *sql.DB, cfg *config.Config) *AuthHandler {
return &AuthHandler{db: db, cfg: cfg}
}
func (h *AuthHandler) Register(c *gin.Context) {
var req model.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 检查是否允许注册
var allowRegister string
h.db.QueryRow("SELECT value FROM settings WHERE key = 'allow_register'").Scan(&allowRegister)
if allowRegister != "true" {
c.JSON(http.StatusForbidden, gin.H{"error": "registration is disabled"})
return
}
// 检查用户名是否已存在
var exists int
h.db.QueryRow("SELECT COUNT(*) FROM users WHERE username = ?", req.Username).Scan(&exists)
if exists > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "username already exists"})
return
}
// 加密密码
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
return
}
// 创建用户
result, err := h.db.Exec(
"INSERT INTO users (username, password_hash, nickname) VALUES (?, ?, ?)",
req.Username, string(hashedPassword), req.Nickname,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"})
return
}
userID, _ := result.LastInsertId()
// 生成 token
token, err := h.generateToken(userID, false)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
return
}
c.JSON(http.StatusCreated, model.LoginResponse{
Token: token,
User: &model.User{
ID: userID,
Username: req.Username,
Nickname: req.Nickname,
},
})
}
func (h *AuthHandler) Login(c *gin.Context) {
var req model.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var user model.User
err := h.db.QueryRow(
"SELECT id, username, password_hash, nickname, avatar_url, bio, is_admin FROM users WHERE username = ?",
req.Username,
).Scan(&user.ID, &user.Username, &user.PasswordHash, &user.Nickname, &user.AvatarURL, &user.Bio, &user.IsAdmin)
if err == sql.ErrNoRows {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
return
}
// 验证密码
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
// 生成 token
token, err := h.generateToken(user.ID, user.IsAdmin)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
return
}
c.JSON(http.StatusOK, model.LoginResponse{
Token: token,
User: &user,
})
}
func (h *AuthHandler) generateToken(userID int64, isAdmin bool) (string, error) {
claims := middleware.Claims{
UserID: userID,
IsAdmin: isAdmin,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * 24 * time.Hour)), // 30 天
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(h.cfg.JWTSecret))
}

View File

@@ -0,0 +1,114 @@
package handler
import (
"context"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"time"
"healthflow/internal/config"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type UploadHandler struct {
cfg *config.Config
s3Client *s3.Client
}
func NewUploadHandler(cfg *config.Config) *UploadHandler {
h := &UploadHandler{cfg: cfg}
// 初始化 R2 客户端
if cfg.R2AccountID != "" && cfg.R2AccessKeyID != "" {
r2Resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
return aws.Endpoint{
URL: fmt.Sprintf("https://%s.r2.cloudflarestorage.com", cfg.R2AccountID),
}, nil
})
awsCfg, err := awsconfig.LoadDefaultConfig(context.TODO(),
awsconfig.WithEndpointResolverWithOptions(r2Resolver),
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
cfg.R2AccessKeyID,
cfg.R2AccessKeySecret,
"",
)),
awsconfig.WithRegion("auto"),
)
if err == nil {
h.s3Client = s3.NewFromConfig(awsCfg)
}
}
return h
}
func (h *UploadHandler) Upload(c *gin.Context) {
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
return
}
defer file.Close()
// 生成唯一文件名
ext := filepath.Ext(header.Filename)
filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), uuid.New().String()[:8], ext)
// 上传到 R2
if h.s3Client != nil {
key := "uploads/" + filename
_, err := h.s3Client.PutObject(context.TODO(), &s3.PutObjectInput{
Bucket: aws.String(h.cfg.R2BucketName),
Key: aws.String(key),
Body: file,
ContentType: aws.String(header.Header.Get("Content-Type")),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to upload to R2"})
return
}
url := fmt.Sprintf("%s/%s", h.cfg.R2PublicURL, key)
c.JSON(http.StatusOK, gin.H{"url": url})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "no storage configured"})
}
func (h *UploadHandler) GetFile(c *gin.Context) {
filepath := c.Param("filepath")
filepath = strings.TrimPrefix(filepath, "/")
if h.s3Client == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
result, err := h.s3Client.GetObject(context.TODO(), &s3.GetObjectInput{
Bucket: aws.String(h.cfg.R2BucketName),
Key: aws.String(filepath),
})
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
defer result.Body.Close()
if result.ContentType != nil {
c.Header("Content-Type", *result.ContentType)
}
io.Copy(c.Writer, result.Body)
}

View File

@@ -0,0 +1,115 @@
package handler
import (
"database/sql"
"net/http"
"healthflow/internal/config"
"healthflow/internal/middleware"
"healthflow/internal/model"
"github.com/gin-gonic/gin"
)
type UserHandler struct {
db *sql.DB
cfg *config.Config
}
func NewUserHandler(db *sql.DB, cfg *config.Config) *UserHandler {
return &UserHandler{db: db, cfg: cfg}
}
func (h *UserHandler) GetProfile(c *gin.Context) {
userID := middleware.GetUserID(c)
var user model.User
var createdAt string
err := h.db.QueryRow(
"SELECT id, username, nickname, avatar_url, bio, is_admin, created_at FROM users WHERE id = ?",
userID,
).Scan(&user.ID, &user.Username, &user.Nickname, &user.AvatarURL, &user.Bio, &user.IsAdmin, &createdAt)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
return
}
c.JSON(http.StatusOK, model.ProfileResponse{
User: &user,
CreatedAt: createdAt,
})
}
func (h *UserHandler) UpdateProfile(c *gin.Context) {
userID := middleware.GetUserID(c)
var req model.UpdateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err := h.db.Exec(
"UPDATE users SET nickname = ?, bio = ? WHERE id = ?",
req.Nickname, req.Bio, userID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update profile"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "profile updated"})
}
func (h *UserHandler) UpdateAvatar(c *gin.Context) {
userID := middleware.GetUserID(c)
var req struct {
AvatarURL string `json:"avatar_url" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err := h.db.Exec("UPDATE users SET avatar_url = ? WHERE id = ?", req.AvatarURL, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update avatar"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "avatar updated"})
}
func (h *UserHandler) GetSettings(c *gin.Context) {
rows, err := h.db.Query("SELECT key, value FROM settings")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
return
}
defer rows.Close()
settings := make(map[string]string)
for rows.Next() {
var key, value string
rows.Scan(&key, &value)
settings[key] = value
}
c.JSON(http.StatusOK, settings)
}
func (h *UserHandler) UpdateSettings(c *gin.Context) {
var settings map[string]string
if err := c.ShouldBindJSON(&settings); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
for key, value := range settings {
h.db.Exec("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", key, value)
}
c.JSON(http.StatusOK, gin.H{"message": "settings updated"})
}

View File

@@ -0,0 +1,69 @@
package handler
import (
"database/sql"
"net/http"
"healthflow/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 info VersionInfo
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(&info.VersionCode, &info.VersionName, &info.DownloadURL, &info.UpdateLog, &info.ForceUpdate)
if err == sql.ErrNoRows {
c.JSON(http.StatusOK, VersionInfo{
VersionCode: 1,
VersionName: "1.0.0",
})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
return
}
c.JSON(http.StatusOK, info)
}
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
}
_, 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, req.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

@@ -0,0 +1,71 @@
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID int64 `json:"user_id"`
IsAdmin bool `json:"is_admin"`
jwt.RegisteredClaims
}
func AuthMiddleware(jwtSecret string) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"})
c.Abort()
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization format"})
c.Abort()
return
}
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(jwtSecret), nil
})
if err != nil || !token.Valid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
c.Abort()
return
}
claims, ok := token.Claims.(*Claims)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token claims"})
c.Abort()
return
}
c.Set("user_id", claims.UserID)
c.Set("is_admin", claims.IsAdmin)
c.Next()
}
}
func AdminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
isAdmin, exists := c.Get("is_admin")
if !exists || !isAdmin.(bool) {
c.JSON(http.StatusForbidden, gin.H{"error": "admin access required"})
c.Abort()
return
}
c.Next()
}
}
func GetUserID(c *gin.Context) int64 {
userID, _ := c.Get("user_id")
return userID.(int64)
}

View File

@@ -0,0 +1,41 @@
package model
import "time"
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"-"`
Nickname string `json:"nickname"`
AvatarURL string `json:"avatar_url"`
Bio string `json:"bio"`
IsAdmin bool `json:"is_admin"`
CreatedAt time.Time `json:"created_at"`
}
// 请求/响应结构
type RegisterRequest struct {
Username string `json:"username" binding:"required,min=3,max=20"`
Password string `json:"password" binding:"required,min=6"`
Nickname string `json:"nickname" binding:"required,min=1,max=50"`
}
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
type LoginResponse struct {
Token string `json:"token"`
User *User `json:"user"`
}
type UpdateProfileRequest struct {
Nickname string `json:"nickname" binding:"max=50"`
Bio string `json:"bio" binding:"max=200"`
}
type ProfileResponse struct {
User *User `json:"user"`
CreatedAt string `json:"created_at"`
}

View File

@@ -0,0 +1,73 @@
package router
import (
"database/sql"
"healthflow/internal/config"
"healthflow/internal/handler"
"healthflow/internal/middleware"
"github.com/gin-gonic/gin"
)
func Setup(db *sql.DB, cfg *config.Config) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
// CORS
r.Use(func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
// 静态文件服务 (本地上传的文件)
if cfg.LocalUploadPath != "" {
r.Static("/uploads", cfg.LocalUploadPath)
}
// Handlers
authHandler := handler.NewAuthHandler(db, cfg)
userHandler := handler.NewUserHandler(db, cfg)
uploadHandler := handler.NewUploadHandler(cfg)
versionHandler := handler.NewVersionHandler(db, cfg)
// R2 文件代理 (公开访问)
r.GET("/files/*filepath", uploadHandler.GetFile)
// 公开接口
r.POST("/api/auth/register", authHandler.Register)
r.POST("/api/auth/login", authHandler.Login)
r.GET("/api/version", versionHandler.GetLatestVersion)
r.POST("/api/version", versionHandler.SetVersion)
// 需要认证的接口
auth := r.Group("/api")
auth.Use(middleware.AuthMiddleware(cfg.JWTSecret))
{
// 用户
auth.GET("/user/profile", userHandler.GetProfile)
auth.PUT("/user/profile", userHandler.UpdateProfile)
auth.PUT("/user/avatar", userHandler.UpdateAvatar)
// 上传
auth.POST("/upload", uploadHandler.Upload)
// 管理员接口
admin := auth.Group("/admin")
admin.Use(middleware.AdminMiddleware())
{
admin.GET("/settings", userHandler.GetSettings)
admin.PUT("/settings", userHandler.UpdateSettings)
admin.POST("/version", versionHandler.SetVersion)
}
}
return r
}

27
server/main.go Normal file
View File

@@ -0,0 +1,27 @@
package main
import (
"log"
"healthflow/internal/config"
"healthflow/internal/database"
"healthflow/internal/router"
)
func main() {
// 加载配置
cfg := config.Load()
// 初始化数据库
db, err := database.Init(cfg.DBPath)
if err != nil {
log.Fatalf("Failed to init database: %v", err)
}
defer db.Close()
// 启动服务器
r := router.Setup(db, cfg)
log.Printf("HealthFlow server starting on %s", cfg.ServerAddr)
if err := r.Run(cfg.ServerAddr); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}