feat(gateway): 添加活动服务网关支持及管理面板

- 新增 docker-compose 配置,包含活动服务、YARP 网关、PostgreSQL 与 Redis
- 添加活动服务与网关集成文档,详细介绍配置步骤、API 和故障排查
- 删除旧的 bash 注册脚本,新增跨平台 PowerShell 和通用 bash 注册脚本
- 实现网关相关接口的 TypeScript 客户端调用封装,支持服务注册、路由管理
- 新增网关管理前端界面,包含服务统计、服务注册、路由刷新等功能
- 调整请求客户端默认开启 token 刷新以支持更稳定的认证体验
- 制定微服务命名与版本规范,标准化 API 路径和集群命名规则
This commit is contained in:
Sam 2026-02-08 20:44:03 +08:00
parent d2d70b462e
commit 38db4e2e73
6 changed files with 705 additions and 4 deletions

View File

@ -86,7 +86,7 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
client,
doReAuthenticate,
doRefreshToken,
enableRefreshToken: preferences.app.enableRefreshToken,
enableRefreshToken: true,
formatToken,
}),
);

View File

@ -0,0 +1,111 @@
import { defHttp } from '/@/api/core';
import type { GatewayStatistics, GatewayService, GatewayRoute, GatewayInstance } from './model';
enum Api {
STATISTICS = '/statistics',
SERVICES = '/services',
ROUTES = '/routes',
INSTANCES = '/instances',
RELOAD = '/reload',
}
export const getStatistics = () => {
return defHttp.get<GatewayStatistics>({
url: `${Api.STATISTICS}`,
});
};
export const getServices = (params?: { globalOnly?: boolean; tenantCode?: string }) => {
return defHttp.get<GatewayService[]>({
url: `${Api.SERVICES}`,
params,
});
};
export const getService = (serviceName: string, params?: { tenantCode?: string }) => {
return defHttp.get<GatewayService>({
url: `${Api.SERVICES}/${serviceName}`,
params,
});
};
export const registerService = (data: {
servicePrefix: string;
serviceName: string;
version: string;
serviceAddress: string;
destinationId?: string;
weight?: number;
isGlobal?: boolean;
tenantCode?: string;
}) => {
return defHttp.post<GatewayService>({
url: `${Api.SERVICES}`,
data,
});
};
export const unregisterService = (serviceName: string, params?: { tenantCode?: string }) => {
return defHttp.delete({
url: `${Api.SERVICES}/${serviceName}`,
params,
});
};
export const getRoutes = (params?: { globalOnly?: boolean }) => {
return defHttp.get<GatewayRoute[]>({
url: `${Api.ROUTES}`,
params,
});
};
export const createRoute = (data: {
serviceName: string;
clusterId: string;
pathPattern: string;
priority?: number;
isGlobal?: boolean;
tenantCode?: string;
}) => {
return defHttp.post<GatewayRoute>({
url: `${Api.ROUTES}`,
data,
});
};
export const getInstances = (clusterId: string) => {
return defHttp.get<GatewayInstance[]>({
url: `${Api.INSTANCES}`.replace('{clusterId}', clusterId),
});
};
export const addInstance = (data: {
clusterId: string;
destinationId: string;
address: string;
weight?: number;
}) => {
return defHttp.post<GatewayInstance>({
url: `${Api.INSTANCES}`,
data,
});
};
export const removeInstance = (instanceId: number) => {
return defHttp.delete({
url: `${Api.INSTANCES}/${instanceId}`,
});
};
export const updateInstanceWeight = (instanceId: number, weight: number) => {
return defHttp.put({
url: `${Api.INSTANCES}/${instanceId}/weight`,
params: { weight },
});
};
export const reloadGateway = () => {
return defHttp.post({
url: `${Api.RELOAD}`,
});
};

View File

@ -0,0 +1,66 @@
export interface GatewayStatistics {
totalServices: number;
globalRoutes: number;
tenantRoutes: number;
totalInstances: number;
healthyInstances: number;
recentServices: GatewayService[];
}
export interface GatewayService {
id: number;
servicePrefix: string;
serviceName: string;
version: string;
clusterId: string;
pathPattern: string;
serviceAddress: string;
destinationId: string;
weight: number;
instanceCount: number;
isGlobal: boolean;
tenantCode?: string;
status: number;
createdAt: string;
}
export interface GatewayRoute {
id: number;
serviceName: string;
clusterId: string;
pathPattern: string;
priority: number;
isGlobal: boolean;
tenantCode?: string;
status: number;
instanceCount: number;
}
export interface GatewayInstance {
id: number;
clusterId: string;
destinationId: string;
address: string;
weight: number;
health: number;
status: number;
createdAt: string;
}
export interface RegisterServiceForm {
servicePrefix: string;
serviceName: string;
version: string;
serviceAddress: string;
destinationId?: string;
weight: number;
isGlobal: boolean;
tenantCode?: string;
}
export interface AddInstanceForm {
clusterId: string;
destinationId: string;
address: string;
weight: number;
}

View File

@ -39,6 +39,15 @@ const routes: RouteRecordRaw[] = [
},
component: () => import('#/views/system/dept/list.vue'),
},
{
path: '/system/gateway',
name: 'SystemGateway',
meta: {
icon: 'ant-design:api-outlined',
title: '网关管理',
},
component: () => import('#/views/system/gateway/GatewayManagement.vue'),
},
],
},
];

View File

@ -0,0 +1,518 @@
<template>
<div class="gateway-management">
<PageHeader title="网关管理" description="管理微服务网关配置和路由">
<template #extra>
<Button type="primary" @click="handleRegisterService">
<Icon icon="ant-design:plus-outlined" />
注册服务
</Button>
<Button @click="handleReloadGateway">
<Icon icon="ant-design:reload-outlined" />
刷新配置
</Button>
</template>
</PageHeader>
<!-- Statistics Cards -->
<a-row :gutter="16" class="mb-4">
<a-col :span="4">
<a-card>
<Statistic title="服务总数" :value="statistics?.totalServices || 0">
<template #prefix>
<Icon icon="ant-design:cloud-server-outlined" />
</template>
</Statistic>
</a-card>
</a-col>
<a-col :span="4">
<a-card>
<Statistic title="全局路由" :value="statistics?.globalRoutes || 0">
<template #prefix>
<Icon icon="ant-design:global-outlined" />
</template>
</Statistic>
</a-card>
</a-col>
<a-col :span="4">
<a-card>
<Statistic title="租户路由" :value="statistics?.tenantRoutes || 0">
<template #prefix>
<Icon icon="ant-design:team-outlined" />
</template>
</Statistic>
</a-card>
</a-col>
<a-col :span="4">
<a-card>
<Statistic title="实例总数" :value="statistics?.totalInstances || 0">
<template #prefix>
<Icon icon="ant-design:server-outlined" />
</template>
</Statistic>
</a-card>
</a-col>
<a-col :span="4">
<a-card>
<Statistic title="健康实例" :value="statistics?.healthyInstances || 0">
<template #prefix>
<Icon icon="ant-design:check-circle-outlined" />
</template>
<template #suffix>
<span class="success-text">/ {{ statistics?.totalInstances || 0 }}</span>
</template>
</Statistic>
</a-card>
</a-col>
</a-row>
<!-- Tabs -->
<a-tabs v-model:activeKey="activeTab">
<a-tab-pane key="services" tab="服务列表">
<a-card>
<a-table
:columns="serviceColumns"
:dataSource="services"
:loading="loading"
:pagination="false"
rowKey="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-badge :status="record.status === 1 ? 'success' : 'default'" :text="record.status === 1 ? '活跃' : '停用'" />
</template>
<template v-if="column.key === 'isGlobal'">
<a-tag :color="record.isGlobal ? 'blue' : 'green'">
{{ record.isGlobal ? '全局' : '租户专用' }}
</a-tag>
</template>
<template v-if="column.key === 'actions'">
<a-space>
<a-button size="small" @click="handleViewInstances(record)">
查看实例
</a-button>
<a-popconfirm title="确定要注销此服务吗?" @confirm="handleUnregisterService(record)">
<a-button size="small" danger type="link">
注销
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
</a-tab-pane>
<a-tab-pane key="routes" tab="路由配置">
<a-card>
<a-table
:columns="routeColumns"
:dataSource="routes"
:loading="loading"
:pagination="false"
rowKey="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'isGlobal'">
<a-tag :color="record.isGlobal ? 'blue' : 'green'">
{{ record.isGlobal ? '全局' : '租户专用' }}
</a-tag>
</template>
<template v-if="column.key === 'status'">
<a-badge :status="record.status === 1 ? 'success' : 'default'" :text="record.status === 1 ? '活跃' : '停用'" />
</template>
<template v-if="column.key === 'actions'">
<a-button size="small" danger type="link" @click="handleDeleteRoute(record)">
删除
</a-button>
</template>
</template>
</a-table>
</a-card>
</a-tab-pane>
</a-tabs>
<!-- Register Service Modal -->
<a-modal
v-model:open="registerModalVisible"
title="注册新服务"
:confirmLoading="registerLoading"
@ok="submitRegisterService"
>
<a-form :model="registerForm" :labelCol="{ span: 6 }" :wrapperCol="{ span: 18 }">
<a-form-item label="服务前缀" required>
<a-input v-model:value="registerForm.servicePrefix" placeholder="例如: activity, member" />
</a-form-item>
<a-form-item label="显示名称" required>
<a-input v-model:value="registerForm.serviceName" placeholder="例如: 活动服务" />
</a-form-item>
<a-form-item label="API版本">
<a-select v-model:value="registerForm.version">
<a-select-option value="v1">v1</a-select-option>
<a-select-option value="v2">v2</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="服务地址" required>
<a-input v-model:value="registerForm.serviceAddress" placeholder="http://localhost:5001" />
</a-form-item>
<a-form-item label="权重">
<a-input-number v-model:value="registerForm.weight" :min="1" :max="100" />
</a-form-item>
<a-form-item label="路由类型">
<a-radio-group v-model:value="registerForm.isGlobal">
<a-radio :value="true">全局路由</a-radio>
<a-radio :value="false">租户专用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item v-if="!registerForm.isGlobal" label="租户代码">
<a-input v-model:value="registerForm.tenantCode" placeholder="租户代码" />
</a-form-item>
</a-form>
</a-modal>
<!-- Instances Drawer -->
<a-drawer
v-model:open="instancesDrawerVisible"
title="服务实例列表"
:width="600"
>
<template #extra>
<a-button type="primary" @click="handleAddInstance">
<Icon icon="ant-design:plus-outlined" />
添加实例
</a-button>
</template>
<a-table
:columns="instanceColumns"
:dataSource="currentInstances"
:loading="instancesLoading"
:pagination="false"
rowKey="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'health'">
<a-badge :status="record.health === 1 ? 'success' : 'error'" :text="record.health === 1 ? '健康' : '异常'" />
</template>
<template v-if="column.key === 'weight'">
<a-input-number v-model:value="record.weight" :min="1" :max="100" @change="(val) => handleUpdateWeight(record, val)" />
</template>
<template v-if="column.key === 'actions'">
<a-popconfirm title="确定要删除此实例吗?" @confirm="handleRemoveInstance(record)">
<a-button size="small" danger type="link">
删除
</a-button>
</a-popconfirm>
</template>
</template>
</a-table>
</a-drawer>
<!-- Add Instance Modal -->
<a-modal
v-model:open="addInstanceModalVisible"
title="添加服务实例"
:confirmLoading="addInstanceLoading"
@ok="submitAddInstance"
>
<a-form :model="addInstanceForm" :labelCol="{ span: 6 }" :wrapperCol="{ span: 18 }">
<a-form-item label="集群ID" required>
<a-input v-model:value="addInstanceForm.clusterId" disabled />
</a-form-item>
<a-form-item label="实例ID" required>
<a-input v-model:value="addInstanceForm.destinationId" placeholder="例如: activity-2" />
</a-form-item>
<a-form-item label="实例地址" required>
<a-input v-model:value="addInstanceForm.address" placeholder="http://localhost:5002" />
</a-form-item>
<a-form-item label="权重">
<a-input-number v-model:value="addInstanceForm.weight" :min="1" :max="100" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { useMessage } from '/@/hooks/web/useMessage';
import { PageHeader } from '/@/components/Page';
import {
getStatistics,
getServices,
getRoutes,
getInstances,
registerService,
unregisterService,
addInstance,
removeInstance,
updateInstanceWeight,
reloadGateway,
} from '/@/api/gateway';
import type { GatewayStatistics, GatewayService, GatewayRoute, GatewayInstance } from '/@/api/gateway/model';
const { notification, message } = useMessage();
const activeTab = ref('services');
const loading = ref(false);
const statistics = ref<GatewayStatistics>();
const services = ref<GatewayService[]>([]);
const routes = ref<GatewayRoute[]>([]);
const currentInstances = ref<GatewayInstance[]>([]);
const instancesLoading = ref(false);
// Modal states
const registerModalVisible = ref(false);
const registerLoading = ref(false);
const registerForm = reactive({
servicePrefix: '',
serviceName: '',
version: 'v1',
serviceAddress: '',
destinationId: '',
weight: 1,
isGlobal: true,
tenantCode: '',
});
const instancesDrawerVisible = ref(false);
const addInstanceModalVisible = ref(false);
const addInstanceLoading = ref(false);
const addInstanceForm = reactive({
clusterId: '',
destinationId: '',
address: '',
weight: 1,
});
const currentService = ref<GatewayService>();
const serviceColumns = [
{ title: '服务前缀', dataIndex: 'servicePrefix', key: 'servicePrefix' },
{ title: '显示名称', dataIndex: 'serviceName', key: 'serviceName' },
{ title: '版本', dataIndex: 'version', key: 'version' },
{ title: '路径模式', dataIndex: 'pathPattern', key: 'pathPattern', ellipsis: true },
{ title: '实例数量', dataIndex: 'instanceCount', key: 'instanceCount' },
{ title: '类型', dataIndex: 'isGlobal', key: 'isGlobal' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{ title: '操作', key: 'actions', fixed: 'right', width: 150 },
];
const routeColumns = [
{ title: '服务名称', dataIndex: 'serviceName', key: 'serviceName' },
{ title: '集群ID', dataIndex: 'clusterId', key: 'clusterId' },
{ title: '路径模式', dataIndex: 'pathPattern', key: 'pathPattern', ellipsis: true },
{ title: '优先级', dataIndex: 'priority', key: 'priority' },
{ title: '类型', dataIndex: 'isGlobal', key: 'isGlobal' },
{ title: '实例数', dataIndex: 'instanceCount', key: 'instanceCount' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{ title: '操作', key: 'actions', fixed: 'right', width: 80 },
];
const instanceColumns = [
{ title: '实例ID', dataIndex: 'destinationId', key: 'destinationId' },
{ title: '地址', dataIndex: 'address', key: 'address', ellipsis: true },
{ title: '权重', dataIndex: 'weight', key: 'weight', width: 100 },
{ title: '健康', dataIndex: 'health', key: 'health' },
{ title: '操作', key: 'actions', width: 80 },
];
const loadData = async () => {
loading.value = true;
try {
statistics.value = await getStatistics();
services.value = await getServices();
routes.value = await getRoutes();
} catch (error: any) {
notification.error({
message: '加载失败',
description: error.message || '无法加载网关数据',
});
} finally {
loading.value = false;
}
};
const handleRegisterService = () => {
registerForm.servicePrefix = '';
registerForm.serviceName = '';
registerForm.version = 'v1';
registerForm.serviceAddress = '';
registerForm.destinationId = '';
registerForm.weight = 1;
registerForm.isGlobal = true;
registerForm.tenantCode = '';
registerModalVisible.value = true;
};
const submitRegisterService = async () => {
if (!registerForm.servicePrefix || !registerForm.serviceName || !registerForm.serviceAddress) {
message.warning('请填写必填字段');
return;
}
registerLoading.value = true;
try {
await registerService({
servicePrefix: registerForm.servicePrefix,
serviceName: registerForm.serviceName,
version: registerForm.version,
serviceAddress: registerForm.serviceAddress,
destinationId: registerForm.destinationId || undefined,
weight: registerForm.weight,
isGlobal: registerForm.isGlobal,
tenantCode: registerForm.tenantCode || undefined,
});
notification.success({
message: '注册成功',
description: `服务 ${registerForm.serviceName} 已成功注册`,
});
registerModalVisible.value = false;
loadData();
} catch (error: any) {
notification.error({
message: '注册失败',
description: error.message,
});
} finally {
registerLoading.value = false;
}
};
const handleUnregisterService = async (record: GatewayService) => {
try {
await unregisterService(record.servicePrefix, record.tenantCode);
notification.success({
message: '注销成功',
description: `服务 ${record.serviceName} 已注销`,
});
loadData();
} catch (error: any) {
notification.error({
message: '注销失败',
description: error.message,
});
}
};
const handleViewInstances = async (record: GatewayService) => {
currentService.value = record;
instancesDrawerVisible.value = true;
instancesLoading.value = true;
try {
currentInstances.value = await getInstances(record.clusterId);
} catch (error: any) {
notification.error({
message: '加载失败',
description: error.message,
});
} finally {
instancesLoading.value = false;
}
};
const handleAddInstance = () => {
addInstanceForm.clusterId = currentService.value?.clusterId || '';
addInstanceForm.destinationId = `${currentService.value?.servicePrefix}-2`;
addInstanceForm.address = '';
addInstanceForm.weight = 1;
addInstanceModalVisible.value = true;
};
const submitAddInstance = async () => {
if (!addInstanceForm.destinationId || !addInstanceForm.address) {
message.warning('请填写必填字段');
return;
}
addInstanceLoading.value = true;
try {
await addInstance({
clusterId: addInstanceForm.clusterId,
destinationId: addInstanceForm.destinationId,
address: addInstanceForm.address,
weight: addInstanceForm.weight,
});
notification.success({
message: '添加成功',
description: '实例已成功添加',
});
addInstanceModalVisible.value = false;
handleViewInstances(currentService.value!);
} catch (error: any) {
notification.error({
message: '添加失败',
description: error.message,
});
} finally {
addInstanceLoading.value = false;
}
};
const handleRemoveInstance = async (record: GatewayInstance) => {
try {
await removeInstance(record.id);
notification.success({
message: '删除成功',
description: '实例已删除',
});
handleViewInstances(currentService.value!);
} catch (error: any) {
notification.error({
message: '删除失败',
description: error.message,
});
}
};
const handleUpdateWeight = async (record: GatewayInstance, weight: number) => {
try {
await updateInstanceWeight(record.id, weight);
notification.success({
message: '权重已更新',
});
} catch (error: any) {
notification.error({
message: '更新失败',
description: error.message,
});
}
};
const handleDeleteRoute = async (record: GatewayRoute) => {
message.info('删除路由功能开发中');
};
const handleReloadGateway = async () => {
try {
await reloadGateway();
notification.success({
message: '配置已刷新',
description: '网关配置已重新加载',
});
loadData();
} catch (error: any) {
notification.error({
message: '刷新失败',
description: error.message,
});
}
};
onMounted(() => {
loadData();
});
</script>
<style lang="less" scoped>
.gateway-management {
padding: 16px;
}
.mb-4 {
margin-bottom: 16px;
}
.success-text {
color: #52c41a;
}
</style>

View File

@ -702,9 +702,6 @@ importers:
dayjs:
specifier: 'catalog:'
version: 1.11.19
oidc-client-ts:
specifier: ^3.4.1
version: 3.4.1
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3))