feat(private-deploy): 支持 MySQL/Redis 外部连接和托管模式部署
- 添加 external 和 managed 两种数据库/缓存模式支持 - 实现 MySQL/Redis 托管安装脚本和配置向导 - 支持客户自备连接或部署脚本新建基础设施 - 更新部署文档说明不同模式的配置和验证要求 - 添加应用版本防重复上传和删除功能 - 实现应用商店预提交检查和发布计划功能
这个提交包含在:
父节点
57f8b36fab
当前提交
b77ccc663a
@ -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: '' }
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户