Files
mokeegateway/docs/integration-guide.md
2026-07-15 16:13:27 +08:00

1015 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Mokee Gateway 系统接入完整指南
> 版本2026-06-13 | 适用于所有需要通过 Mokee Gateway 统一登录的业务系统
---
## 目录
1. [架构概览](#一架构概览)
2. [接入流程](#二接入流程)
3. [前端接入(完整代码)](#三前端接入完整代码)
4. [后端接入(完整代码)](#四后端接入完整代码)
5. [API 接口参考(完整请求/响应结构)](#五api-接口参考)
6. [数据库初始化](#六数据库初始化)
7. [创建管理员账号](#七创建网关管理员账号)
8. [创建管理员账号](#七创建网关管理员账号)
9. [UserChip Widget 一键接入](#八usership-widget-一键接入)
10. [对接测试清单](#九对接测试清单)
---
## 一、架构概览
```
┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ 业务前端 │────▶│ Mokee Gateway │────▶│ 业务后端 │
│ (Vue/React) │ │ gw.server.zgitm.com │ │ (任意语言) │
│ │ │ │ │ │
│ 携带 Token │ │ 验证 Token │ │ 从请求头读取: │
│ + X-System-Code │ 识别系统编码 │ │ X-User-Id │
│ │ │ 转发请求 │ │ X-User-Name │
│ │ │ 注入用户信息头 │ │ X-System-Code │
└──────────────┘ └──────────────────┘ └──────────────────┘
```
### 流程
1. 用户访问业务前端 → 无 Token → 跳转统一登录页
2. 登录成功 → 获得 JWT Token → 回跳到业务前端
3. 业务前端所有 API 请求 → 网关 (携带 Authorization + X-System-Code)
4. 网关验证 Token → 注入用户信息请求头 → 转发到业务后端
5. 业务后端从请求头读取用户信息 → 执行业务逻辑
### 三条关键规则
**规则一:前端只调网关** — 业务前端 baseURL 配置为网关地址,不能直接调业务后端。
**规则二:后端必须开跨域 (CORS)** — 业务后端需要允许来自网关域名的跨域请求。
**规则三:业务后端不再做登录/鉴权** — Token 验证由网关完成,业务后端只需从请求头读取用户信息。
---
## 二、接入流程
1. 在网关管理后台注册业务系统 → 获得 `systemCode``docToken`
2. 在网关数据库执行初始化 SQL → 创建菜单和角色
3. 前端接入:配置 Axios 实例 + 路由守卫
4. 后端接入:配置 CORS + UserContext 中间件
5. 创建管理员账号 → 登录管理后台维护本系统数据
6. 对接测试 → 完成验收
---
## 三、前端接入(完整代码)
### 3.1 安装依赖
```bash
npm install axios
```
### 3.2 环境变量
```env
VITE_GATEWAY_URL = https://gw.server.zgitm.com
VITE_LOGIN_URL = https://web.gw.zgitm.com/login
VITE_SYSTEM_CODE = YOUR_SYSTEM_CODE
```
| 变量 | 说明 | 从哪里获取 |
|------|------|-----------|
| `VITE_GATEWAY_URL` | 网关 API 地址 | 固定值,见附录地址表 |
| `VITE_LOGIN_URL` | 统一登录页地址 | 固定值,见附录地址表 |
| `VITE_SYSTEM_CODE` | 你的业务系统编码 | 在网关管理后台注册系统后获得(联系平台管理员) |
### 3.3 Axios 实例 (src/utils/request.ts)
```typescript
import axios from 'axios'
import { ElMessage } from 'element-plus'
const SYSTEM_CODE = import.meta.env.VITE_SYSTEM_CODE
const request = axios.create({
baseURL: import.meta.env.VITE_GATEWAY_URL,
timeout: 30000,
})
// 从 Cookie 读取 Token登录后网关写入的跨域 Cookie
function getToken(): string | null {
const match = document.cookie.match(/(?:^|;\s*)token=([^;]*)/)
return match ? decodeURIComponent(match[1]) : null
}
// 请求拦截器:携带 Token 和系统编码
request.interceptors.request.use(config => {
const token = getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// 关键:网关根据此头识别业务系统
config.headers['X-System-Code'] = SYSTEM_CODE
return config
})
// 响应拦截器401 自动跳转登录
request.interceptors.response.use(
res => res.data.code === 200 ? res.data : Promise.reject(res.data),
err => {
if (err.response?.status === 401) {
window.location.href = `${import.meta.env.VITE_LOGIN_URL}?systemCode=${SYSTEM_CODE}`
}
return Promise.reject(err)
}
)
export default request
```
### 3.4 路由守卫 (src/router/index.ts)
```typescript
import { createRouter, createWebHistory } from 'vue-router'
const SYSTEM_CODE = import.meta.env.VITE_SYSTEM_CODE
const LOGIN_URL = import.meta.env.VITE_LOGIN_URL
const router = createRouter({
history: createWebHistory(),
routes: [ /* 你的路由 */ ],
})
router.beforeEach((to, _from, next) => {
const token = document.cookie.match(/(?:^|;\s*)token=([^;]*)/)?.[1]
if (!token && to.path !== '/login') {
// 无 Token → 跳统一登录页
window.location.href = `${LOGIN_URL}?systemCode=${SYSTEM_CODE}`
return
}
next()
})
export default router
```
### 3.5 登录成功回调处理
用户在统一登录页登录成功后,网关将 Token 写入 **Cookie**domain: `.zgitm.com`,跨子域共享),然后跳回系统注册时填写的 `frontUrl`(即你的业务前端首页地址)。业务前端无需自行实现登录接口,只需从 Cookie 中读取 Token 即可。
```typescript
// 从 Cookie 读取 Token
function getToken(): string | null {
const match = document.cookie.match(/(?:^|;\s*)token=([^;]*)/)
return match ? decodeURIComponent(match[1]) : null
}
// App.vue onMounted 或 main.ts
const token = getToken()
if (token) {
// 用户已登录,正常渲染应用
// 如需获取用户信息,调用网关 API
const res = await request.get('/zgapi/v1/auth/user/info')
console.log('当前用户:', res.data)
} else {
// 无 Token → 跳统一登录页
window.location.href = `${import.meta.env.VITE_LOGIN_URL}?systemCode=${SYSTEM_CODE}`
}
```
#### 多系统场景说明
一个用户可能绑定了多个业务系统。跳转登录页时在 URL 中带上 `systemCode` 参数,登录页会自动选择对应系统:
```
https://web.gw.zgitm.com/login?systemCode=YOUR_SYSTEM_CODE
```
这就是为什么路由守卫和 Axios 拦截器中跳转登录时都要拼 `?systemCode=${SYSTEM_CODE}`——网关登录页根据这个参数知道用户要从哪个系统登录,登录成功后直接回跳到该系统的 `frontUrl`
> **💡 Token 存储说明**Token 存在 Cookie 中(而非 localStorage因为 Cookie 支持跨子域共享(`.zgitm.com`),统一登录、管理后台、文件服务等不同子站都能读取到同一个 Token。Cookie 有效期 2 小时,与 Token 过期时间一致。
### 3.6 按钮权限控制v-permission 指令)
用户信息接口(`/zgapi/v1/auth/user/info`)会返回 `permissions` 字段(按钮权限标识列表),前端据此控制按钮的显示/隐藏。你需要注册一个 `v-permission` 自定义指令:
**src/directives/permission.ts**
```typescript
import type { Directive } from 'vue'
// 从 Cookie 读取 Token解析出 payload 中的 permissions
// 或从 Vuex/Pinia store 中读取(取决于你的状态管理方案)
function getPermissions(): string[] {
const token = document.cookie.match(/(?:^|;\s*)token=([^;]*)/)?.[1]
if (!token) return []
try {
// JWT payload 在中间那段Base64
const payload = JSON.parse(atob(token.split('.')[1]))
return payload.permissions ?? []
} catch {
return []
}
}
export const permission: Directive = {
mounted(el, binding) {
const permissions = getPermissions()
const required = binding.value as string
// 如果用户没有该权限,隐藏按钮
if (!permissions.includes(required)) {
el.parentNode?.removeChild(el)
}
},
}
```
**src/main.ts 中全局注册:**
```typescript
import { createApp } from 'vue'
import App from './App.vue'
import { permission } from './directives/permission'
const app = createApp(App)
app.directive('permission', permission)
app.mount('#app')
```
**业务代码中使用:**
```html
<template>
<!-- 只有拥有 system:user:add 权限的用户才能看到"新增"按钮 -->
<el-button type="primary" v-permission="'system:user:add'">新增用户</el-button>
<el-button v-permission="'system:user:edit'">编辑</el-button>
<el-button type="danger" v-permission="'system:user:delete'">删除</el-button>
</template>
```
> **原理**:后端返回的 `permissions` 就是你在 `sys_menu` 表里配置的 `permission` 字段值menu_type=3 的按钮记录)。它们通过角色绑定到用户,登录时返回。前端 `v-permission` 指令检查当前用户是否有该权限,没有就从 DOM 中移除元素。
---
## 四、后端接入(完整代码)
### 4.1 网关转发时注入的请求头
| 请求头名称 | 数据类型 | 说明 |
|-----------|---------|------|
| `X-User-Id` | String | 当前登录用户的唯一ID |
| `X-User-Name` | String | 用户真实姓名 |
| `X-User-Account` | String | 用户登录账号 |
| `X-User-Email` | String | 用户邮箱 |
| `X-User-Post` | String | 用户岗位 |
| `X-User-Roles` | JSON | 用户角色列表 `[{roleId, roleName, roleCode}]` |
| `X-System-Id` | String | 当前业务系统的数据库ID |
| `X-System-Code` | String | 当前业务系统的唯一编码 |
| `X-System-Name` | String | 当前业务系统的显示名称 |
> **重要**:这些请求头由网关自动注入,业务后端可以信任这些值。不需要再验证 Token 或查询用户信息。
### 4.2 Java — UserContext 工具类
```java
// ====== UserContext.java ======
import java.util.*;
public class UserContext {
private static final ThreadLocal<Map<String, String>> CTX = new ThreadLocal<>();
public static void set(Map<String, String> user) { CTX.set(user); }
public static Map<String, String> get() { return CTX.get(); }
public static void clear() { CTX.remove(); }
// 快捷方法
public static String getUserId() { return get().get("X-User-Id"); }
public static String getUserName() { return get().get("X-User-Name"); }
public static String getUserAccount(){ return get().get("X-User-Account"); }
public static String getSystemCode() { return get().get("X-System-Code"); }
public static String getSystemId() { return get().get("X-System-Id"); }
}
// ====== UserContextFilter.java ======
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.*;
@Component
public class UserContextFilter implements Filter {
private static final String[] HEADERS = {
"X-User-Id", "X-User-Name", "X-User-Account",
"X-User-Email", "X-User-Post", "X-User-Roles",
"X-System-Id", "X-System-Code", "X-System-Name"
};
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
Map<String, String> user = new HashMap<>();
for (String h : HEADERS) {
String v = request.getHeader(h);
if (v != null) user.put(h, v);
}
UserContext.set(user);
try {
chain.doFilter(req, res);
} finally {
UserContext.clear();
}
}
}
// ====== 业务代码中使用 ======
@RestController
public class OrderController {
@GetMapping("/api/orders")
public List<Order> list() {
String userId = UserContext.getUserId(); // "123"
String userName = UserContext.getUserName(); // "张三"
String systemCode = UserContext.getSystemCode(); // "msg"
return orderService.findByUser(userId, systemCode);
}
}
```
### 4.3 Python — Flask 中间件
```python
# ====== middleware.py ======
from flask import request, g
USER_HEADERS = [
'X-User-Id', 'X-User-Name', 'X-User-Account',
'X-User-Email', 'X-User-Post', 'X-User-Roles',
'X-System-Id', 'X-System-Code', 'X-System-Name',
]
def user_context_middleware():
"""将网关注入的用户信息存到 Flask g 对象"""
for h in USER_HEADERS:
val = request.headers.get(h)
if val:
setattr(g, h.replace('-', '_').lower(), val)
# 注册到 app
# app.before_request(user_context_middleware)
# ====== 业务代码中使用 ======
from flask import g
@app.route('/api/orders')
def list_orders():
user_id = g.x_user_id # "123"
user_name = g.x_user_name # "张三"
system_code = g.x_system_code # "msg"
return order_service.query(user_id, system_code)
```
### 4.4 Go — 中间件
```go
// ====== middleware.go ======
package middleware
import (
"context"
"net/http"
)
type contextKey string
func UserContext(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
for _, h := range []string{
"X-User-Id", "X-User-Name", "X-User-Account",
"X-User-Email", "X-User-Roles",
"X-System-Id", "X-System-Code",
} {
if v := r.Header.Get(h); v != "" {
ctx = context.WithValue(ctx, contextKey(h), v)
}
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// 快捷函数
func GetUserID(ctx context.Context) string {
return ctx.Value(contextKey("X-User-Id")).(string)
}
func GetSystemCode(ctx context.Context) string {
return ctx.Value(contextKey("X-System-Code")).(string)
}
// ====== main.go: 注册中间件 ======
// http.Handle("/api/", middleware.UserContext(yourHandler))
// ====== 业务代码 ======
func listOrders(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context()) // "123"
systemCode := middleware.GetSystemCode(r.Context()) // "msg"
// ...
}
```
### 4.5 Java CORS 配置
```java
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("*")
.allowedHeaders("*")
.allowCredentials(true);
}
}
```
> **注意**`permissions` 字段**仅在当前系统为网关gateway时返回**。业务系统调用此接口时 `permissions` 为 `null`,业务系统应通过 §5.2 菜单路由接口获取自身系统的权限列表。
---
## 五、API 接口参考
> 所有接口统一通过网关地址访问:`https://gw.server.zgitm.com`
### 统一响应格式 `Result<T>`
```json
{
"code": 200,
"msg": "操作成功",
"data": {},
"timestamp": 1718294400000
}
```
| code 值 | 含义 | HTTP 状态码 | 说明 |
|---------|------|------------|------|
| `200` | 成功 | 200 | 业务正常返回 |
| `401` | 未登录/Token 过期 | 401 | 前端自动跳转登录页 |
| `403` | 无权限 | 403 | Token 有效但用户无此接口权限 |
| `500` | 服务器错误 | 500 | 后端异常,查看网关日志 |
> **💡 注意**`code` 和 HTTP 状态码是**两套独立的值**。通常它们保持一致,但判断业务结果应看响应体的 `code` 字段而不是 HTTP status code。响应拦截器里 `res.data.code === 200` 判断成功就是这个道理。
### 接口速查表
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/zgapi/v1/auth/user/info` | 获取当前登录用户信息 |
| GET | `/zgapi/v1/auth/menu/router?systemCode=xxx` | 获取树形菜单路由 + 系统所有权限标识 |
| GET | `/zgapi/v1/auth/system/list` | 获取可切换的业务系统列表 |
| PUT | `/zgapi/v1/auth/password/update?oldPwd=xxx&newPwd=xxx` | 修改当前用户密码 |
---
### 5.1 获取当前用户信息
```
GET /zgapi/v1/auth/user/info
Authorization: Bearer {token}
```
**响应 data (UserInfoVO)**
```json
{
"userId": "a1b2c3d4e5f6789012345678",
"username": "zhangsan",
"realName": "张三",
"email": "zhangsan@example.com",
"phone": "13800138000",
"avatar": "https://file.wg.zgitm.com/api/download/xxx?token=...",
"post": "高级工程师",
"createdAt": "2025-01-15T10:30:00",
"lastLoginTime": "2026-06-13T09:15:30"
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `userId` | String | 用户UUID |
| `username` | String | 用户名 |
| `realName` | String | 真实姓名 |
| `email` | String | 邮箱 |
| `phone` | String | 手机号 |
| `avatar` | String | 头像URL可能来自文件服务每次都需刷新 Token |
| `post` | String | 岗位 |
| `createdAt` | String | 注册时间 (ISO 8601) |
| `lastLoginTime` | String | 最后登录时间 |
---
### 5.2 获取系统菜单路由
```
GET /zgapi/v1/auth/menu/router?systemCode=YOUR_CODE
Authorization: Bearer {token}
```
**响应 data (MenuRouterResponse)**
```json
{
"menu": [
{
"id": "menu-001",
"menuName": "系统管理",
"menuType": 1,
"icon": "Setting",
"permission": null,
"path": null,
"name": "系统管理",
"component": "Layout",
"sortOrder": 1,
"status": 1,
"meta": { "title": "系统管理", "icon": "Setting" },
"children": [
{
"id": "menu-002",
"menuName": "用户管理",
"menuType": 2,
"icon": "User",
"permission": "system:user:list",
"path": "/system/user",
"name": "用户管理",
"component": "system/user/index",
"sortOrder": 1,
"status": 1,
"meta": { "title": "用户管理", "icon": "User" },
"children": null
},
{
"id": "menu-003",
"menuName": "角色管理",
"menuType": 2,
"icon": "Avatar",
"permission": "system:role:list",
"path": "/system/role",
"name": "角色管理",
"component": "system/role/index",
"sortOrder": 2,
"status": 1,
"meta": { "title": "角色管理", "icon": "Avatar" },
"children": null
}
]
}
],
"permission": [
"system:user:list",
"system:user:add",
"system:user:edit",
"system:user:delete",
"system:role:list",
"system:role:add",
"system:role:edit",
"system:role:delete"
]
}
```
| 顶层字段 | 类型 | 说明 |
|------|------|------|
| `menu` | List\<MenuRouterVO\> | 树形菜单路由列表(结构见下表) |
| `permission` | List\<String\> | 该系统所有权限标识(用于前端 v-permission 指令控制按钮显隐) |
**menu[] 中每个菜单节点的字段说明:**
|------|------|------|
| `id` | String | 菜单ID |
| `menuName` | String | 菜单显示名称 |
| `menuType` | Integer | **1=目录**折叠分组path=nullcomponent="Layout"<br>**2=菜单**页面有path和component<br>**3=按钮**(权限点,不在菜单显示) |
| `icon` | String | 图标名称 |
| `permission` | String | 权限标识(如 `system:user:list` |
| `path` | String | 前端路由路径(目录为 null |
| `name` | String | 路由名称 |
| `component` | String | Vue 组件路径 |
| `sortOrder` | Integer | 排序号(越小越靠前) |
| `status` | Integer | 0=隐藏, 1=显示 |
| `meta` | Object | `{title, icon}` 路由元数据 |
| `children` | List | 子菜单(叶子节点为 **null** |
> **注意**:菜单按用户角色过滤,不同角色看到的菜单不同。叶子节点 `children` 为 `null`(非空数组),前端据此判断是否显示展开箭头。
---
### 5.3 获取可切换系统列表
```
GET /zgapi/v1/auth/system/list
Authorization: Bearer {token}
```
**响应 data (List\<SystemVO\>)**
```json
[
{
"systemId": "sys-001",
"systemCode": "erp",
"systemName": "ERP系统",
"frontUrl": "https://erp.example.com",
"icon": "el-icon-s-shop",
"description": "企业资源计划管理系统"
}
]
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `systemId` | String | 系统UUID |
| `systemCode` | String | 系统编码 |
| `systemName` | String | 系统名称 |
| `frontUrl` | String | 前端入口地址 |
| `icon` | String | 系统图标 |
| `description` | String | 系统描述 |
> 列表中不包含网关系统本身systemCode="gateway"),用户无绑定系统时返回空数组 `[]`。
---
### 5.4 修改当前用户密码
```
PUT /zgapi/v1/auth/password/update?oldPwd={旧密码}&newPwd={新密码}
Authorization: Bearer {token}
```
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `oldPwd` | String | 是 | 旧密码Query参数 |
| `newPwd` | String | 是 | 新密码Query参数至少6位 |
响应:`{"code":200,"msg":"密码修改成功","data":null}`
需验证旧密码正确后才能修改,修改后需重新登录。
---
## 六、数据库初始化
在网关数据库执行以下 SQL为你的业务系统创建默认菜单和角色。
### 6.1 涉及的表
#### sys_menu菜单/权限表)
菜单采用**三级结构**:目录 → 页面菜单 → 按钮权限。后端返回菜单树时按角色过滤,用户只能看到自己角色绑定的菜单。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `id` | VARCHAR(64) | 是 | UUID 主键,用 `REPLACE(UUID(),'-','')` 生成 |
| `system_id` | VARCHAR(64) | 是 | 所属业务系统的 ID`sys_system` 表查出 |
| `parent_id` | VARCHAR(64) | 是 | 父菜单 ID顶级菜单填 `'0'`,子菜单填父菜单的 ID |
| `menu_name` | VARCHAR(64) | 是 | 菜单显示名称(如"用户管理"、"新增用户" |
| `menu_path` | VARCHAR(256) | 否 | 前端路由路径。**目录**填 `/xxx`**页面菜单**填 `/xxx/yyy`**按钮**填 `NULL` |
| `component` | VARCHAR(256) | 否 | Vue 组件路径。**目录**固定填 `'Layout'`**页面菜单**填 `'xxx/yyy/index'`**按钮**填 `NULL` |
| `icon` | VARCHAR(128) | 否 | 图标名称Element Plus 图标)。**目录/菜单**填写,**按钮**填空串 `''` |
| `menu_type` | TINYINT | 是 | **1=目录**(侧边栏折叠分组,不对应具体页面)<br>**2=页面菜单**(具体页面,可点击跳转)<br>**3=按钮**(页面内的操作按钮,不显示在菜单树上,仅用于后端权限校验) |
| `permission` | VARCHAR(128) | 否 | 权限标识符,命名规范:`{模块}:{资源}:{操作}`。**目录**填 `NULL`**页面菜单**填 `xxx:yyy:list`**按钮**填 `xxx:yyy:add/edit/delete` 等 |
| `sort_order` | INT | 是 | 排序号,数字越小越靠前。同级菜单按此字段升序排列 |
| `status` | TINYINT | 是 | 0=隐藏不在菜单树中返回1=显示 |
**permission 命名规范示例**
| 资源 | 页面菜单 (list) | 新增按钮 (add) | 编辑按钮 (edit) | 删除按钮 (delete) |
|------|----------------|---------------|----------------|----------------|
| 用户 | `system:user:list` | `system:user:add` | `system:user:edit` | `system:user:delete` |
| 角色 | `system:role:list` | `system:role:add` | `system:role:edit` | `system:role:delete` |
| 订单 | `biz:order:list` | `biz:order:add` | `biz:order:edit` | `biz:order:delete` |
> 接入方可以自定义 permission 标识,只要和前端 `v-permission` 指令、后端 `@RequirePermission` 注解保持一致即可。
#### sys_role角色表
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `id` | VARCHAR(64) | 是 | UUID 主键 |
| `system_id` | VARCHAR(64) | 是 | 所属业务系统 ID |
| `role_name` | VARCHAR(64) | 是 | 角色显示名称(如"管理员"、"普通用户" |
| `role_code` | VARCHAR(64) | 是 | 角色编码(唯一标识,如 `admin``user`),同一系统内不可重复 |
| `description` | VARCHAR(256) | 否 | 角色描述 |
| `status` | TINYINT | 是 | 0=禁用1=启用 |
#### sys_role_menu角色菜单关联表
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `id` | VARCHAR(64) | 是 | UUID 主键 |
| `role_id` | VARCHAR(64) | 是 | 角色 ID |
| `menu_id` | VARCHAR(64) | 是 | 菜单 ID含按钮权限 |
> 一条 `sys_role_menu` 记录表示:该角色拥有该菜单/按钮的权限。菜单接口返回时会根据当前用户的角色自动过滤。
### 6.2 初始化 SQL
```sql
-- ============================================
-- {系统名称} 菜单 & 角色初始化 SQL
-- 系统编码: {systemCode}
-- ============================================
-- 0. 获取系统 ID
SET @system_id = (SELECT id FROM sys_system WHERE system_code = '{systemCode}' LIMIT 1);
-- 1. 创建顶级菜单目录menu_type=1permission 为 NULL
-- 用变量持有父菜单 UUID后续子菜单直接引用变量名无需手动查 ID
-- 目录仅作为侧边栏折叠分组,不绑定具体页面,不需要权限标识
SET @menu_sys = REPLACE(UUID(),'-','');
SET @menu_biz = REPLACE(UUID(),'-','');
INSERT INTO sys_menu (id, system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status, permission) VALUES
(@menu_sys, @system_id, '0', '系统管理', '/system', 'Layout', 'Setting', 1, 1, 1, NULL),
(@menu_biz, @system_id, '0', '业务管理', '/biz', 'Layout', 'Document', 1, 2, 1, NULL);
-- 2. 创建页面菜单menu_type=2permission 为页面访问权限标识)
-- parent_id 直接用上面定义的变量MySQL 变量在同一个会话内全局可见
-- 命名规范:{模块}:{资源}:list
INSERT INTO sys_menu (id, system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status, permission) VALUES
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '用户管理', '/system/user', 'system/user/index', 'User', 2, 1, 1, 'system:user:list'),
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '角色管理', '/system/role', 'system/role/index', 'Avatar', 2, 2, 1, 'system:role:list'),
(REPLACE(UUID(),'-',''), @system_id, @menu_biz, '订单管理', '/biz/order', 'biz/order/index', 'Tickets', 2, 1, 1, 'biz:order:list');
-- 3. 创建按钮权限menu_type=3permission 为按钮操作权限标识)
-- 按钮的 parent_id 统一挂在对应页面的父菜单目录下(@menu_sys 或 @menu_biz
-- 按钮不显示在菜单树中仅用于后端权限校验path/component/icon 均为 NULL/空串
-- 命名规范:{模块}:{资源}:{操作}add/edit/delete
INSERT INTO sys_menu (id, system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status, permission) VALUES
-- 用户管理按钮
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '新增用户', NULL, NULL, '', 3, 1, 1, 'system:user:add'),
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '编辑用户', NULL, NULL, '', 3, 2, 1, 'system:user:edit'),
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '删除用户', NULL, NULL, '', 3, 3, 1, 'system:user:delete'),
-- 角色管理按钮
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '新增角色', NULL, NULL, '', 3, 1, 1, 'system:role:add'),
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '编辑角色', NULL, NULL, '', 3, 2, 1, 'system:role:edit'),
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '删除角色', NULL, NULL, '', 3, 3, 1, 'system:role:delete'),
-- 订单管理按钮
(REPLACE(UUID(),'-',''), @system_id, @menu_biz, '新增订单', NULL, NULL, '', 3, 1, 1, 'biz:order:add'),
(REPLACE(UUID(),'-',''), @system_id, @menu_biz, '编辑订单', NULL, NULL, '', 3, 2, 1, 'biz:order:edit'),
(REPLACE(UUID(),'-',''), @system_id, @menu_biz, '删除订单', NULL, NULL, '', 3, 3, 1, 'biz:order:delete');
-- 4. 创建角色(用变量持有角色 UUID供后续步骤 5 分配用户时引用)
SET @role_admin = REPLACE(UUID(),'-','');
SET @role_user = REPLACE(UUID(),'-','');
INSERT INTO sys_role (id, system_id, role_name, role_code, description, status) VALUES
(@role_admin, @system_id, '管理员', 'admin', '拥有全部权限', 1),
(@role_user, @system_id, '普通用户', 'user', '拥有基本权限', 1);
-- 5. 将本系统所有菜单(含按钮权限)分配给管理员角色
INSERT INTO sys_role_menu (id, role_id, menu_id)
SELECT REPLACE(UUID(),'-',''), @role_admin, m.id
FROM sys_menu m WHERE m.system_id = @system_id;
-- 6. (可选)为普通用户分配菜单——根据实际需求手动给 user 角色分配菜单
-- INSERT INTO sys_role_menu (id, role_id, menu_id)
-- SELECT REPLACE(UUID(),'-',''), @role_user, m.id
-- FROM sys_menu m WHERE m.system_id = @system_id
-- AND m.menu_type != 3; -- 普通用户只分配页面菜单,不给按钮权限
```
> **💡 普通用户 (user) 角色**:初始化 SQL 只给管理员分配了菜单,普通用户角色默认<b>没有任何菜单权限</b>。这是因为不同系统对普通用户的权限要求不同,无法预置。接入后请登录管理后台,在「角色管理」中手动为 `user` 角色分配菜单。或者取消上面第 6 步的注释,给普通用户分配所有页面菜单(不含按钮)。
---
## 七、创建网关管理员账号
执行以下 SQL 创建一个应用系统管理员账号,该管理员只能看到自己绑定系统的数据:
```sql
-- ============================================
-- 创建应用系统管理员账号
-- 角色编码: gateway-app-admin (只能看到自己绑定系统的数据)
-- ============================================
-- 1. 创建管理员用户(密码 'admin123' 的 BCrypt 密文,建议自行生成替换)
INSERT INTO sys_user (id, username, password, real_name, email, phone, status, is_deleted)
SELECT REPLACE(UUID(),'-',''), 'admin_{systemCode}',
'$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi',
'{系统名称}管理员', 'admin@{systemCode}.com', NULL, 1, 0
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM sys_user WHERE username = 'admin_{systemCode}');
-- 2. 绑定管理员到网关系统 (gateway用于登录管理后台)
INSERT INTO sys_user_system (id, user_id, system_id)
SELECT REPLACE(UUID(),'-',''), u.id, s.id
FROM sys_user u, sys_system s
WHERE u.username = 'admin_{systemCode}'
AND s.system_code = 'gateway'
AND NOT EXISTS (
SELECT 1 FROM sys_user_system us
WHERE us.user_id = u.id AND us.system_id = s.id
);
-- 3. 分配 gateway-app-admin 角色(管理后台权限,只能看到自己绑定系统的数据)
INSERT INTO sys_user_role (id, user_id, role_id)
SELECT REPLACE(UUID(),'-',''), u.id, r.id
FROM sys_user u, sys_role r, sys_system s
WHERE u.username = 'admin_{systemCode}'
AND r.role_code = 'gateway-app-admin'
AND r.system_id = s.id
AND s.system_code = 'gateway'
AND NOT EXISTS (
SELECT 1 FROM sys_user_role ur
WHERE ur.user_id = u.id AND ur.role_id = r.id
);
-- 4. 绑定管理员到自己的业务系统(用于操作本系统的用户/角色/菜单)
INSERT INTO sys_user_system (id, user_id, system_id)
SELECT REPLACE(UUID(),'-',''), u.id, s.id
FROM sys_user u, sys_system s
WHERE u.username = 'admin_{systemCode}'
AND s.system_code = '{systemCode}'
AND NOT EXISTS (
SELECT 1 FROM sys_user_system us
WHERE us.user_id = u.id AND us.system_id = s.id
);
-- 5. 分配本系统的管理员角色admin拥有本系统全部菜单权限
INSERT INTO sys_user_role (id, user_id, role_id)
SELECT REPLACE(UUID(),'-',''), u.id, r.id
FROM sys_user u, sys_role r, sys_system s
WHERE u.username = 'admin_{systemCode}'
AND r.role_code = 'admin'
AND r.system_id = s.id
AND s.system_code = '{systemCode}'
AND NOT EXISTS (
SELECT 1 FROM sys_user_role ur
WHERE ur.user_id = u.id AND ur.role_id = r.id
);
```
> ⚠️ **如何生成 BCrypt 密文**
>
> **Python最方便**
> ```bash
> python -c "import bcrypt; print(bcrypt.hashpw(b'your_password', bcrypt.gensalt()).decode())"
> ```
> 如果没装 bcrypt 库:`pip install bcrypt`Windows 用 `pip install bcrypt`Linux 用 `pip3 install bcrypt`)。
>
> **Java**
> ```java
> String hash = BCrypt.hashpw("your_password", BCrypt.gensalt());
> ```
>
> **在线工具**仅测试环境https://www.bcryptcalculator.com
>
> 如果已创建过用户,跳过步骤 1只执行步骤 2-5。
---
## 八、UserChip Widget 一键接入
在页面右上角添加用户头像和下拉菜单,引入网关提供的 UserChip Widget按以下三步操作。
> 🚫 **常见错误:`process is not defined`**
> 直接在 `index.html` 中写 `<script src="...user-chip.js">` 会报这个错。原因是 Widget 脚本内部打包了 Vue 3.x引用了 `process.env.NODE_ENV`,浏览器没有 Node.js 的 `process` 全局变量。正确做法是下面的三步接入。
### 步骤 1在 index.html 中引入 Widget 样式
```html
<!-- index.html -->
<head>
<link rel="stylesheet" href="https://web.gw.zgitm.com/widgets/user-chip.css" />
</head>
```
> ⚠️ **注意**:不要在这里引入 JS 脚本,不要在 body 中放占位 div。
### 步骤 2在 TopBar 组件中放置占位 div
```vue
<!-- src/layouts/components/TopBar.vue -->
<template>
<header class="topbar">
<div class="topbar-left">...</div>
<div class="topbar-right">
<!-- Mokee Gateway UserChip Widget 占位 -->
<div id="mokee-user-chip"
data-api-base="https://gw.server.zgitm.com"
data-cookie-domain=".zgitm.com"></div>
</div>
</header>
</template>
<style scoped>
#mokee-user-chip { flex-shrink: 0; }
</style>
```
> ⚠️ 占位 div 必须在 Vue 组件模板中,这样 Vue mount 后 DOM 中才存在该元素。放在 `index.html` 的 `<body>` 中无效。
### 步骤 3在 main.js 中挂载后动态加载 Widget 脚本
```javascript
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
app.mount('#app')
// ⚠️ 关键:必须在 mount 之后加载,且需要 process polyfill
window.process = { env: {} }
const widgetScript = document.createElement('script')
widgetScript.src = 'https://web.gw.zgitm.com/widgets/user-chip.js'
document.body.appendChild(widgetScript)
```
### 加载原理
```
index.html 加载
<link rel="stylesheet" href="user-chip.css"> ← 样式就绪
<script type="module" src="main.js"> ← Vue 入口
createApp → mount('#app') ← Vue 渲染 TopBar#mokee-user-chip 出现在 DOM
window.process = { env: {} } ← polyfill防止 process is not defined
appendChild(<script src="user-chip.js">) ← Widget 脚本执行,找到 #mokee-user-chip 并渲染
```
### 组件功能
- 未登录时显示「🔑 登录」按钮,点击跳转统一登录页
- 已登录时显示用户头像 + 用户名,点击弹出下拉菜单
### 下拉菜单(仅两项)
| 菜单项 | 行为 |
|--------|------|
| 🔄 切换系统 | 跳转到系统选择页 |
| 🚪 退出登录 | 清除 Token 并跳转登录页 |
### 配置属性
| 属性 | 必填 | 默认值 | 说明 |
|------|------|--------|------|
| `data-api-base` | 是 | 无 | 网关 API 地址,生产环境为 `https://gw.server.zgitm.com` |
| `data-login-url` | 否 | 自动推断 | 统一登录页地址,无需配置 |
| `data-select-url` | 否 | 自动推断 | 切换系统页地址,无需配置 |
| `data-cookie-domain` | 否 | 空(仅当前域名) | Cookie 跨域共享域名,跨子域时填 `.zgitm.com` |
### 跨子域共享 Token
如果外部系统和网关在不同子域(如 `app.zgitm.com``web.gw.zgitm.com`),需配置 Cookie domain
```html
<div id="mokee-user-chip" data-cookie-domain=".zgitm.com" ...></div>
```
### 排查清单
| 问题 | 检查项 |
|------|--------|
| Widget 不显示 | `#mokee-user-chip` 是否在 Vue 模板中(非 index.html |
| `process is not defined` | `window.process = { env: {} }` 是否在 Widget 脚本加载前执行 |
| 登录状态不识别 | `data-cookie-domain=".zgitm.com"` 是否配置 |
| 样式错乱 | `user-chip.css` 是否在 index.html 中正确引入 |
### CDN 资源地址
- 样式:`https://web.gw.zgitm.com/widgets/user-chip.css`
- 脚本:`https://web.gw.zgitm.com/widgets/user-chip.js`
---
## 九、对接测试清单
- [ ] 访问业务前端首页 → 自动跳转到统一登录页
- [ ] 使用测试账号登录 → 成功进入业务前端
- [ ] 调用 `GET /zgapi/v1/auth/user/info` → 返回用户信息
- [ ] 调用 `GET /zgapi/v1/auth/menu/router?systemCode=xxx` → 返回菜单树 + 权限列表
- [ ] 业务后端 Controller 中 UserContext.getUserId() 能获取到用户ID
- [ ] 退出登录后访问业务页面 → 重新跳转登录页
- [ ] Widget 组件正常显示头像 + 下拉菜单
---
## 附录:相关地址
| 服务 | 地址 |
|------|------|
| 统一登录页 | `https://web.gw.zgitm.com/login` |
| 系统选择页 | `https://web.gw.zgitm.com/select` |
| API 网关 | `https://gw.server.zgitm.com` |
| 管理后台 | `https://web.gw.zgitm.com/admin` |
| 文件服务 | `https://file.wg.zgitm.com` |
| Widget JS | `https://web.gw.zgitm.com/widgets/user-chip.js` |
| Widget CSS | `https://web.gw.zgitm.com/widgets/user-chip.css` |