From 0c733f9a36658241c17b674af1dfb974cb512531 Mon Sep 17 00:00:00 2001 From: amos wong Date: Sat, 20 Dec 2025 23:51:40 +0800 Subject: [PATCH] =?UTF-8?q?=E5=81=A5=E5=BA=B7=E6=97=A5=E8=AE=B0=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.production | 14 + .gitignore | 28 ++ README.md | 41 +++ android/app/build.gradle.kts | 90 ++++++ android/app/proguard-rules.pro | 33 +++ android/app/src/main/AndroidManifest.xml | 27 ++ .../java/com/healthflow/app/HealthFlowApp.kt | 38 +++ .../java/com/healthflow/app/MainActivity.kt | 64 ++++ .../com/healthflow/app/data/api/ApiClient.kt | 67 +++++ .../com/healthflow/app/data/api/ApiService.kt | 34 +++ .../healthflow/app/data/local/TokenManager.kt | 34 +++ .../com/healthflow/app/data/model/Models.kt | 70 +++++ .../app/ui/navigation/Navigation.kt | 108 +++++++ .../healthflow/app/ui/screen/HomeScreen.kt | 84 ++++++ .../healthflow/app/ui/screen/LoginScreen.kt | 230 ++++++++++++++ .../healthflow/app/ui/screen/ProfileScreen.kt | 150 ++++++++++ .../java/com/healthflow/app/ui/theme/Color.kt | 33 +++ .../java/com/healthflow/app/ui/theme/Theme.kt | 84 ++++++ .../java/com/healthflow/app/ui/theme/Type.kt | 93 ++++++ .../app/ui/viewmodel/AuthViewModel.kt | 41 +++ .../res/drawable/ic_launcher_foreground.xml | 25 ++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../res/values/ic_launcher_background.xml | 4 + android/app/src/main/res/values/strings.xml | 4 + android/app/src/main/res/values/themes.xml | 8 + android/build.gradle.kts | 6 + android/gradle.properties | 12 + android/settings.gradle.kts | 24 ++ docker-compose.yml | 22 ++ release.sh | 280 ++++++++++++++++++ server/Dockerfile | 21 ++ server/go.mod | 55 ++++ server/go.sum | 128 ++++++++ server/internal/config/config.go | 44 +++ server/internal/database/database.go | 69 +++++ server/internal/handler/auth.go | 138 +++++++++ server/internal/handler/upload.go | 114 +++++++ server/internal/handler/user.go | 115 +++++++ server/internal/handler/version.go | 69 +++++ server/internal/middleware/auth.go | 71 +++++ server/internal/model/model.go | 41 +++ server/internal/router/router.go | 73 +++++ server/main.go | 27 ++ 44 files changed, 2723 insertions(+) create mode 100644 .env.production create mode 100644 .gitignore create mode 100644 README.md create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/com/healthflow/app/HealthFlowApp.kt create mode 100644 android/app/src/main/java/com/healthflow/app/MainActivity.kt create mode 100644 android/app/src/main/java/com/healthflow/app/data/api/ApiClient.kt create mode 100644 android/app/src/main/java/com/healthflow/app/data/api/ApiService.kt create mode 100644 android/app/src/main/java/com/healthflow/app/data/local/TokenManager.kt create mode 100644 android/app/src/main/java/com/healthflow/app/data/model/Models.kt create mode 100644 android/app/src/main/java/com/healthflow/app/ui/navigation/Navigation.kt create mode 100644 android/app/src/main/java/com/healthflow/app/ui/screen/HomeScreen.kt create mode 100644 android/app/src/main/java/com/healthflow/app/ui/screen/LoginScreen.kt create mode 100644 android/app/src/main/java/com/healthflow/app/ui/screen/ProfileScreen.kt create mode 100644 android/app/src/main/java/com/healthflow/app/ui/theme/Color.kt create mode 100644 android/app/src/main/java/com/healthflow/app/ui/theme/Theme.kt create mode 100644 android/app/src/main/java/com/healthflow/app/ui/theme/Type.kt create mode 100644 android/app/src/main/java/com/healthflow/app/ui/viewmodel/AuthViewModel.kt create mode 100644 android/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 android/app/src/main/res/values/ic_launcher_background.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/settings.gradle.kts create mode 100644 docker-compose.yml create mode 100755 release.sh create mode 100644 server/Dockerfile create mode 100644 server/go.mod create mode 100644 server/go.sum create mode 100644 server/internal/config/config.go create mode 100644 server/internal/database/database.go create mode 100644 server/internal/handler/auth.go create mode 100644 server/internal/handler/upload.go create mode 100644 server/internal/handler/user.go create mode 100644 server/internal/handler/version.go create mode 100644 server/internal/middleware/auth.go create mode 100644 server/internal/model/model.go create mode 100644 server/internal/router/router.go create mode 100644 server/main.go diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..07cc430 --- /dev/null +++ b/.env.production @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..678e562 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a51e518 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..ab6f36e --- /dev/null +++ b/android/app/build.gradle.kts @@ -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") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..0764ac5 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,33 @@ +# Retrofit +-keepattributes Signature +-keepattributes *Annotation* +-keep class retrofit2.** { *; } +-keepclasseswithmembers class * { + @retrofit2.http.* ; +} + +# 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.** { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d5dd631 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/healthflow/app/HealthFlowApp.kt b/android/app/src/main/java/com/healthflow/app/HealthFlowApp.kt new file mode 100644 index 0000000..5132b59 --- /dev/null +++ b/android/app/src/main/java/com/healthflow/app/HealthFlowApp.kt @@ -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() + } +} diff --git a/android/app/src/main/java/com/healthflow/app/MainActivity.kt b/android/app/src/main/java/com/healthflow/app/MainActivity.kt new file mode 100644 index 0000000..7112124 --- /dev/null +++ b/android/app/src/main/java/com/healthflow/app/MainActivity.kt @@ -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() } + ) + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/healthflow/app/data/api/ApiClient.kt b/android/app/src/main/java/com/healthflow/app/data/api/ApiClient.kt new file mode 100644 index 0000000..6043047 --- /dev/null +++ b/android/app/src/main/java/com/healthflow/app/data/api/ApiClient.kt @@ -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 +} diff --git a/android/app/src/main/java/com/healthflow/app/data/api/ApiService.kt b/android/app/src/main/java/com/healthflow/app/data/api/ApiService.kt new file mode 100644 index 0000000..89b3654 --- /dev/null +++ b/android/app/src/main/java/com/healthflow/app/data/api/ApiService.kt @@ -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 + + @POST("auth/register") + suspend fun register(@Body request: RegisterRequest): Response + + // User + @GET("user/profile") + suspend fun getProfile(): Response + + @PUT("user/profile") + suspend fun updateProfile(@Body request: UpdateProfileRequest): Response + + @PUT("user/avatar") + suspend fun updateAvatar(@Body request: Map): Response + + // Upload + @Multipart + @POST("upload") + suspend fun upload(@Part file: MultipartBody.Part): Response + + // Version + @GET("version") + suspend fun getVersion(): Response +} diff --git a/android/app/src/main/java/com/healthflow/app/data/local/TokenManager.kt b/android/app/src/main/java/com/healthflow/app/data/local/TokenManager.kt new file mode 100644 index 0000000..eaa6049 --- /dev/null +++ b/android/app/src/main/java/com/healthflow/app/data/local/TokenManager.kt @@ -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 by preferencesDataStore(name = "healthflow_prefs") + +class TokenManager(private val context: Context) { + companion object { + private val TOKEN_KEY = stringPreferencesKey("auth_token") + } + + val token: Flow = 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) + } + } +} diff --git a/android/app/src/main/java/com/healthflow/app/data/model/Models.kt b/android/app/src/main/java/com/healthflow/app/data/model/Models.kt new file mode 100644 index 0000000..eee4950 --- /dev/null +++ b/android/app/src/main/java/com/healthflow/app/data/model/Models.kt @@ -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 +) diff --git a/android/app/src/main/java/com/healthflow/app/ui/navigation/Navigation.kt b/android/app/src/main/java/com/healthflow/app/ui/navigation/Navigation.kt new file mode 100644 index 0000000..a1608ce --- /dev/null +++ b/android/app/src/main/java/com/healthflow/app/ui/navigation/Navigation.kt @@ -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) + ) + } + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/healthflow/app/ui/screen/HomeScreen.kt b/android/app/src/main/java/com/healthflow/app/ui/screen/HomeScreen.kt new file mode 100644 index 0000000..90634b2 --- /dev/null +++ b/android/app/src/main/java/com/healthflow/app/ui/screen/HomeScreen.kt @@ -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 + ) + } + } + } +} diff --git a/android/app/src/main/java/com/healthflow/app/ui/screen/LoginScreen.kt b/android/app/src/main/java/com/healthflow/app/ui/screen/LoginScreen.kt new file mode 100644 index 0000000..eaf9b33 --- /dev/null +++ b/android/app/src/main/java/com/healthflow/app/ui/screen/LoginScreen.kt @@ -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(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 + ) + } + } + } +} diff --git a/android/app/src/main/java/com/healthflow/app/ui/screen/ProfileScreen.kt b/android/app/src/main/java/com/healthflow/app/ui/screen/ProfileScreen.kt new file mode 100644 index 0000000..b6e1587 --- /dev/null +++ b/android/app/src/main/java/com/healthflow/app/ui/screen/ProfileScreen.kt @@ -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 + ) + } + } + } + } +} diff --git a/android/app/src/main/java/com/healthflow/app/ui/theme/Color.kt b/android/app/src/main/java/com/healthflow/app/ui/theme/Color.kt new file mode 100644 index 0000000..e3fe234 --- /dev/null +++ b/android/app/src/main/java/com/healthflow/app/ui/theme/Color.kt @@ -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) diff --git a/android/app/src/main/java/com/healthflow/app/ui/theme/Theme.kt b/android/app/src/main/java/com/healthflow/app/ui/theme/Theme.kt new file mode 100644 index 0000000..61046c9 --- /dev/null +++ b/android/app/src/main/java/com/healthflow/app/ui/theme/Theme.kt @@ -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 + ) +} diff --git a/android/app/src/main/java/com/healthflow/app/ui/theme/Type.kt b/android/app/src/main/java/com/healthflow/app/ui/theme/Type.kt new file mode 100644 index 0000000..20261ae --- /dev/null +++ b/android/app/src/main/java/com/healthflow/app/ui/theme/Type.kt @@ -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 + ) +) diff --git a/android/app/src/main/java/com/healthflow/app/ui/viewmodel/AuthViewModel.kt b/android/app/src/main/java/com/healthflow/app/ui/viewmodel/AuthViewModel.kt new file mode 100644 index 0000000..4cee44d --- /dev/null +++ b/android/app/src/main/java/com/healthflow/app/ui/viewmodel/AuthViewModel.kt @@ -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 = _isLoggedIn + + private val _user = MutableStateFlow(null) + val user: StateFlow = _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 + } + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..7dda7a9 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..5ed0a2d --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..5ed0a2d --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..b5f3615 --- /dev/null +++ b/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #10B981 + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..ffbd448 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + HealthFlow + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..a788394 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..da0c673 --- /dev/null +++ b/android/build.gradle.kts @@ -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 +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..7b73b87 --- /dev/null +++ b/android/gradle.properties @@ -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 diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..f69ca7a --- /dev/null +++ b/android/settings.gradle.kts @@ -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") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9b98a0c --- /dev/null +++ b/docker-compose.yml @@ -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} diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..a27bbef --- /dev/null +++ b/release.sh @@ -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 "✨ 全部完成!" diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..e0b7929 --- /dev/null +++ b/server/Dockerfile @@ -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"] diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..d07db67 --- /dev/null +++ b/server/go.mod @@ -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 +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..576e857 --- /dev/null +++ b/server/go.sum @@ -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= diff --git a/server/internal/config/config.go b/server/internal/config/config.go new file mode 100644 index 0000000..acd4ead --- /dev/null +++ b/server/internal/config/config.go @@ -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 +} diff --git a/server/internal/database/database.go b/server/internal/database/database.go new file mode 100644 index 0000000..a29ca4f --- /dev/null +++ b/server/internal/database/database.go @@ -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 +} diff --git a/server/internal/handler/auth.go b/server/internal/handler/auth.go new file mode 100644 index 0000000..fcebb5a --- /dev/null +++ b/server/internal/handler/auth.go @@ -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)) +} diff --git a/server/internal/handler/upload.go b/server/internal/handler/upload.go new file mode 100644 index 0000000..67b2d49 --- /dev/null +++ b/server/internal/handler/upload.go @@ -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) +} diff --git a/server/internal/handler/user.go b/server/internal/handler/user.go new file mode 100644 index 0000000..79bf4ad --- /dev/null +++ b/server/internal/handler/user.go @@ -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"}) +} diff --git a/server/internal/handler/version.go b/server/internal/handler/version.go new file mode 100644 index 0000000..9ececfb --- /dev/null +++ b/server/internal/handler/version.go @@ -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"}) +} diff --git a/server/internal/middleware/auth.go b/server/internal/middleware/auth.go new file mode 100644 index 0000000..1d00752 --- /dev/null +++ b/server/internal/middleware/auth.go @@ -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) +} diff --git a/server/internal/model/model.go b/server/internal/model/model.go new file mode 100644 index 0000000..b69e151 --- /dev/null +++ b/server/internal/model/model.go @@ -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"` +} diff --git a/server/internal/router/router.go b/server/internal/router/router.go new file mode 100644 index 0000000..29f4cae --- /dev/null +++ b/server/internal/router/router.go @@ -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 +} diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..ead5b82 --- /dev/null +++ b/server/main.go @@ -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) + } +}