feat(update): 重构灰度发布系统并新增标签管理功能

- 灰度模式简化为 PERCENT 和 MEMBERS 两种
- 新增成员标签系统,支持标签 CRUD 和按标签选择发版
- 成员同步保留已有标签,手动成员不受同步影响
- 支持标签 + 额外成员组合选择:groupNames + extraMemberIds
- 发布时回调集成方获取成员列表,支持 AppSecret 签名验证
- 从 IM 服务导入成员功能
- 修复 isInGrayRelease() 中的 String.contains() 误匹配 bug
- 移除 IM_PUSH_USERS、CUSTOMER_SYNC、CUSTOMER_CALLBACK 模式
- 更新前端界面,优化灰度成员选择体验
- 添加发布配置和操作日志等相关数据库表结构
这个提交包含在:
XuqmGroup 2026-06-11 12:30:13 +08:00
父节点 cbc29cf255
当前提交 f116b63369
共有 2 个文件被更改,包括 163 次插入159 次删除

查看文件

@ -83,7 +83,6 @@ export type StoreType = 'HUAWEI' | 'MI' | 'OPPO' | 'VIVO' | 'HONOR' | 'APP_STORE
export type StoreReviewState = 'PENDING' | 'SUBMITTING' | 'UNDER_REVIEW' | 'APPROVED' | 'REJECTED' export type StoreReviewState = 'PENDING' | 'SUBMITTING' | 'UNDER_REVIEW' | 'APPROVED' | 'REJECTED'
export type PublishMode = 'MANUAL' | 'NOW' | 'SCHEDULED' | 'AUTO_REVIEW' export type PublishMode = 'MANUAL' | 'NOW' | 'SCHEDULED' | 'AUTO_REVIEW'
export type GrayMode = 'PERCENT' | 'MEMBERS' export type GrayMode = 'PERCENT' | 'MEMBERS'
export type GraySelectionSource = 'LOCAL' | 'CALLBACK'
export interface PublishConfig { export interface PublishConfig {
id: string id: string
@ -107,15 +106,15 @@ export interface OperationLog {
export interface GrayMember { export interface GrayMember {
userId: string userId: string
name?: string name: string
groupName?: string source: 'SYNC' | 'MANUAL'
extraJson?: string active: boolean
updatedAt?: string tags: string[]
} }
export interface GrayMemberGroup { export interface GrayTag {
groupName: string tagName: string
members: GrayMember[] memberCount: number
} }
export interface StoreConfig { export interface StoreConfig {
@ -264,8 +263,8 @@ export const updateAdminApi = {
enabled: boolean enabled: boolean
grayMode: GrayMode grayMode: GrayMode
percent?: number percent?: number
memberIds?: string[] groupNames?: string[]
selectionSource?: GraySelectionSource extraMemberIds?: string[]
}) { }) {
return updateClient.post(`/api/v1/updates/app/${id}/gray`, body) return updateClient.post(`/api/v1/updates/app/${id}/gray`, body)
}, },
@ -298,8 +297,8 @@ export const updateAdminApi = {
enabled: boolean enabled: boolean
grayMode: GrayMode grayMode: GrayMode
percent?: number percent?: number
memberIds?: string[] groupNames?: string[]
selectionSource?: GraySelectionSource extraMemberIds?: string[]
}) { }) {
return updateClient.post(`/api/v1/rn/${id}/gray`, body) return updateClient.post(`/api/v1/rn/${id}/gray`, body)
}, },
@ -404,15 +403,47 @@ export const updateAdminApi = {
}) })
}, },
listGrayMembers(appKey: string, keyword?: string, groupName?: string) { listGrayMembers(appKey: string) {
return updateClient.get<{ data: GrayMemberGroup[] }>('/api/v1/updates/gray/members', { return updateClient.get<{ data: GrayMember[] }>('/api/v1/updates/gray/members', {
params: { appKey, ...(keyword && { keyword }), ...(groupName && { groupName }) },
})
},
syncGrayMembers(appKey: string) {
return updateClient.post<{ data: GrayMemberGroup[] }>('/api/v1/updates/gray/members/sync', null, {
params: { appKey }, params: { appKey },
}) })
}, },
addGrayMembers(appKey: string, userIds: string[], name?: string) {
return updateClient.post('/api/v1/updates/gray/members', { appKey, userIds, name })
},
deleteGrayMember(appKey: string, userId: string) {
return updateClient.delete(`/api/v1/updates/gray/members/${userId}`, { params: { appKey } })
},
syncGrayMembers(appKey: string, members: { userId: string; name?: string }[]) {
return updateClient.post<{ data: { added: number; updated: number; removed: number } }>(
'/api/v1/updates/gray/members/sync', { appKey, members })
},
importGrayMembersFromIm(appKey: string) {
return updateClient.post<{ data: { added: number; updated: number; removed: number } }>(
'/api/v1/updates/gray/members/import-im', null, { params: { appKey } })
},
listGrayTags(appKey: string) {
return updateClient.get<{ data: GrayTag[] }>('/api/v1/updates/gray/tags', { params: { appKey } })
},
createGrayTag(appKey: string, tagName: string, userIds: string[]) {
return updateClient.post('/api/v1/updates/gray/tags', { appKey, tagName, userIds })
},
deleteGrayTag(appKey: string, tagName: string) {
return updateClient.delete(`/api/v1/updates/gray/tags/${tagName}`, { params: { appKey } })
},
addMembersToTag(appKey: string, tagName: string, userIds: string[]) {
return updateClient.post(`/api/v1/updates/gray/tags/${tagName}/members`, { appKey, userIds })
},
removeMembersFromTag(appKey: string, tagName: string, userIds: string[]) {
return updateClient.delete(`/api/v1/updates/gray/tags/${tagName}/members`, { data: { appKey, userIds } })
},
} }

查看文件

@ -335,26 +335,11 @@
<el-slider v-model="publishConfigForm.defaultGrayPercent" :min="1" :max="100" show-input :disabled="publishConfigForm.allowAnonymousUpdateCheck" /> <el-slider v-model="publishConfigForm.defaultGrayPercent" :min="1" :max="100" show-input :disabled="publishConfigForm.allowAnonymousUpdateCheck" />
</el-form-item> </el-form-item>
<template v-if="publishConfigForm.grayMode === 'MEMBERS'"> <template v-if="publishConfigForm.grayMode === 'MEMBERS'">
<el-form-item label="成员选择回调"> <el-form-item label="成员同步地址">
<el-input v-model="publishConfigForm.graySelectCallbackUrl" placeholder="选择成员时调用的回调地址" :disabled="publishConfigForm.allowAnonymousUpdateCheck" /> <el-input v-model="publishConfigForm.graySyncUrl" placeholder="集成方成员列表接口地址(留空则使用 AppSecret 签名)" :disabled="publishConfigForm.allowAnonymousUpdateCheck" />
</el-form-item> </el-form-item>
<el-form-item label="成员选择密钥"> <el-form-item label="发布时回调地址">
<el-input v-model="publishConfigForm.graySelectCallbackSecret" type="password" show-password placeholder="可选,用于成员选择回调验签" :disabled="publishConfigForm.allowAnonymousUpdateCheck" /> <el-input v-model="publishConfigForm.publishCallbackUrl" placeholder="发版灰度时回调集成方获取成员列表(可选)" :disabled="publishConfigForm.allowAnonymousUpdateCheck" />
</el-form-item>
<el-form-item label="成员目录同步回调">
<el-input v-model="publishConfigForm.grayDirectorySyncCallbackUrl" placeholder="同步所有成员时调用的回调地址" :disabled="publishConfigForm.allowAnonymousUpdateCheck" />
</el-form-item>
<el-form-item label="成员目录密钥">
<el-input v-model="publishConfigForm.grayDirectorySyncCallbackSecret" type="password" show-password placeholder="可选,用于成员同步回调验签" :disabled="publishConfigForm.allowAnonymousUpdateCheck" />
</el-form-item>
<el-form-item label="成员选择方式">
<el-radio-group v-model="publishConfigForm.graySelectionSource" :disabled="publishConfigForm.allowAnonymousUpdateCheck">
<el-radio-button value="LOCAL" :disabled="!hasGrayDirectorySyncCallback">同步后本地选择</el-radio-button>
<el-radio-button value="CALLBACK" :disabled="!hasGraySelectCallback">回调直接返回成员</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item>
<el-button @click="syncGrayMembers" :loading="loadingGrayMembers" :disabled="!hasGrayDirectorySyncCallback || publishConfigForm.allowAnonymousUpdateCheck">同步成员</el-button>
</el-form-item> </el-form-item>
</template> </template>
</el-form> </el-form>
@ -417,7 +402,7 @@
</el-dialog> </el-dialog>
<!-- Gray Release Dialog --> <!-- Gray Release Dialog -->
<el-dialog v-model="showGray" title="灰度发布配置" :width="dialogWidth"> <el-dialog v-model="showGray" title="灰度发布配置" width="600px">
<el-form label-width="110px"> <el-form label-width="110px">
<el-form-item label="开启灰度"><el-switch v-model="grayForm.enabled" /></el-form-item> <el-form-item label="开启灰度"><el-switch v-model="grayForm.enabled" /></el-form-item>
<el-form-item label="灰度方式" v-if="grayForm.enabled"> <el-form-item label="灰度方式" v-if="grayForm.enabled">
@ -429,51 +414,47 @@
<el-form-item label="灰度比例" v-if="grayForm.enabled && grayForm.grayMode === 'PERCENT'"> <el-form-item label="灰度比例" v-if="grayForm.enabled && grayForm.grayMode === 'PERCENT'">
<el-slider v-model="grayForm.percent" :min="1" :max="100" show-input /> <el-slider v-model="grayForm.percent" :min="1" :max="100" show-input />
</el-form-item> </el-form-item>
<template v-else-if="grayForm.enabled && grayForm.grayMode === 'MEMBERS'"> <template v-if="grayForm.enabled && grayForm.grayMode === 'MEMBERS'">
<el-form-item label="成员来源"> <!-- 按标签选择 -->
<el-radio-group v-model="grayForm.selectionSource"> <el-form-item label="按标签选择">
<el-radio-button value="LOCAL">同步后本地选择</el-radio-button> <div style="width:100%">
<el-radio-button value="CALLBACK">回调直接返回成员</el-radio-button> <el-checkbox-group v-model="grayForm.groupNames">
</el-radio-group> <div v-for="tag in grayTags" :key="tag.tagName" class="gray-tag-item">
</el-form-item> <el-checkbox :value="tag.tagName">
<el-form-item label="成员搜索" v-if="grayForm.selectionSource === 'LOCAL'"> {{ tag.tagName }} <el-tag size="small" type="info">{{ tag.memberCount }}</el-tag>
<el-input
v-model="grayMemberKeyword"
placeholder="搜索 userId / name / 分组"
clearable
style="width: 320px"
@change="loadGrayMembers"
/>
<el-button style="margin-left: 8px" @click="syncGrayMembers" :loading="loadingGrayMembers">同步成员</el-button>
</el-form-item>
<el-alert
v-if="grayForm.selectionSource === 'CALLBACK'"
type="info"
:closable="false"
show-icon
title="选择成员时将调用发布配置里的成员选择回调,由你的系统直接返回可参与灰度的成员列表。"
/>
<div v-if="grayForm.selectionSource === 'LOCAL'" class="gray-member-groups">
<el-empty v-if="!grayMembers.length" description="暂无成员数据,请先同步" />
<el-collapse v-else>
<el-collapse-item v-for="group in grayMembers" :key="group.groupName" :name="group.groupName">
<template #title>
<div class="gray-group-title">
<span>{{ group.groupName }}</span>
<el-tag size="small">{{ group.members.length }}</el-tag>
</div>
</template>
<el-checkbox-group v-model="grayMemberIds">
<div v-for="member in group.members" :key="member.userId" class="gray-member-row">
<el-checkbox :value="member.userId">
<span class="gray-member-id">{{ member.userId }}</span>
<span class="gray-member-name">{{ member.name || '未命名' }}</span>
</el-checkbox> </el-checkbox>
</div> </div>
</el-checkbox-group> </el-checkbox-group>
</el-collapse-item> <el-empty v-if="!grayTags.length" description="暂无标签,请先在成员管理中创建" :image-size="40" />
</el-collapse>
</div> </div>
</el-form-item>
<!-- 额外指定成员 -->
<el-form-item label="追加指定成员">
<el-input
v-model="grayExtraInput"
placeholder="输入 userId,多个用逗号分隔"
clearable
@blur="addExtraMembers"
style="width: 100%"
/>
<div v-if="grayForm.extraMemberIds.length" class="gray-extra-tags">
<el-tag
v-for="uid in grayForm.extraMemberIds"
:key="uid"
closable
@close="removeExtraMember(uid)"
size="small"
>{{ uid }}</el-tag>
</div>
</el-form-item>
<!-- 汇总 -->
<el-form-item label="灰度人数">
<span class="gray-summary">
标签 {{ grayTagMemberCount }} + 指定 {{ grayForm.extraMemberIds.length }}
<template v-if="grayOverlapCount > 0">去重 {{ grayOverlapCount }} </template>
= <strong>{{ grayTotalCount }}</strong>
</span>
</el-form-item>
</template> </template>
</el-form> </el-form>
<template #footer> <template #footer>
@ -1076,18 +1057,11 @@ const publishConfigForm = ref({
allowAnonymousUpdateCheck: false, allowAnonymousUpdateCheck: false,
defaultGrayPercent: 0, defaultGrayPercent: 0,
grayMode: 'PERCENT' as GrayMode, grayMode: 'PERCENT' as GrayMode,
graySelectionSource: 'LOCAL' as GraySelectionSource, graySyncUrl: '',
graySelectCallbackUrl: '', publishCallbackUrl: '',
graySelectCallbackSecret: '', enableRealtimeNotification: false,
grayDirectorySyncCallbackUrl: '',
grayDirectorySyncCallbackSecret: '',
parallelStoreUpload: true, parallelStoreUpload: true,
}) })
const grayMembers = ref<GrayMemberGroup[]>([])
const loadingGrayMembers = ref(false)
const grayMemberKeyword = ref('')
const grayMemberGroupFilter = ref('')
const grayMemberIds = ref<string[]>([])
const appPackageInspecting = ref(false) const appPackageInspecting = ref(false)
const appPackageUploadProgress = ref(0) const appPackageUploadProgress = ref(0)
const appVersionUploadProgress = ref(0) const appVersionUploadProgress = ref(0)
@ -1110,9 +1084,6 @@ let storeReviewReloadTimer: ReturnType<typeof setTimeout> | null = null
let storeReviewAutoRefreshTimer: ReturnType<typeof setInterval> | null = null let storeReviewAutoRefreshTimer: ReturnType<typeof setInterval> | null = null
let storeReviewDialogPollTimer: ReturnType<typeof setInterval> | null = null let storeReviewDialogPollTimer: ReturnType<typeof setInterval> | null = null
const hasGraySelectCallback = computed(() => Boolean(publishConfigForm.value.graySelectCallbackUrl.trim()))
const hasGrayDirectorySyncCallback = computed(() => Boolean(publishConfigForm.value.grayDirectorySyncCallbackUrl.trim()))
const hasAnyGrayCallback = computed(() => hasGraySelectCallback.value || hasGrayDirectorySyncCallback.value)
const allowAnonymousUpdateCheck = computed(() => Boolean(publishConfigForm.value.allowAnonymousUpdateCheck)) const allowAnonymousUpdateCheck = computed(() => Boolean(publishConfigForm.value.allowAnonymousUpdateCheck))
type FieldDef = { type FieldDef = {
@ -1774,13 +1745,48 @@ async function confirmSubmitToStores() {
const showGray = ref(false) const showGray = ref(false)
const submittingGray = ref(false) const submittingGray = ref(false)
const grayTarget = ref<{ id: string; type: 'app' | 'rn' } | null>(null) const grayTarget = ref<{ id: string; type: 'app' | 'rn' } | null>(null)
const grayTags = ref<GrayTag[]>([])
const grayExtraInput = ref('')
const grayForm = ref({ const grayForm = ref({
enabled: true, enabled: true,
grayMode: 'PERCENT' as GrayMode, grayMode: 'PERCENT' as GrayMode,
percent: 10, percent: 10,
selectionSource: 'LOCAL' as GraySelectionSource, groupNames: [] as string[],
extraMemberIds: [] as string[],
}) })
const grayTagMemberCount = computed(() => {
const selected = new Set(grayForm.value.groupNames)
return grayTags.value
.filter(t => selected.has(t.tagName))
.reduce((sum, t) => sum + t.memberCount, 0)
})
const grayOverlapCount = computed(() => {
// 0
return 0
})
const grayTotalCount = computed(() => {
return grayTagMemberCount.value + grayForm.value.extraMemberIds.length - grayOverlapCount.value
})
function addExtraMembers() {
const input = grayExtraInput.value.trim()
if (!input) return
const ids = input.split(/[,,\s]+/).map(s => s.trim()).filter(Boolean)
for (const id of ids) {
if (!grayForm.value.extraMemberIds.includes(id)) {
grayForm.value.extraMemberIds.push(id)
}
}
grayExtraInput.value = ''
}
function removeExtraMember(uid: string) {
grayForm.value.extraMemberIds = grayForm.value.extraMemberIds.filter(id => id !== uid)
}
function openGrayDialog(row: { id: string }, type: 'app' | 'rn') { function openGrayDialog(row: { id: string }, type: 'app' | 'rn') {
if (allowAnonymousUpdateCheck.value) { if (allowAnonymousUpdateCheck.value) {
ElMessage.warning('当前应用开启了免登录检查更新,灰度发布已禁用') ElMessage.warning('当前应用开启了免登录检查更新,灰度发布已禁用')
@ -1789,49 +1795,22 @@ function openGrayDialog(row: { id: string }, type: 'app' | 'rn') {
grayTarget.value = { id: row.id, type } grayTarget.value = { id: row.id, type }
grayForm.value = { grayForm.value = {
enabled: true, enabled: true,
grayMode: hasAnyGrayCallback.value ? publishConfigForm.value.grayMode : 'PERCENT', grayMode: 'PERCENT',
percent: publishConfigForm.value.defaultGrayPercent || 10, percent: publishConfigForm.value.defaultGrayPercent || 10,
selectionSource: hasGrayDirectorySyncCallback.value groupNames: [],
? publishConfigForm.value.graySelectionSource extraMemberIds: [],
: (hasGraySelectCallback.value ? 'CALLBACK' : 'LOCAL'),
} }
grayMemberIds.value = [] grayExtraInput.value = ''
grayMemberKeyword.value = ''
grayMemberGroupFilter.value = ''
grayMembers.value = []
showGray.value = true showGray.value = true
if (grayForm.value.selectionSource === 'LOCAL' && hasGrayDirectorySyncCallback.value) { loadGrayTags()
loadGrayMembers()
}
} }
async function loadGrayMembers() { async function loadGrayTags() {
if (!showGray.value || !hasGrayDirectorySyncCallback.value) return
loadingGrayMembers.value = true
try { try {
const res = await updateAdminApi.listGrayMembers(appKey.value, grayMemberKeyword.value || undefined, grayMemberGroupFilter.value || undefined) const res = await updateAdminApi.listGrayTags(appKey.value)
grayMembers.value = res.data.data grayTags.value = res.data.data
} catch { } catch {
grayMembers.value = [] grayTags.value = []
} finally {
loadingGrayMembers.value = false
}
}
async function syncGrayMembers() {
if (!hasGrayDirectorySyncCallback.value) {
ElMessage.warning('未配置成员目录同步回调,无法同步成员')
return
}
loadingGrayMembers.value = true
try {
const res = await updateAdminApi.syncGrayMembers(appKey.value)
grayMembers.value = res.data.data
ElMessage.success('成员已同步')
} catch {
ElMessage.error('同步失败')
} finally {
loadingGrayMembers.value = false
} }
} }
@ -1841,16 +1820,9 @@ async function submitGray() {
ElMessage.warning('当前应用开启了免登录检查更新,灰度发布已禁用') ElMessage.warning('当前应用开启了免登录检查更新,灰度发布已禁用')
return return
} }
if (grayForm.value.enabled && grayForm.value.grayMode === 'MEMBERS' && grayForm.value.selectionSource === 'LOCAL' && !hasGrayDirectorySyncCallback.value) { if (grayForm.value.enabled && grayForm.value.grayMode === 'MEMBERS'
ElMessage.warning('未配置成员目录同步回调,无法选择本地成员') && !grayForm.value.groupNames.length && !grayForm.value.extraMemberIds.length) {
return ElMessage.warning('请至少选择一个标签或指定一个成员')
}
if (grayForm.value.enabled && grayForm.value.grayMode === 'MEMBERS' && grayForm.value.selectionSource === 'CALLBACK' && !hasGraySelectCallback.value) {
ElMessage.warning('未配置成员选择回调,无法使用回调成员选择')
return
}
if (grayForm.value.enabled && grayForm.value.grayMode === 'MEMBERS' && grayForm.value.selectionSource === 'LOCAL' && !grayMemberIds.value.length) {
ElMessage.warning('请选择至少一个灰度成员')
return return
} }
submittingGray.value = true submittingGray.value = true
@ -1860,17 +1832,17 @@ async function submitGray() {
enabled: boolean enabled: boolean
grayMode: GrayMode grayMode: GrayMode
percent?: number percent?: number
memberIds?: string[] groupNames?: string[]
selectionSource?: GraySelectionSource extraMemberIds?: string[]
} = { } = {
enabled: grayForm.value.enabled, enabled: grayForm.value.enabled,
grayMode: grayForm.value.grayMode, grayMode: grayForm.value.grayMode,
selectionSource: grayForm.value.selectionSource,
} }
if (grayForm.value.grayMode === 'PERCENT') { if (grayForm.value.grayMode === 'PERCENT') {
payload.percent = grayForm.value.percent payload.percent = grayForm.value.percent
} else { } else {
payload.memberIds = grayMemberIds.value payload.groupNames = grayForm.value.groupNames
payload.extraMemberIds = grayForm.value.extraMemberIds
} }
if (type === 'app') { if (type === 'app') {
await updateAdminApi.grayAppVersion(id, payload) await updateAdminApi.grayAppVersion(id, payload)
@ -2791,27 +2763,28 @@ onBeforeUnmount(() => {
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
} }
.gray-member-groups { .gray-tag-item {
width: 100%;
}
.gray-group-title {
display: flex;
align-items: center;
gap: 8px;
}
.gray-member-row {
padding: 6px 0; padding: 6px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.gray-tag-item:last-child {
border-bottom: none;
} }
.gray-member-id { .gray-extra-tags {
font-weight: 600; display: flex;
margin-right: 8px; flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
} }
.gray-member-name { .gray-summary {
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
font-size: 13px;
}
.gray-summary strong {
color: var(--el-color-primary);
font-size: 16px;
} }
.upload-progress-block { .upload-progress-block {