健康日记项目初始化
This commit is contained in:
14
.env.production
Normal file
14
.env.production
Normal 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
28
.gitignore
vendored
Normal 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
41
README.md
Normal 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
|
||||
```
|
||||
90
android/app/build.gradle.kts
Normal file
90
android/app/build.gradle.kts
Normal 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
33
android/app/proguard-rules.pro
vendored
Normal 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.** { *; }
|
||||
27
android/app/src/main/AndroidManifest.xml
Normal file
27
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
64
android/app/src/main/java/com/healthflow/app/MainActivity.kt
Normal file
64
android/app/src/main/java/com/healthflow/app/MainActivity.kt
Normal 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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
25
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
25
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#10B981</color>
|
||||
</resources>
|
||||
4
android/app/src/main/res/values/strings.xml
Normal file
4
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">HealthFlow</string>
|
||||
</resources>
|
||||
8
android/app/src/main/res/values/themes.xml
Normal file
8
android/app/src/main/res/values/themes.xml
Normal 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
6
android/build.gradle.kts
Normal 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
12
android/gradle.properties
Normal 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
|
||||
24
android/settings.gradle.kts
Normal file
24
android/settings.gradle.kts
Normal 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
22
docker-compose.yml
Normal 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
280
release.sh
Executable 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
21
server/Dockerfile
Normal 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
55
server/go.mod
Normal 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
128
server/go.sum
Normal 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=
|
||||
44
server/internal/config/config.go
Normal file
44
server/internal/config/config.go
Normal 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
|
||||
}
|
||||
69
server/internal/database/database.go
Normal file
69
server/internal/database/database.go
Normal 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
|
||||
}
|
||||
138
server/internal/handler/auth.go
Normal file
138
server/internal/handler/auth.go
Normal 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))
|
||||
}
|
||||
114
server/internal/handler/upload.go
Normal file
114
server/internal/handler/upload.go
Normal 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)
|
||||
}
|
||||
115
server/internal/handler/user.go
Normal file
115
server/internal/handler/user.go
Normal 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"})
|
||||
}
|
||||
69
server/internal/handler/version.go
Normal file
69
server/internal/handler/version.go
Normal 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"})
|
||||
}
|
||||
71
server/internal/middleware/auth.go
Normal file
71
server/internal/middleware/auth.go
Normal 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)
|
||||
}
|
||||
41
server/internal/model/model.go
Normal file
41
server/internal/model/model.go
Normal 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"`
|
||||
}
|
||||
73
server/internal/router/router.go
Normal file
73
server/internal/router/router.go
Normal 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
27
server/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user