Files
2026-07-15 16:13:27 +08:00

946 lines
40 KiB
Vue
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
<!--
系统接入文档页面公开访问通过 docToken 路由访问
路由/docs/:token
页面路由在 router 中配置为无需登录即可访问公开文档页
内容包含 8 个章节
1. 当前系统配置 展示系统基本信息编码名称前后端地址
2. 架构概览 ASCII 架构图 + 5 步流程说明 + 3 条关键规则
3. 前端接入 完整的 Axios 实例和路由守卫复制即用代码
4. 后端接入 Java/Python/Go 三种语言的 UserContext 中间件示例
5. API 接口参考 GET 用户信息GET 菜单路由POST 退出登录
6. 数据库初始化 生成当前系统的菜单和角色初始化 SQL
7. 创建网关管理员账号 生成 gateway-app-admin 账号的 SQL
8. 对接测试清单 6 项验收检查
数据来源
- 系统信息GET /zgapi/v1/admin/system/doc/:token公开接口无需登录
SQL 生成逻辑
- menuSql根据当前系统的 systemCode 生成菜单角色角色-菜单关联的 INSERT 语句
- adminSql生成应用系统管理员账号系统绑定角色分配的 INSERT 语句
密码使用 BCrypt 密文$2a$10$...账号格式为 admin_{systemCode}
角色编码固定为 gateway-app-admin仅能看到自己绑定系统的数据
所有 INSERT 使用 NOT EXISTS 子查询防止重复执行
-->
<template>
<div class="doc-page">
<div class="doc-container">
<!-- ====== 页头 ====== -->
<header class="doc-hero">
<div class="doc-hero-left">
<svg class="doc-logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
<div>
<h1>{{ sys?.systemName || '系统' }} 接入文档</h1>
<p>Mokee Gateway 统一登录网关 · 系统对接完整指南</p>
</div>
</div>
<div class="doc-hero-right">
<el-tag size="large" type="primary" effect="dark" round>系统编码: {{ sys?.systemCode }}</el-tag>
</div>
</header>
<!-- ====== 系统信息 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">1</span>当前系统配置</h2>
<el-descriptions :column="2" border size="large">
<el-descriptions-item label="系统编码"><code class="inline-code">{{ sys?.systemCode }}</code></el-descriptions-item>
<el-descriptions-item label="系统名称">{{ sys?.systemName }}</el-descriptions-item>
<el-descriptions-item label="前端地址"><code class="inline-code">{{ sys?.frontUrl }}</code></el-descriptions-item>
<el-descriptions-item label="后端地址"><code class="inline-code">{{ sys?.backendUrl }}</code></el-descriptions-item>
<el-descriptions-item label="系统描述" :span="2">{{ sys?.description || '-' }}</el-descriptions-item>
</el-descriptions>
</section>
<!-- ====== 架构概览 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">2</span>架构概览</h2>
<div class="arch-diagram">
<pre class="arch-text">
业务前端 Mokee Gateway 业务后端
(Vue/React) gw.server.zgitm.com (任意语言)
携带 Token 验证 Token 从请求头读取:
+ SystemCode 识别系统编码 X-User-Id
转发请求 X-User-Name
注入用户信息头 X-System-Code
</pre>
</div>
<div class="flow-steps">
<div class="flow-step"><b></b> 用户访问业务前端 Token 跳转统一登录页</div>
<div class="flow-step"><b></b> 登录成功 获得 JWT Token 回跳到业务前端</div>
<div class="flow-step"><b></b> 业务前端所有 API 请求 网关 (携带 Authorization + X-System-Code)</div>
<div class="flow-step"><b></b> 网关验证 Token 注入用户信息请求头 转发到业务后端</div>
<div class="flow-step"><b></b> 业务后端从请求头读取用户信息 执行业务逻辑</div>
</div>
<div class="arch-warnings">
<div class="warn-item">
<b>🔴 关键规则一前端只调网关</b>
<p>业务前端的 <code>baseURL</code> 必须配置为 <b>网关地址</b> <code>{{ gatewayUrl }}</code><b>绝对不能</b>直接调用业务后端网关根据请求头 <code>X-System-Code</code> 自动识别并转发到对应后端</p>
</div>
<div class="warn-item">
<b>🔴 关键规则二后端必须开跨域 (CORS)</b>
<p>业务后端需要允许来自网关域名的跨域请求网关转发请求时 origin 是浏览器地址不是网关地址</p>
<p><b>Java Spring Boot 配置</b></p>
<div class="code-box"><pre><code>@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("*")
.allowedHeaders("*")
.allowCredentials(true);
}
}</code></pre></div>
</div>
<div class="warn-item">
<b>🔴 关键规则三业务后端不再做登录/鉴权</b>
<p>Token 验证由网关完成业务后端只需从请求头读取用户信息不需要再验证 Token不需要查询用户表</p>
</div>
</div>
</section>
<!-- ====== 前端接入 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">3</span>前端接入 (完整代码)</h2>
<h3 class="section-h3">3.1 安装依赖</h3>
<div class="code-box">
<div class="code-label">Shell</div>
<pre><code>npm install axios</code></pre>
</div>
<h3 class="section-h3">3.2 环境变量 (.env)</h3>
<div class="code-box">
<div class="code-label">.env</div>
<pre><code>VITE_GATEWAY_URL = {{ gatewayUrl }}
VITE_LOGIN_URL = {{ loginUrl }}
VITE_SYSTEM_CODE = {{ sys?.systemCode || 'YOUR_CODE' }}</code></pre>
</div>
<h3 class="section-h3">3.3 Axios 实例 (src/utils/request.ts)</h3>
<div class="code-box">
<div class="code-label">TypeScript</div>
<pre><code>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</code></pre>
</div>
<h3 class="section-h3">3.4 路由守卫 (src/router/index.ts)</h3>
<div class="code-box">
<div class="code-label">TypeScript</div>
<pre><code>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</code></pre>
</div>
<h3 class="section-h3">3.5 登录成功回调处理</h3>
<p class="text-p">
用户在统一登录页登录成功后网关将 Token 写入 <b>Cookie</b>domain: <code>.zgitm.com</code>同域子站共享
然后跳回业务前端的地址 <code>VITE_LOGIN_URL</code> 配置的地址
</p>
<p class="text-p">
业务前端<b>无需自行实现登录接口</b>只需从 Cookie 中读取 Token 即可
</p>
<div class="code-box">
<div class="code-label">TypeScript 读取 Cookie 中的 Token</div>
<pre><code>// 从 Cookie 读取 Token登录后网关自动写入的跨域 Cookie
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}`
}</code></pre>
</div>
<div class="warn-item" style="margin-top:16px">
<b>💡 Token 存储说明</b>
<p>Token 存在 Cookie 而非 localStorage因为 Cookie 支持跨子域共享<code>.zgitm.com</code>
这样统一登录管理后台文件服务等不同子站都能读取到同一个 Token<br>
Cookie 有效期 2 小时 Token 过期时间一致</p>
</div>
</section>
<!-- ====== 后端接入 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">4</span>后端接入 (完整代码)</h2>
<h3 class="section-h3">4.1 网关转发到后端时注入的请求头</h3>
<el-table :data="headerTable" border stripe size="large" style="width:100%">
<el-table-column prop="name" label="请求头名称" width="200" />
<el-table-column prop="type" label="数据类型" width="120" />
<el-table-column prop="desc" label="说明" />
</el-table>
<p class="text-p" style="margin-top:16px"><b>重要</b>这些请求头由网关注入业务后端可以信任这些值不需要再验证 Token 或查询用户信息</p>
<h3 class="section-h3">4.2 Java UserContext 工具类</h3>
<p class="text-p"> ThreadLocal 缓存请求头业务代码零侵入获取用户信息</p>
<div class="code-box">
<div class="code-label">Java</div>
<pre><code>// ====== UserContext.java ======
import java.util.*;
public class UserContext {
private static final ThreadLocal&lt;Map&lt;String, String&gt;&gt; CTX = new ThreadLocal&lt;&gt;();
public static void set(Map&lt;String, String&gt; user) { CTX.set(user); }
public static Map&lt;String, String&gt; 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&lt;String, String&gt; user = new HashMap&lt;&gt;();
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&lt;Order&gt; list() {
String userId = UserContext.getUserId(); // "123"
String userName = UserContext.getUserName(); // "张三"
String systemCode = UserContext.getSystemCode(); // "msg"
return orderService.findByUser(userId, systemCode);
}
}</code></pre>
</div>
<h3 class="section-h3">4.3 Python Flask 中间件</h3>
<div class="code-box">
<div class="code-label">Python</div>
<pre><code># ====== 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 (Flask)
# 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)</code></pre>
</div>
<h3 class="section-h3">4.4 Go 中间件</h3>
<div class="code-box">
<div class="code-label">Go</div>
<pre><code>// ====== 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"
// ...
}</code></pre>
</div>
</section>
<!-- ====== API 接口参考 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">5</span>API 接口参考</h2>
<h3 class="section-h3">5.1 获取当前用户信息</h3>
<div class="api-card">
<div class="api-method get">GET</div>
<div class="api-path">/zgapi/v1/auth/user/info</div>
<div class="api-headers">
<b>请求头</b>Authorization: Bearer {token}
</div>
<div class="api-response">
<b>响应</b>
<div class="code-box"><pre><code>{
"code": 200,
"data": {
"userId": 1,
"username": "admin",
"realName": "管理员",
"email": "admin@zgitm.com",
"phone": "13800000000",
"avatar": null,
"post": "管理员"
}
}</code></pre></div>
</div>
</div>
<h3 class="section-h3">5.2 获取系统菜单路由</h3>
<div class="api-card">
<div class="api-method get">GET</div>
<div class="api-path">/zgapi/v1/auth/menu/router?systemCode={{ sys?.systemCode || 'YOUR_CODE' }}</div>
<div class="api-headers">
<b>请求头</b>Authorization: Bearer {token}
</div>
<div class="api-response">
<b>响应</b>树形菜单结构包含 path / name / component / children / meta( title, icon)
</div>
</div>
<h3 class="section-h3">5.3 退出登录</h3>
<div class="api-card">
<div class="api-method post">POST</div>
<div class="api-path">/zgapi/v1/auth/logout</div>
<div class="api-headers">
<b>请求头</b>Authorization: Bearer {token}
</div>
</div>
</section>
<!-- ====== 数据库初始化 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">6</span>数据库初始化</h2>
<p class="text-p">在网关平台的数据库执行以下 SQL{{ sys?.systemName }}创建默认菜单和角色</p>
<div class="code-box">
<div class="code-label">SQL</div>
<pre><code>{{ menuSql }}</code></pre>
</div>
</section>
<!-- ====== 创建网关管理员账号 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">7</span>创建网关管理员账号</h2>
<p class="text-p">
业务系统接入后系统管理员需要登录网关管理后台维护本系统的菜单和角色
执行以下 SQL 创建一个<b>应用系统管理员</b>账号角色编码 <code>gateway-app-admin</code>该管理员只能看到自己绑定系统的数据
</p>
<div class="code-box">
<div class="code-label">SQL 创建应用系统管理员</div>
<pre><code>{{ adminSql }}</code></pre>
</div>
<div class="warn-item" style="margin-top:16px">
<b> 注意</b>
<p>密码需用 BCrypt 加密可使用在线工具或 Java <code>BCrypt.hashpw("your_password", BCrypt.gensalt())</code> 生成密文<br>
如果已创建过用户跳过步骤 1只执行步骤 2 3 绑定系统和角色即可</p>
</div>
</section>
<!-- ====== 测试清单 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">8</span>对接测试清单</h2>
<div class="checklist">
<div v-for="(item, i) in testChecklist" :key="i" class="check-item">
<el-icon :size="20" color="var(--color-primary)"><component :is="getIconComponent(item.done ? 'CircleCheckFilled' : 'CircleClose')" /></el-icon>
<span>{{ item.text }}</span>
</div>
</div>
</section>
<!-- ====== 文件对象存储服务 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">9</span>文件对象存储服务</h2>
<p class="text-p">
文件上传/下载/删除由独立的<b>文件对象存储服务</b>提供该服务直接对接网关数据库上传后文件信息自动出现在文件列表中
</p>
<el-descriptions :column="2" border size="large" style="margin-bottom:20px">
<el-descriptions-item label="服务地址"><code class="inline-code">https://file.wg.zgitm.com</code></el-descriptions-item>
<el-descriptions-item label="认证方式">Bearer Token一次一密每次操作前重新获取</el-descriptions-item>
<el-descriptions-item label="最大文件">100MB</el-descriptions-item>
<el-descriptions-item label="Token 有效期">12 小时</el-descriptions-item>
</el-descriptions>
<h3 class="section-h3">9.1 获取 Token</h3>
<div class="api-card">
<div class="api-method post">POST</div>
<div class="api-path">/api/token</div>
<div class="api-headers"><b>Content-Type:</b> application/json;charset=utf-8</div>
<p style="margin:8px 0"><b>请求体</b></p>
<div class="code-box"><pre><code>{
"systemCode": "{{ sys?.systemCode || 'YOUR_CODE' }}"
}</code></pre></div>
<p style="margin:8px 0"><b>响应</b></p>
<div class="code-box"><pre><code>{
"code": 200,
"data": {
"token": "eyJhbGciOiJIUzM4NCJ9...",
"expiresIn": 43200,
"systemName": "系统名称"
}
}</code></pre></div>
</div>
<h3 class="section-h3">9.2 上传文件</h3>
<div class="api-card">
<div class="api-method post">POST</div>
<div class="api-path">/api/upload</div>
<div class="api-headers"><b>Authorization:</b> Bearer {token} &nbsp;|&nbsp; <b>Content-Type:</b> multipart/form-data</div>
<p style="margin:8px 0"><b>curl 示例</b></p>
<div class="code-box"><pre><code># 获取 Token
TOKEN=$(curl -s -X POST https://file.wg.zgitm.com/api/token \
-H "Content-Type: application/json" \
-d '{"systemCode":"{{ sys?.systemCode || 'YOUR_CODE' }}"}' | jq -r '.data.token')
# 上传
curl -X POST https://file.wg.zgitm.com/api/upload \
-H "Authorization: Bearer $TOKEN" \
-F "file=@/path/to/file.pdf"</code></pre></div>
<p style="margin:8px 0"><b>成功响应</b></p>
<div class="code-box"><pre><code>{
"code": 200,
"data": {
"fileId": "a1b2c3d4e5f67890...",
"fileName": "报告.pdf",
"fileSize": 1048576,
"uploadTime": "2026-06-13T10:30:00"
}
}</code></pre></div>
</div>
<h3 class="section-h3">9.3 下载文件</h3>
<div class="api-card">
<div class="api-method get">GET</div>
<div class="api-path">/api/download/{fileId}</div>
<div class="api-headers"><b>Authorization:</b> Bearer {token}</div>
<p style="margin:8px 0">
成功返回文件二进制流<br>
<b>Token 两种传递方式</b><br>
Header<code>Authorization: Bearer &lt;token&gt;</code><br>
Query 参数<code>/api/download/{fileId}?token=&lt;token&gt;</code>浏览器直链下载时使用
</p>
</div>
<h3 class="section-h3">9.4 删除文件</h3>
<div class="api-card">
<div class="api-method get">GET</div>
<div class="api-path">/api/delete/{fileId}</div>
<div class="api-headers"><b>Authorization:</b> Bearer {token}</div>
<p style="margin:8px 0">删除会同时清理磁盘文件与数据库记录不可恢复</p>
</div>
<div class="warn-item" style="margin-top:16px">
<b> 注意事项</b>
<p>1. 每次操作前需重新获取 Token一次一密<br>
2. 文件最大 100MB超时 12 小时<br>
3. 上传成功后文件信息自动写入网关数据库 sys_file <br>
4. 删除不可恢复请谨慎操作</p>
</div>
</section>
<!-- ====== 对外开放 API邮件发送 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">10</span>对外开放 API</h2>
<p class="text-p">
以下接口通过<b>一次性 Token</b> 认证Token 获取后 12 小时内有效<b>每次调用业务接口即消费</b>
</p>
<h3 class="section-h3">10.1 获取 Token</h3>
<div class="api-card">
<div class="api-method post">POST</div>
<div class="api-path">/zgapi/v1/open/token</div>
<div class="api-headers"><b>Content-Type:</b> application/json;charset=UTF-8</div>
<p style="margin:8px 0"><b>请求体</b></p>
<div class="code-box"><pre><code>{
"systemCode": "{{ sys?.systemCode || 'YOUR_CODE' }}"
}</code></pre></div>
<p style="margin:8px 0"><b>响应</b></p>
<div class="code-box"><pre><code>{
"code": 200,
"data": {
"token": "eyJhbGciOiJIUzM4NCJ9...",
"expiresIn": 43200,
"systemName": "系统名称",
"usageNote": "此 Token 只能消费一次,消费后立即失效"
}
}</code></pre></div>
</div>
<h3 class="section-h3">10.2 发送邮件消费 Token</h3>
<div class="api-card">
<div class="api-method post">POST</div>
<div class="api-path">/zgapi/v1/open/email/send</div>
<div class="api-headers"><b>Authorization:</b> Bearer {token} &nbsp;|&nbsp; <b>Content-Type:</b> application/json;charset=UTF-8</div>
<p style="margin:8px 0"><b>请求体</b></p>
<div class="code-box"><pre><code>{
"to": "user@example.com",
"subject": "邮件主题",
"content": "&lt;h1&gt;邮件内容&lt;/h1&gt;",
"contentType": "html"
}</code></pre></div>
<p style="margin:8px 0">
<b>参数说明</b><br>
<code>to</code> 收件人邮箱必填<br>
<code>subject</code> 邮件主题必填<br>
<code>content</code> 邮件内容必填支持 HTML<br>
<code>contentType</code> <code>"html"</code>默认 <code>"text"</code>
</p>
<p style="margin:8px 0"><b>curl 示例</b></p>
<div class="code-box"><pre><code># 1. 获取 Token
TOKEN=$(curl -s -X POST https://gw.server.zgitm.com/zgapi/v1/open/token \
-H "Content-Type: application/json" \
-d '{"systemCode":"{{ sys?.systemCode || 'YOUR_CODE' }}"}' | jq -r '.data.token')
# 2. 发送邮件
curl -X POST https://gw.server.zgitm.com/zgapi/v1/open/email/send \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"to": "user@example.com",
"subject": "测试邮件",
"content": "&lt;h1&gt;你好&lt;/h1&gt;&lt;p&gt;这是一封测试邮件&lt;/p&gt;",
"contentType": "html"
}'</code></pre></div>
<p style="margin:8px 0"><b>成功响应</b></p>
<div class="code-box"><pre><code>{
"code": 200,
"data": {
"msg": "发送成功",
"tokenConsumed": true
}
}</code></pre></div>
<p style="margin:8px 0">
<b>注意</b>邮件发送成功后才消费 Token发送失败不消费 Token可重试
</p>
</div>
<div class="warn-item" style="margin-top:16px">
<b> 注意事项</b>
<p>1. 每个 Token 只能使用一次调用任意业务接口即消费<br>
2. 发送邮件前请先获取新 Token<br>
3. 发件人为 <code>admin@zgitm.com</code>阿里企业邮箱<br>
4. 编码统一使用 UTF-8</p>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { CircleCheckFilled, CircleClose } from '@element-plus/icons-vue'
import { getIconComponent } from '@/utils/icons'
import request from '@/utils/request'
const route = useRoute()
const sys = ref<any>(null) // 系统信息对象systemCode, systemName, frontUrl, backendUrl, description
// docToken 从 URL params 读取,用于请求系统文档信息
const token = route.params.token as string
// 网关地址和登录地址从环境变量读取,未配置时使用默认值
const gatewayUrl = computed(() => import.meta.env.VITE_GATEWAY_URL || 'https://gw.server.zgitm.com')
const loginUrl = computed(() => import.meta.env.VITE_LOGIN_URL || 'https://login.user.zgitm.com')
// 网关转发时注入的 HTTP 请求头列表
// 业务后端从这些请求头中读取用户信息,无需自行验证 Token
const headerTable = [
{ name: 'X-User-Id', type: 'String', desc: '当前登录用户的唯一ID' },
{ name: 'X-User-Name', type: 'String', desc: '用户真实姓名' },
{ name: 'X-User-Account', type: 'String', desc: '用户登录账号' },
{ name: 'X-User-Email', type: 'String', desc: '用户邮箱' },
{ name: 'X-User-Post', type: 'String', desc: '用户岗位' },
{ name: 'X-User-Roles', type: 'JSON', desc: '用户角色列表 [{roleId, roleName, roleCode}]' },
{ name: 'X-System-Id', type: 'String', desc: '当前业务系统的数据库ID' },
{ name: 'X-System-Code', type: 'String', desc: '当前业务系统的唯一编码' },
{ name: 'X-System-Name', type: 'String', desc: '当前业务系统的显示名称' },
]
/**
* 生成菜单和角色初始化 SQL
*
* 内容:
* 1. 创建"系统管理"和"业务管理"两个顶级目录
* 2. 创建"用户管理"、"角色管理"、"订单管理"三个子菜单
* 3. 创建"管理员"和"普通用户"两个角色
* 4. 将所有菜单分配给管理员角色
*
* SQL 特点:
* - 使用 SELECT ... FROM sys_system WHERE system_code = 'xxx' 子查询获取当前系统的 system_id
* - 子菜单的 parent_id 通过自连接获取对应父菜单 ID
*/
const menuSql = computed(() => {
const sc = sys.value?.systemCode || 'YOUR_SYSTEM_CODE'
const sn = sys.value?.systemName || '系统'
return `-- =============================================
-- ${sn} 菜单 & 角色初始化 SQL
-- 系统编码: ${sc}
-- 将 system_code 替换为你的编码后直接执行
-- =============================================
-- 1. 创建顶级菜单目录 (parent_id = 0)
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status)
SELECT s.id, 0, '系统管理', '/system', 'Layout', 'Setting', 1, 1, 1
FROM sys_system s WHERE s.system_code = '${sc}';
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status)
SELECT s.id, 0, '业务管理', '/biz', 'Layout', 'Document', 1, 2, 1
FROM sys_system s WHERE s.system_code = '${sc}';
-- 2. 创建子菜单 (parent_id = 对应父菜单ID)
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status)
SELECT s.id, m.id, '用户管理', '/system/user', 'system/user/index', 'User', 2, 1, 1
FROM sys_system s, sys_menu m
WHERE s.system_code = '${sc}' AND m.menu_name = '系统管理' AND m.system_id = s.id;
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status)
SELECT s.id, m.id, '角色管理', '/system/role', 'system/role/index', 'Avatar', 2, 2, 1
FROM sys_system s, sys_menu m
WHERE s.system_code = '${sc}' AND m.menu_name = '系统管理' AND m.system_id = s.id;
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status)
SELECT s.id, m.id, '订单管理', '/biz/order', 'biz/order/index', 'Tickets', 2, 1, 1
FROM sys_system s, sys_menu m
WHERE s.system_code = '${sc}' AND m.menu_name = '业务管理' AND m.system_id = s.id;
-- 3. 创建角色
INSERT INTO sys_role (system_id, role_name, role_code, description, status)
SELECT s.id, '管理员', 'admin', '拥有全部权限', 1
FROM sys_system s WHERE s.system_code = '${sc}';
INSERT INTO sys_role (system_id, role_name, role_code, description, status)
SELECT s.id, '普通用户', 'user', '拥有基本权限', 1
FROM sys_system s WHERE s.system_code = '${sc}';
-- 4. 将菜单分配给角色
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT r.id, m.id
FROM sys_role r, sys_menu m, sys_system s
WHERE r.role_code = 'admin'
AND r.system_id = s.id
AND m.system_id = s.id
AND s.system_code = '${sc}';`
})
/**
* 生成网关管理员账号创建 SQL
*
* 步骤:
* 1. 创建管理员用户(用户名 admin_{systemCode},密码为 admin123 的 BCrypt 密文)
* 使用 NOT EXISTS 防止重复创建
* 2. 绑定用户到 gateway 系统sys_user_system 表)
* 3. 分配 gateway-app-admin 角色(该角色只能看到自己绑定系统的数据)
*
* 注意:
* - BCrypt 密文需自行生成替换,当前示例密文对应密码 "admin123"
* - 如果用户已存在,步骤 1 跳过,只执行步骤 2、3
* - 所有 INSERT 使用 NOT EXISTS 防止重复执行
*/
const adminSql = computed(() => {
const sc = sys.value?.systemCode || 'YOUR_SYSTEM_CODE'
return `-- =============================================
-- 创建网关管理平台的应用系统管理员账号
-- 角色编码: gateway-app-admin (只能看到自己绑定系统的数据)
-- =============================================
-- 1. 创建管理员用户(密码 'admin123' 的 BCrypt 密文,建议自行生成替换)
INSERT INTO sys_user (id, username, password, real_name, email, phone, status, is_deleted)
SELECT REPLACE(UUID(),'-',''), 'admin_${sc}',
'$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi',
'${sys.value?.systemName || '系统'}管理员', 'admin@${sc}.com', NULL, 1, 0
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM sys_user WHERE username = 'admin_${sc}');
-- 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_${sc}'
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 角色gateway 系统下的应用管理员角色)
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_${sc}'
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
);`
})
// 测试清单(全部默认未完成,由接入方自行勾选确认)
const testChecklist = computed(() => [
{ text: '访问业务前端首页 → 自动跳转到统一登录页', done: false },
{ text: '使用测试账号登录 → 成功进入业务前端', done: false },
{ text: '调用 GET /zgapi/v1/auth/user/info → 返回用户信息', done: false },
{ text: '业务后端 Controller 中 UserContext.getUserId() 能获取到用户ID', done: false },
{ text: '未放行的 API 请求被网关返回 403', done: false },
{ text: '退出登录后访问业务页面 → 重新跳转登录页', done: false },
])
onMounted(async () => {
try {
const res = await request.get('/zgapi/v1/admin/system/doc/' + token)
sys.value = res.data
} catch { /* ignore */ }
})
</script>
<style scoped>
/* ===== 整体 ===== */
.doc-page { min-height:100vh; background:var(--bg-page); padding:20px; }
.doc-container { max-width:960px; margin:0 auto; }
/* ===== 页头 ===== */
.doc-hero {
display:flex; align-items:center; justify-content:space-between;
padding:28px 32px; background:var(--bg-surface);
border-radius:var(--radius-xl); border:1px solid var(--border-color);
margin-bottom:24px; box-shadow:var(--shadow-sm);
}
.doc-hero-left { display:flex; align-items:center; gap:14px; }
.doc-logo { width:36px; height:36px; color:var(--color-primary); flex-shrink:0; }
.doc-hero-left h1 { font-size:22px; font-weight:700; margin:0 0 4px; color:var(--text-primary); }
.doc-hero-left p { font-size:14px; color:var(--text-muted); margin:0; }
/* ===== 章节 ===== */
.doc-section {
background:var(--bg-surface); border-radius:var(--radius-xl);
border:1px solid var(--border-color); padding:28px 32px;
margin-bottom:20px; box-shadow:var(--shadow-sm);
}
.section-h2 {
display:flex; align-items:center; gap:12px;
font-size:20px; font-weight:700; color:var(--text-primary);
margin:0 0 20px; padding-bottom:16px;
border-bottom:1px solid var(--border-light);
}
.s-num {
display:inline-flex; align-items:center; justify-content:center;
width:32px; height:32px; border-radius:var(--radius-md);
background:var(--color-primary); color:#fff;
font-size:15px; font-weight:700; flex-shrink:0;
}
.section-h3 {
font-size:16px; font-weight:600; color:var(--text-primary);
margin:24px 0 10px;
}
.section-h3:first-of-type { margin-top:0; }
/* ===== 文字 ===== */
.text-p { font-size:14px; color:var(--text-secondary); line-height:1.7; margin:0 0 12px; }
.inline-code {
background:var(--color-primary-bg); color:var(--color-primary);
padding:2px 8px; border-radius:4px; font-size:13px;
font-family:'SF Mono','Consolas',monospace;
}
/* ===== 架构图 ===== */
.arch-text {
background:#1e293b; color:#e2e8f0; padding:16px 20px;
border-radius:var(--radius-md); font-size:13px;
line-height:1.7; overflow-x:auto; white-space:pre;
font-family:'SF Mono','Consolas',monospace;
}
.flow-steps { margin-top:16px; display:flex; flex-direction:column; gap:10px; }
.flow-step { font-size:14px; color:var(--text-secondary); display:flex; align-items:flex-start; gap:8px; line-height:1.6; }
.flow-step b { color:var(--color-primary); flex-shrink:0; }
/* ===== 代码块 ===== */
.code-box {
position:relative; background:#1e293b; border-radius:var(--radius-md);
margin-bottom:16px; overflow:hidden;
}
.code-label {
position:absolute; top:8px; right:12px; z-index:1;
font-size:11px; color:rgba(255,255,255,0.45); font-weight:600;
letter-spacing:0.5px; text-transform:uppercase;
}
.code-box pre {
margin:0; padding:20px; overflow-x:auto;
}
.code-box code {
font-family:'SF Mono','Fira Code','Consolas',monospace;
font-size:13px; line-height:1.75; color:#e2e8f0; white-space:pre;
}
/* ===== API 卡片 ===== */
.api-card {
border:1px solid var(--border-color); border-radius:var(--radius-lg);
padding:20px; margin-bottom:16px;
}
.api-method {
display:inline-block; padding:2px 10px; border-radius:4px;
font-size:12px; font-weight:700; color:#fff; margin-bottom:8px;
}
.api-method.get { background:#10b981; }
.api-method.post { background:#6366f1; }
.api-method.put { background:#f59e0b; }
.api-path {
font-size:15px; font-weight:600; color:var(--text-primary);
font-family:'SF Mono','Consolas',monospace; margin-bottom:8px;
}
.api-headers { font-size:14px; color:var(--text-secondary); margin-bottom:8px; }
.api-response { font-size:14px; color:var(--text-secondary); }
/* ===== 测试清单 ===== */
.checklist { display:flex; flex-direction:column; gap:14px; }
.check-item { display:flex; align-items:center; gap:10px; font-size:14px; color:var(--text-secondary); }
/* 架构警告 */
.arch-warnings { margin-top:16px; display:flex; flex-direction:column; gap:12px; }
.warn-item {
background:#fef2f2; border:1px solid #fecaca; border-radius:var(--radius-md);
padding:14px 18px;
}
.warn-item b { color:#dc2626; font-size:14px; display:block; margin-bottom:4px; }
.warn-item p { color:var(--text-secondary); font-size:14px; margin:0 0 8px; line-height:1.7; }
@media (max-width:768px) {
.doc-hero { flex-direction:column; gap:12px; align-items:flex-start; }
.doc-section { padding:20px; }
}
</style>