1015 lines
37 KiB
Markdown
1015 lines
37 KiB
Markdown
# 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=null,component="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=1,permission 为 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=2,permission 为页面访问权限标识)
|
||
-- 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=3,permission 为按钮操作权限标识)
|
||
-- 按钮的 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` |
|