初始化

This commit is contained in:
zg
2026-07-15 16:13:27 +08:00
parent 6d6f559793
commit c4023c1623
276 changed files with 42622 additions and 0 deletions

View File

@@ -0,0 +1,176 @@
package com.mokee.admin.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.mokee.admin.service.FileService;
import com.mokee.common.Result;
import com.mokee.common.annotation.OperationLog;
import com.mokee.common.entity.SysFile;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
/**
* 文件管理控制器
* <p>
* 文件二进制数据存储于本地磁盘,数据库仅保存元信息。
* 向下兼容存量 BLOB 数据file_data 非空时优先读磁盘,磁盘无文件则回退 DB BLOB
* <p>
* 请求路径前缀:{@code /zgapi/v1/admin/file}
*
* @author mokee
*/
@RestController
@RequestMapping("/zgapi/v1/admin/file")
@RequiredArgsConstructor
public class FileController {
private final FileService fileService;
/**
* 上传文件(管理后台)
* <p>
* 上传者显示名从请求头 X-User-Name 获取(由网关 HeaderInjectFilter 注入,已 URL 解码)。
*/
@OperationLog(module = "文件管理", action = "上传")
@PostMapping("/upload")
public Result<SysFile> upload(@RequestParam("file") MultipartFile file,
@RequestParam(defaultValue = "other") String fileType,
@RequestParam(required = false) String systemId,
HttpServletRequest request) throws IOException {
String userId = request.getHeader("X-User-Id");
// X-User-Name 经网关 URL 编码(防 HTTP 转发中文损坏),需解码
String userName = decodeHeader(request.getHeader("X-User-Name"));
if (userName == null || userName.isEmpty()) {
userName = request.getHeader("X-User-Account");
}
SysFile sf = fileService.upload(file, fileType, systemId, userId, userName);
sf.setFileData(null); // 不返回二进制数据
return Result.ok(sf);
}
/**
* 分页查询文件列表(不含 file_data
*/
@PostMapping("/page")
public Result<IPage<SysFile>> page(@RequestBody java.util.Map<String, Object> params) {
Integer page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
Integer size = params.get("pageSize") != null ? ((Number) params.get("pageSize")).intValue() : 10;
String fileType = (String) params.get("fileType");
String fileName = (String) params.get("fileName");
String uploadUserName = (String) params.get("uploadUserName");
String systemId = params.get("systemId") != null ? params.get("systemId").toString() : null;
return Result.ok(fileService.page(page, size, fileType, fileName, uploadUserName, systemId));
}
/**
* 文件列表查询(不分页,不含 file_data
*/
@GetMapping("/list")
public Result<List<SysFile>> list(@RequestParam(required = false) String fileType,
@RequestParam(required = false) String systemId) {
return Result.ok(fileService.list(fileType, systemId));
}
/**
* 获取文件元信息(不含二进制数据)
*/
@GetMapping("/{id}")
public Result<SysFile> getById(@PathVariable String id) {
return Result.ok(fileService.getMetaById(id));
}
/**
* 预览文件 — 直接输出文件流
* <p>
* 优先读磁盘文件;磁盘文件不存在时回退到 DB BLOB存量兼容
* 设置 CORS 和缓存头,支持被 img 标签跨域引用。
*/
@GetMapping("/{id}/view")
public void view(@PathVariable String id, HttpServletResponse response) throws IOException {
SysFile sf = fileService.getById(id);
if (sf == null) { response.setStatus(404); return; }
Path diskPath = fileService.getDiskPath(sf);
if (!Files.exists(diskPath)) {
response.setStatus(404);
response.getWriter().write("File not found");
return;
}
byte[] data = Files.readAllBytes(diskPath);
response.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "*");
String mimeType = sf.getMimeType() != null ? sf.getMimeType() : "application/octet-stream";
response.setContentType(mimeType);
response.setHeader(HttpHeaders.CACHE_CONTROL, "public, max-age=86400");
response.setContentLengthLong(data.length);
response.getOutputStream().write(data);
response.getOutputStream().flush();
}
/**
* 下载文件 — 触发浏览器下载
* <p>
* 与 view 逻辑相同,额外设置 Content-Disposition: attachment 头。
*/
@GetMapping("/{id}/download")
public void download(@PathVariable String id, HttpServletResponse response) throws IOException {
SysFile sf = fileService.getById(id);
if (sf == null) { response.setStatus(404); return; }
Path diskPath = fileService.getDiskPath(sf);
if (!Files.exists(diskPath)) {
response.setStatus(404);
response.getWriter().write("File not found");
return;
}
byte[] data = Files.readAllBytes(diskPath);
// Content-Disposition: filename=ASCII兜底, filename*=UTF-8编码的原始文件名
String safeName = "download" + ext(sf.getFileName());
String encodedName = URLEncoder.encode(sf.getFileName(), StandardCharsets.UTF_8)
.replace("+", "%20");
response.setContentType("application/octet-stream");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + safeName + "\"; filename*=UTF-8''" + encodedName);
response.setContentLengthLong(data.length);
response.getOutputStream().write(data);
response.getOutputStream().flush();
}
/** 从文件名提取扩展名(含点) */
private static String ext(String name) {
if (name == null) return "";
int i = name.lastIndexOf('.');
return i > 0 ? name.substring(i) : "";
}
/**
* 删除文件 — 删磁盘文件 + DB 记录
*/
@OperationLog(module = "文件管理", action = "删除")
@DeleteMapping("/{id}")
public Result<?> delete(@PathVariable String id) throws IOException {
fileService.delete(id);
return Result.ok();
}
/** URL 解码 HTTP 头值(网关对中文值做了 URL 编码) */
private String decodeHeader(String value) {
if (value == null) return null;
try {
return URLDecoder.decode(value, StandardCharsets.UTF_8);
} catch (Exception e) {
return value;
}
}
}

View File

@@ -0,0 +1,738 @@
<!--
文件对象储存 表格列表页
功能
- 表格展示所有文件缩略图原文件名储存文件名类型大小上传者时间
- 图片缩略图 50x50点击弹窗预览大图
- 操作下载 / 复制下载链接 / 删除
管理后台接口Cookie 登录态
- GET /zgapi/v1/admin/file/{id}/view 预览
- GET /zgapi/v1/admin/file/{id}/download 下载
- DELETE /zgapi/v1/admin/file/{id} 删除
- POST /zgapi/v1/admin/file/page 分页
开放 API需外部系统自行获取 token
{gatewayUrl}/zgapi/v1/open/file/{id}/download?token=YOUR_TOKEN
-->
<template>
<div class="page-wrapper">
<!-- 搜索+操作栏 -->
<el-card shadow="never">
<template #header>
<div class="card-header-title">
<el-icon><Search /></el-icon>
<span>搜索条件</span>
</div>
</template>
<el-form :model="query" inline>
<el-form-item label="原文件名">
<el-input v-model="query.fileName" placeholder="请输入文件名" clearable @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="上传者">
<el-input v-model="query.uploadUserName" placeholder="请输入上传者" clearable />
</el-form-item>
<el-form-item label="文件类型">
<el-select v-model="query.fileType" placeholder="全部" clearable style="width:120px">
<el-option label="头像" value="avatar" />
<el-option label="图标" value="icon" />
<el-option label="文档" value="doc" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="uploadVisible = true" v-permission="'system:file:upload'">
<el-icon><Upload /></el-icon>上传文件
</el-button>
</div>
</el-card>
<!-- 上传弹窗 -->
<el-dialog v-model="uploadVisible" width="640px" destroy-on-close @closed="resetUpload" class="upload-dialog">
<template #header>
<div class="dialog-header-custom">
<div class="header-icon-box">
<el-icon :size="22"><UploadFilled /></el-icon>
</div>
<div>
<div class="header-title">上传文件</div>
<div class="header-sub">{{ uploadQueue.length ? uploadQueue.length + ' 个文件待上传' : '支持批量选择,逐个上传' }}</div>
</div>
</div>
</template>
<!-- 拖拽区域 -->
<div
class="drop-zone"
:class="{ 'drag-over': dragOver, 'has-files': uploadQueue.length > 0 }"
@click="triggerUpload"
@dragover.prevent="dragOver = true"
@dragleave="dragOver = false"
@drop.prevent="onDrop"
>
<!-- 空状态 -->
<div v-if="uploadQueue.length === 0" class="drop-empty">
<div class="drop-icon-ring">
<el-icon :size="36"><Cloudy /></el-icon>
</div>
<p class="drop-title">将文件拖拽到此处</p>
<p class="drop-hint">或点击此区域选择文件支持多选</p>
</div>
<!-- 文件队列 -->
<div v-else class="queue-list">
<div
v-for="(f, i) in uploadQueue" :key="i"
class="queue-card"
:class="f._status"
>
<!-- 状态图标 -->
<div class="q-icon" :class="f._status">
<el-icon v-if="f._status === 'done'" :size="22"><Check /></el-icon>
<el-icon v-else-if="f._status === 'fail'" :size="22"><Close /></el-icon>
<div v-else-if="f._status === 'uploading'" class="spinner"></div>
<el-icon v-else :size="22"><Document /></el-icon>
</div>
<!-- 文件信息 -->
<div class="q-body">
<div class="q-name">{{ f.file.name }}</div>
<div class="q-meta">
<span>{{ formatSize(f.file.size) }}</span>
</div>
<!-- 进度条 -->
<div v-if="f._status === 'uploading'" class="q-progress">
<el-progress :percentage="uploadPercent" :stroke-width="8" :show-text="true" :color="'#6366f1'" />
<div class="q-speed">{{ uploadSpeed }}</div>
</div>
</div>
<!-- 操作 -->
<el-button
v-if="f._status === 'wait'"
class="q-remove" circle size="small" text
@click.stop="removeQueueItem(i)"
>
<el-icon><Close /></el-icon>
</el-button>
<el-tag v-else-if="f._status === 'done'" type="success" size="small" effect="light" round>完成</el-tag>
<el-tag v-else-if="f._status === 'fail'" type="danger" size="small" effect="light" round>失败</el-tag>
</div>
</div>
<input ref="fileInputRef" type="file" multiple style="display:none" @change="onFileSelected" />
</div>
<!-- 底部操作栏 -->
<div class="upload-footer">
<div class="footer-left">
<el-select v-model="uploadForm.fileType" style="width:110px" :disabled="uploading" size="default">
<el-option label="🖼 头像" value="avatar" />
<el-option label="⭐ 图标" value="icon" />
<el-option label="📄 文档" value="doc" />
<el-option label="📦 其他" value="other" />
</el-select>
<el-button @click="triggerUpload" :disabled="uploading" size="default">
<el-icon><FolderAdd /></el-icon>添加文件
</el-button>
<el-button v-if="uploadQueue.length > 0" @click="uploadQueue = []" :disabled="uploading" size="default" text>
清空列表
</el-button>
</div>
<el-button
type="primary" :loading="uploading" size="large"
:disabled="uploadQueue.length === 0 || allDone"
@click="startUpload"
class="btn-start"
>
<template v-if="!uploading">
<el-icon><Upload /></el-icon>
开始上传{{ uploadQueue.filter(f => f._status !== 'done').length }} 个文件
</template>
<template v-else>
{{ doneCount }}/{{ uploadQueue.length }}
</template>
</el-button>
</div>
</el-dialog>
<!-- 表格 -->
<el-card shadow="never">
<el-table :data="fileList" border stripe v-loading="loading" style="width:100%">
<!-- 压缩图 -->
<el-table-column label="压缩图" width="80" align="center">
<template #default="{ row }">
<div class="thumb-box" @click="preview(row)" v-if="isImage(row.mimeType)">
<img :src="getViewUrl(row.id)" class="thumb-img" loading="lazy" />
</div>
<el-icon v-else :size="28" color="#94a3b8"><Document /></el-icon>
</template>
</el-table-column>
<!-- 原文件名 -->
<el-table-column prop="fileName" label="原文件名" min-width="180" show-overflow-tooltip />
<!-- 储存文件名 -->
<el-table-column prop="storageFileName" label="储存文件名" width="200" show-overflow-tooltip>
<template #default="{ row }">
<code class="storage-name-cell">{{ row.storageFileName || '-' }}</code>
</template>
</el-table-column>
<!-- 文件类型 -->
<el-table-column prop="fileType" label="文件类型" width="90" align="center">
<template #default="{ row }">
<el-tag :type="fileTypeTag(row.fileType)" size="small" effect="dark">
{{ fileTypeLabel(row.fileType) }}
</el-tag>
</template>
</el-table-column>
<!-- 文件大小 -->
<el-table-column label="文件大小" width="100" align="center">
<template #default="{ row }">
{{ formatSize(row.fileSize) }}
</template>
</el-table-column>
<!-- 上传者 -->
<el-table-column prop="uploadUserName" label="上传者" width="120" show-overflow-tooltip>
<template #default="{ row }">
{{ row.uploadUserName || '-' }}
</template>
</el-table-column>
<!-- 上传时间 -->
<el-table-column label="上传时间" width="170">
<template #default="{ row }">
{{ row.createdAt ? new Date(row.createdAt).toLocaleString('zh-CN') : '-' }}
</template>
</el-table-column>
<!-- 操作 -->
<el-table-column label="操作" width="220" align="center" class-name="ops-col" fixed="right">
<template #default="{ row }">
<div class="actions">
<el-button size="small" @click="downloadFile(row)">
<el-icon><Download /></el-icon>下载
</el-button>
<el-button size="small" @click="copyDownloadUrl(row)">
<el-icon><Link /></el-icon>复制链接
</el-button>
<el-popconfirm title="确定删除此文件?" @confirm="handleDelete(row.id)">
<template #reference>
<el-button size="small" type="danger">
<el-icon><Delete /></el-icon>删除
</el-button>
</template>
</el-popconfirm>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无文件" :image-size="100" />
</template>
</el-table>
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="fetchData"
@size-change="fetchData"
/>
</div>
</el-card>
<!-- 预览弹窗 -->
<el-dialog v-model="previewVisible" title="文件预览" width="720px" destroy-on-close>
<div class="preview-body">
<template v-if="previewItem && isImage(previewItem.mimeType)">
<img :src="getViewUrl(previewItem.id)" style="max-width:100%;max-height:65vh;display:block;margin:0 auto" />
</template>
<template v-else>
<el-empty description="非图片文件,无法预览" />
</template>
<div class="preview-meta" v-if="previewItem">
<div class="meta-line"><span class="meta-label">原文件名</span>{{ previewItem.fileName }}</div>
<div class="meta-line"><span class="meta-label">储存文件名</span>{{ previewItem.storageFileName || '-' }}</div>
<div class="meta-line"><span class="meta-label">类型</span>{{ previewItem.mimeType || '-' }}</div>
<div class="meta-line"><span class="meta-label">大小</span>{{ formatSize(previewItem.fileSize) }}</div>
<div class="meta-line"><span class="meta-label">上传者</span>{{ previewItem.uploadUserName || '-' }}</div>
<div class="meta-line"><span class="meta-label">上传时间</span>{{ previewItem.createdAt ? new Date(previewItem.createdAt).toLocaleString('zh-CN') : '-' }}</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import {
Search, Document, Delete, Upload, UploadFilled, Close, Download, Link,
Cloudy, FolderAdd, Check,
} from '@element-plus/icons-vue'
import request from '@/utils/request'
import { useUploadStore } from '@/stores/upload'
interface FileItem {
id: string
fileName: string
storageFileName: string
fileType: string
fileSize: number
mimeType: string
md5: string
uploadUserName: string
createdAt: string
}
const loading = ref(false)
const fileList = ref<FileItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || ''
// 搜索条件
const query = reactive({
fileName: '',
uploadUserName: '',
fileType: '' as string,
})
const previewVisible = ref(false)
const previewItem = ref<FileItem | null>(null)
// ===== 上传队列 =====
interface QueueItem { file: File; _status: 'wait' | 'uploading' | 'done' | 'fail' }
const uploadStore = useUploadStore()
const uploadVisible = ref(false)
const uploading = ref(false)
const uploadPercent = ref(0)
const uploadSpeed = ref('')
const dragOver = ref(false)
const fileInputRef = ref<HTMLInputElement>()
const uploadQueue = ref<QueueItem[]>([])
const uploadForm = reactive({ fileType: 'other' })
let lastLoaded = 0
let lastTime = 0
const doneCount = computed(() => uploadQueue.value.filter(f => f._status === 'done').length)
const allDone = computed(() => uploadQueue.value.length > 0 && uploadQueue.value.every(f => f._status === 'done' || f._status === 'fail'))
function resetUpload() {
if (uploading.value) return // 上传中不重置
uploading.value = false
uploadPercent.value = 0
uploadSpeed.value = ''
uploadQueue.value = []
dragOver.value = false
}
function triggerUpload() { fileInputRef.value?.click() }
function addFiles(files: FileList | File[]) {
for (let i = 0; i < files.length; i++) {
const file = files[i]
uploadQueue.value.push({ file, _status: 'wait' })
// 根据第一个文件自动判断类型
if (uploadQueue.value.length === 1) {
const ext = file.name.split('.').pop()?.toLowerCase()
if (ext && ['png','jpg','jpeg','gif','webp','svg','ico'].includes(ext)) uploadForm.fileType = 'icon'
else if (ext && ['pdf','doc','docx','xls','xlsx','txt','md'].includes(ext)) uploadForm.fileType = 'doc'
}
}
}
function removeQueueItem(i: number) {
uploadQueue.value.splice(i, 1)
}
function onFileSelected(e: Event) {
const files = (e.target as HTMLInputElement).files
if (files && files.length > 0) addFiles(files)
// 重置 input 以允许重复选同一个文件
if (fileInputRef.value) fileInputRef.value.value = ''
}
function onDrop(e: DragEvent) {
dragOver.value = false
const files = e.dataTransfer?.files
if (files && files.length > 0) addFiles(files)
}
/** 逐个上传队列中的文件(串行) */
async function startUpload() {
if (uploadQueue.value.length === 0) return
uploading.value = true
for (let i = 0; i < uploadQueue.value.length; i++) {
const item = uploadQueue.value[i]
if (item._status === 'done') continue
item._status = 'uploading'
uploadPercent.value = 0
uploadSpeed.value = '准备上传...'
lastLoaded = 0; lastTime = Date.now()
// 注册到全局上传面板
const taskId = Date.now().toString(36) + i
uploadStore.addTask({
id: taskId, fileName: item.file.name, fileSize: item.file.size,
fileType: uploadForm.fileType, file: item.file,
status: 'uploading', progress: 0, speed: '', startedAt: Date.now(),
})
try {
const fd = new FormData()
fd.append('file', item.file)
fd.append('fileType', uploadForm.fileType)
const axios = (await import('axios')).default
const baseURL = import.meta.env.VITE_GATEWAY_URL || ''
const token = document.cookie.match(/(?:^|;\s*)token=([^;]*)/)?.[1] || ''
const res = await axios.post(baseURL + '/zgapi/v1/admin/file/upload', fd, {
timeout: 43200000, // 12小时
headers: { 'Content-Type': 'multipart/form-data', ...(token ? { Authorization: 'Bearer ' + token } : {}) },
onUploadProgress: (e: any) => {
if (e.total > 0) {
uploadPercent.value = Math.round((e.loaded / e.total) * 100)
const now = Date.now()
const elapsed = (now - lastTime) / 1000
if (elapsed >= 1) {
const speed = (e.loaded - lastLoaded) / elapsed
uploadSpeed.value = formatSpeed(speed)
lastLoaded = e.loaded; lastTime = now
}
uploadStore.updateTask(taskId, { progress: uploadPercent.value, speed: uploadSpeed.value })
}
},
})
// 检查业务状态码
if (res.data?.code && res.data.code !== 200) {
throw new Error(res.data.msg || '服务器返回错误')
}
item._status = 'done'
uploadStore.updateTask(taskId, { status: 'completed', progress: 100 })
} catch (e: any) {
item._status = 'fail'
// 提取错误信息
let errMsg = '上传失败'
if (e.response?.data?.msg) {
errMsg = e.response.data.msg
} else if (e.response?.status) {
errMsg = `服务器错误(${e.response.status})`
} else if (e.message && e.message !== '上传失败') {
errMsg = e.message
}
uploadStore.updateTask(taskId, { status: 'failed', error: errMsg })
console.error(`[上传失败] ${item.file.name}:`, errMsg, e)
}
}
uploading.value = false
ElMessage.success(`上传完成: ${doneCount.value}/${uploadQueue.value.length} 个文件成功`)
page.value = 1
fetchData()
}
function formatSpeed(bytesPerSec: number) {
if (bytesPerSec < 1024) return bytesPerSec.toFixed(0) + ' B/s'
if (bytesPerSec < 1048576) return (bytesPerSec / 1024).toFixed(1) + ' KB/s'
return (bytesPerSec / 1048576).toFixed(1) + ' MB/s'
}
// ===== 数据 =====
function getViewUrl(id: string) { return `${gatewayUrl}/zgapi/v1/admin/file/${id}/view` }
function getDownloadUrl(id: string) { return `${gatewayUrl}/zgapi/v1/admin/file/${id}/download` }
function isImage(mime: string | null | undefined) { return mime ? mime.startsWith('image/') : false }
function formatSize(bytes: number | null) {
if (!bytes) return '0 B'
if (bytes < 1024) return bytes + ' B'
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1048576).toFixed(1) + ' MB'
}
function fileTypeTag(type: string) {
const map: Record<string, string> = { avatar: 'success', icon: 'warning', doc: '', other: 'info' }
return map[type] || 'info'
}
function fileTypeLabel(type: string) {
const map: Record<string, string> = { avatar: '头像', icon: '图标', doc: '文档', other: '其他' }
return map[type] || type || '其他'
}
async function fetchData() {
loading.value = true
try {
const params: Record<string, unknown> = {
page: page.value,
pageSize: pageSize.value,
}
if (query.fileName) params.fileName = query.fileName
if (query.uploadUserName) params.uploadUserName = query.uploadUserName
if (query.fileType) params.fileType = query.fileType
const res = await request.post('/zgapi/v1/admin/file/page', params)
fileList.value = res.data?.records || []
total.value = res.data?.total || 0
} catch { /* ignore */ } finally {
loading.value = false
}
}
function handleSearch() {
page.value = 1
fetchData()
}
function handleReset() {
query.fileName = ''
query.uploadUserName = ''
query.fileType = ''
page.value = 1
fetchData()
}
// ===== 预览 =====
function preview(item: FileItem) {
previewItem.value = item
previewVisible.value = true
}
// ===== 下载fetch + blob支持跨域 =====
async function downloadFile(item: FileItem) {
try {
const res = await fetch(getDownloadUrl(item.id), { credentials: 'include' })
if (!res.ok) { ElMessage.error('下载失败'); return }
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = item.fileName
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch {
ElMessage.error('下载失败')
}
}
// ===== 复制下载链接(自动获取一次性 token =====
async function copyDownloadUrl(item: FileItem) {
try {
// 自动获取开放 API Token
const tokenRes = await request.post('/zgapi/v1/open/token', { systemCode: 'gateway' })
const token = tokenRes.data?.token
if (!token) { ElMessage.error('获取下载Token失败'); return }
const url = `${gatewayUrl}/zgapi/v1/open/file/${item.id}/download?token=${token}`
await navigator.clipboard.writeText(url)
ElMessage.success('已复制下载链接Token 12小时有效仅可使用一次')
} catch {
ElMessage.error('复制失败')
}
}
// ===== 删除 =====
async function handleDelete(id: string) {
try {
await request.delete(`/zgapi/v1/admin/file/${id}`)
ElMessage.success('已删除')
if (fileList.value.length === 1 && page.value > 1) page.value--
fetchData()
} catch { /* ignore */ }
}
// 防误刷:上传中刷新/关闭页面时弹提示
function beforeUnload(e: BeforeUnloadEvent) {
if (uploading.value) {
e.preventDefault()
e.returnValue = ''
return ''
}
}
onMounted(() => {
fetchData()
window.addEventListener('beforeunload', beforeUnload)
})
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', beforeUnload)
})
</script>
<style scoped>
.page-wrapper { display:flex; flex-direction:column; gap:16px; }
/* 搜索卡片 */
.card-header-title {
display:flex; align-items:center; gap:6px; font-weight:600; color:#303133;
}
/* 工具栏 */
.toolbar { display:flex; justify-content:flex-start; margin-bottom:0; }
.toolbar-left { display:flex; gap:12px; }
.toolbar-right { display:flex; gap:8px; }
/* 缩略图 */
.thumb-box {
width:50px; height:50px; border-radius:6px; overflow:hidden;
cursor:pointer; border:1px solid var(--border-color);
}
.thumb-box:hover { border-color:var(--color-primary); }
.thumb-img {
width:100%; height:100%; object-fit:cover; display:block;
}
/* 储存文件名 */
.storage-name-cell {
font-family:'SF Mono','Fira Code',monospace;
font-size:12px; color:var(--text-muted);
background:var(--bg-page); padding:2px 6px; border-radius:4px;
}
/* 分页 */
.pagination-wrapper { display:flex; justify-content:flex-end; margin-top:20px; }
/* 操作 */
.actions { display:flex; gap:4px; flex-wrap:wrap; justify-content:center; }
/* ===== 上传弹窗 ===== */
.upload-dialog :deep(.el-dialog__header) { display:none; }
.upload-dialog :deep(.el-dialog__body) { padding:0; }
/* 自定义标题 */
.dialog-header-custom {
display:flex; align-items:center; gap:14px;
padding:20px 28px 0;
}
.header-icon-box {
width:44px; height:44px; border-radius:var(--radius-lg);
background:linear-gradient(135deg, #6366f1, #818cf8);
display:flex; align-items:center; justify-content:center; color:#fff; flex-shrink:0;
}
.header-title { font-size:17px; font-weight:700; color:var(--text-primary); }
.header-sub { font-size:12px; color:var(--text-muted); margin-top:2px; }
/* 拖拽区域 */
.drop-zone {
margin:20px 28px; padding:24px;
border:2px dashed var(--border-color); border-radius:var(--radius-xl);
cursor:pointer; transition:all 0.25s; position:relative;
min-height:160px; max-height:360px; overflow-y:auto;
}
.drop-zone:hover, .drop-zone.drag-over {
border-color:var(--color-primary); background:linear-gradient(135deg, rgba(99,102,241,0.03), rgba(129,140,248,0.05));
}
.drop-zone.drag-over { border-style:solid; box-shadow:0 0 0 4px rgba(99,102,241,0.1); }
.drop-zone.has-files { border-style:solid; border-color:var(--border-color); }
/* 空状态 */
.drop-empty { text-align:center; padding:20px 0; }
.drop-icon-ring {
width:72px; height:72px; border-radius:50%;
background:linear-gradient(135deg, #eef2ff, #e0e7ff);
display:inline-flex; align-items:center; justify-content:center;
margin-bottom:16px; color:var(--color-primary);
}
.drop-title { font-size:16px; font-weight:600; color:var(--text-primary); margin-bottom:4px; }
.drop-hint { font-size:13px; color:var(--text-muted); }
/* 文件卡片 */
.queue-list { display:flex; flex-direction:column; gap:10px; }
.queue-card {
display:flex; align-items:center; gap:14px;
padding:14px 16px; border-radius:var(--radius-lg);
background:#fff; border:1px solid var(--border-color);
transition:all 0.2s;
}
.queue-card:hover { box-shadow:var(--shadow-sm); }
.queue-card.done { border-color:#bbf7d0; background:#f0fdf4; }
.queue-card.fail { border-color:#fecaca; background:#fef2f2; }
.queue-card.uploading { border-color:#c7d2fe; background:#eef2ff; }
/* 状态图标 */
.q-icon {
width:44px; height:44px; border-radius:var(--radius-md);
display:flex; align-items:center; justify-content:center; flex-shrink:0;
background:var(--bg-page); color:var(--text-muted);
}
.q-icon.done { background:#dcfce7; color:#16a34a; }
.q-icon.fail { background:#fecaca; color:#dc2626; }
.q-icon.uploading { background:#e0e7ff; }
/* 旋转动画 */
.spinner {
width:20px; height:20px; border:2.5px solid #c7d2fe;
border-top-color:var(--color-primary); border-radius:50%;
animation:spin 0.8s linear infinite;
}
@keyframes spin { to { transform:rotate(360deg); } }
/* 文件信息 */
.q-body { flex:1; min-width:0; }
.q-name { font-size:14px; font-weight:500; color:var(--text-primary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.q-meta { font-size:12px; color:var(--text-muted); margin-top:2px; }
.q-progress { margin-top:8px; }
.q-speed { font-size:11px; color:var(--text-muted); text-align:right; margin-top:2px; }
.q-remove { flex-shrink:0; color:var(--text-muted); }
.q-remove:hover { color:var(--color-danger); background:#fef2f2; }
/* 底部栏 */
.upload-footer {
display:flex; align-items:center; justify-content:space-between;
padding:16px 28px 24px; border-top:1px solid var(--border-light);
gap:12px;
}
.footer-left { display:flex; align-items:center; gap:8px; }
.btn-start { min-width:180px; }
/* 移动端 */
@media (max-width:768px) {
.upload-dialog :deep(.el-dialog) { width:100vw !important; max-width:100vw !important; margin:0 !important; border-radius:0; }
.drop-zone { margin:12px 16px; padding:16px; min-height:120px; max-height:240px; }
.dialog-header-custom { padding:16px 16px 0; }
.upload-footer { flex-direction:column; padding:12px 16px 20px; }
.footer-left { width:100%; flex-wrap:wrap; }
.btn-start { width:100%; }
}
/* 预览弹窗 */
.preview-meta { margin-top:20px; padding:16px; background:var(--bg-page); border-radius:var(--radius-md); }
.meta-line { font-size:13px; color:var(--text-secondary); line-height:2; }
.meta-label { color:var(--text-muted); }
/* 移动端 */
@media (max-width:768px) {
.toolbar { flex-direction:column; }
.toolbar .el-button { width:100%; }
.search-card :deep(.el-form-item) { width:100%; margin-right:0; }
.search-card :deep(.el-input),
.search-card :deep(.el-select) { width:100% !important; }
.storage-name-cell { font-size:10px; }
}
</style>

View File

@@ -0,0 +1,49 @@
package com.mokee.admin.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.mokee.common.entity.SysFile;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
/**
* 文件服务接口
* <p>
* 文件二进制数据存储于本地磁盘,数据库仅保存元信息。
*
* @author mokee
*/
public interface FileService {
/**
* 上传文件 — 写入磁盘 + 存元信息到 DB
*
* @param file 上传的文件
* @param fileType 文件类型
* @param systemId 所属系统ID可选
* @param userId 上传用户ID可选管理后台上传时传入
* @param uploadUserName 上传者显示名(用户姓名或系统名称)
*/
SysFile upload(MultipartFile file, String fileType, String systemId,
String userId, String uploadUserName) throws IOException;
/** 分页查询(不含 file_data支持 fileName/uploadUserName 模糊搜索 */
IPage<SysFile> page(Integer page, Integer size, String fileType, String fileName, String uploadUserName, String systemId);
/** 列表查询(不含 file_data */
List<SysFile> list(String fileType, String systemId);
/** 查询完整记录(含 file_data 兜底,用于下载/预览) */
SysFile getById(String id);
/** 查询元信息(不含 file_data */
SysFile getMetaById(String id);
/** 获取文件在磁盘上的存储路径 */
Path getDiskPath(SysFile sf);
/** 删除文件 — 删磁盘文件 + DB 记录 */
void delete(String id) throws IOException;
}

View File

@@ -0,0 +1,178 @@
package com.mokee.admin.service.impl;
import cn.hutool.crypto.digest.MD5;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.mokee.admin.mapper.SysFileMapper;
import com.mokee.admin.service.ConfigService;
import com.mokee.admin.service.FileService;
import com.mokee.common.entity.SysFile;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
/**
* 文件服务实现 — 文件二进制数据存储于本地磁盘
* <p>
* 磁盘路径结构:{basePath}/{yyyy-MM}/{storageFileName}
* 例:/data/mokee/gw/file/2026-06/a1b2c3d4e5f6.png
* <p>
* 向下兼容:存量 BLOB 数据file_data 不为空)仍可正常读取。
*
* @author mokee
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FileServiceImpl implements FileService {
private final SysFileMapper sysFileMapper;
private final ConfigService configService;
private static final DateTimeFormatter YYYY_MM = DateTimeFormatter.ofPattern("yyyy-MM");
private static final String DEFAULT_BASE_PATH = "/data/mokee/gw/file";
/** 获取配置的存储根目录 */
private String getBasePath() {
return configService.getValue("file.storage.disk.path", DEFAULT_BASE_PATH);
}
/**
* 从原始文件名提取扩展名
*/
private String ext(String fileName) {
if (fileName == null) return "";
int i = fileName.lastIndexOf('.');
return i > 0 ? fileName.substring(i) : "";
}
@Override
public SysFile upload(MultipartFile file, String fileType, String systemId,
String userId, String uploadUserName) throws IOException {
byte[] data = file.getBytes();
// 计算 MD5
String md5 = MD5.create().digestHex(data);
// 构建实体(先生成 ID 用于存储文件名)
String fileId = cn.hutool.core.lang.UUID.randomUUID().toString().replace("-", "");
String storageFileName = fileId + ext(file.getOriginalFilename());
LocalDateTime now = LocalDateTime.now();
SysFile sf = SysFile.builder()
.id(fileId)
.systemId(systemId)
.fileName(file.getOriginalFilename())
.storageFileName(storageFileName)
.fileType(fileType != null ? fileType : "other")
.fileSize(file.getSize())
.mimeType(file.getContentType())
.md5(md5)
.uploadUserId(userId)
.uploadUserName(uploadUserName)
.fileData(null) // 新文件不写入 BLOB
.createdAt(now)
.build();
// 写入磁盘
String basePath = getBasePath();
Path dir = Paths.get(basePath, now.format(YYYY_MM));
try {
Files.createDirectories(dir);
} catch (IOException e) {
log.error("创建存储目录失败: {} — 请检查磁盘权限和路径配置", dir);
throw new IOException("存储目录创建失败: " + dir + " (请检查磁盘权限和路径配置)", e);
}
Path filePath = dir.resolve(storageFileName);
try {
Files.write(filePath, data);
} catch (IOException e) {
log.error("写入文件失败: {} — 可能磁盘空间不足或权限不足", filePath);
throw new IOException("文件写入失败: " + filePath + " (可能磁盘空间不足或权限不足)", e);
}
log.info("文件已写入磁盘: {} ({} bytes, md5={})", filePath, file.getSize(), md5);
// 存元信息到 DB
sysFileMapper.insert(sf);
return sf;
}
@Override
public IPage<SysFile> page(Integer page, Integer size, String fileType,
String fileName, String uploadUserName, String systemId) {
LambdaQueryWrapper<SysFile> w = new LambdaQueryWrapper<>();
if (fileType != null && !fileType.isEmpty()) w.eq(SysFile::getFileType, fileType);
if (fileName != null && !fileName.isEmpty()) w.like(SysFile::getFileName, fileName);
if (uploadUserName != null && !uploadUserName.isEmpty()) w.like(SysFile::getUploadUserName, uploadUserName);
if (systemId != null) w.eq(SysFile::getSystemId, systemId);
w.orderByDesc(SysFile::getCreatedAt);
w.select(SysFile.class, f -> !"file_data".equals(f.getColumn()));
return sysFileMapper.selectPage(new Page<>(page, size), w);
}
@Override
public List<SysFile> list(String fileType, String systemId) {
LambdaQueryWrapper<SysFile> w = new LambdaQueryWrapper<>();
if (fileType != null && !fileType.isEmpty()) w.eq(SysFile::getFileType, fileType);
if (systemId != null) w.eq(SysFile::getSystemId, systemId);
w.orderByDesc(SysFile::getCreatedAt);
w.select(SysFile.class, f -> !"file_data".equals(f.getColumn()));
return sysFileMapper.selectList(w);
}
@Override
public SysFile getById(String id) {
return sysFileMapper.selectById(id);
}
@Override
public SysFile getMetaById(String id) {
LambdaQueryWrapper<SysFile> w = new LambdaQueryWrapper<>();
w.eq(SysFile::getId, id);
w.select(SysFile.class, f -> !"file_data".equals(f.getColumn()));
return sysFileMapper.selectOne(w);
}
@Override
public Path getDiskPath(SysFile sf) {
String basePath = getBasePath();
// 存量兼容:旧文件 storageFileName 为空时,用 id + 扩展名兜底
String storageName = sf.getStorageFileName();
if (storageName == null || storageName.isEmpty()) {
storageName = sf.getId() + ext(sf.getFileName());
}
return Paths.get(basePath, sf.getCreatedAt().format(YYYY_MM), storageName);
}
@Override
public void delete(String id) throws IOException {
SysFile sf = sysFileMapper.selectById(id);
if (sf == null) return;
// 删除磁盘文件(失败不阻断 DB 删除)
try {
Path path = getDiskPath(sf);
if (Files.exists(path)) {
Files.delete(path);
log.info("磁盘文件已删除: {}", path);
}
} catch (Exception e) {
log.warn("删除磁盘文件失败继续删DB记录 - id: {}, error: {}", id, e.getMessage());
}
// 删除 DB 记录
sysFileMapper.deleteById(id);
}
}

View File

@@ -0,0 +1,936 @@
<!--
系统接入文档页面公开访问通过 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,
})
// 请求拦截器:携带 Token 和系统编码
request.interceptors.request.use(config => {
const token = localStorage.getItem('token')
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) {
localStorage.removeItem('token')
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 = localStorage.getItem('token')
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 写入 localStorage 并跳回业务前端业务前端无需处理登录请求只需从 localStorage 读取 Token</p>
<div class="code-box">
<div class="code-label">TypeScript</div>
<pre><code>// main.ts 或 App.vue onMounted
const token = localStorage.getItem('token')
if (token) {
// 用户已登录,正常渲染应用
// 如需获取用户信息,调用网关 API
const res = await request.get('/zgapi/v1/auth/user/info')
console.log('当前用户:', res.data)
}</code></pre>
</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>
<!-- ====== 开放 API文件上传/下载 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">9</span>开放 API 文件上传 / 下载</h2>
<p class="text-p">
对外开放 API 使用<b>一次性 Token</b> 认证可用于外部系统上传文件到文件对象存储或从存储中下载文件
每个 Token <b>12 小时</b>内有效<b>只能使用一次</b>调用任意接口即消费
</p>
<h3 class="section-h3">9.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">9.2 校验 Token不消费</h3>
<div class="api-card">
<div class="api-method post">POST</div>
<div class="api-path">/zgapi/v1/open/token/validate</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>{
"token": "eyJhbGciOiJIUzM4NCJ9..."
}</code></pre></div>
<p style="margin:8px 0"><b>成功响应Token 有效未被消费</b></p>
<div class="code-box"><pre><code>{
"code": 200,
"data": {
"valid": true,
"systemCode": "gateway",
"msg": "Token 有效,未被消费"
}
}</code></pre></div>
<p style="color:var(--color-danger);font-size:13px"><b> 此接口不会消费 Token</b>校验通过后 Token 仍可用于后续操作</p>
</div>
<h3 class="section-h3">9.3 标记 Token 已消费</h3>
<div class="api-card">
<div class="api-method post">POST</div>
<div class="api-path">/zgapi/v1/open/token/consume</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>{
"token": "eyJhbGciOiJIUzM4NCJ9...",
"action": "上传文件"
}</code></pre></div>
<p style="margin:8px 0"><b>参数说明</b></p>
<el-table :data="openApiConsumeParams" border stripe size="small" style="width:100%">
<el-table-column prop="name" label="参数" width="100" />
<el-table-column prop="type" label="类型" width="80" />
<el-table-column prop="required" label="必填" width="60" />
<el-table-column prop="desc" label="说明" />
</el-table>
<p style="margin:8px 0;margin-top:12px"><b>成功响应</b></p>
<div class="code-box"><pre><code>{
"code": 200,
"data": {
"tokenConsumed": true,
"action": "上传文件",
"msg": "Token 已标记为已消费"
}
}</code></pre></div>
<p style="color:var(--text-muted);font-size:13px">
适用场景外部系统在自己的业务中消费了 Token如上传到自有存储调用此接口告知网关防止重复使用
</p>
</div>
<h3 class="section-h3">9.4 上传文件</h3>
<div class="api-card">
<div class="api-method post">POST</div>
<div class="api-path">/zgapi/v1/open/file/upload</div>
<div class="api-headers"><b>Content-Type:</b> multipart/form-data</div>
<el-table :data="openApiUploadParams" border stripe size="small" style="margin:12px 0;width:100%">
<el-table-column prop="name" label="参数" width="130" />
<el-table-column prop="type" label="类型" width="80" />
<el-table-column prop="required" label="必填" width="60" />
<el-table-column prop="desc" label="说明" />
</el-table>
<p style="margin:8px 0"><b>curl 示例</b></p>
<div class="code-box"><pre><code># 获取 Token
TOKEN=$(curl -s -X POST "{{ gatewayUrl }}/zgapi/v1/open/token" \
-H "Content-Type: application/json" \
-d '{"systemCode":"{{ sys?.systemCode || 'YOUR_CODE' }}"}' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
# 上传文件Token 作为 form 字段
curl -X POST "{{ gatewayUrl }}/zgapi/v1/open/file/upload" \
-F "file=@/path/to/file.png" \
-F "token=$TOKEN" \
-F "fileType=other"</code></pre></div>
<p style="margin:8px 0"><b>成功响应Token 已消费</b></p>
<div class="code-box"><pre><code>{
"code": 200,
"data": {
"fileId": "a1b2c3d4e5f6",
"fileName": "file.png",
"storageFileName": "a1b2c3d4e5f6.png",
"fileSize": 51200,
"tokenConsumed": true
}
}</code></pre></div>
</div>
<h3 class="section-h3">9.5 下载文件</h3>
<div class="api-card">
<div class="api-method get">GET</div>
<div class="api-path">/zgapi/v1/open/file/{id}/download?token={token}</div>
<div class="api-headers" style="margin-bottom:8px">
<b>Token 传递方式</b>URL query 参数 <code>?token=xxx</code>不能用 Header浏览器 &lt;a&gt; 标签下载时无法自定义 Header
</div>
<p style="margin:8px 0"><b>curl 示例</b></p>
<div class="code-box"><pre><code># 下载文件Token 通过 query 参数传递
curl -o downloaded.png \
"{{ gatewayUrl }}/zgapi/v1/open/file/a1b2c3d4e5f6/download?token=$TOKEN"</code></pre></div>
<p style="margin:8px 0"><b>成功响应</b>直接返回文件二进制流Content-Disposition: attachment 触发浏览器下载</p>
<p style="margin:8px 0"><b>错误响应</b></p>
<el-table :data="openApiErrors" border stripe size="small" style="width:100%">
<el-table-column prop="code" label="HTTP" width="80" />
<el-table-column prop="msg" label="说明" />
</el-table>
</div>
<h3 class="section-h3">9.6 发送邮件</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>curl 示例</b></p>
<div class="code-box"><pre><code>curl -X POST "{{ gatewayUrl }}/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;","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>
</div>
<div class="warn-item" style="margin-top:16px">
<b> 注意事项</b>
<p>1. 每个 Token 只能使用一次调用任意开放接口上传/下载/发邮件即消费<br>
2. 调用失败<b>不消费</b> Token可重试如邮件发送失败文件写入失败<br>
3. 文件上传大小限制为 2GB超时时间 12 小时<br>
4. 下载链接中的 Token 通过 URL 明文传递请勿在公开页面中暴露<br>
5. 上传成功后返回 <code>fileId</code>请妥善保存用于后续下载</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
);`
})
// 开放 API — 标记消费参数
const openApiConsumeParams = [
{ name: 'token', type: 'String', required: '是', desc: '需要标记已消费的 Token' },
{ name: 'action', type: 'String', required: '是', desc: '消费动作:发送邮件 / 上传文件 / 下载文件' },
]
// 开放 API — 上传参数表
const openApiUploadParams = [
{ name: 'file', type: 'File', required: '是', desc: '上传的文件' },
{ name: 'token', type: 'String', required: '是', desc: '通过 /open/token 获取的一次性 Tokenform 字段传递)' },
{ name: 'fileType', type: 'String', required: '否', desc: '文件类型: avatar / icon / doc / other默认 other' },
]
// 开放 API — 错误码
const openApiErrors = [
{ code: '400', msg: '缺少 file 或 token 参数' },
{ code: '401', msg: 'Token 无效、已过期或已消费' },
{ code: '404', msg: '文件不存在或文件数据丢失' },
{ code: '500', msg: '文件上传/下载失败(失败不消费 Token可重试' },
]
// 测试清单(全部默认未完成,由接入方自行勾选确认)
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>

View File

@@ -0,0 +1,507 @@
package com.mokee.admin.controller;
import cn.hutool.core.io.IoUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.mokee.admin.mapper.SysEmailLogMapper;
import com.mokee.admin.mapper.SysOpenTokenLogMapper;
import com.mokee.admin.mapper.SysSystemMapper;
import com.mokee.admin.service.FileService;
import com.mokee.common.Result;
import com.mokee.common.entity.SysEmailLog;
import com.mokee.common.entity.SysFile;
import com.mokee.common.entity.SysOpenTokenLog;
import com.mokee.common.entity.SysSystem;
import com.mokee.common.service.EmailService;
import com.mokee.common.utils.IpUtil;
import com.mokee.common.utils.JwtUtil;
import com.mokee.common.utils.RedisUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* 对外开放 API
* <p>
* Token 一次消费:每个 token 只能用一次,消费后立即失效
* API 文档地址http://服务地址:9002/doc.html → "对外开放API" 分组
*/
@Slf4j
@RestController
@RequestMapping("/zgapi/v1/open")
@RequiredArgsConstructor
@Tag(name = "对外开放API", description = "使用 systemCode 获取一次性 TokenToken 消费后立即失效。文档: /doc.html")
public class OpenApiController {
private final SysSystemMapper sysSystemMapper;
private final EmailService emailService;
private final FileService fileService;
private final SysEmailLogMapper emailLogMapper;
private final SysOpenTokenLogMapper openTokenLogMapper;
private final JwtUtil jwtUtil;
private final RedisUtil redisUtil;
private static final int OPEN_TOKEN_TTL_MINUTES = 720; // 12小时
// ==================== Token 获取 ====================
/**
* 获取一次性 API Token
* <p>
* 每个 Token 只能消费一次(调用任意 /zgapi/v1/open/* 接口即为消费),消费后立即失效。
* Token 有效期 2 小时,过期未消费也会失效。
*/
@PostMapping("/token")
@Operation(summary = "获取一次性API Token",
description = "传入 systemCode 换取 JWT Token。Token 有效期2小时只能消费一次消费后立即失效。")
public Result<Map<String, Object>> getToken(HttpServletRequest request) {
Map<String, String> body;
try { body = readJsonBody(request); } catch (Exception e) { return Result.fail(400, "JSON解析失败"); }
String systemCode = body.get("systemCode");
if (systemCode == null || systemCode.isEmpty()) {
return Result.fail(400, "缺少 systemCode");
}
SysSystem system = sysSystemMapper.selectOne(
new LambdaQueryWrapper<SysSystem>()
.eq(SysSystem::getSystemCode, systemCode)
.eq(SysSystem::getStatus, 1)
);
if (system == null) {
return Result.fail(401, "systemCode 无效或系统已停用");
}
String tokenId = UUID.randomUUID().toString();
String token = jwtUtil.generateOpenToken(system.getId(), system.getSystemCode(), tokenId, OPEN_TOKEN_TTL_MINUTES * 60L);
// 缓存到 Redis
Map<String, Object> tokenInfo = Map.of(
"systemId", system.getId(),
"systemCode", system.getSystemCode(),
"systemName", system.getSystemName(),
"tokenId", tokenId,
"type", "open_api"
);
redisUtil.set("open:token:" + tokenId, JSONUtil.toJsonStr(tokenInfo), OPEN_TOKEN_TTL_MINUTES, TimeUnit.MINUTES);
// 记录获取日志
SysOpenTokenLog log = SysOpenTokenLog.builder()
.systemId(system.getId())
.systemCode(system.getSystemCode())
.tokenId(tokenId)
.tokenPreview(token.substring(0, 20) + "..." + token.substring(token.length() - 10))
.acquiredAt(LocalDateTime.now())
.consumed(0)
.build();
openTokenLogMapper.insert(log);
return Result.ok(Map.of(
"token", token,
"expiresIn", OPEN_TOKEN_TTL_MINUTES * 60,
"systemName", system.getSystemName(),
"usageNote", "此 Token 只能消费一次,消费后立即失效"
));
}
// ==================== Token 校验(不消费) ====================
/**
* 校验 Token 是否有效(不消费)
* <p>
* 仅检查 Token 是否存在且未被消费,不做消费标记。
* 用于调用方在实际调用前预先确认 Token 可用。
*/
@PostMapping("/token/validate")
@Operation(summary = "校验Token是否有效不消费",
description = "传入 token 检查是否存在且未被消费。注意:此接口不会消费 Token仅做状态查询。")
public Result<Map<String, Object>> validateToken(HttpServletRequest request) {
Map<String, String> body;
try { body = readJsonBody(request); } catch (Exception e) { return Result.fail(400, "JSON解析失败"); }
String token = body.get("token");
if (token == null || token.isEmpty()) {
return Result.fail(400, "缺少 token 参数");
}
String tokenId;
try {
io.jsonwebtoken.Claims claims = jwtUtil.validateToken(token);
String type = claims.get("type", String.class);
if (!"open_api".equals(type)) {
return Result.fail(401, "Token 类型错误,非开放 API Token");
}
tokenId = jwtUtil.getTokenId(claims);
if (tokenId == null) {
return Result.fail(401, "Token 无效");
}
} catch (Exception e) {
return Result.fail(401, "Token 无效或已过期");
}
// 检查 Redis 中是否存在
if (!redisUtil.exists("open:token:" + tokenId)) {
return Result.fail(401, "Token 不存在或已过期");
}
// 检查是否已消费
if (redisUtil.exists("open:token:" + tokenId + ":consumed")) {
return Result.fail(401, "Token 已被消费");
}
// 从 Redis 获取 Token 信息
String redisJson = redisUtil.get("open:token:" + tokenId);
String systemCode = "";
if (redisJson != null) {
cn.hutool.json.JSONObject info = cn.hutool.json.JSONUtil.parseObj(redisJson);
systemCode = info.getStr("systemCode", "");
}
return Result.ok(Map.of(
"valid", true,
"systemCode", systemCode,
"msg", "Token 有效,未被消费"
));
}
// ==================== 标记 Token 已消费 ====================
/**
* 标记 Token 为已消费(供外部系统回调)
* <p>
* 外部系统在自己的业务中消费了 Token 后,调用此接口告知网关标记已消费,
* 防止该 Token 被重复使用。
*/
@PostMapping("/token/consume")
@Operation(summary = "标记Token已消费",
description = "外部系统调用此接口主动标记 Token 已消费。Token 被标记后不可再使用。")
public Result<Map<String, Object>> markConsumed(HttpServletRequest request) {
Map<String, String> body;
try { body = readJsonBody(request); } catch (Exception e) { return Result.fail(400, "JSON解析失败"); }
String token = body.get("token");
String action = body.get("action");
if (token == null || token.isEmpty()) return Result.fail(400, "缺少 token 参数");
if (action == null || action.isEmpty()) return Result.fail(400, "缺少 action 参数(如:发送邮件/上传文件/下载文件)");
// 验证 Token
String tokenId;
try {
io.jsonwebtoken.Claims claims = jwtUtil.validateToken(token);
String type = claims.get("type", String.class);
if (!"open_api".equals(type)) return Result.fail(401, "Token 类型错误");
tokenId = jwtUtil.getTokenId(claims);
if (tokenId == null) return Result.fail(401, "Token 无效");
} catch (Exception e) {
return Result.fail(401, "Token 无效或已过期");
}
if (!redisUtil.exists("open:token:" + tokenId)) {
return Result.fail(401, "Token 不存在或已过期");
}
if (redisUtil.exists("open:token:" + tokenId + ":consumed")) {
return Result.fail(401, "Token 已被消费");
}
// 标记已消费
consumeToken(tokenId);
recordConsumption(tokenId, request, action);
return Result.ok(Map.of(
"tokenConsumed", true,
"action", action,
"msg", "Token 已标记为已消费"
));
}
// ==================== 邮件发送(消费 Token ====================
/**
* 发送邮件 — 消费 Token
* <p>
* 只有邮件发送成功后才消费 Token发送失败不消费允许重试。
*/
@PostMapping("/email/send")
@Operation(summary = "发送邮件消费Token",
description = "调用此接口将消费 TokenToken 只能用一次。邮件发送成功后 Token 立即失效,发送失败不消费 Token。")
public Result<?> sendEmail(HttpServletRequest request) {
// 第1步验证 Token不消费只校验有效性
String tokenId = validateOpenToken(request);
if (tokenId == null) {
return Result.fail(401, "Token 无效或已消费");
}
// 第2步读取请求体兼容 UTF-8/GBK 编码
Map<String, String> body;
try {
body = readJsonBody(request);
} catch (Exception e) {
return Result.fail(400, "请求体 JSON 解析失败: " + e.getMessage());
}
String to = body.get("to");
String subject = body.get("subject");
String content = body.get("content");
String contentType = body.getOrDefault("contentType", "html");
if (to == null || subject == null || content == null) {
return Result.fail(400, "缺少必填参数: to, subject, content");
}
// 第3步先发邮件成功后再消费 Token
SysEmailLog log = SysEmailLog.builder()
.toAddress(to)
.subject(subject)
.content(content)
.contentType(contentType)
.createdAt(LocalDateTime.now())
.build();
try {
if ("html".equals(contentType)) {
emailService.sendHtml(to, subject, content);
} else {
emailService.sendText(to, subject, content);
}
log.setStatus(1);
emailLogMapper.insert(log);
// 发邮件成功 → 消费 Token标记已用不可重试
consumeToken(tokenId);
recordConsumption(tokenId, request, "/zgapi/v1/open/email/send");
return Result.ok(Map.of("msg", "发送成功", "tokenConsumed", true));
} catch (Exception e) {
log.setStatus(0);
log.setErrorMsg(e.getMessage());
emailLogMapper.insert(log);
// 发送失败不消费 token允许重试
return Result.fail(500, "发送失败: " + e.getMessage());
}
}
// ==================== 文件上传/下载(消费 Token ====================
/**
* 开放 API 文件上传 — 消费 Token
* <p>
* Token 通过 multipart form 字段传递。上传成功后消费 Token。
*/
@PostMapping("/file/upload")
@Operation(summary = "上传文件消费Token",
description = "上传文件到文件对象存储。Token 作为 form 字段传递,上传成功后立即消费 Token。")
public Result<Map<String, Object>> uploadFile(
@RequestParam("file") MultipartFile file,
@RequestParam("token") String token,
@RequestParam(defaultValue = "other") String fileType,
HttpServletRequest request) {
// 1. 验证 Token
String tokenId = validateOpenTokenFromValue(token);
if (tokenId == null) {
return Result.fail(401, "Token 无效或已消费");
}
// 2. 从 Redis 获取系统信息
String systemId = null;
String systemName = null;
String redisJson = redisUtil.get("open:token:" + tokenId);
if (redisJson != null) {
cn.hutool.json.JSONObject info = JSONUtil.parseObj(redisJson);
systemId = info.getStr("systemId");
systemName = info.getStr("systemName");
}
// 3. 执行上传(上传者显示系统名称)
try {
SysFile sf = fileService.upload(file, fileType, systemId, null, systemName);
// 4. 上传成功 → 消费 Token
consumeToken(tokenId);
recordConsumption(tokenId, request, "/zgapi/v1/open/file/upload");
return Result.ok(Map.of(
"fileId", sf.getId(),
"fileName", sf.getFileName(),
"storageFileName", sf.getStorageFileName(),
"fileSize", sf.getFileSize(),
"tokenConsumed", true
));
} catch (IOException e) {
log.error("开放 API 文件上传失败: {}", e.getMessage());
// 上传失败不消费 token
return Result.fail(500, "文件上传失败: " + e.getMessage());
}
}
/**
* 开放 API 文件下载 — 消费 Token
* <p>
* Token 通过 query 参数传递。下载成功后消费 Token。
* 直接输出文件二进制流,设置 Content-Disposition 触发浏览器下载。
*/
@GetMapping("/file/{id}/download")
@Operation(summary = "下载文件消费Token",
description = "下载文件对象存储中的文件。Token 通过 ?token=xxx 参数传递,下载成功后立即消费 Token。")
public void downloadFile(@PathVariable String id,
@RequestParam("token") String token,
HttpServletRequest request,
HttpServletResponse response) throws IOException {
// 1. 验证 Token
String tokenId = validateOpenTokenFromValue(token);
if (tokenId == null) {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(401);
response.getWriter().write(JSONUtil.toJsonStr(Result.fail(401, "Token 无效或已消费")));
return;
}
// 2. 查询文件
SysFile sf = fileService.getById(id);
if (sf == null) {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(404);
response.getWriter().write(JSONUtil.toJsonStr(Result.fail(404, "文件不存在")));
return;
}
// 3. 读取磁盘文件
java.nio.file.Path diskPath = fileService.getDiskPath(sf);
if (!java.nio.file.Files.exists(diskPath)) {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(404);
response.getWriter().write(JSONUtil.toJsonStr(Result.fail(404, "文件不存在")));
return;
}
byte[] data = java.nio.file.Files.readAllBytes(diskPath);
// 4. 下载成功 → 消费 Token
consumeToken(tokenId);
recordConsumption(tokenId, request, "/zgapi/v1/open/file/" + id + "/download");
// 5. 输出文件流
String safeName = "download" + ext(sf.getFileName());
String encodedName = URLEncoder.encode(sf.getFileName(), StandardCharsets.UTF_8)
.replace("+", "%20");
response.setContentType("application/octet-stream");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + safeName + "\"; filename*=UTF-8''" + encodedName);
response.setContentLengthLong(data.length);
response.getOutputStream().write(data);
response.getOutputStream().flush();
}
private static String ext(String name) {
if (name == null) return "";
int i = name.lastIndexOf('.');
return i > 0 ? name.substring(i) : "";
}
// ==================== 私有方法 ====================
/**
* 验证 Open API Token 有效性(不消费)
* <p>仅校验 JWT 合法性 + Redis 中存在且未被消费,不做消费标记
*/
private String validateOpenToken(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) return null;
String token = authHeader.substring(7);
try {
io.jsonwebtoken.Claims claims = jwtUtil.validateToken(token);
String tokenId = jwtUtil.getTokenId(claims);
if (tokenId == null) return null;
// 检查 Redis 中 token 是否存在
if (!redisUtil.exists("open:token:" + tokenId)) return null;
// 检查是否已消费
String consumedKey = "open:token:" + tokenId + ":consumed";
if (redisUtil.exists(consumedKey)) return null;
return tokenId;
} catch (Exception e) {
return null;
}
}
/**
* 消费 Token标记为已消费业务操作成功后调用
*/
private void consumeToken(String tokenId) {
redisUtil.set("open:token:" + tokenId + ":consumed", "1", OPEN_TOKEN_TTL_MINUTES, TimeUnit.MINUTES);
}
/**
* 记录 Token 消费日志
*/
private void recordConsumption(String tokenId, HttpServletRequest request, String api) {
try {
SysOpenTokenLog log = new SysOpenTokenLog();
log.setTokenId(tokenId);
log.setConsumed(1);
log.setConsumedAt(LocalDateTime.now());
log.setConsumeApi(api);
log.setConsumeIp(IpUtil.getClientIp(request));
// 根据 tokenId 更新记录
LambdaQueryWrapper<SysOpenTokenLog> w = new LambdaQueryWrapper<>();
w.eq(SysOpenTokenLog::getTokenId, tokenId);
openTokenLogMapper.update(log, w);
} catch (Exception ignored) {}
}
/**
* 验证 Open API Token 有效性(不消费),从原始 token 字符串直接验证
* <p>用于 token 不在 Authorization 头的场景(如 form 字段、query 参数)
*/
private String validateOpenTokenFromValue(String token) {
if (token == null || token.isEmpty()) return null;
try {
io.jsonwebtoken.Claims claims = jwtUtil.validateToken(token);
String type = claims.get("type", String.class);
if (!"open_api".equals(type)) return null;
String tokenId = jwtUtil.getTokenId(claims);
if (tokenId == null) return null;
// 检查 Redis 中 token 是否存在且未被消费
if (!redisUtil.exists("open:token:" + tokenId)) return null;
if (redisUtil.exists("open:token:" + tokenId + ":consumed")) return null;
return tokenId;
} catch (Exception e) {
return null;
}
}
/**
* 从 HttpServletRequest 读取 JSON 请求体,自动检测 UTF-8/GBK 编码
*/
@SuppressWarnings("unchecked")
private Map<String, String> readJsonBody(HttpServletRequest request) throws java.io.IOException {
byte[] bytes = IoUtil.readBytes(request.getInputStream());
if (bytes == null || bytes.length == 0) return java.util.Collections.emptyMap();
String json = new String(bytes, java.nio.charset.StandardCharsets.UTF_8);
if (json.indexOf('<27>') >= 0) {
json = new String(bytes, java.nio.charset.Charset.forName("GBK"));
}
return JSONUtil.toBean(json, Map.class);
}
}

View File

@@ -0,0 +1,85 @@
<!--
系统配置页
功能配置文件对象存储的磁盘路径
数据来源GET /zgapi/v1/admin/config/list POST /zgapi/v1/admin/config/save
-->
<template>
<div class="config-page">
<!-- 文件对象存储配置 -->
<el-card shadow="never">
<template #header>
<div class="card-header-title">
<el-icon><FolderOpened /></el-icon>
<span>文件对象存储配置</span>
</div>
</template>
<el-form ref="formRef" :model="form" label-width="120px" style="max-width: 600px">
<el-form-item label="存储磁盘路径">
<el-input
v-model="form.diskPath"
placeholder="如: /data/mokee/gw/file"
clearable
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="handleSave">
<el-icon><Check /></el-icon>
保存配置
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { FolderOpened, Check } from '@element-plus/icons-vue'
import request from '@/utils/request'
const saving = ref(false)
const form = reactive({ diskPath: '' })
onMounted(async () => {
try {
const res = await request.get('/zgapi/v1/admin/config/list')
const list: any[] = res.data ?? []
const diskCfg = list.find((c: any) => c.configKey === 'file.storage.disk.path')
if (diskCfg) {
form.diskPath = diskCfg.configValue || ''
}
} catch { /* ignore */ }
})
async function handleSave() {
saving.value = true
try {
await request.post('/zgapi/v1/admin/config/save', {
configKey: 'file.storage.disk.path',
configValue: form.diskPath,
description: '文件对象存储磁盘根目录',
})
ElMessage.success('保存成功')
} catch { /* 拦截器已提示 */ } finally {
saving.value = false
}
}
</script>
<style scoped>
.config-page {
max-width: 700px;
}
.card-header-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
}
</style>

View File

@@ -0,0 +1,449 @@
# Mokee Gateway 对外开放 API 文档
**网关地址**: `https://gw.server.zgitm.com`
**版本**: v1
**更新时间**: 2026-06-13
---
## 1. 概述
对外开放 API 用于外部业务系统接入 Mokee 网关平台,支持获取一次性 Token 并调用邮件发送等功能。
> ⚠️ **编码要求**:所有请求和响应均使用 **UTF-8** 编码。`Content-Type` 必须指定 `charset=UTF-8`,否则中文会出现乱码或解析错误。
### 认证流程
```
外部系统 ──POST /zgapi/v1/open/token──▶ 获取一次性 Token
外部系统 ──POST /zgapi/v1/open/token/validate──▶ 校验 Token不消费
外部系统 ──POST /zgapi/v1/open/token/consume──▶ 标记 Token 已消费
外部系统 ──POST /zgapi/v1/open/email/send──▶ 发送邮件(消费 Token
外部系统 ──POST /zgapi/v1/open/file/upload──▶ 上传文件(消费 Token
外部系统 ──GET /zgapi/v1/open/file/{id}/download──▶ 下载文件(消费 Token
```
### Token 规则
| 规则 | 说明 |
|------|------|
| 获取方式 | 传入 `systemCode` 即可获取,无需密钥 |
| 有效期 | 获取后 **12 小时**内有效 |
| 消费限制 | 每个 Token **只能使用一次**,调用任意接口即消费 |
| 消费失败 | 接口调用失败(如邮件发送失败)**不消费** Token可重试 |
---
## 2. 获取 Token
传入系统编码,获取一次性 JWT Token。
**请求**
```
POST /zgapi/v1/open/token
Content-Type: application/json; charset=UTF-8
```
> **重要**:所有请求 Body 必须使用 **UTF-8** 编码。
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| systemCode | string | 是 | 系统编码,需在网关平台已注册且启用 |
**请求示例**
```bash
curl -X POST "https://gw.server.zgitm.com/zgapi/v1/open/token" \
-H "Content-Type: application/json" \
-d '{"systemCode": "gateway"}'
```
**响应**
```json
{
"code": 200,
"data": {
"token": "eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOi...",
"expiresIn": 43200,
"systemName": "网关管理平台",
"usageNote": "此 Token 只能消费一次,消费后立即失效"
}
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| code | int | 200=成功 |
| data.token | string | JWT Token后续请求放入 Authorization 头 |
| data.expiresIn | int | 有效时间43200 = 12小时 |
| data.systemName | string | 系统名称 |
| data.usageNote | string | 使用提示 |
**错误响应**
| code | 说明 |
|:----:|------|
| 400 | 缺少 `systemCode` 参数 |
| 401 | `systemCode` 无效或系统已停用 |
---
## 3. 校验 Token不消费
检查 Token 是否有效(存在且未被消费)。**此接口不会消费 Token**,仅做状态查询。调用方可在实际操作前预先确认 Token 可用。
**请求**
```
POST /zgapi/v1/open/token/validate
Content-Type: application/json; charset=UTF-8
```
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| token | string | 是 | 需要校验的 JWT Token |
**请求示例**
```bash
curl -X POST "https://gw.server.zgitm.com/zgapi/v1/open/token/validate" \
-H "Content-Type: application/json" \
-d '{"token": "eyJhbGciOiJIUzM4NCJ9..."}'
```
**成功响应Token 有效,未被消费)**
```json
{
"code": 200,
"data": {
"valid": true,
"systemCode": "gateway",
"msg": "Token 有效,未被消费"
}
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| data.valid | bool | true = Token 有效可用 |
| data.systemCode | string | Token 所属的系统编码 |
| data.msg | string | 状态描述 |
**错误响应**
| code | 说明 |
|:----:|------|
| 400 | 缺少 `token` 参数 |
| 401 | Token 无效、已过期、已被消费、或类型错误 |
> **关键:此接口不会消费 Token。** 校验通过后 Token 仍可用于后续操作。
---
## 4. 标记 Token 已消费
外部系统在自己的业务逻辑中消费了 Token 后(如上传文件到自己的存储),调用此接口告知网关标记 Token 已消费,防止被重复使用。
**请求**
```
POST /zgapi/v1/open/token/consume
Content-Type: application/json; charset=UTF-8
```
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| token | string | 是 | 需要标记已消费的 JWT Token |
| action | string | 是 | 消费动作描述,如 `发送邮件` / `上传文件` / `下载文件` |
**请求示例**
```bash
curl -X POST "https://gw.server.zgitm.com/zgapi/v1/open/token/consume" \
-H "Content-Type: application/json" \
-d '{"token": "eyJhbGciOiJIUzM4NCJ9...", "action": "上传文件"}'
```
**成功响应**
```json
{
"code": 200,
"data": {
"tokenConsumed": true,
"action": "上传文件",
"msg": "Token 已标记为已消费"
}
}
```
**错误响应**
| code | 说明 |
|:----:|------|
| 400 | 缺少 `token``action` 参数 |
| 401 | Token 无效、已过期、或已被消费 |
---
## 5. 发送邮件
使用 Token 认证后发送邮件。**调用此接口将消费当前 Token。**
**请求**
```
POST /zgapi/v1/open/email/send
Content-Type: application/json; charset=UTF-8
Authorization: Bearer <token>
```
> **重要**:请求 Body 必须使用 **UTF-8** 编码。
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| to | string | 是 | 收件人邮箱地址 |
| subject | string | 是 | 邮件主题 |
| content | string | 是 | 邮件内容 |
| contentType | string | 否 | `html`(默认)或 `text` |
**请求示例**
```bash
# 先获取 Token
TOKEN=$(curl -s -X POST "https://gw.server.zgitm.com/zgapi/v1/open/token" \
-H "Content-Type: application/json" \
-d '{"systemCode":"gateway"}' | sed 's/.*"token":"\([^"]*\)".*/\1/')
# 发送 HTML 邮件
curl -X POST "https://gw.server.zgitm.com/zgapi/v1/open/email/send" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"to": "user@example.com",
"subject": "通知标题",
"content": "<h1>您好</h1><p>这是邮件内容</p>",
"contentType": "html"
}'
```
**成功响应**
```json
{
"code": 200,
"data": {
"msg": "发送成功",
"tokenConsumed": true
}
}
```
**错误响应**
| code | 说明 |
|:----:|------|
| 400 | 缺少必填参数 `to` / `subject` / `content` |
| 401 | Token 无效、已过期或已消费 |
| 500 | 邮件发送失败Token 未消费,可重试) |
---
## 6. 上传文件
使用 Token 认证后上传文件到文件对象存储。**调用此接口将消费当前 Token。**
> 上传成功后返回文件 ID可用于后续下载。
**请求**
```
POST /zgapi/v1/open/file/upload
Content-Type: multipart/form-data
```
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| file | file | 是 | 上传的文件 |
| token | string | 是 | 开放 API Token作为 form 字段传递) |
| fileType | string | 否 | 文件类型: `avatar`/`icon`/`doc`/`other`(默认 `other` |
**请求示例**
```bash
# 先获取 Token
TOKEN=$(curl -s -X POST "https://gw.server.zgitm.com/zgapi/v1/open/token" \
-H "Content-Type: application/json" \
-d '{"systemCode":"gateway"}' | sed 's/.*"token":"\([^"]*\)".*/\1/')
# 上传文件
curl -X POST "https://gw.server.zgitm.com/zgapi/v1/open/file/upload" \
-F "file=@/path/to/avatar.png" \
-F "token=$TOKEN" \
-F "fileType=avatar"
```
**成功响应**
```json
{
"code": 200,
"data": {
"fileId": "a1b2c3d4e5f6",
"fileName": "avatar.png",
"storageFileName": "a1b2c3d4e5f6.png",
"fileSize": 51200,
"tokenConsumed": true
}
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| data.fileId | string | 文件唯一 ID用于下载 |
| data.fileName | string | 原始文件名 |
| data.storageFileName | string | 磁盘存储的实际文件名 |
| data.fileSize | long | 文件大小(字节) |
| data.tokenConsumed | bool | Token 已消费 |
**错误响应**
| code | 说明 |
|:----:|------|
| 400 | 缺少 `file``token` 参数 |
| 401 | Token 无效、已过期或已消费 |
| 500 | 文件上传失败Token 未消费,可重试) |
---
## 7. 下载文件
使用 Token 认证后下载文件。**调用此接口将消费当前 Token。**
Token 通过 URL query 参数传递。
> **注意**: 每个下载链接的 Token 只能使用一次。如需多次下载,需每次获取新 Token。
**请求**
```
GET /zgapi/v1/open/file/{id}/download?token=<token>
```
| 参数 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| id | string | 是 | 文件 ID路径参数 |
| token | string | 是 | 开放 API Tokenquery 参数) |
**请求示例**
```bash
# 先获取 Token
TOKEN=$(curl -s -X POST "https://gw.server.zgitm.com/zgapi/v1/open/token" \
-H "Content-Type: application/json" \
-d '{"systemCode":"gateway"}' | sed 's/.*"token":"\([^"]*\)".*/\1/')
# 下载文件(保存到本地)
curl -o downloaded.png \
"https://gw.server.zgitm.com/zgapi/v1/open/file/a1b2c3d4e5f6/download?token=$TOKEN"
```
**成功响应**: 直接返回文件二进制流,`Content-Type: application/octet-stream``Content-Disposition: attachment`
**错误响应**
| HTTP 状态码 | code | 说明 |
|:----------:|:----:|------|
| 401 | 401 | Token 无效、已过期或已消费 |
| 404 | 404 | 文件不存在或文件数据丢失 |
---
## 8. Java 调用示例
使用 Hutool 或 OkHttp 均可,以下为 Hutool 示例:
```java
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
public class OpenApiDemo {
private static final String BASE = "https://gw.server.zgitm.com";
public static void main(String[] args) {
// 1. 获取 Token
JSONObject tokenReq = JSONUtil.createObj().set("systemCode", "gateway");
String tokenResp = HttpRequest.post(BASE + "/zgapi/v1/open/token")
.header("Content-Type", "application/json; charset=UTF-8")
.body(tokenReq.toString())
.execute()
.body();
JSONObject tokenJson = JSONUtil.parseObj(tokenResp);
String token = tokenJson.getJSONObject("data").getStr("token");
System.out.println("Token: " + token.substring(0, 30) + "...");
// 2. 上传文件
JSONObject uploadResp = HttpRequest.post(BASE + "/zgapi/v1/open/file/upload")
.form("file", new java.io.File("./avatar.png"))
.form("token", token)
.form("fileType", "avatar")
.execute()
.body();
System.out.println("上传结果: " + uploadResp);
// 3. 发送邮件
JSONObject emailReq = JSONUtil.createObj()
.set("to", "user@example.com")
.set("subject", "测试邮件")
.set("content", "<h1>Hello</h1><p>这是一封测试邮件</p>")
.set("contentType", "html");
String emailResp = HttpRequest.post(BASE + "/zgapi/v1/open/email/send")
.header("Authorization", "Bearer " + token)
.body(emailReq.toString())
.execute()
.body();
System.out.println(emailResp);
}
}
```
### Maven 依赖
```xml
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.28</version>
</dependency>
```
---
## 9. 附录
### 可用系统编码
请联系管理员获取已注册的系统编码,或登录网关管理平台查看。
### 错误码汇总
| HTTP 状态码 | code | 说明 |
|:----------:|:----:|------|
| 200 | 200 | 成功 |
| 200 | 400 | 参数错误 |
| 200 | 401 | 认证失败 |
| 200 | 500 | 服务内部错误 |
> **注意**:所有响应 HTTP 状态码均为 200实际结果通过响应体中的 `code` 字段判断。
### 联系支持
如有问题请联系 Mokee Gateway 管理员。