feat(private-deploy): 支持 MySQL/Redis 外部连接和托管模式部署
- 添加 external 和 managed 两种数据库/缓存模式支持 - 实现 MySQL/Redis 托管安装脚本和配置向导 - 支持客户自备连接或部署脚本新建基础设施 - 更新部署文档说明不同模式的配置和验证要求 - 添加应用版本防重复上传和删除功能 - 实现应用商店预提交检查和发布计划功能
这个提交包含在:
父节点
57f8b36fab
当前提交
b77ccc663a
@ -15,13 +15,14 @@
|
|||||||
## 私有化流程
|
## 私有化流程
|
||||||
|
|
||||||
1. 运维在私有化部署仓库执行一键部署。
|
1. 运维在私有化部署仓库执行一键部署。
|
||||||
2. 部署脚本使用用户提供的 MySQL、Redis、域名、证书、SMTP 和厂商凭证完成配置。
|
2. 部署脚本先选择 MySQL/Redis 模式:客户自备连接,或由脚本新建并完成数据库、账号、密码和服务配置。
|
||||||
3. 系统初始化内置主租户、运营管理员和默认应用。
|
3. 部署脚本使用 MySQL、Redis、域名、证书、SMTP 和厂商凭证完成配置。
|
||||||
4. 文档站生成私有化 SDK 接入示例。
|
4. 系统初始化内置主租户、运营管理员和默认应用。
|
||||||
5. 客户端集成私有化 SDK。
|
5. 文档站生成私有化 SDK 接入示例。
|
||||||
6. 客户端使用 `xuqm-private-sdk.json` 初始化。
|
6. 客户端集成私有化 SDK。
|
||||||
7. 业务服务端签发 `UserSig`。
|
7. 客户端使用 `xuqm-private-sdk.json` 初始化。
|
||||||
8. 客户端登录 SDK 并使用 IM、Push、Update、File、License 能力。
|
8. 业务服务端签发 `UserSig`。
|
||||||
|
9. 客户端登录 SDK 并使用 IM、Push、Update、File、License 能力。
|
||||||
|
|
||||||
## 服务端签发 UserSig
|
## 服务端签发 UserSig
|
||||||
|
|
||||||
@ -50,6 +51,6 @@
|
|||||||
1. 私有化环境不开放主租户注册。
|
1. 私有化环境不开放主租户注册。
|
||||||
2. 私有化 SDK 不使用 `dev.xuqinmin.com` 作为默认地址。
|
2. 私有化 SDK 不使用 `dev.xuqinmin.com` 作为默认地址。
|
||||||
3. 厂商推送和应用市场自动发布需要客户网络放通厂商公网 API。
|
3. 厂商推送和应用市场自动发布需要客户网络放通厂商公网 API。
|
||||||
4. MySQL、Redis 由客户提供,部署脚本只做连接校验。
|
4. MySQL、Redis 可由客户提供连接,也可选择由部署脚本新建;客户自备模式只做连接校验,脚本新建模式由部署脚本负责安装、初始化和健康检查。
|
||||||
|
|
||||||
[快速开始](./quickstart)
|
[快速开始](./quickstart)
|
||||||
|
|||||||
@ -154,6 +154,26 @@ export interface AppVersion {
|
|||||||
createdAt: string
|
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 {
|
export interface AppPackageInspectResult {
|
||||||
platform: 'ANDROID' | 'IOS' | 'HARMONY'
|
platform: 'ANDROID' | 'IOS' | 'HARMONY'
|
||||||
packageName?: string
|
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(
|
updatePublishSchedule(
|
||||||
versionId: string,
|
versionId: string,
|
||||||
publishType: 'IMMEDIATE' | 'SCHEDULED',
|
publishType: 'IMMEDIATE' | 'SCHEDULED',
|
||||||
|
|||||||
@ -78,14 +78,14 @@
|
|||||||
:type="reviewTagType(item.state)"
|
:type="reviewTagType(item.state)"
|
||||||
size="small"
|
size="small"
|
||||||
style="margin:2px"
|
style="margin:2px"
|
||||||
>{{ storeLabel(item.store) }} · {{ reviewLabel(item.state) }}</el-tag>
|
>{{ storeLabel(item.store) }} · {{ reviewDisplayLabel(item) }}</el-tag>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
<el-tag
|
<el-tag
|
||||||
v-else
|
v-else
|
||||||
:type="reviewTagType(item.state)"
|
:type="reviewTagType(item.state)"
|
||||||
size="small"
|
size="small"
|
||||||
style="margin:2px"
|
style="margin:2px"
|
||||||
>{{ storeLabel(item.store) }} · {{ reviewLabel(item.state) }}</el-tag>
|
>{{ storeLabel(item.store) }} · {{ reviewDisplayLabel(item) }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<el-button
|
<el-button
|
||||||
@ -145,6 +145,10 @@
|
|||||||
v-if="row.downloadUrl && row.publishStatus !== 'DEPRECATED'"
|
v-if="row.downloadUrl && row.publishStatus !== 'DEPRECATED'"
|
||||||
link type="primary" size="small"
|
link type="primary" size="small"
|
||||||
@click="openSubmitStoreDialog(row)">提交市场</el-button>
|
@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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@ -545,6 +549,56 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</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 -->
|
<!-- Store Review Detail Dialog -->
|
||||||
<el-dialog v-model="showStoreReviewDetail" title="应用商店状态详情" :width="isMobile ? '100%' : '760px'" @close="stopDialogPoll">
|
<el-dialog v-model="showStoreReviewDetail" title="应用商店状态详情" :width="isMobile ? '100%' : '760px'" @close="stopDialogPoll">
|
||||||
<div v-if="storeReviewDetailVersion">
|
<div v-if="storeReviewDetailVersion">
|
||||||
@ -574,7 +628,7 @@
|
|||||||
<div class="review-card-head">
|
<div class="review-card-head">
|
||||||
<span class="review-card-name">{{ storeLabel(item.store) }}</span>
|
<span class="review-card-name">{{ storeLabel(item.store) }}</span>
|
||||||
<el-tag :type="reviewTagType(item.state)" size="small" class="review-card-tag">
|
<el-tag :type="reviewTagType(item.state)" size="small" class="review-card-tag">
|
||||||
{{ reviewLabel(item.state) }}
|
{{ reviewDisplayLabel(item) }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -609,10 +663,18 @@
|
|||||||
<span class="meta-label">批次</span>
|
<span class="meta-label">批次</span>
|
||||||
<span class="meta-value meta-mono">{{ item.batchId.slice(0, 8) }}…</span>
|
<span class="meta-value meta-mono">{{ item.batchId.slice(0, 8) }}…</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Rejection / withdrawal reason -->
|
<!-- 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>
|
<el-icon><WarningFilled /></el-icon>
|
||||||
<span>{{ item.reason }}</span>
|
<span>{{ item.reason }}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -915,6 +977,7 @@ import {
|
|||||||
type GrayMemberGroup,
|
type GrayMemberGroup,
|
||||||
type GrayMode,
|
type GrayMode,
|
||||||
type GraySelectionSource,
|
type GraySelectionSource,
|
||||||
|
type PreflightSubmitResultDto,
|
||||||
type PublishMode,
|
type PublishMode,
|
||||||
type RnBundle,
|
type RnBundle,
|
||||||
type RnBundleInspectResult,
|
type RnBundleInspectResult,
|
||||||
@ -1485,9 +1548,30 @@ const selectedStores = ref<StoreType[]>([])
|
|||||||
const submitStoreMode = ref<PublishMode>('MANUAL')
|
const submitStoreMode = ref<PublishMode>('MANUAL')
|
||||||
const submitStoreScheduledAt = ref('')
|
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 showStoreReviewDetail = ref(false)
|
||||||
const storeReviewDetailVersion = ref<AppVersion | null>(null)
|
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 storeReviewDetailLive = ref(false)
|
||||||
const cancellingReview = ref(false)
|
const cancellingReview = ref(false)
|
||||||
const retryingStores = ref<Set<string>>(new Set())
|
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
|
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
|
selectedStores.value = enabledStores.value
|
||||||
.map(s => s.type)
|
.map(s => s.type)
|
||||||
.filter(t => !isStoreActiveReview(row, t))
|
.filter(t => !isStoreActiveReview(row, t))
|
||||||
submitStoreMode.value = row.storeSubmitMode ?? 'MANUAL'
|
submitStoreMode.value = row.storeSubmitMode ?? 'MANUAL'
|
||||||
submitStoreScheduledAt.value = row.storeSubmitScheduledAt ?? ''
|
submitStoreScheduledAt.value = row.storeSubmitScheduledAt ?? ''
|
||||||
showSubmitStore.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSubmitDialogStoreState(version: AppVersion | null, storeType: string): string {
|
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) {
|
async function promptUnpublishRn(id: string) {
|
||||||
const reason = await promptUnpublishReason('下架确认')
|
const reason = await promptUnpublishReason('下架确认')
|
||||||
if (!reason) return
|
if (!reason) return
|
||||||
@ -2179,6 +2328,13 @@ function reviewLabel(state: string): string {
|
|||||||
}[state] ?? state
|
}[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 {
|
function reviewTagType(state: string): string {
|
||||||
return {
|
return {
|
||||||
PENDING: 'info', SUBMITTING: 'primary', UNDER_REVIEW: 'warning',
|
PENDING: 'info', SUBMITTING: 'primary', UNDER_REVIEW: 'warning',
|
||||||
@ -2308,7 +2464,7 @@ function scheduleStoreReviewReload() {
|
|||||||
}, 200)
|
}, 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 []
|
if (!json) return []
|
||||||
try {
|
try {
|
||||||
const m = JSON.parse(json) as Record<string, unknown>
|
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 ?? ''),
|
batchId: String(item.batchId ?? ''),
|
||||||
liveOnStore: item.liveOnStore === true,
|
liveOnStore: item.liveOnStore === true,
|
||||||
preExisting: item.preExisting === 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: '' }
|
return { store, state: String(value ?? ''), reason: '' }
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户