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>
这个提交包含在:
父节点
d4c13a5f76
当前提交
fdc9ae833b
76
Jenkinsfile
vendored
普通文件
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 ? '已开启' : '已关闭')
|
||||
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()
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户