feat(private-deploy): 支持 MySQL/Redis 外部连接和托管模式部署

- 添加 external 和 managed 两种数据库/缓存模式支持
- 实现 MySQL/Redis 托管安装脚本和配置向导
- 支持客户自备连接或部署脚本新建基础设施
- 更新部署文档说明不同模式的配置和验证要求
- 添加应用版本防重复上传和删除功能
- 实现应用商店预提交检查和发布计划功能
这个提交包含在:
XuqmGroup 2026-05-18 18:37:10 +08:00
父节点 57f8b36fab
当前提交 b77ccc663a
共有 3 个文件被更改,包括 209 次插入17 次删除

查看文件

@ -15,13 +15,14 @@
## 私有化流程
1. 运维在私有化部署仓库执行一键部署。
2. 部署脚本使用用户提供的 MySQL、Redis、域名、证书、SMTP 和厂商凭证完成配置。
3. 系统初始化内置主租户、运营管理员和默认应用。
4. 文档站生成私有化 SDK 接入示例。
5. 客户端集成私有化 SDK。
6. 客户端使用 `xuqm-private-sdk.json` 初始化。
7. 业务服务端签发 `UserSig`
8. 客户端登录 SDK 并使用 IM、Push、Update、File、License 能力。
2. 部署脚本先选择 MySQL/Redis 模式:客户自备连接,或由脚本新建并完成数据库、账号、密码和服务配置。
3. 部署脚本使用 MySQL、Redis、域名、证书、SMTP 和厂商凭证完成配置。
4. 系统初始化内置主租户、运营管理员和默认应用。
5. 文档站生成私有化 SDK 接入示例。
6. 客户端集成私有化 SDK。
7. 客户端使用 `xuqm-private-sdk.json` 初始化。
8. 业务服务端签发 `UserSig`
9. 客户端登录 SDK 并使用 IM、Push、Update、File、License 能力。
## 服务端签发 UserSig
@ -50,6 +51,6 @@
1. 私有化环境不开放主租户注册。
2. 私有化 SDK 不使用 `dev.xuqinmin.com` 作为默认地址。
3. 厂商推送和应用市场自动发布需要客户网络放通厂商公网 API。
4. MySQL、Redis 由客户提供,部署脚本只做连接校验。
4. MySQL、Redis 由客户提供连接也可选择由部署脚本新建;客户自备模式只做连接校验,脚本新建模式由部署脚本负责安装、初始化和健康检查
[快速开始](./quickstart)

查看文件

@ -154,6 +154,26 @@ export interface AppVersion {
createdAt: string
}
export interface StoreRemoteStateDto {
storeType: StoreType
reviewState: 'ONLINE' | 'UNDER_REVIEW' | 'UNDER_REVIEW_XIAOMI' | 'REJECTED' | 'NOT_FOUND' | 'UNKNOWN' | 'QUERY_FAILED'
onlineVersionName?: string
onlineVersionCode?: string
reviewVersionName?: string
reviewVersionCode?: string
currentSubmissionLive: boolean
nonCurrentRelease: boolean
canSubmit: boolean
blockReason?: string
}
export interface PreflightSubmitResultDto {
versionId: string
versionName: string
versionCode: number
stores: StoreRemoteStateDto[]
}
export interface AppPackageInspectResult {
platform: 'ANDROID' | 'IOS' | 'HARMONY'
packageName?: string
@ -341,6 +361,17 @@ export const updateAdminApi = {
)
},
preflightStoreSubmission(versionId: string) {
return updateClient.post<{ data: PreflightSubmitResultDto }>(
`/api/v1/updates/store/app/${versionId}/preflight-submit`,
{},
)
},
deleteAppVersion(versionId: string) {
return updateClient.delete<{ data: null }>(`/api/v1/updates/app/${versionId}`)
},
updatePublishSchedule(
versionId: string,
publishType: 'IMMEDIATE' | 'SCHEDULED',

查看文件

@ -78,14 +78,14 @@
:type="reviewTagType(item.state)"
size="small"
style="margin:2px"
>{{ storeLabel(item.store) }} · {{ reviewLabel(item.state) }}</el-tag>
>{{ storeLabel(item.store) }} · {{ reviewDisplayLabel(item) }}</el-tag>
</el-tooltip>
<el-tag
v-else
:type="reviewTagType(item.state)"
size="small"
style="margin:2px"
>{{ storeLabel(item.store) }} · {{ reviewLabel(item.state) }}</el-tag>
>{{ storeLabel(item.store) }} · {{ reviewDisplayLabel(item) }}</el-tag>
</template>
</div>
<el-button
@ -145,6 +145,10 @@
v-if="row.downloadUrl && row.publishStatus !== 'DEPRECATED'"
link type="primary" size="small"
@click="openSubmitStoreDialog(row)">提交市场</el-button>
<el-button
v-if="row.publishStatus === 'DRAFT' && !hasActiveStoreReview(row)"
link type="danger" size="small"
@click="promptDeleteAppVersion(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
@ -545,6 +549,56 @@
</template>
</el-dialog>
<!-- Preflight Dialog -->
<el-dialog v-model="showPreflight" title="提审前检查" :width="isMobile ? '100%' : '560px'">
<div v-if="preflightLoading" v-loading="true" style="min-height:120px" />
<div v-else-if="preflightResult">
<p>版本 <strong>{{ preflightResult.versionName }}</strong> 的前置检查结果</p>
<el-table :data="preflightResult.stores" border size="small" style="margin-top:12px">
<el-table-column prop="storeType" label="厂商" width="100">
<template #default="{row}">{{ storeLabel(row.storeType) }}</template>
</el-table-column>
<el-table-column label="线上版本" width="120">
<template #default="{row}">
<span v-if="row.onlineVersionCode">{{ row.onlineVersionName || row.onlineVersionCode }}</span>
<span v-else class="text-muted"></span>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{row}">
<el-tag :type="preflightTagType(row.reviewState)" size="small">
{{ preflightStateLabel(row.reviewState) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="是否允许" width="90">
<template #default="{row}">
<el-tag :type="row.canSubmit ? 'success' : 'danger'" size="small">
{{ row.canSubmit ? '允许' : '阻止' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="原因" min-width="140">
<template #default="{row}">
<span v-if="row.blockReason" class="text-danger">{{ row.blockReason }}</span>
<span v-else class="text-muted"></span>
</template>
</el-table-column>
</el-table>
<el-checkbox v-model="preflightSkipCheck" style="margin-top:12px" v-if="preflightResult.stores.some(s => !s.canSubmit)">
跳过厂商状态检查后继续提交管理员
</el-checkbox>
</div>
<template #footer>
<el-button @click="showPreflight = false">取消</el-button>
<el-button
type="primary"
:disabled="preflightResult ? !preflightResult.stores.every(s => s.canSubmit) && !preflightSkipCheck : true"
@click="showPreflight = false; showSubmitStore = true"
>继续提交</el-button>
</template>
</el-dialog>
<!-- Store Review Detail Dialog -->
<el-dialog v-model="showStoreReviewDetail" title="应用商店状态详情" :width="isMobile ? '100%' : '760px'" @close="stopDialogPoll">
<div v-if="storeReviewDetailVersion">
@ -574,7 +628,7 @@
<div class="review-card-head">
<span class="review-card-name">{{ storeLabel(item.store) }}</span>
<el-tag :type="reviewTagType(item.state)" size="small" class="review-card-tag">
{{ reviewLabel(item.state) }}
{{ reviewDisplayLabel(item) }}
</el-tag>
</div>
@ -609,10 +663,18 @@
<span class="meta-label">批次</span>
<span class="meta-value meta-mono">{{ item.batchId.slice(0, 8) }}</span>
</div>
<div v-if="item.nonCurrentRelease || item.currentSubmissionLive === false" class="review-card-meta-item">
<span class="meta-label">发布归属</span>
<span class="meta-value">非本次发布</span>
</div>
<div v-if="item.liveVersionName || item.liveVersionCode" class="review-card-meta-item">
<span class="meta-label">线上版本</span>
<span class="meta-value">{{ [item.liveVersionName, item.liveVersionCode].filter(Boolean).join(' · ') }}</span>
</div>
</div>
<!-- Rejection / withdrawal reason -->
<div v-if="item.reason && (item.state === 'REJECTED' || item.state === 'WITHDRAWN' || item.state === 'FAILED')" class="review-card-reason">
<div v-if="item.reason && (item.state === 'REJECTED' || item.state === 'WITHDRAWN' || item.state === 'FAILED' || item.nonCurrentRelease || item.currentSubmissionLive === false)" class="review-card-reason">
<el-icon><WarningFilled /></el-icon>
<span>{{ item.reason }}</span>
</div>
@ -915,6 +977,7 @@ import {
type GrayMemberGroup,
type GrayMode,
type GraySelectionSource,
type PreflightSubmitResultDto,
type PublishMode,
type RnBundle,
type RnBundleInspectResult,
@ -1485,9 +1548,30 @@ const selectedStores = ref<StoreType[]>([])
const submitStoreMode = ref<PublishMode>('MANUAL')
const submitStoreScheduledAt = ref('')
// Preflight dialog state
const showPreflight = ref(false)
const preflightLoading = ref(false)
const preflightResult = ref<PreflightSubmitResultDto | null>(null)
const preflightSkipCheck = ref(false)
const showStoreReviewDetail = ref(false)
const storeReviewDetailVersion = ref<AppVersion | null>(null)
const storeReviewDetailItems = ref<{ store: string; state: string; reason?: string; stage?: string; submittedAt?: string; updatedAt?: string; batchId?: string; liveOnStore?: boolean; preExisting?: boolean }[]>([])
type StoreReviewItem = {
store: string
state: string
reason?: string
stage?: string
submittedAt?: string
updatedAt?: string
batchId?: string
liveOnStore?: boolean
preExisting?: boolean
currentSubmissionLive?: boolean
nonCurrentRelease?: boolean
liveVersionName?: string
liveVersionCode?: string
}
const storeReviewDetailItems = ref<StoreReviewItem[]>([])
const storeReviewDetailLive = ref(false)
const cancellingReview = ref(false)
const retryingStores = ref<Set<string>>(new Set())
@ -1584,15 +1668,32 @@ async function handleUpdatePublishSchedule() {
}
}
function openSubmitStoreDialog(row: AppVersion) {
async function openSubmitStoreDialog(row: AppVersion) {
submitStoreVersion.value = row
// Exclude stores already in active review from the pre-selection
preflightResult.value = null
preflightSkipCheck.value = false
showPreflight.value = true
preflightLoading.value = true
try {
const res = await updateAdminApi.preflightStoreSubmission(row.id)
preflightResult.value = res.data.data
// Auto-open submit dialog if all allowed
const allAllowed = preflightResult.value?.stores.every(s => s.canSubmit) ?? false
if (allAllowed) {
showPreflight.value = false
showSubmitStore.value = true
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '前置检查失败')
} finally {
preflightLoading.value = false
}
// Fallback: pre-select stores
selectedStores.value = enabledStores.value
.map(s => s.type)
.filter(t => !isStoreActiveReview(row, t))
submitStoreMode.value = row.storeSubmitMode ?? 'MANUAL'
submitStoreScheduledAt.value = row.storeSubmitScheduledAt ?? ''
showSubmitStore.value = true
}
function getSubmitDialogStoreState(version: AppVersion | null, storeType: string): string {
@ -2143,6 +2244,54 @@ async function promptUnpublishApp(id: string) {
}
}
async function promptDeleteAppVersion(row: AppVersion) {
try {
await ElMessageBox.confirm(
`确定删除版本 ${row.versionName}${row.versionCode})吗?删除后不可恢复。`,
'删除确认',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' },
)
await updateAdminApi.deleteAppVersion(row.id)
ElMessage.success('已删除')
await loadAppVersions()
await loadOperationLogs()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e?.response?.data?.message || '删除失败')
}
}
}
function hasActiveStoreReview(row: AppVersion): boolean {
if (!row.storeReviewStatus) return false
const items = parseStoreReview(row.storeReviewStatus)
return items.some(i => i.state === 'SUBMITTING' || i.state === 'UNDER_REVIEW')
}
function preflightTagType(state: string): string {
return {
ONLINE: 'success',
UNDER_REVIEW: 'warning',
UNDER_REVIEW_XIAOMI: 'warning',
REJECTED: 'danger',
NOT_FOUND: 'info',
UNKNOWN: 'info',
QUERY_FAILED: 'danger',
}[state] ?? ''
}
function preflightStateLabel(state: string): string {
return {
ONLINE: '已上线',
UNDER_REVIEW: '审核中',
UNDER_REVIEW_XIAOMI: '审核中(版本号可能不准确)',
REJECTED: '已拒绝',
NOT_FOUND: '未找到',
UNKNOWN: '未知',
QUERY_FAILED: '查询失败',
}[state] ?? state
}
async function promptUnpublishRn(id: string) {
const reason = await promptUnpublishReason('下架确认')
if (!reason) return
@ -2179,6 +2328,13 @@ function reviewLabel(state: string): string {
}[state] ?? state
}
function reviewDisplayLabel(item: StoreReviewItem): string {
if (item.state === 'APPROVED' && (item.nonCurrentRelease || item.currentSubmissionLive === false)) {
return '已上线(非本次发布)'
}
return reviewLabel(item.state)
}
function reviewTagType(state: string): string {
return {
PENDING: 'info', SUBMITTING: 'primary', UNDER_REVIEW: 'warning',
@ -2308,7 +2464,7 @@ function scheduleStoreReviewReload() {
}, 200)
}
function parseStoreReview(json?: string): { store: string; state: string; reason?: string; stage?: string; submittedAt?: string; updatedAt?: string; batchId?: string; liveOnStore?: boolean; preExisting?: boolean }[] {
function parseStoreReview(json?: string): StoreReviewItem[] {
if (!json) return []
try {
const m = JSON.parse(json) as Record<string, unknown>
@ -2328,6 +2484,10 @@ function parseStoreReview(json?: string): { store: string; state: string; reason
batchId: String(item.batchId ?? ''),
liveOnStore: item.liveOnStore === true,
preExisting: item.preExisting === true,
currentSubmissionLive: typeof item.currentSubmissionLive === 'boolean' ? item.currentSubmissionLive : undefined,
nonCurrentRelease: item.nonCurrentRelease === true,
liveVersionName: String(item.liveVersionName ?? ''),
liveVersionCode: String(item.liveVersionCode ?? ''),
}
}
return { store, state: String(value ?? ''), reason: '' }