feat: refine ops package workflow and upload handling

这个提交包含在:
徐勤民 2026-03-27 19:51:10 +08:00
父节点 0314acc18e
当前提交 78e362eb22
共有 17 个文件被更改,包括 981 次插入644 次删除

1
.gitignore vendored
查看文件

@ -10,6 +10,7 @@ node_modules/
dist/ dist/
coverage/ coverage/
.pnp.* .pnp.*
*.tsbuildinfo
*.iml *.iml
*.log *.log
AndroidLibs/.gradle-home/ AndroidLibs/.gradle-home/

查看文件

@ -1,58 +0,0 @@
package com.xuqm.sdk.compose.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun AccordionGroup(
title: String,
modifier: Modifier = Modifier,
initiallyExpanded: Boolean = false,
content: @Composable () -> Unit,
) {
var expanded by remember { mutableStateOf(initiallyExpanded) }
Card(modifier = modifier.fillMaxWidth()) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
.padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(title, style = MaterialTheme.typography.titleMedium)
Icon(
imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
contentDescription = null,
)
}
AnimatedVisibility(expanded) {
Column(modifier = Modifier.padding(16.dp)) {
content()
}
}
}
}
}

查看文件

@ -1,29 +0,0 @@
package com.xuqm.sdk.compose.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun FeatureCard(
title: String,
description: String,
modifier: Modifier = Modifier,
) {
Card(modifier = modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(title, style = MaterialTheme.typography.titleMedium)
Text(description, style = MaterialTheme.typography.bodyMedium)
}
}
}

查看文件

@ -12,6 +12,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@ -24,7 +26,6 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import com.xuqm.sdk.CoreSDK import com.xuqm.sdk.CoreSDK
import com.xuqm.sdk.compose.components.FeatureCard
import com.xuqm.szyx.SzyxSDK import com.xuqm.szyx.SzyxSDK
import com.xuqm.szyx.auth.LoginSession import com.xuqm.szyx.auth.LoginSession
import com.xuqm.szyx.auth.UserSessionManager import com.xuqm.szyx.auth.UserSessionManager
@ -97,14 +98,14 @@ private fun PluginUiScreen(
} }
item { item {
FeatureCard( PluginInfoCard(
title = "插件独立运行", title = "插件独立运行",
description = "plugin-ui 是独立 APK,可单独安装运行,也可由宿主通过包名直接拉起。", description = "plugin-ui 是独立 APK,可单独安装运行,也可由宿主通过包名直接拉起。",
) )
} }
item { item {
FeatureCard( PluginInfoCard(
title = "共享缓存", title = "共享缓存",
description = "通过 commonsdk-core 的 SharedCacheProvider 与宿主共享并更新用户会话。", description = "通过 commonsdk-core 的 SharedCacheProvider 与宿主共享并更新用户会话。",
) )
@ -112,3 +113,23 @@ private fun PluginUiScreen(
} }
} }
} }
@Composable
private fun PluginInfoCard(
title: String,
description: String,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(title, style = MaterialTheme.typography.titleMedium)
Text(description, style = MaterialTheme.typography.bodyMedium)
}
}
}

查看文件

@ -24,6 +24,7 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -40,8 +41,7 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.xuqm.sdk.CoreSDK import com.xuqm.sdk.CoreSDK
import com.xuqm.sdk.compose.components.AccordionGroup import com.xuqm.sdk.compose.components.accordion.AccordionPanel
import com.xuqm.sdk.compose.components.FeatureCard
import com.xuqm.sdk.plugin.PluginPackageManager import com.xuqm.sdk.plugin.PluginPackageManager
import com.xuqm.sdk.ui.ToastCenter import com.xuqm.sdk.ui.ToastCenter
import com.xuqm.sdk.update.DownloadState import com.xuqm.sdk.update.DownloadState
@ -485,7 +485,7 @@ private fun SampleHome(
} }
item { item {
AccordionGroup(title = "当前方案", initiallyExpanded = true) { AccordionPanel(title = "当前方案", initiallyExpanded = true) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("1. 未登录启动时直接进入 lib-szyx 登录页") Text("1. 未登录启动时直接进入 lib-szyx 登录页")
Text("2. 插件和应用更新都在应用内直接下载,并输出实时进度") Text("2. 插件和应用更新都在应用内直接下载,并输出实时进度")
@ -495,7 +495,7 @@ private fun SampleHome(
} }
item { item {
FeatureCard( InfoCard(
title = "插件结构", title = "插件结构",
description = "宿主 sample-app + 业务插件 plugin-ui,二者共享 commonsdk-core / commonsdk-compose / lib-szyx。", description = "宿主 sample-app + 业务插件 plugin-ui,二者共享 commonsdk-core / commonsdk-compose / lib-szyx。",
) )
@ -504,6 +504,26 @@ private fun SampleHome(
} }
} }
@Composable
private fun InfoCard(
title: String,
description: String,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(title, style = MaterialTheme.typography.titleMedium)
Text(description, style = MaterialTheme.typography.bodyMedium)
}
}
}
private fun DownloadState.toDisplayText(): String { private fun DownloadState.toDisplayText(): String {
return when (this) { return when (this) {
DownloadState.Idle -> "未开始" DownloadState.Idle -> "未开始"

查看文件

@ -48,6 +48,7 @@ export interface ReleaseRecord {
entryActivity?: string | null entryActivity?: string | null
minHostVersionCode?: number | null minHostVersionCode?: number | null
minHostVersionName?: string | null minHostVersionName?: string | null
forceUpdate?: boolean
status: 'DRAFT' | 'PUBLISHED' | 'GRAYSCALE' status: 'DRAFT' | 'PUBLISHED' | 'GRAYSCALE'
publishStrategy: string publishStrategy: string
grayRule?: { grayRule?: {
@ -100,9 +101,10 @@ export interface QuickSelection {
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://127.0.0.1:8080' const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://127.0.0.1:8080'
async function request<T>(path: string, init?: RequestInit): Promise<T> { async function request<T>(path: string, init?: RequestInit): Promise<T> {
const isFormData = typeof FormData !== 'undefined' && init?.body instanceof FormData
const response = await fetch(`${API_BASE_URL}${path}`, { const response = await fetch(`${API_BASE_URL}${path}`, {
headers: { headers: {
'Content-Type': 'application/json', ...(isFormData ? {} : { 'Content-Type': 'application/json' }),
...(init?.headers ?? {}), ...(init?.headers ?? {}),
}, },
...init, ...init,
@ -163,6 +165,20 @@ export const api = {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) })
}, },
uploadAppArtifact(appId: string, file: File) {
const formData = new FormData()
formData.append('file', file)
return request<{
packageName: string
versionCode: number
versionName: string
uploadedFileName: string
downloadUrl: string
}>(`/api/v1/ops/apps/${appId}/packages/artifact`, {
method: 'POST',
body: formData,
})
},
listPlugins(appId: string) { listPlugins(appId: string) {
return request<PluginConfig[]>(`/api/v1/ops/apps/${appId}/plugins`) return request<PluginConfig[]>(`/api/v1/ops/apps/${appId}/plugins`)
}, },
@ -187,12 +203,37 @@ export const api = {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) })
}, },
uploadPluginArtifact(pluginId: string, file: File) {
const formData = new FormData()
formData.append('file', file)
return request<{
packageName: string
versionCode: number
versionName: string
uploadedFileName: string
downloadUrl: string
}>(`/api/v1/ops/plugins/${pluginId}/packages/artifact`, {
method: 'POST',
body: formData,
})
},
publishPackage(releaseId: string, payload: Record<string, unknown>) { publishPackage(releaseId: string, payload: Record<string, unknown>) {
return request<ReleaseRecord>(`/api/v1/ops/packages/${releaseId}/publish`, { return request<ReleaseRecord>(`/api/v1/ops/packages/${releaseId}/publish`, {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) })
}, },
updatePackage(releaseId: string, payload: Record<string, unknown>) {
return request<ReleaseRecord>(`/api/v1/ops/packages/${releaseId}`, {
method: 'PUT',
body: JSON.stringify(payload),
})
},
deletePackage(releaseId: string) {
return request<void>(`/api/v1/ops/packages/${releaseId}`, {
method: 'DELETE',
})
},
listAudienceGroups() { listAudienceGroups() {
return request<AudienceGroup[]>('/api/v1/ops/version/audiences/groups') return request<AudienceGroup[]>('/api/v1/ops/version/audiences/groups')
}, },

查看文件

@ -37,10 +37,14 @@ select {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
position: sticky;
top: 0;
height: 100vh;
} }
.content { .content {
padding: 28px; padding: 28px;
min-width: 0;
} }
.nav { .nav {
@ -162,6 +166,11 @@ button {
color: #163454; color: #163454;
} }
.danger {
background: #fff1f1;
color: #b42318;
}
.app-card { .app-card {
border-top: 1px solid rgba(16, 35, 61, 0.08); border-top: 1px solid rgba(16, 35, 61, 0.08);
margin-top: 20px; margin-top: 20px;
@ -200,6 +209,11 @@ button {
color: white; color: white;
} }
.wide-tabs button[disabled] {
opacity: 0.45;
cursor: not-allowed;
}
.sub-panel { .sub-panel {
margin-top: 20px; margin-top: 20px;
padding-top: 20px; padding-top: 20px;
@ -248,6 +262,26 @@ button {
margin-top: 12px; margin-top: 12px;
} }
.modal-mask {
position: fixed;
inset: 0;
background: rgba(7, 19, 37, 0.38);
display: grid;
place-items: center;
padding: 24px;
z-index: 40;
}
.modal-card {
width: min(960px, 100%);
max-height: 80vh;
overflow: auto;
background: white;
border-radius: 24px;
padding: 24px;
box-shadow: 0 24px 60px rgba(15, 39, 71, 0.18);
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.shell, .shell,
.stack { .stack {
@ -256,5 +290,7 @@ button {
.sidebar { .sidebar {
gap: 24px; gap: 24px;
position: static;
height: auto;
} }
} }

查看文件

@ -1,147 +1,88 @@
<template> <template>
<section class="page stack"> <section class="page">
<div class="panel"> <div class="panel">
<div class="section-head"> <div class="section-head">
<div> <div>
<p class="section-tag">App 管理</p> <p class="section-tag">App 管理</p>
<h2>应用列表</h2> <h2>{{ selectedApp ? selectedApp.application.name : '请选择应用' }}</h2>
<p class="muted">
{{ selectedApp ? selectedApp.application.packageName : '点击左侧 App 管理后,先从弹窗中选择一个应用。' }}
</p>
</div> </div>
<button class="primary" @click="showCreateApp = !showCreateApp">创建 App</button> <div class="actions">
</div> <button class="secondary" @click="openSelector">切换应用</button>
<button v-if="selectedApp" class="primary" @click="openCreateModal">
<form v-if="showCreateApp" class="form-grid" @submit.prevent="createApp"> {{ tab === 'packages' ? '添加 APK' : '添加插件' }}
<label><span>名称</span><input v-model="appForm.name" required /></label>
<label><span>包名</span><input v-model="appForm.packageName" required /></label>
<label><span>插件包名前缀</span><input v-model="appForm.pluginPackageName" /></label>
<label class="full"><span>说明</span><input v-model="appForm.description" /></label>
<label class="toggle"><input type="checkbox" v-model="appForm.pluginManagementEnabled" />支持插件化</label>
<button class="primary">保存 App</button>
</form>
<div class="list-grid">
<button
v-for="item in applications"
:key="item.application.id"
class="list-card"
:data-active="selectedApp?.application.id === item.application.id"
@click="selectApp(item)"
>
<strong>{{ item.application.name }}</strong>
<span>{{ item.application.packageName }}</span>
<span>{{ item.application.pluginManagementEnabled ? '支持插件化' : '仅宿主' }}</span>
</button> </button>
</div> </div>
</div> </div>
<div class="panel" v-if="selectedApp"> <template v-if="selectedApp">
<div class="section-head"> <div class="selected-bar">
<div> <div class="chips">
<p class="section-tag">当前应用</p> <span>{{ selectedApp.application.pluginManagementEnabled ? '支持插件化' : '仅宿主 APK' }}</span>
<h2>{{ selectedApp.application.name }}</h2> <span>{{ selectedApp.application.businessModules.join(' / ') }}</span>
</div> </div>
<button class="secondary" @click="loadAll">刷新</button> <button class="secondary" @click="loadSelectedApp">刷新当前应用</button>
</div> </div>
<form class="form-grid" @submit.prevent="saveApp"> <div class="tab-row wide-tabs">
<label><span>名称</span><input v-model="appEdit.name" required /></label> <button :class="tab === 'packages' ? 'primary' : 'secondary'" @click="tab = 'packages'">APK 列表</button>
<label><span>包名</span><input v-model="appEdit.packageName" required /></label>
<label><span>插件包名前缀</span><input v-model="appEdit.pluginPackageName" /></label>
<label class="full"><span>说明</span><input v-model="appEdit.description" /></label>
<label class="toggle"><input type="checkbox" v-model="appEdit.pluginManagementEnabled" />支持插件化</label>
<button class="primary">修改 App 信息</button>
</form>
<div class="tab-row">
<button :class="tab === 'packages' ? 'primary' : 'secondary'" @click="tab = 'packages'">安装包</button>
<button <button
v-if="selectedApp.application.pluginManagementEnabled"
:class="tab === 'plugins' ? 'primary' : 'secondary'" :class="tab === 'plugins' ? 'primary' : 'secondary'"
:disabled="!selectedApp.application.pluginManagementEnabled"
@click="tab = 'plugins'" @click="tab = 'plugins'"
> >
插件列表 插件列表
</button> </button>
</div> </div>
<template v-if="tab === 'packages'"> <div class="sub-panel">
<form class="form-grid" @submit.prevent="uploadAppPackage"> <div class="section-head">
<label><span>包名</span><input v-model="appPackageForm.packageName" required /></label> <div>
<label><span>版本名</span><input v-model="appPackageForm.versionName" required /></label> <p class="section-tag">{{ tab === 'packages' ? 'APK 管理' : '插件管理' }}</p>
<label><span>版本码</span><input v-model.number="appPackageForm.versionCode" type="number" required /></label> <h3>{{ tab === 'packages' ? 'APK 安装包列表' : '插件安装包列表' }}</h3>
<label><span>文件名</span><input v-model="appPackageForm.uploadedFileName" required /></label> </div>
<label class="full"><span>下载地址</span><input v-model="appPackageForm.downloadUrl" required /></label> </div>
<label class="full"><span>标题</span><input v-model="appPackageForm.title" required /></label>
<label class="full"><span>更新说明</span><textarea v-model="appPackageForm.changelog" rows="3" /></label>
<button class="primary">上传安装包</button>
</form>
<table class="table"> <table class="table">
<thead><tr><th>版本</th><th>状态</th><th>操作</th></tr></thead> <thead v-if="tab === 'packages'">
<tbody> <tr><th>版本</th><th>文件名</th><th>状态</th><th>操作</th></tr>
</thead>
<thead v-else>
<tr><th>插件</th><th>版本</th><th>宿主最低版本</th><th>状态</th><th>操作</th></tr>
</thead>
<tbody v-if="tab === 'packages'">
<tr v-for="release in appPackages" :key="release.id"> <tr v-for="release in appPackages" :key="release.id">
<td>{{ release.versionName }} ({{ release.versionCode }})</td> <td>{{ release.versionName }} ({{ release.versionCode }})</td>
<td>{{ release.uploadedFileName }}</td>
<td>{{ release.status }}</td> <td>{{ release.status }}</td>
<td class="actions"> <td class="actions">
<button class="ghost" @click="publishFull(release.id)">发布当前安装包</button> <button class="ghost" @click="downloadFile(release.downloadUrl)">下载</button>
<button class="ghost" @click="prepareGray(release.id)">配置灰度</button> <button class="ghost" @click="publishFull(release.id)">发布</button>
<button class="ghost" @click="prepareGray(release.id)">灰度</button>
<button class="ghost" @click="openEditReleaseModal(release, 'APP')">编辑</button>
<button v-if="release.status === 'DRAFT'" class="ghost danger" @click="removeRelease(release.id)">删除</button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> <tbody v-else>
</template> <tr v-for="release in pluginReleaseRows" :key="release.id">
<td>{{ release.pluginName }}</td>
<template v-else>
<form class="form-grid" @submit.prevent="createPlugin">
<label><span>插件名</span><input v-model="pluginForm.name" required /></label>
<label><span>插件包名</span><input v-model="pluginForm.packageName" required /></label>
<label class="full"><span>入口 Activity</span><input v-model="pluginForm.entryActivity" /></label>
<label class="full"><span>说明</span><input v-model="pluginForm.description" /></label>
<button class="primary">新建插件</button>
</form>
<div class="list-grid">
<button
v-for="plugin in plugins"
:key="plugin.id"
class="list-card"
:data-active="selectedPlugin?.id === plugin.id"
@click="selectPlugin(plugin)"
>
<strong>{{ plugin.name }}</strong>
<span>{{ plugin.packageName }}</span>
<span>{{ plugin.enabled ? '已启用' : '未启用' }}</span>
</button>
</div>
<div v-if="selectedPlugin" class="sub-panel">
<h3>{{ selectedPlugin.name }} 安装包</h3>
<form class="form-grid" @submit.prevent="uploadPluginPackage">
<label><span>版本名</span><input v-model="pluginPackageForm.versionName" required /></label>
<label><span>版本码</span><input v-model.number="pluginPackageForm.versionCode" type="number" required /></label>
<label><span>宿主最低版本码</span><input v-model.number="pluginPackageForm.minHostVersionCode" type="number" /></label>
<label><span>宿主最低版本名</span><input v-model="pluginPackageForm.minHostVersionName" /></label>
<label><span>文件名</span><input v-model="pluginPackageForm.uploadedFileName" required /></label>
<label class="full"><span>下载地址</span><input v-model="pluginPackageForm.downloadUrl" required /></label>
<label class="full"><span>标题</span><input v-model="pluginPackageForm.title" required /></label>
<label class="full"><span>更新说明</span><textarea v-model="pluginPackageForm.changelog" rows="3" /></label>
<button class="primary">上传插件安装包</button>
</form>
<table class="table">
<thead><tr><th>版本</th><th>宿主最低版本</th><th>状态</th><th>操作</th></tr></thead>
<tbody>
<tr v-for="release in pluginPackages" :key="release.id">
<td>{{ release.versionName }} ({{ release.versionCode }})</td> <td>{{ release.versionName }} ({{ release.versionCode }})</td>
<td>{{ release.minHostVersionName || '-' }}</td> <td>{{ release.minHostVersionName || '-' }}</td>
<td>{{ release.status }}</td> <td>{{ release.status }}</td>
<td class="actions"> <td class="actions">
<button class="ghost" @click="publishFull(release.id)">发版</button> <button class="ghost" @click="downloadFile(release.downloadUrl)">下载</button>
<button class="ghost" @click="publishFull(release.id)">发布</button>
<button class="ghost" @click="prepareGray(release.id)">灰度</button> <button class="ghost" @click="prepareGray(release.id)">灰度</button>
<button class="ghost" @click="openEditReleaseModal(release, 'PLUGIN')">编辑</button>
<button v-if="release.status === 'DRAFT'" class="ghost danger" @click="removeRelease(release.id)">删除</button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</template>
<div v-if="grayReleaseId" class="sub-panel"> <div v-if="grayReleaseId" class="sub-panel">
<h3>灰度信息配置</h3> <h3>灰度信息配置</h3>
@ -176,94 +117,484 @@
<button class="primary" @click="publishGray">确认灰度发布</button> <button class="primary" @click="publishGray">确认灰度发布</button>
</div> </div>
</div> </div>
</template>
</div>
<div v-if="selectorOpen" class="modal-mask" @click.self="selectorOpen = false">
<div class="modal-card">
<div class="section-head">
<div>
<p class="section-tag">应用选择</p>
<h3>App 列表</h3>
</div>
<div class="actions">
<button class="secondary" @click="openCreateAppModal">创建应用</button>
<button class="secondary" @click="selectorOpen = false">关闭</button>
</div>
</div>
<div class="list-grid">
<div
v-for="item in applications"
:key="item.application.id"
class="list-card"
:data-active="selectedApp?.application.id === item.application.id"
>
<strong>{{ item.application.name }}</strong>
<span>{{ item.application.packageName }}</span>
<span>{{ item.application.pluginManagementEnabled ? '支持插件化' : '仅宿主' }}</span>
<div class="actions">
<button class="ghost" @click="selectApp(item)">进入</button>
<button class="ghost" @click="openEditAppModal(item)">编辑</button>
</div>
</div>
</div>
</div>
</div>
<div v-if="appModalOpen" class="modal-mask" @click.self="closeAppModal">
<div class="modal-card">
<div class="section-head">
<div>
<p class="section-tag">应用设置</p>
<h3>{{ appModalMode === 'create' ? '创建应用' : '编辑应用' }}</h3>
</div>
<button class="secondary" @click="closeAppModal">关闭</button>
</div>
<form class="form-grid" @submit.prevent="submitAppModal">
<label><span>名称</span><input v-model="appForm.name" required /></label>
<label><span>包名</span><input v-model="appForm.packageName" required /></label>
<label><span>插件包名前缀</span><input v-model="appForm.pluginPackageName" /></label>
<label class="full"><span>说明</span><input v-model="appForm.description" /></label>
<label class="toggle"><input type="checkbox" v-model="appForm.pluginManagementEnabled" />支持插件化</label>
<button class="primary">保存</button>
</form>
</div>
</div>
<div v-if="releaseModalOpen" class="modal-mask" @click.self="closeReleaseModal">
<div class="modal-card">
<div class="section-head">
<div>
<p class="section-tag">{{ releaseModalType === 'APP' ? 'APK' : '插件' }}</p>
<h3>{{ releaseModalMode === 'create' ? '新增安装包' : '编辑安装包' }}</h3>
</div>
<button class="secondary" @click="closeReleaseModal">关闭</button>
</div>
<template v-if="releaseModalType === 'APP'">
<form class="form-grid" @submit.prevent="submitAppRelease">
<label v-if="releaseModalMode === 'create'" class="full">
<span>安装包文件</span>
<input type="file" accept=".apk" @change="onAppFileSelected" />
</label>
<label><span>版本名</span><input v-model="appPackageForm.versionName" readonly /></label>
<label><span>版本码</span><input v-model.number="appPackageForm.versionCode" type="number" readonly /></label>
<label><span>文件名</span><input v-model="appPackageForm.uploadedFileName" readonly /></label>
<label class="full"><span>下载地址</span><input v-model="appPackageForm.downloadUrl" readonly /></label>
<label class="full"><span>标题</span><input v-model="appPackageForm.title" required /></label>
<label class="full"><span>更新说明</span><textarea v-model="appPackageForm.changelog" rows="3" /></label>
<button class="primary" :disabled="uploadingArtifact">{{ uploadingArtifact ? '上传中...' : '保存' }}</button>
</form>
</template>
<template v-else>
<div v-if="releaseModalMode === 'create'" class="tab-row">
<button type="button" :class="pluginCreateMode === 'plugin' ? 'primary' : 'secondary'" @click="pluginCreateMode = 'plugin'">
新建插件
</button>
<button type="button" :class="pluginCreateMode === 'package' ? 'primary' : 'secondary'" @click="pluginCreateMode = 'package'">
上传插件包
</button>
</div>
<form v-if="releaseModalMode === 'create' && pluginCreateMode === 'plugin'" class="form-grid" @submit.prevent="submitCreatePlugin">
<label><span>插件名</span><input v-model="pluginForm.name" required /></label>
<label><span>插件包名</span><input v-model="pluginForm.packageName" required /></label>
<label class="full"><span>入口 Activity</span><input v-model="pluginForm.entryActivity" /></label>
<label class="full"><span>说明</span><input v-model="pluginForm.description" /></label>
<button class="primary">保存插件</button>
</form>
<form v-else class="form-grid" @submit.prevent="submitPluginRelease">
<label v-if="releaseModalMode === 'create'">
<span>插件</span>
<select v-model="pluginReleaseForm.pluginId" required>
<option value="">请选择插件</option>
<option v-for="plugin in plugins" :key="plugin.id" :value="plugin.id">{{ plugin.name }}</option>
</select>
</label>
<label v-if="releaseModalMode === 'create'" class="full">
<span>安装包文件</span>
<input type="file" accept=".apk" @change="onPluginFileSelected" />
</label>
<label><span>版本名</span><input v-model="pluginReleaseForm.versionName" readonly /></label>
<label><span>版本码</span><input v-model.number="pluginReleaseForm.versionCode" type="number" readonly /></label>
<label><span>宿主最低版本码</span><input v-model.number="pluginReleaseForm.minHostVersionCode" type="number" /></label>
<label><span>宿主最低版本名</span><input v-model="pluginReleaseForm.minHostVersionName" /></label>
<label><span>文件名</span><input v-model="pluginReleaseForm.uploadedFileName" readonly /></label>
<label class="full"><span>下载地址</span><input v-model="pluginReleaseForm.downloadUrl" readonly /></label>
<label class="full"><span>标题</span><input v-model="pluginReleaseForm.title" required /></label>
<label class="full"><span>更新说明</span><textarea v-model="pluginReleaseForm.changelog" rows="3" /></label>
<button class="primary" :disabled="uploadingArtifact">{{ uploadingArtifact ? '上传中...' : '保存' }}</button>
</form>
</template>
</div>
</div> </div>
</section> </section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { api, type ApplicationDetail, type AudienceGroup, type AudienceUser, type PluginConfig, type QuickSelection, type ReleaseRecord } from '../api/client' import { api, type ApplicationDetail, type AudienceGroup, type AudienceUser, type PluginConfig, type QuickSelection, type ReleaseRecord } from '../api/client'
type ReleaseModalType = 'APP' | 'PLUGIN'
type ModalMode = 'create' | 'edit'
type PluginReleaseRow = ReleaseRecord & { pluginName: string }
const STORAGE_KEY = 'ops-selected-app-id'
const applications = ref<ApplicationDetail[]>([]) const applications = ref<ApplicationDetail[]>([])
const selectedApp = ref<ApplicationDetail | null>(null) const selectedApp = ref<ApplicationDetail | null>(null)
const selectedPlugin = ref<PluginConfig | null>(null)
const appPackages = ref<ReleaseRecord[]>([]) const appPackages = ref<ReleaseRecord[]>([])
const pluginPackages = ref<ReleaseRecord[]>([])
const plugins = ref<PluginConfig[]>([]) const plugins = ref<PluginConfig[]>([])
const pluginReleaseRows = ref<PluginReleaseRow[]>([])
const users = ref<AudienceUser[]>([]) const users = ref<AudienceUser[]>([])
const groups = ref<AudienceGroup[]>([]) const groups = ref<AudienceGroup[]>([])
const quickSelections = ref<QuickSelection[]>([]) const quickSelections = ref<QuickSelection[]>([])
const selectedUsers = ref<string[]>([]) const selectedUsers = ref<string[]>([])
const grayReleaseId = ref('') const grayReleaseId = ref('')
const showCreateApp = ref(false) const uploadingArtifact = ref(false)
const selectorOpen = ref(false)
const appModalOpen = ref(false)
const releaseModalOpen = ref(false)
const appModalMode = ref<ModalMode>('create')
const releaseModalMode = ref<ModalMode>('create')
const releaseModalType = ref<ReleaseModalType>('APP')
const pluginCreateMode = ref<'plugin' | 'package'>('package')
const editingAppId = ref('')
const editingReleaseId = ref('')
const tab = ref<'packages' | 'plugins'>('packages') const tab = ref<'packages' | 'plugins'>('packages')
const filters = reactive({ keyword: '', groupCode: '', quickSelectionCode: '' }) const filters = reactive({ keyword: '', groupCode: '', quickSelectionCode: '' })
const appForm = reactive({ name: '', packageName: '', pluginPackageName: '', description: '', pluginManagementEnabled: true, businessModules: ['IM', 'PUSH', 'VERSION'] }) const appForm = reactive({ name: '', packageName: '', pluginPackageName: '', description: '', pluginManagementEnabled: true, businessModules: ['IM', 'PUSH', 'VERSION'] })
const appEdit = reactive({ name: '', packageName: '', pluginPackageName: '', description: '', pluginManagementEnabled: true, businessModules: ['IM', 'PUSH', 'VERSION'] })
const pluginForm = reactive({ name: '', packageName: '', entryActivity: '', description: '' }) const pluginForm = reactive({ name: '', packageName: '', entryActivity: '', description: '' })
const appPackageForm = reactive({ packageName: '', versionCode: 1, versionName: '', title: '发现新版本', changelog: '', downloadUrl: '', uploadedFileName: '', entryActivity: '', forceUpdate: false }) const appPackageForm = reactive({ packageName: '', versionCode: 1, versionName: '', title: '发现新版本', changelog: '', downloadUrl: '', uploadedFileName: '', entryActivity: '', forceUpdate: false })
const pluginPackageForm = reactive({ versionCode: 1, versionName: '', title: '插件更新', changelog: '', downloadUrl: '', uploadedFileName: '', entryActivity: '', minHostVersionCode: 0, minHostVersionName: '' }) const pluginReleaseForm = reactive({ pluginId: '', versionCode: 1, versionName: '', title: '插件更新', changelog: '', downloadUrl: '', uploadedFileName: '', entryActivity: '', minHostVersionCode: 0, minHostVersionName: '' })
const pluginOptions = computed(() => plugins.value.map(item => ({ label: item.name, value: item.id })))
function resetAppForm() {
Object.assign(appForm, {
name: '',
packageName: '',
pluginPackageName: '',
description: '',
pluginManagementEnabled: true,
businessModules: ['IM', 'PUSH', 'VERSION'],
})
}
function resetAppPackageForm() {
Object.assign(appPackageForm, {
packageName: selectedApp.value?.application.packageName ?? '',
versionCode: 1,
versionName: '',
title: '有新版本待更新',
changelog: '',
downloadUrl: '',
uploadedFileName: '',
entryActivity: '',
forceUpdate: false,
})
}
function resetPluginForm() {
Object.assign(pluginForm, {
name: '',
packageName: '',
entryActivity: '',
description: '',
})
}
function resetPluginReleaseForm() {
Object.assign(pluginReleaseForm, {
pluginId: pluginOptions.value[0]?.value ?? '',
versionCode: 1,
versionName: '',
title: '有新版本待更新',
changelog: '',
downloadUrl: '',
uploadedFileName: '',
entryActivity: '',
minHostVersionCode: 0,
minHostVersionName: '',
})
}
function persistSelectedApp(appId: string) {
localStorage.setItem(STORAGE_KEY, appId)
}
function loadStoredAppId() {
return localStorage.getItem(STORAGE_KEY)
}
function openSelector() {
selectorOpen.value = true
}
function openCreateAppModal() {
appModalMode.value = 'create'
editingAppId.value = ''
resetAppForm()
appModalOpen.value = true
}
function openEditAppModal(item: ApplicationDetail) {
appModalMode.value = 'edit'
editingAppId.value = item.application.id
Object.assign(appForm, item.application)
appModalOpen.value = true
}
function closeAppModal() {
appModalOpen.value = false
}
function openCreateModal() {
releaseModalMode.value = 'create'
editingReleaseId.value = ''
releaseModalType.value = tab.value === 'packages' ? 'APP' : 'PLUGIN'
if (releaseModalType.value === 'APP') {
resetAppPackageForm()
} else {
pluginCreateMode.value = plugins.value.length > 0 ? 'package' : 'plugin'
resetPluginForm()
resetPluginReleaseForm()
}
releaseModalOpen.value = true
}
function openEditReleaseModal(release: ReleaseRecord, type: ReleaseModalType) {
releaseModalMode.value = 'edit'
releaseModalType.value = type
editingReleaseId.value = release.id
if (type === 'APP') {
Object.assign(appPackageForm, {
packageName: release.packageName,
versionCode: release.versionCode,
versionName: release.versionName,
title: release.title,
changelog: release.changelog,
downloadUrl: release.downloadUrl,
uploadedFileName: release.uploadedFileName,
entryActivity: release.entryActivity ?? '',
forceUpdate: release.forceUpdate ?? false,
})
} else {
Object.assign(pluginReleaseForm, {
pluginId: release.pluginId ?? '',
versionCode: release.versionCode,
versionName: release.versionName,
title: release.title,
changelog: release.changelog,
downloadUrl: release.downloadUrl,
uploadedFileName: release.uploadedFileName,
entryActivity: release.entryActivity ?? '',
minHostVersionCode: release.minHostVersionCode ?? 0,
minHostVersionName: release.minHostVersionName ?? '',
})
}
releaseModalOpen.value = true
}
function closeReleaseModal() {
releaseModalOpen.value = false
}
async function loadAll() { async function loadAll() {
applications.value = await api.listApplications() applications.value = await api.listApplications()
const [groupList, quickList] = await Promise.all([api.listAudienceGroups(), api.listQuickSelections()]) const [groupList, quickList] = await Promise.all([api.listAudienceGroups(), api.listQuickSelections()])
groups.value = groupList groups.value = groupList
quickSelections.value = quickList quickSelections.value = quickList
if (applications.value.length > 0 && !selectedApp.value) {
await selectApp(applications.value[0]) const storedAppId = loadStoredAppId()
const matched = applications.value.find(item => item.application.id === storedAppId) ?? null
if (matched) {
await selectApp(matched, false)
} else {
selectorOpen.value = true
} }
} }
async function selectApp(item: ApplicationDetail) { async function loadSelectedApp() {
if (!selectedApp.value) {
selectorOpen.value = true
return
}
const latest = await api.listApplications()
applications.value = latest
const matched = latest.find(item => item.application.id === selectedApp.value?.application.id)
if (matched) {
await selectApp(matched, false)
}
}
async function refreshPluginReleaseRows() {
const packageGroups = await Promise.all(
plugins.value.map(async plugin => {
const releases = await api.listPluginPackages(plugin.id)
return releases.map(release => ({ ...release, pluginName: plugin.name }))
}),
)
pluginReleaseRows.value = packageGroups.flat().sort((a, b) => b.versionCode - a.versionCode)
}
async function selectApp(item: ApplicationDetail, closeModal = true) {
selectedApp.value = item selectedApp.value = item
selectedPlugin.value = null selectedUsers.value = []
grayReleaseId.value = ''
tab.value = 'packages' tab.value = 'packages'
Object.assign(appEdit, item.application) persistSelectedApp(item.application.id)
appPackageForm.packageName = item.application.packageName
appPackages.value = await api.listAppPackages(item.application.id) appPackages.value = await api.listAppPackages(item.application.id)
plugins.value = await api.listPlugins(item.application.id) plugins.value = await api.listPlugins(item.application.id)
await refreshPluginReleaseRows()
if (closeModal) {
selectorOpen.value = false
}
} }
async function createApp() { async function submitAppModal() {
if (appModalMode.value === 'create') {
await api.createApplication(appForm) await api.createApplication(appForm)
showCreateApp.value = false } else {
await api.updateApplication(editingAppId.value, appForm)
}
closeAppModal()
await loadAll() await loadAll()
} }
async function saveApp() { async function submitAppRelease() {
if (!selectedApp.value) return if (!selectedApp.value) return
await api.updateApplication(selectedApp.value.application.id, appEdit) if (!appPackageForm.downloadUrl) {
await loadAll() window.alert('请先选择并上传 APK 文件')
return
} }
if (releaseModalMode.value === 'create') {
async function uploadAppPackage() {
if (!selectedApp.value) return
await api.uploadAppPackage(selectedApp.value.application.id, appPackageForm) await api.uploadAppPackage(selectedApp.value.application.id, appPackageForm)
} else {
await api.updatePackage(editingReleaseId.value, {
versionCode: appPackageForm.versionCode,
versionName: appPackageForm.versionName,
title: appPackageForm.title,
changelog: appPackageForm.changelog,
downloadUrl: appPackageForm.downloadUrl,
uploadedFileName: appPackageForm.uploadedFileName,
entryActivity: appPackageForm.entryActivity,
forceUpdate: appPackageForm.forceUpdate,
})
}
closeReleaseModal()
appPackages.value = await api.listAppPackages(selectedApp.value.application.id) appPackages.value = await api.listAppPackages(selectedApp.value.application.id)
} }
async function createPlugin() { async function submitPluginRelease() {
if (!selectedApp.value) return
if (!pluginReleaseForm.downloadUrl) {
window.alert('请先选择并上传 APK 文件')
return
}
if (releaseModalMode.value === 'create') {
await api.uploadPluginPackage(pluginReleaseForm.pluginId, pluginReleaseForm)
} else {
await api.updatePackage(editingReleaseId.value, {
packageName: '',
versionCode: pluginReleaseForm.versionCode,
versionName: pluginReleaseForm.versionName,
title: pluginReleaseForm.title,
changelog: pluginReleaseForm.changelog,
downloadUrl: pluginReleaseForm.downloadUrl,
uploadedFileName: pluginReleaseForm.uploadedFileName,
entryActivity: pluginReleaseForm.entryActivity,
minHostVersionCode: pluginReleaseForm.minHostVersionCode,
minHostVersionName: pluginReleaseForm.minHostVersionName,
forceUpdate: false,
})
}
closeReleaseModal()
plugins.value = await api.listPlugins(selectedApp.value.application.id)
await refreshPluginReleaseRows()
}
async function submitCreatePlugin() {
if (!selectedApp.value) return if (!selectedApp.value) return
await api.createPlugin(selectedApp.value.application.id, pluginForm) await api.createPlugin(selectedApp.value.application.id, pluginForm)
plugins.value = await api.listPlugins(selectedApp.value.application.id) plugins.value = await api.listPlugins(selectedApp.value.application.id)
pluginCreateMode.value = 'package'
resetPluginForm()
resetPluginReleaseForm()
} }
async function selectPlugin(plugin: PluginConfig) { async function onAppFileSelected(event: Event) {
selectedPlugin.value = plugin if (!selectedApp.value) return
pluginPackages.value = await api.listPluginPackages(plugin.id) const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
try {
uploadingArtifact.value = true
const artifact = await api.uploadAppArtifact(selectedApp.value.application.id, file)
appPackageForm.packageName = artifact.packageName
appPackageForm.versionCode = artifact.versionCode
appPackageForm.versionName = artifact.versionName
appPackageForm.uploadedFileName = artifact.uploadedFileName
appPackageForm.downloadUrl = artifact.downloadUrl
} catch (error) {
window.alert(error instanceof Error ? error.message : 'APK 上传失败')
input.value = ''
} finally {
uploadingArtifact.value = false
}
} }
async function uploadPluginPackage() { async function onPluginFileSelected(event: Event) {
if (!selectedPlugin.value) return const input = event.target as HTMLInputElement
await api.uploadPluginPackage(selectedPlugin.value.id, pluginPackageForm) const file = input.files?.[0]
pluginPackages.value = await api.listPluginPackages(selectedPlugin.value.id) if (!file) return
const pluginId = pluginReleaseForm.pluginId || (releaseModalMode.value === 'edit' ? pluginReleaseForm.pluginId : '')
if (!pluginId) {
window.alert('请先选择插件,再上传安装包')
input.value = ''
return
}
try {
uploadingArtifact.value = true
const artifact = await api.uploadPluginArtifact(pluginId, file)
pluginReleaseForm.versionCode = artifact.versionCode
pluginReleaseForm.versionName = artifact.versionName
pluginReleaseForm.uploadedFileName = artifact.uploadedFileName
pluginReleaseForm.downloadUrl = artifact.downloadUrl
} catch (error) {
window.alert(error instanceof Error ? error.message : '插件包上传失败')
input.value = ''
} finally {
uploadingArtifact.value = false
}
}
function downloadFile(url: string) {
window.open(url, '_blank', 'noopener')
} }
async function publishFull(releaseId: string) { async function publishFull(releaseId: string) {
await api.publishPackage(releaseId, { grayPublish: false }) await api.publishPackage(releaseId, { grayPublish: false })
if (selectedApp.value) appPackages.value = await api.listAppPackages(selectedApp.value.application.id) if (!selectedApp.value) return
if (selectedPlugin.value) pluginPackages.value = await api.listPluginPackages(selectedPlugin.value.id) if (tab.value === 'packages') {
appPackages.value = await api.listAppPackages(selectedApp.value.application.id)
} else {
await refreshPluginReleaseRows()
}
} }
function prepareGray(releaseId: string) { function prepareGray(releaseId: string) {
@ -291,6 +622,22 @@ async function publishGray() {
userIds: selectedUsers.value, userIds: selectedUsers.value,
}) })
grayReleaseId.value = '' grayReleaseId.value = ''
if (tab.value === 'packages' && selectedApp.value) {
appPackages.value = await api.listAppPackages(selectedApp.value.application.id)
} else {
await refreshPluginReleaseRows()
}
}
async function removeRelease(releaseId: string) {
const confirmed = window.confirm('删除后不可恢复,确认删除当前安装包吗?')
if (!confirmed || !selectedApp.value) return
await api.deletePackage(releaseId)
if (tab.value === 'packages') {
appPackages.value = await api.listAppPackages(selectedApp.value.application.id)
} else {
await refreshPluginReleaseRows()
}
} }
onMounted(() => { void loadAll() }) onMounted(() => { void loadAll() })

查看文件

@ -1,66 +0,0 @@
<template>
<section class="page">
<div class="panel">
<p class="section-tag">开放注册</p>
<h2>运营平台主账户注册</h2>
<p class="muted">注册后默认进入待审核状态管理平台可统一审核与禁用</p>
<form class="form-grid" @submit.prevent="submit">
<label>
企业名称
<input v-model="form.accountName" required placeholder="例如:星云运营中心" />
</label>
<label>
联系人
<input v-model="form.contactName" required placeholder="请输入联系人" />
</label>
<label>
邮箱
<input v-model="form.email" required type="email" placeholder="ops@example.com" />
</label>
<label>
手机号
<input v-model="form.phone" required placeholder="13800138000" />
</label>
<button class="primary" :disabled="loading">{{ loading ? '提交中...' : '提交注册' }}</button>
</form>
<p v-if="message" class="success-text">{{ message }}</p>
</div>
<div class="panel soft">
<p class="section-tag">账号规则</p>
<ul class="plain-list">
<li>主账户由运营方自行注册</li>
<li>子账户由主账户创建并赋权权限按业务动作拆分</li>
<li>版本发布插件化开关灰度发布都建议只给受控子账户</li>
</ul>
</div>
</section>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { api } from '../api/client'
const form = reactive({
accountName: '',
contactName: '',
email: '',
phone: '',
})
const loading = ref(false)
const message = ref('')
async function submit() {
loading.value = true
message.value = ''
try {
const result = await api.registerAccount(form)
message.value = `注册成功:${result.accountName}(状态:${result.status}`
} catch (error) {
message.value = error instanceof Error ? error.message : '注册失败'
} finally {
loading.value = false
}
}
</script>

查看文件

@ -1,293 +0,0 @@
<template>
<section class="page stack">
<div class="panel">
<div class="section-head">
<div>
<p class="section-tag">业务模块</p>
<h2>版本管理</h2>
</div>
<button class="secondary" @click="loadAll">刷新</button>
</div>
<div v-for="item in applications" :key="item.application.id" class="app-card">
<div class="app-card__top">
<div>
<h3>{{ item.application.name }}</h3>
<p class="muted">{{ item.application.packageName }}</p>
<p class="chips">
<span v-for="module in item.application.businessModules" :key="module">{{ module }}</span>
</p>
</div>
<label class="toggle">
<input
type="checkbox"
:checked="item.application.pluginManagementEnabled"
@change="togglePlugin(item.application.id, ($event.target as HTMLInputElement).checked)"
/>
插件化管理
</label>
</div>
<form class="form-grid" @submit.prevent="upload(item.application.id)">
<label>
包类型
<select v-model="uploadForms[item.application.id].packageType">
<option value="APP">App</option>
<option value="PLUGIN">插件</option>
</select>
</label>
<label>
版本号
<input v-model="uploadForms[item.application.id].versionName" placeholder="0.3.0" required />
</label>
<label>
版本码
<input v-model.number="uploadForms[item.application.id].versionCode" type="number" min="1" required />
</label>
<label>
包文件名
<input v-model="uploadForms[item.application.id].uploadedFileName" placeholder="app-release.apk" required />
</label>
<label class="full">
下载地址
<input v-model="uploadForms[item.application.id].downloadUrl" placeholder="https://example.com/app.apk" required />
</label>
<label class="full">
更新标题
<input v-model="uploadForms[item.application.id].title" placeholder="发现新版本" required />
</label>
<label class="full">
更新说明
<textarea v-model="uploadForms[item.application.id].changelog" rows="3"></textarea>
</label>
<button class="primary">上传版本包</button>
</form>
<table class="table">
<thead>
<tr>
<th>版本</th>
<th>类型</th>
<th>状态</th>
<th>策略</th>
<th>灰度目标</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="release in item.releases" :key="release.id">
<td>{{ release.versionName }} ({{ release.versionCode }})</td>
<td>{{ release.packageType }}</td>
<td>{{ release.status }}</td>
<td>{{ release.publishStrategy }}</td>
<td>{{ graySummary(release) }}</td>
<td class="actions">
<button class="ghost" @click="publishFull(item.application.id, release.id)">全量发布</button>
<button class="ghost" @click="selectGrayRelease(item.application.id, release.id)">灰度发布</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="panel">
<div class="section-head">
<div>
<p class="section-tag">灰度发布</p>
<h2>用户平台 Hook 选人</h2>
</div>
<p class="muted">用户数据已脱敏可按分组快速选择或单个用户圈定</p>
</div>
<div class="filters">
<label>
分组
<select v-model="filters.groupCode" @change="loadUsers">
<option value="">全部</option>
<option v-for="group in groups" :key="group.code" :value="group.code">
{{ group.name }} ({{ group.userCount }})
</option>
</select>
</label>
<label>
快速选择
<select v-model="filters.quickSelectionCode" @change="loadUsers">
<option value="">全部</option>
<option v-for="item in quickSelections" :key="item.code" :value="item.code">
{{ item.name }} ({{ item.userCount }})
</option>
</select>
</label>
<label class="grow">
搜索
<input v-model="filters.keyword" placeholder="用户 ID / 昵称 / 地区" @input="loadUsers" />
</label>
</div>
<div class="selected-bar">
<span>当前灰度版本{{ selectedReleaseId || '请先点一个版本的灰度发布' }}</span>
<span>已选择用户{{ selectedUsers.length }}</span>
<button class="primary" :disabled="!selectedReleaseId" @click="publishGray">确认灰度发布</button>
</div>
<div class="chips">
<button
v-for="item in quickSelections"
:key="item.code"
class="chip-button"
@click="applyQuickSelection(item.code)"
>
{{ item.name }}
</button>
</div>
<table class="table">
<thead>
<tr>
<th></th>
<th>用户 ID</th>
<th>昵称</th>
<th>手机号</th>
<th>邮箱</th>
<th>地区</th>
<th>分组</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>
<input type="checkbox" :checked="selectedUsers.includes(user.id)" @change="toggleUser(user.id)" />
</td>
<td>{{ user.id }}</td>
<td>{{ user.nickname }}</td>
<td>{{ user.phone }}</td>
<td>{{ user.email }}</td>
<td>{{ user.region }}</td>
<td>{{ user.groupName }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { api, type ApplicationDetail, type AudienceGroup, type AudienceUser, type QuickSelection, type ReleaseRecord } from '../api/client'
const applications = ref<ApplicationDetail[]>([])
const groups = ref<AudienceGroup[]>([])
const quickSelections = ref<QuickSelection[]>([])
const users = ref<AudienceUser[]>([])
const selectedUsers = ref<string[]>([])
const selectedAppId = ref('')
const selectedReleaseId = ref('')
const filters = reactive({
keyword: '',
groupCode: '',
quickSelectionCode: '',
})
const uploadForms = reactive<Record<string, {
packageType: 'APP' | 'PLUGIN'
versionCode: number
versionName: string
title: string
changelog: string
downloadUrl: string
uploadedFileName: string
entryActivity: string
forceUpdate: boolean
}>>({})
function ensureForm(appId: string) {
if (!uploadForms[appId]) {
uploadForms[appId] = {
packageType: 'APP',
versionCode: 1,
versionName: '',
title: '发现新版本',
changelog: '',
downloadUrl: '',
uploadedFileName: '',
entryActivity: 'com.xuqm.plugin.ui.PluginUiActivity',
forceUpdate: false,
}
}
}
function graySummary(release: ReleaseRecord) {
if (!release.grayRule) return '全量'
const rule = release.grayRule
return `${rule.groupCodes.length} / 快选 ${rule.quickSelectionCodes.length} / 用户 ${rule.userIds.length}`
}
function selectGrayRelease(appId: string, releaseId: string) {
selectedAppId.value = appId
selectedReleaseId.value = releaseId
}
async function loadAll() {
const [appList, groupList, quickList] = await Promise.all([
api.listApplications(),
api.listAudienceGroups(),
api.listQuickSelections(),
])
applications.value = appList
groups.value = groupList
quickSelections.value = quickList
appList.forEach(item => ensureForm(item.application.id))
await loadUsers()
}
async function loadUsers() {
users.value = await api.listAudienceUsers(filters)
}
async function togglePlugin(appId: string, enabled: boolean) {
await api.togglePluginManagement(appId, enabled)
await loadAll()
}
async function upload(appId: string) {
const form = uploadForms[appId]
await api.uploadRelease(appId, form)
await loadAll()
}
async function publishFull(appId: string, releaseId: string) {
await api.publishRelease(appId, releaseId, { grayPublish: false })
await loadAll()
}
function toggleUser(userId: string) {
if (selectedUsers.value.includes(userId)) {
selectedUsers.value = selectedUsers.value.filter(item => item !== userId)
} else {
selectedUsers.value = [...selectedUsers.value, userId]
}
}
function applyQuickSelection(code: string) {
filters.quickSelectionCode = code
void loadUsers()
}
async function publishGray() {
if (!selectedReleaseId.value || !selectedAppId.value) return
await api.publishRelease(selectedAppId.value, selectedReleaseId.value, {
grayPublish: true,
hookName: 'user-platform-gray-hook',
groupCodes: filters.groupCode ? [filters.groupCode] : [],
quickSelectionCodes: filters.quickSelectionCode ? [filters.quickSelectionCode] : [],
userIds: selectedUsers.value,
})
await loadAll()
}
onMounted(() => {
void loadAll()
})
</script>

查看文件

@ -48,6 +48,7 @@
<target>${maven.compiler.target}</target> <target>${maven.compiler.target}</target>
<release>${maven.compiler.release}</release> <release>${maven.compiler.release}</release>
<encoding>${project.build.sourceEncoding}</encoding> <encoding>${project.build.sourceEncoding}</encoding>
<parameters>true</parameters>
</configuration> </configuration>
</plugin> </plugin>
<plugin> <plugin>

查看文件

@ -45,6 +45,11 @@
<groupId>com.fasterxml.jackson.datatype</groupId> <groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId> <artifactId>jackson-datatype-jsr310</artifactId>
</dependency> </dependency>
<dependency>
<groupId>net.dongliu</groupId>
<artifactId>apk-parser</artifactId>
<version>2.6.10</version>
</dependency>
<dependency> <dependency>
<groupId>com.mysql</groupId> <groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId> <artifactId>mysql-connector-j</artifactId>
@ -70,6 +75,7 @@
<source>${maven.compiler.source}</source> <source>${maven.compiler.source}</source>
<target>${maven.compiler.target}</target> <target>${maven.compiler.target}</target>
<release>${java.version}</release> <release>${java.version}</release>
<parameters>true</parameters>
</configuration> </configuration>
</plugin> </plugin>
</plugins> </plugins>

查看文件

@ -25,8 +25,8 @@ public class CompatibilityUpdateController {
@GetMapping("/api/v1/updates/app/latest") @GetMapping("/api/v1/updates/app/latest")
public ApiResponse<Map<String, Object>> latestApp( public ApiResponse<Map<String, Object>> latestApp(
@RequestParam String packageName, @RequestParam("packageName") String packageName,
@RequestParam(required = false) String userId @RequestParam(value = "userId", required = false) String userId
) { ) {
PlatformData.ReleaseRecord release = versionManagementService.getLatestAppRelease(packageName, userId); PlatformData.ReleaseRecord release = versionManagementService.getLatestAppRelease(packageName, userId);
Map<String, Object> payload = new LinkedHashMap<>(); Map<String, Object> payload = new LinkedHashMap<>();
@ -42,9 +42,9 @@ public class CompatibilityUpdateController {
@GetMapping("/api/v1/updates/plugin/latest") @GetMapping("/api/v1/updates/plugin/latest")
public ApiResponse<Map<String, Object>> latestPlugin( public ApiResponse<Map<String, Object>> latestPlugin(
@RequestParam String packageName, @RequestParam("packageName") String packageName,
@RequestParam(required = false) String userId, @RequestParam(value = "userId", required = false) String userId,
@RequestParam(required = false) Integer hostVersionCode @RequestParam(value = "hostVersionCode", required = false) Integer hostVersionCode
) { ) {
PlatformData.ReleaseRecord release = versionManagementService.getLatestPluginRelease(packageName, userId, hostVersionCode); PlatformData.ReleaseRecord release = versionManagementService.getLatestPluginRelease(packageName, userId, hostVersionCode);
Map<String, Object> payload = new LinkedHashMap<>(); Map<String, Object> payload = new LinkedHashMap<>();
@ -60,9 +60,9 @@ public class CompatibilityUpdateController {
@GetMapping("/api/v1/updates/plugins/catalog") @GetMapping("/api/v1/updates/plugins/catalog")
public ApiResponse<java.util.List<VersionManagementService.PluginCatalogItem>> pluginCatalog( public ApiResponse<java.util.List<VersionManagementService.PluginCatalogItem>> pluginCatalog(
@RequestParam String appPackageName, @RequestParam("appPackageName") String appPackageName,
@RequestParam(required = false) String userId, @RequestParam(value = "userId", required = false) String userId,
@RequestParam long hostVersionCode @RequestParam("hostVersionCode") long hostVersionCode
) { ) {
return ApiResponse.success(versionManagementService.getPluginCatalog(appPackageName, userId, hostVersionCode)); return ApiResponse.success(versionManagementService.getPluginCatalog(appPackageName, userId, hostVersionCode));
} }

查看文件

@ -2,12 +2,21 @@ package com.xuqm.versionmanagement.controller;
import com.xuqm.versionmanagement.model.ApiResponse; import com.xuqm.versionmanagement.model.ApiResponse;
import com.xuqm.versionmanagement.model.PlatformData; import com.xuqm.versionmanagement.model.PlatformData;
import com.xuqm.versionmanagement.service.ReleaseArtifactService;
import com.xuqm.versionmanagement.service.UserHookService; import com.xuqm.versionmanagement.service.UserHookService;
import com.xuqm.versionmanagement.service.VersionManagementService; import com.xuqm.versionmanagement.service.VersionManagementService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import java.util.List; import java.util.List;
import java.util.Map;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
@ -15,6 +24,7 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@Validated @Validated
@RestController @RestController
@ -23,10 +33,16 @@ public class OpsVersionController {
private final VersionManagementService versionManagementService; private final VersionManagementService versionManagementService;
private final UserHookService userHookService; private final UserHookService userHookService;
private final ReleaseArtifactService releaseArtifactService;
public OpsVersionController(VersionManagementService versionManagementService, UserHookService userHookService) { public OpsVersionController(
VersionManagementService versionManagementService,
UserHookService userHookService,
ReleaseArtifactService releaseArtifactService
) {
this.versionManagementService = versionManagementService; this.versionManagementService = versionManagementService;
this.userHookService = userHookService; this.userHookService = userHookService;
this.releaseArtifactService = releaseArtifactService;
} }
@GetMapping("/apps") @GetMapping("/apps")
@ -107,6 +123,18 @@ public class OpsVersionController {
return ApiResponse.success(release, "版本包已上传"); return ApiResponse.success(release, "版本包已上传");
} }
@PostMapping(value = "/apps/{appId}/packages/artifact", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<ReleaseArtifactService.UploadedArtifact> uploadAppArtifact(
@PathVariable String appId,
@RequestParam("file") MultipartFile file,
HttpServletRequest request
) {
return ApiResponse.success(
releaseArtifactService.uploadAppArtifact(appId, file, baseUrl(request)),
"安装包上传成功"
);
}
@GetMapping("/apps/{appId}/plugins") @GetMapping("/apps/{appId}/plugins")
public ApiResponse<List<PlatformData.PluginConfig>> listPlugins(@PathVariable String appId) { public ApiResponse<List<PlatformData.PluginConfig>> listPlugins(@PathVariable String appId) {
return ApiResponse.success(versionManagementService.listPlugins(appId)); return ApiResponse.success(versionManagementService.listPlugins(appId));
@ -174,6 +202,18 @@ public class OpsVersionController {
return ApiResponse.success(release, "插件安装包已上传"); return ApiResponse.success(release, "插件安装包已上传");
} }
@PostMapping(value = "/plugins/{pluginId}/packages/artifact", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<ReleaseArtifactService.UploadedArtifact> uploadPluginArtifact(
@PathVariable String pluginId,
@RequestParam("file") MultipartFile file,
HttpServletRequest request
) {
return ApiResponse.success(
releaseArtifactService.uploadPluginArtifact(pluginId, file, baseUrl(request)),
"插件安装包上传成功"
);
}
@PostMapping("/packages/{releaseId}/publish") @PostMapping("/packages/{releaseId}/publish")
public ApiResponse<PlatformData.ReleaseRecord> publishRelease( public ApiResponse<PlatformData.ReleaseRecord> publishRelease(
@PathVariable String releaseId, @PathVariable String releaseId,
@ -192,6 +232,36 @@ public class OpsVersionController {
return ApiResponse.success(release, request.grayPublish() ? "灰度发布已创建" : "全量发布成功"); return ApiResponse.success(release, request.grayPublish() ? "灰度发布已创建" : "全量发布成功");
} }
@PutMapping("/packages/{releaseId}")
public ApiResponse<PlatformData.ReleaseRecord> updateRelease(
@PathVariable String releaseId,
@RequestBody @Validated UpdateReleaseRequest request
) {
PlatformData.ReleaseRecord release = versionManagementService.updateRelease(
releaseId,
new VersionManagementService.UpdateReleaseCommand(
request.packageName(),
request.versionCode(),
request.versionName(),
request.title(),
request.changelog(),
request.downloadUrl(),
request.entryActivity(),
request.minHostVersionCode(),
request.minHostVersionName(),
request.forceUpdate(),
request.uploadedFileName()
)
);
return ApiResponse.success(release, "安装包信息已更新");
}
@DeleteMapping("/packages/{releaseId}")
public ApiResponse<Void> deleteRelease(@PathVariable String releaseId) {
versionManagementService.deleteRelease(releaseId);
return ApiResponse.success(null, "安装包已删除");
}
@GetMapping("/audiences/users") @GetMapping("/audiences/users")
public ApiResponse<List<UserHookService.MaskedUser>> listAudienceUsers( public ApiResponse<List<UserHookService.MaskedUser>> listAudienceUsers(
@RequestParam(required = false) String keyword, @RequestParam(required = false) String keyword,
@ -211,6 +281,15 @@ public class OpsVersionController {
return ApiResponse.success(userHookService.getAudienceBundle(null, null, null).quickSelections()); return ApiResponse.success(userHookService.getAudienceBundle(null, null, null).quickSelections());
} }
@GetMapping("/assets/download/{storageKey}")
public ResponseEntity<Resource> downloadArtifact(@PathVariable String storageKey) {
ReleaseArtifactService.DownloadResource resource = releaseArtifactService.loadAsResource(storageKey);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + resource.fileName() + "\"")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(resource.resource());
}
public record AppRequest( public record AppRequest(
@NotBlank(message = "不能为空") String name, @NotBlank(message = "不能为空") String name,
@NotBlank(message = "不能为空") String packageName, @NotBlank(message = "不能为空") String packageName,
@ -270,6 +349,21 @@ public class OpsVersionController {
) { ) {
} }
public record UpdateReleaseRequest(
String packageName,
int versionCode,
@NotBlank(message = "不能为空") String versionName,
@NotBlank(message = "不能为空") String title,
String changelog,
@NotBlank(message = "不能为空") String downloadUrl,
String entryActivity,
Integer minHostVersionCode,
String minHostVersionName,
boolean forceUpdate,
@NotBlank(message = "不能为空") String uploadedFileName
) {
}
public record PublishReleaseRequest( public record PublishReleaseRequest(
boolean grayPublish, boolean grayPublish,
String hookName, String hookName,
@ -289,4 +383,8 @@ public class OpsVersionController {
return userIds == null ? List.of() : userIds; return userIds == null ? List.of() : userIds;
} }
} }
private String baseUrl(HttpServletRequest request) {
return request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort();
}
} }

查看文件

@ -0,0 +1,157 @@
package com.xuqm.versionmanagement.service;
import com.xuqm.versionmanagement.persistence.entity.ApplicationEntity;
import com.xuqm.versionmanagement.persistence.entity.PluginEntity;
import com.xuqm.versionmanagement.persistence.repository.ApplicationRepository;
import com.xuqm.versionmanagement.persistence.repository.PluginRepository;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Locale;
import java.util.UUID;
import net.dongliu.apk.parser.ApkFile;
import net.dongliu.apk.parser.bean.ApkMeta;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@Service
public class ReleaseArtifactService {
private final ApplicationRepository applicationRepository;
private final PluginRepository pluginRepository;
private final Path releaseDir;
public ReleaseArtifactService(
ApplicationRepository applicationRepository,
PluginRepository pluginRepository,
@Value("${app.storage.release-dir}") String releaseDir
) {
this.applicationRepository = applicationRepository;
this.pluginRepository = pluginRepository;
this.releaseDir = Path.of(releaseDir).toAbsolutePath().normalize();
}
public UploadedArtifact uploadAppArtifact(String appId, MultipartFile file, String baseUrl) {
ApplicationEntity app = applicationRepository.findById(appId)
.orElseThrow(() -> new IllegalArgumentException("应用不存在"));
ParsedApk parsedApk = parseAndStore(file);
if (!app.getPackageName().equals(parsedApk.packageName())) {
deleteQuietly(parsedApk.storageKey());
throw new IllegalArgumentException("安装包包名与当前应用不一致,不允许上传");
}
return toUploadedArtifact(parsedApk, baseUrl);
}
public UploadedArtifact uploadPluginArtifact(String pluginId, MultipartFile file, String baseUrl) {
PluginEntity plugin = pluginRepository.findById(pluginId)
.orElseThrow(() -> new IllegalArgumentException("插件不存在"));
ParsedApk parsedApk = parseAndStore(file);
if (!plugin.getPackageName().equals(parsedApk.packageName())) {
deleteQuietly(parsedApk.storageKey());
throw new IllegalArgumentException("安装包包名与当前插件不一致,不允许上传");
}
return toUploadedArtifact(parsedApk, baseUrl);
}
public DownloadResource loadAsResource(String storageKey) {
Path filePath = releaseDir.resolve(storageKey).normalize();
if (!filePath.startsWith(releaseDir) || !Files.exists(filePath)) {
throw new IllegalArgumentException("安装包文件不存在");
}
Resource resource = new FileSystemResource(filePath);
return new DownloadResource(resource, filePath.getFileName().toString());
}
private UploadedArtifact toUploadedArtifact(ParsedApk parsedApk, String baseUrl) {
return new UploadedArtifact(
parsedApk.packageName(),
parsedApk.versionCode(),
parsedApk.versionName(),
parsedApk.originalFileName(),
baseUrl + "/api/v1/ops/assets/download/" + parsedApk.storageKey()
);
}
private ParsedApk parseAndStore(MultipartFile file) {
if (file.isEmpty()) {
throw new IllegalArgumentException("请选择安装包文件");
}
String originalFileName = file.getOriginalFilename() == null ? "release.apk" : Path.of(file.getOriginalFilename()).getFileName().toString();
if (!originalFileName.toLowerCase(Locale.ROOT).endsWith(".apk")) {
throw new IllegalArgumentException("仅支持上传 APK 文件");
}
String storageKey = UUID.randomUUID() + "-" + originalFileName;
Path target = releaseDir.resolve(storageKey);
try {
Files.createDirectories(releaseDir);
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, target, StandardCopyOption.REPLACE_EXISTING);
}
try (ApkFile apkFile = new ApkFile(target.toFile())) {
ApkMeta meta = apkFile.getApkMeta();
return new ParsedApk(
meta.getPackageName(),
meta.getVersionCode() == null ? 0 : meta.getVersionCode().intValue(),
meta.getVersionName(),
originalFileName,
storageKey
);
}
} catch (IOException exception) {
throw new IllegalArgumentException("安装包上传失败: " + exception.getMessage());
} catch (Exception exception) {
try {
Files.deleteIfExists(target);
} catch (IOException ignored) {
}
throw new IllegalArgumentException("安装包解析失败,请确认文件为有效 APK");
}
}
public void deleteByDownloadUrl(String downloadUrl) {
if (downloadUrl == null || downloadUrl.isBlank()) {
return;
}
try {
String path = URI.create(downloadUrl).getPath();
String storageKey = path.substring(path.lastIndexOf('/') + 1);
deleteQuietly(storageKey);
} catch (Exception ignored) {
}
}
private void deleteQuietly(String storageKey) {
try {
Files.deleteIfExists(releaseDir.resolve(storageKey));
} catch (IOException ignored) {
}
}
public record UploadedArtifact(
String packageName,
int versionCode,
String versionName,
String uploadedFileName,
String downloadUrl
) {
}
public record DownloadResource(Resource resource, String fileName) {
}
private record ParsedApk(
String packageName,
int versionCode,
String versionName,
String originalFileName,
String storageKey
) {
}
}

查看文件

@ -22,19 +22,22 @@ public class VersionManagementService {
private final ReleaseRepository releaseRepository; private final ReleaseRepository releaseRepository;
private final UserHookService userHookService; private final UserHookService userHookService;
private final PlatformMapper platformMapper; private final PlatformMapper platformMapper;
private final ReleaseArtifactService releaseArtifactService;
public VersionManagementService( public VersionManagementService(
ApplicationRepository applicationRepository, ApplicationRepository applicationRepository,
PluginRepository pluginRepository, PluginRepository pluginRepository,
ReleaseRepository releaseRepository, ReleaseRepository releaseRepository,
UserHookService userHookService, UserHookService userHookService,
PlatformMapper platformMapper PlatformMapper platformMapper,
ReleaseArtifactService releaseArtifactService
) { ) {
this.applicationRepository = applicationRepository; this.applicationRepository = applicationRepository;
this.pluginRepository = pluginRepository; this.pluginRepository = pluginRepository;
this.releaseRepository = releaseRepository; this.releaseRepository = releaseRepository;
this.userHookService = userHookService; this.userHookService = userHookService;
this.platformMapper = platformMapper; this.platformMapper = platformMapper;
this.releaseArtifactService = releaseArtifactService;
} }
public List<ApplicationDetail> listApplications() { public List<ApplicationDetail> listApplications() {
@ -102,6 +105,35 @@ public class VersionManagementService {
return platformMapper.toRelease(releaseRepository.save(entity)); return platformMapper.toRelease(releaseRepository.save(entity));
} }
@Transactional
public PlatformData.ReleaseRecord updateRelease(String releaseId, UpdateReleaseCommand command) {
ReleaseEntity entity = findReleaseEntity(releaseId);
if (command.packageName() != null && !command.packageName().isBlank()) {
entity.setPackageName(command.packageName());
}
entity.setVersionCode(command.versionCode());
entity.setVersionName(command.versionName());
entity.setTitle(command.title());
entity.setChangelog(command.changelog());
entity.setDownloadUrl(command.downloadUrl());
entity.setEntryActivity(command.entryActivity());
entity.setMinHostVersionCode(command.minHostVersionCode());
entity.setMinHostVersionName(command.minHostVersionName());
entity.setForceUpdate(command.forceUpdate());
entity.setUploadedFileName(command.uploadedFileName());
return platformMapper.toRelease(releaseRepository.save(entity));
}
@Transactional
public void deleteRelease(String releaseId) {
ReleaseEntity entity = findReleaseEntity(releaseId);
if (entity.getStatus() != PlatformData.ReleaseStatus.DRAFT) {
throw new IllegalArgumentException("仅草稿状态安装包允许删除");
}
releaseArtifactService.deleteByDownloadUrl(entity.getDownloadUrl());
releaseRepository.delete(entity);
}
public List<PlatformData.PluginConfig> listPlugins(String appId) { public List<PlatformData.PluginConfig> listPlugins(String appId) {
findApplicationEntity(appId); findApplicationEntity(appId);
return pluginRepository.findByAppIdOrderByCreatedAtAsc(appId).stream() return pluginRepository.findByAppIdOrderByCreatedAtAsc(appId).stream()
@ -367,6 +399,21 @@ public class VersionManagementService {
) { ) {
} }
public record UpdateReleaseCommand(
String packageName,
int versionCode,
String versionName,
String title,
String changelog,
String downloadUrl,
String entryActivity,
Integer minHostVersionCode,
String minHostVersionName,
boolean forceUpdate,
String uploadedFileName
) {
}
public record PublishReleaseCommand( public record PublishReleaseCommand(
boolean grayPublish, boolean grayPublish,
String hookName, String hookName,

查看文件

@ -4,6 +4,10 @@ server:
spring: spring:
application: application:
name: version-management-service name: version-management-service
servlet:
multipart:
max-file-size: 200MB
max-request-size: 200MB
datasource: datasource:
url: ${DB_URL:jdbc:mysql://39.107.53.187:3306/android-libs?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8} url: ${DB_URL:jdbc:mysql://39.107.53.187:3306/android-libs?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8}
username: ${DB_USERNAME:android-libs} username: ${DB_USERNAME:android-libs}
@ -46,3 +50,7 @@ spring:
logging: logging:
level: level:
org.hibernate.SQL: info org.hibernate.SQL: info
app:
storage:
release-dir: ${APP_RELEASE_DIR:${user.dir}/uploads/releases}