初始化
This commit is contained in:
176
_backup_20260613/FileController.java
Normal file
176
_backup_20260613/FileController.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
738
_backup_20260613/FileList.vue
Normal file
738
_backup_20260613/FileList.vue
Normal 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>
|
||||
49
_backup_20260613/FileService.java
Normal file
49
_backup_20260613/FileService.java
Normal 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;
|
||||
}
|
||||
178
_backup_20260613/FileServiceImpl.java
Normal file
178
_backup_20260613/FileServiceImpl.java
Normal 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);
|
||||
}
|
||||
}
|
||||
936
_backup_20260613/IntegrationDoc.vue
Normal file
936
_backup_20260613/IntegrationDoc.vue
Normal 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<Map<String, String>> CTX = new ThreadLocal<>();
|
||||
|
||||
public static void set(Map<String, String> user) { CTX.set(user); }
|
||||
public static Map<String, String> get() { return CTX.get(); }
|
||||
public static void clear() { CTX.remove(); }
|
||||
|
||||
// 快捷方法
|
||||
public static String getUserId() { return get().get("X-User-Id"); }
|
||||
public static String getUserName() { return get().get("X-User-Name"); }
|
||||
public static String getUserAccount(){ return get().get("X-User-Account"); }
|
||||
public static String getSystemCode() { return get().get("X-System-Code"); }
|
||||
public static String getSystemId() { return get().get("X-System-Id"); }
|
||||
}
|
||||
|
||||
// ====== UserContextFilter.java ======
|
||||
import jakarta.servlet.*;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.io.IOException;
|
||||
import java.util.*;
|
||||
|
||||
@Component
|
||||
public class UserContextFilter implements Filter {
|
||||
private static final String[] HEADERS = {
|
||||
"X-User-Id", "X-User-Name", "X-User-Account",
|
||||
"X-User-Email", "X-User-Post", "X-User-Roles",
|
||||
"X-System-Id", "X-System-Code", "X-System-Name"
|
||||
};
|
||||
|
||||
@Override
|
||||
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
|
||||
throws IOException, ServletException {
|
||||
HttpServletRequest request = (HttpServletRequest) req;
|
||||
Map<String, String> user = new HashMap<>();
|
||||
for (String h : HEADERS) {
|
||||
String v = request.getHeader(h);
|
||||
if (v != null) user.put(h, v);
|
||||
}
|
||||
UserContext.set(user);
|
||||
try {
|
||||
chain.doFilter(req, res);
|
||||
} finally {
|
||||
UserContext.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====== 业务代码中使用 ======
|
||||
@RestController
|
||||
public class OrderController {
|
||||
|
||||
@GetMapping("/api/orders")
|
||||
public List<Order> list() {
|
||||
String userId = UserContext.getUserId(); // "123"
|
||||
String userName = UserContext.getUserName(); // "张三"
|
||||
String systemCode = UserContext.getSystemCode(); // "msg"
|
||||
return orderService.findByUser(userId, systemCode);
|
||||
}
|
||||
}</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,浏览器 <a> 标签下载时无法自定义 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} | <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": "<h1>邮件内容</h1>",
|
||||
"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":"<h1>你好</h1>","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 获取的一次性 Token(form 字段传递)' },
|
||||
{ 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>
|
||||
507
_backup_20260613/OpenApiController.java
Normal file
507
_backup_20260613/OpenApiController.java
Normal 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 获取一次性 Token,Token 消费后立即失效。文档: /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 = "调用此接口将消费 Token,Token 只能用一次。邮件发送成功后 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);
|
||||
}
|
||||
}
|
||||
85
_backup_20260613/SystemConfig.vue
Normal file
85
_backup_20260613/SystemConfig.vue
Normal 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>
|
||||
449
_backup_20260613/open-api-doc.md
Normal file
449
_backup_20260613/open-api-doc.md
Normal 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 Token(query 参数) |
|
||||
|
||||
**请求示例**
|
||||
|
||||
```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 管理员。
|
||||
Reference in New Issue
Block a user