feat: appSecret security UX, IM admin management, remove SecretKey, CI pipeline

- AppDetailView: appSecret hidden by default, email verification to reveal/reset
- AppDetailView: remove per-service SecretKey, service enable requires ops approval request
- ImManagementView: add register user and create group dialogs
- app.ts/im.ts: update API types and add new endpoints
- Add Jenkinsfile for Web apps (yarn build → Docker → ACR → deploy)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-04-24 20:54:03 +08:00
父节点 d4c13a5f76
当前提交 fdc9ae833b
共有 5 个文件被更改,包括 329 次插入36 次删除

76
Jenkinsfile vendored 普通文件
查看文件

@ -0,0 +1,76 @@
pipeline {
agent any
parameters {
choice(name: 'APP', choices: ['tenant-platform', 'ops-platform'], description: '要构建的 Web 应用')
string(name: 'IMAGE_TAG', defaultValue: 'latest', description: '镜像 Tag')
booleanParam(name: 'DEPLOY', defaultValue: true, description: '构建后是否自动部署')
}
environment {
ACR_REGISTRY = 'registry.cn-hangzhou.aliyuncs.com' // 替换为你的 ACR 地址
ACR_NAMESPACE = 'xuqmgroup'
ACR_USERNAME = 'your-acr-username'
PROD_HOST = '106.54.23.149'
PROD_USER = 'ubuntu'
COMPOSE_FILE = '/opt/xuqm/deploy/compose.production.yaml'
}
stages {
stage('Checkout') {
steps { checkout scm }
}
stage('Install & Build') {
steps {
dir("${params.APP}") {
sh '''
yarn install --frozen-lockfile
yarn build
'''
}
}
}
stage('Docker Build & Push') {
steps {
withCredentials([string(credentialsId: 'ACR_PASSWORD', variable: 'ACR_PASS')]) {
script {
def imageName = "${ACR_REGISTRY}/${ACR_NAMESPACE}/web-${params.APP}:${params.IMAGE_TAG}"
sh """
docker login ${ACR_REGISTRY} -u ${ACR_USERNAME} -p \${ACR_PASS}
docker build -f ${params.APP}/Dockerfile -t ${imageName} .
docker push ${imageName}
docker rmi ${imageName}
"""
}
}
}
}
stage('Deploy to Production') {
when { expression { return params.DEPLOY } }
steps {
withCredentials([sshUserPrivateKey(credentialsId: 'PROD_SSH_KEY', keyFileVariable: 'SSH_KEY')]) {
script {
def svcName = params.APP == 'tenant-platform' ? 'web' : 'ops-web'
def imageName = "${ACR_REGISTRY}/${ACR_NAMESPACE}/web-${params.APP}:${params.IMAGE_TAG}"
sh """
ssh -i \${SSH_KEY} -o StrictHostKeyChecking=no ${PROD_USER}@${PROD_HOST} "
docker pull ${imageName} &&
cd /opt/xuqm/deploy &&
docker compose -f ${COMPOSE_FILE} up -d --no-deps ${svcName} &&
docker image prune -f
"
"""
}
}
}
}
}
post {
success { echo "✅ ${params.APP}:${params.IMAGE_TAG} 构建部署成功" }
failure { echo "❌ 构建失败,请检查日志" }
}
}

查看文件

@ -25,7 +25,6 @@ export interface FeatureService {
platform: 'ANDROID' | 'IOS' | 'HARMONY'
serviceType: 'IM' | 'PUSH' | 'UPDATE'
enabled: boolean
secretKey: string
createdAt: string
}
@ -48,6 +47,14 @@ export const appApi = {
params: { platform, serviceType, enable },
}),
regenerateKey: (appId: string, serviceId: string) =>
client.post<{ data: FeatureService }>(`/apps/${appId}/services/${serviceId}/regenerate-key`),
requestSecretVerify: (appId: string, purpose: 'REVEAL_SECRET' | 'RESET_SECRET') =>
client.post<{ data: null }>(`/apps/${appId}/request-secret-verify`, null, {
params: { purpose },
}),
revealSecret: (appId: string, code: string) =>
client.post<{ data: { appSecret: string } }>(`/apps/${appId}/reveal-secret`, { code }),
resetSecret: (appId: string, code: string) =>
client.post<{ data: { appSecret: string } }>(`/apps/${appId}/reset-secret`, { code }),
}

查看文件

@ -57,4 +57,20 @@ export const imAdminApi = {
getStats(appId: string) {
return imClient.get<{ data: ImStats }>('/api/im/admin/stats', { params: { appId } })
},
registerUser(appId: string, userId: string, nickname?: string, avatar?: string) {
return imClient.post<{ data: ImUser }>(
'/api/im/admin/users',
{ userId, nickname, avatar },
{ params: { appId } },
)
},
createGroup(appId: string, name: string, creatorId: string, memberIds: string[]) {
return imClient.post<{ data: ImGroup }>(
'/api/im/admin/groups',
{ name, creatorId, memberIds },
{ params: { appId } },
)
},
}

查看文件

@ -11,8 +11,14 @@
<el-button link @click="copy(app.appKey)"><el-icon><CopyDocument /></el-icon></el-button>
</el-descriptions-item>
<el-descriptions-item label="AppSecret">
<el-text class="mono">{{ app.appSecret }}</el-text>
<el-button link @click="copy(app.appSecret)"><el-icon><CopyDocument /></el-icon></el-button>
<el-text class="mono">{{ revealedSecret ?? '••••••••••••••••' }}</el-text>
<el-button link @click="openVerifyDialog('REVEAL_SECRET')" title="查看">
<el-icon><View /></el-icon>
</el-button>
<el-button v-if="revealedSecret" link @click="copy(revealedSecret!)">
<el-icon><CopyDocument /></el-icon>
</el-button>
<el-button link type="warning" @click="openVerifyDialog('RESET_SECRET')">重置</el-button>
</el-descriptions-item>
<el-descriptions-item label="简述" :span="2">{{ app.description ?? '-' }}</el-descriptions-item>
</el-descriptions>
@ -32,23 +38,10 @@
<span class="service-name">{{ serviceLabel(svcType) }}</span>
<el-switch
:model-value="isEnabled(activePlatform, svcType)"
@change="(val: boolean) => toggleService(activePlatform, svcType, val)"
@change="(val: boolean) => onToggleService(activePlatform, svcType, val)"
/>
</div>
<template v-if="isEnabled(activePlatform, svcType)">
<div class="key-row">
<span class="key-label">SecretKey</span>
<el-text class="mono key-value" size="small">
{{ getService(activePlatform, svcType)?.secretKey }}
</el-text>
<el-button link size="small" @click="copy(getService(activePlatform, svcType)?.secretKey ?? '')">
<el-icon><CopyDocument /></el-icon>
</el-button>
<el-button link size="small" type="warning"
@click="regenerate(getService(activePlatform, svcType)?.id ?? '')">
重新生成
</el-button>
</div>
<div style="margin-top:10px">
<el-button
v-if="svcType === 'IM'"
@ -64,33 +57,92 @@
</el-button>
</div>
</template>
<template v-else>
<div style="margin-top:10px">
<el-button size="small" type="primary" plain @click="openActivationRequest(activePlatform, svcType)">
申请开通
</el-button>
</div>
</template>
</el-card>
</div>
</el-card>
<!-- Email Verify Dialog (reveal or reset) -->
<el-dialog v-model="showVerifyDialog" :title="verifyPurpose === 'REVEAL_SECRET' ? '查看 AppSecret' : '重置 AppSecret'" width="420px">
<div v-if="!codeSent">
<p style="color:#555;margin-bottom:16px">
{{ verifyPurpose === 'REVEAL_SECRET'
? '为保护账号安全,查看 AppSecret 需要通过邮箱验证。'
: '重置后旧 AppSecret 立即失效,请确认后继续。' }}
</p>
<el-button type="primary" :loading="sendingCode" @click="sendVerifyCode">
发送验证码到邮箱
</el-button>
</div>
<div v-else>
<p style="color:#555;margin-bottom:16px">验证码已发送至您的注册邮箱请查收并输入</p>
<el-input v-model="verifyCode" placeholder="6位验证码" maxlength="6" style="margin-bottom:12px" />
<div style="display:flex;gap:8px">
<el-button @click="sendVerifyCode" :loading="sendingCode" size="small">重新发送</el-button>
</div>
</div>
<template #footer>
<el-button @click="showVerifyDialog = false">取消</el-button>
<el-button v-if="codeSent" type="primary" :loading="submittingVerify" @click="submitVerify">
{{ verifyPurpose === 'REVEAL_SECRET' ? '查看' : '确认重置' }}
</el-button>
</template>
</el-dialog>
<!-- Activation Request Dialog -->
<el-dialog v-model="showActivationDialog" title="申请开通服务" width="420px">
<el-form label-width="80px">
<el-form-item label="服务">{{ activationForm.platform }} / {{ serviceLabel(activationForm.serviceType) }}</el-form-item>
<el-form-item label="申请理由">
<el-input v-model="activationForm.reason" type="textarea" :rows="3" placeholder="请描述您的业务场景" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showActivationDialog = false">取消</el-button>
<el-button type="primary" :loading="submittingActivation" @click="submitActivationRequest">提交申请</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { View } from '@element-plus/icons-vue'
import { appApi, type App, type FeatureService } from '@/api/app'
import client from '@/api/client'
const route = useRoute()
const app = ref<App | null>(null)
const services = ref<FeatureService[]>([])
const activePlatform = ref<'ANDROID' | 'IOS' | 'HARMONY'>('ANDROID')
const revealedSecret = ref<string | null>(null)
const showVerifyDialog = ref(false)
const verifyPurpose = ref<'REVEAL_SECRET' | 'RESET_SECRET'>('REVEAL_SECRET')
const codeSent = ref(false)
const sendingCode = ref(false)
const verifyCode = ref('')
const submittingVerify = ref(false)
const showActivationDialog = ref(false)
const submittingActivation = ref(false)
const activationForm = ref({ platform: '', serviceType: '', reason: '' })
function isEnabled(platform: string, svcType: string) {
return services.value.some(
s => s.platform === platform && s.serviceType === svcType && s.enabled
)
}
function getService(platform: string, svcType: string) {
return services.value.find(s => s.platform === platform && s.serviceType === svcType)
}
function serviceLabel(type: string) {
return { IM: '即时通讯 (IM)', PUSH: '离线推送', UPDATE: '版本管理' }[type] ?? type
}
@ -102,18 +154,87 @@ async function loadData() {
])
app.value = appRes.data.data
services.value = svcRes.data.data
revealedSecret.value = null
}
async function toggleService(platform: string, svcType: string, enable: boolean) {
await appApi.toggleService(route.params.id as string, platform, svcType, enable)
ElMessage.success(enable ? '已开启' : '已关闭')
loadData()
async function onToggleService(platform: string, svcType: string, enable: boolean) {
if (enable) {
openActivationRequest(platform, svcType)
} else {
await ElMessageBox.confirm(`确认关闭 ${platform} 平台的 ${serviceLabel(svcType)} 服务?`, '关闭服务', {
type: 'warning', confirmButtonText: '确认关闭', cancelButtonText: '取消',
})
await appApi.toggleService(route.params.id as string, platform, svcType, false)
ElMessage.success('已关闭')
loadData()
}
}
async function regenerate(serviceId: string) {
await appApi.regenerateKey(route.params.id as string, serviceId)
ElMessage.success('密钥已重新生成')
loadData()
function openActivationRequest(platform: string, svcType: string) {
activationForm.value = { platform, serviceType: svcType, reason: '' }
showActivationDialog.value = true
}
async function submitActivationRequest() {
if (!activationForm.value.reason.trim()) {
return ElMessage.warning('请填写申请理由')
}
submittingActivation.value = true
try {
await client.post(`/apps/${route.params.id}/services/request-activation`, null, {
params: {
platform: activationForm.value.platform,
serviceType: activationForm.value.serviceType,
applyReason: activationForm.value.reason,
},
})
ElMessage.success('申请已提交,等待运营审核')
showActivationDialog.value = false
} finally {
submittingActivation.value = false
}
}
function openVerifyDialog(purpose: 'REVEAL_SECRET' | 'RESET_SECRET') {
verifyPurpose.value = purpose
codeSent.value = false
verifyCode.value = ''
showVerifyDialog.value = true
}
async function sendVerifyCode() {
sendingCode.value = true
try {
await appApi.requestSecretVerify(route.params.id as string, verifyPurpose.value)
codeSent.value = true
ElMessage.success('验证码已发送')
} catch {
ElMessage.error('发送失败,请稍后重试')
} finally {
sendingCode.value = false
}
}
async function submitVerify() {
if (verifyCode.value.length !== 6) return ElMessage.warning('请输入6位验证码')
submittingVerify.value = true
try {
const id = route.params.id as string
if (verifyPurpose.value === 'REVEAL_SECRET') {
const res = await appApi.revealSecret(id, verifyCode.value)
revealedSecret.value = res.data.data.appSecret
ElMessage.success('AppSecret 已显示,请妥善保管')
} else {
const res = await appApi.resetSecret(id, verifyCode.value)
revealedSecret.value = res.data.data.appSecret
ElMessage.success('AppSecret 已重置,旧密钥立即失效')
}
showVerifyDialog.value = false
} catch {
ElMessage.error('验证码错误或已过期')
} finally {
submittingVerify.value = false
}
}
function copy(text: string) {
@ -128,9 +249,6 @@ onMounted(loadData)
.mono { font-family: monospace; font-size: 12px; }
.service-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 16px; }
.service-card { border: 1px solid #e8e8e8; }
.service-header { display: flex; justify-content: space-between; align-items: center; font-weight: 500; margin-bottom: 12px; }
.service-header { display: flex; justify-content: space-between; align-items: center; font-weight: 500; }
.service-name { font-size: 15px; }
.key-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.key-label { font-size: 12px; color: #888; }
.key-value { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
</style>

查看文件

@ -19,6 +19,7 @@
<!-- Users Tab -->
<el-tab-pane label="注册用户" name="users">
<div class="toolbar">
<el-button type="primary" @click="showRegisterUser = true">注册用户</el-button>
<el-button @click="loadUsers" :loading="loadingUsers">刷新</el-button>
</div>
<el-table :data="users" v-loading="loadingUsers" border stripe>
@ -65,6 +66,7 @@
<!-- Groups Tab -->
<el-tab-pane label="群组列表" name="groups">
<div class="toolbar">
<el-button type="primary" @click="showCreateGroup = true">创建群组</el-button>
<el-button @click="loadGroups" :loading="loadingGroups">刷新</el-button>
</div>
<el-table :data="groups" v-loading="loadingGroups" border stripe>
@ -81,6 +83,34 @@
</el-tab-pane>
</el-tabs>
</el-card>
<!-- Register User Dialog -->
<el-dialog v-model="showRegisterUser" title="注册用户" width="400px">
<el-form :model="registerForm" label-width="80px">
<el-form-item label="用户ID"><el-input v-model="registerForm.userId" placeholder="全局唯一标识" /></el-form-item>
<el-form-item label="昵称"><el-input v-model="registerForm.nickname" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="showRegisterUser = false">取消</el-button>
<el-button type="primary" :loading="submittingRegister" @click="submitRegisterUser">注册</el-button>
</template>
</el-dialog>
<!-- Create Group Dialog -->
<el-dialog v-model="showCreateGroup" title="创建群组" width="420px">
<el-form :model="createGroupForm" label-width="90px">
<el-form-item label="群名称"><el-input v-model="createGroupForm.name" /></el-form-item>
<el-form-item label="创建者ID"><el-input v-model="createGroupForm.creatorId" /></el-form-item>
<el-form-item label="初始成员">
<el-input v-model="createGroupForm.memberIdsRaw" type="textarea" :rows="3"
placeholder="每行一个用户ID" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateGroup = false">取消</el-button>
<el-button type="primary" :loading="submittingCreateGroup" @click="submitCreateGroup">创建</el-button>
</template>
</el-dialog>
</div>
</template>
@ -105,6 +135,14 @@ const userTotal = ref(0)
const groups = ref<ImGroup[]>([])
const loadingGroups = ref(false)
const showRegisterUser = ref(false)
const submittingRegister = ref(false)
const registerForm = ref({ userId: '', nickname: '' })
const showCreateGroup = ref(false)
const submittingCreateGroup = ref(false)
const createGroupForm = ref({ name: '', creatorId: '', memberIdsRaw: '' })
const statCards = computed(() => [
{ label: '注册用户', value: stats.value?.totalUsers ?? '-' },
{ label: '群组数', value: stats.value?.totalGroups ?? '-' },
@ -165,6 +203,44 @@ function handleTabChange(tab: string) {
if (tab === 'groups' && groups.value.length === 0) loadGroups()
}
async function submitRegisterUser() {
if (!registerForm.value.userId.trim()) return ElMessage.warning('请填写用户ID')
submittingRegister.value = true
try {
await imAdminApi.registerUser(appId, registerForm.value.userId, registerForm.value.nickname)
ElMessage.success('用户注册成功')
showRegisterUser.value = false
registerForm.value = { userId: '', nickname: '' }
loadUsers()
loadStats()
} catch {
ElMessage.error('注册失败,用户ID可能已存在')
} finally {
submittingRegister.value = false
}
}
async function submitCreateGroup() {
if (!createGroupForm.value.name.trim() || !createGroupForm.value.creatorId.trim()) {
return ElMessage.warning('请填写群名称和创建者ID')
}
submittingCreateGroup.value = true
try {
const memberIds = createGroupForm.value.memberIdsRaw
.split('\n').map(s => s.trim()).filter(Boolean)
await imAdminApi.createGroup(appId, createGroupForm.value.name, createGroupForm.value.creatorId, memberIds)
ElMessage.success('群组创建成功')
showCreateGroup.value = false
createGroupForm.value = { name: '', creatorId: '', memberIdsRaw: '' }
loadGroups()
loadStats()
} catch {
ElMessage.error('创建失败')
} finally {
submittingCreateGroup.value = false
}
}
onMounted(() => {
loadStats()
loadUsers()