diff --git a/_backup_20260613/FileController.java b/_backup_20260613/FileController.java
new file mode 100644
index 0000000..6994f85
--- /dev/null
+++ b/_backup_20260613/FileController.java
@@ -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;
+
+/**
+ * 文件管理控制器
+ *
+ * 文件二进制数据存储于本地磁盘,数据库仅保存元信息。
+ * 向下兼容存量 BLOB 数据(file_data 非空时优先读磁盘,磁盘无文件则回退 DB BLOB)。
+ *
+ * 请求路径前缀:{@code /zgapi/v1/admin/file}
+ *
+ * @author mokee
+ */
+@RestController
+@RequestMapping("/zgapi/v1/admin/file")
+@RequiredArgsConstructor
+public class FileController {
+ private final FileService fileService;
+
+ /**
+ * 上传文件(管理后台)
+ *
+ * 上传者显示名从请求头 X-User-Name 获取(由网关 HeaderInjectFilter 注入,已 URL 解码)。
+ */
+ @OperationLog(module = "文件管理", action = "上传")
+ @PostMapping("/upload")
+ public Result 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> page(@RequestBody java.util.Map 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(@RequestParam(required = false) String fileType,
+ @RequestParam(required = false) String systemId) {
+ return Result.ok(fileService.list(fileType, systemId));
+ }
+
+ /**
+ * 获取文件元信息(不含二进制数据)
+ */
+ @GetMapping("/{id}")
+ public Result getById(@PathVariable String id) {
+ return Result.ok(fileService.getMetaById(id));
+ }
+
+ /**
+ * 预览文件 — 直接输出文件流
+ *
+ * 优先读磁盘文件;磁盘文件不存在时回退到 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();
+ }
+
+ /**
+ * 下载文件 — 触发浏览器下载
+ *
+ * 与 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;
+ }
+ }
+}
diff --git a/_backup_20260613/FileList.vue b/_backup_20260613/FileList.vue
new file mode 100644
index 0000000..40c137d
--- /dev/null
+++ b/_backup_20260613/FileList.vue
@@ -0,0 +1,738 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 搜索
+
+ 重置
+
+
+
+
+ 上传文件
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
将文件拖拽到此处
+
或点击此区域选择文件(支持多选)
+
+
+
+
+
+
+
+
+
+
+
{{ f.file.name }}
+
+ {{ formatSize(f.file.size) }}
+
+
+
+
+
+
+
+
+
+
完成
+
失败
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.storageFileName || '-' }}
+
+
+
+
+
+
+
+ {{ fileTypeLabel(row.fileType) }}
+
+
+
+
+
+
+
+ {{ formatSize(row.fileSize) }}
+
+
+
+
+
+
+ {{ row.uploadUserName || '-' }}
+
+
+
+
+
+
+ {{ row.createdAt ? new Date(row.createdAt).toLocaleString('zh-CN') : '-' }}
+
+
+
+
+
+
+
+
+ 下载
+
+
+ 复制链接
+
+
+
+
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/_backup_20260613/FileService.java b/_backup_20260613/FileService.java
new file mode 100644
index 0000000..835145a
--- /dev/null
+++ b/_backup_20260613/FileService.java
@@ -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;
+
+/**
+ * 文件服务接口
+ *
+ * 文件二进制数据存储于本地磁盘,数据库仅保存元信息。
+ *
+ * @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 page(Integer page, Integer size, String fileType, String fileName, String uploadUserName, String systemId);
+
+ /** 列表查询(不含 file_data) */
+ List 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;
+}
diff --git a/_backup_20260613/FileServiceImpl.java b/_backup_20260613/FileServiceImpl.java
new file mode 100644
index 0000000..3ab3f83
--- /dev/null
+++ b/_backup_20260613/FileServiceImpl.java
@@ -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;
+
+/**
+ * 文件服务实现 — 文件二进制数据存储于本地磁盘
+ *
+ * 磁盘路径结构:{basePath}/{yyyy-MM}/{storageFileName}
+ * 例:/data/mokee/gw/file/2026-06/a1b2c3d4e5f6.png
+ *
+ * 向下兼容:存量 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 page(Integer page, Integer size, String fileType,
+ String fileName, String uploadUserName, String systemId) {
+ LambdaQueryWrapper 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 list(String fileType, String systemId) {
+ LambdaQueryWrapper 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 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);
+ }
+}
diff --git a/_backup_20260613/IntegrationDoc.vue b/_backup_20260613/IntegrationDoc.vue
new file mode 100644
index 0000000..748930d
--- /dev/null
+++ b/_backup_20260613/IntegrationDoc.vue
@@ -0,0 +1,936 @@
+
+
+
+
+
+
+
+
+
+ 1当前系统配置
+
+ {{ sys?.systemCode }}
+ {{ sys?.systemName }}
+ {{ sys?.frontUrl }}
+ {{ sys?.backendUrl }}
+ {{ sys?.description || '-' }}
+
+
+
+
+
+ 2架构概览
+
+
┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐
+│ 业务前端 │────▶│ Mokee Gateway │────▶│ 业务后端 │
+│ (Vue/React) │ │ gw.server.zgitm.com │ │ (任意语言) │
+│ │ │ │ │ │
+│ 携带 Token │ │ 验证 Token │ │ 从请求头读取: │
+│ + SystemCode │ │ 识别系统编码 │ │ X-User-Id │
+│ │ │ 转发请求 │ │ X-User-Name │
+│ │ │ 注入用户信息头 │ │ X-System-Code │
+└──────────────┘ └──────────────────┘ └──────────────────┘
+
+
+
① 用户访问业务前端 → 无 Token → 跳转统一登录页
+
② 登录成功 → 获得 JWT Token → 回跳到业务前端
+
③ 业务前端所有 API 请求 → 网关 (携带 Authorization + X-System-Code)
+
④ 网关验证 Token → 注入用户信息请求头 → 转发到业务后端
+
⑤ 业务后端从请求头读取用户信息 → 执行业务逻辑
+
+
+
+
+
🔴 关键规则一:前端只调网关
+
业务前端的 baseURL 必须配置为 网关地址(如 {{ gatewayUrl }}),绝对不能直接调用业务后端。网关根据请求头 X-System-Code 自动识别并转发到对应后端。
+
+
+
🔴 关键规则二:后端必须开跨域 (CORS)
+
业务后端需要允许来自网关域名的跨域请求。网关转发请求时 origin 是浏览器地址,不是网关地址。
+
Java Spring Boot 配置:
+
@Configuration
+public class CorsConfig implements WebMvcConfigurer {
+ @Override
+ public void addCorsMappings(CorsRegistry registry) {
+ registry.addMapping("/**")
+ .allowedOriginPatterns("*")
+ .allowedMethods("*")
+ .allowedHeaders("*")
+ .allowCredentials(true);
+ }
+}
+
+
+
🔴 关键规则三:业务后端不再做登录/鉴权
+
Token 验证由网关完成。业务后端只需从请求头读取用户信息,不需要再验证 Token、不需要查询用户表。
+
+
+
+
+
+
+ 3前端接入 (完整代码)
+
+ 3.1 安装依赖
+
+
Shell
+
npm install axios
+
+
+ 3.2 环境变量 (.env)
+
+
.env
+
VITE_GATEWAY_URL = {{ gatewayUrl }}
+VITE_LOGIN_URL = {{ loginUrl }}
+VITE_SYSTEM_CODE = {{ sys?.systemCode || 'YOUR_CODE' }}
+
+
+ 3.3 Axios 实例 (src/utils/request.ts)
+
+
TypeScript
+
import axios from 'axios'
+import { ElMessage } from 'element-plus'
+
+const SYSTEM_CODE = import.meta.env.VITE_SYSTEM_CODE
+
+const request = axios.create({
+ baseURL: import.meta.env.VITE_GATEWAY_URL,
+ timeout: 30000,
+})
+
+// 请求拦截器:携带 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
+
+
+ 3.4 路由守卫 (src/router/index.ts)
+
+
TypeScript
+
import { createRouter, createWebHistory } from 'vue-router'
+
+const SYSTEM_CODE = import.meta.env.VITE_SYSTEM_CODE
+const LOGIN_URL = import.meta.env.VITE_LOGIN_URL
+
+const router = createRouter({
+ history: createWebHistory(),
+ routes: [ /* 你的路由 */ ],
+})
+
+router.beforeEach((to, _from, next) => {
+ const token = localStorage.getItem('token')
+ if (!token && to.path !== '/login') {
+ // 无 Token → 跳统一登录页
+ window.location.href = `${LOGIN_URL}?systemCode=${SYSTEM_CODE}`
+ return
+ }
+ next()
+})
+
+export default router
+
+
+ 3.5 登录成功回调处理
+ 统一登录成功后,网关会将 Token 写入 localStorage 并跳回业务前端。业务前端无需处理登录请求,只需从 localStorage 读取 Token。
+
+
TypeScript
+
// 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)
+}
+
+
+
+
+
+ 4后端接入 (完整代码)
+
+ 4.1 网关转发到后端时注入的请求头
+
+
+
+
+
+ 重要:这些请求头由网关注入,业务后端可以信任这些值。不需要再验证 Token 或查询用户信息。
+
+ 4.2 Java — UserContext 工具类
+ 用 ThreadLocal 缓存请求头,业务代码零侵入获取用户信息。
+
+
Java
+
// ====== UserContext.java ======
+import java.util.*;
+
+public class UserContext {
+ private static final ThreadLocal<Map<String, String>> CTX = new ThreadLocal<>();
+
+ public static void set(Map<String, String> user) { CTX.set(user); }
+ public static Map<String, String> get() { return CTX.get(); }
+ public static void clear() { CTX.remove(); }
+
+ // 快捷方法
+ public static String getUserId() { return get().get("X-User-Id"); }
+ public static String getUserName() { return get().get("X-User-Name"); }
+ public static String getUserAccount(){ return get().get("X-User-Account"); }
+ public static String getSystemCode() { return get().get("X-System-Code"); }
+ public static String getSystemId() { return get().get("X-System-Id"); }
+}
+
+// ====== UserContextFilter.java ======
+import jakarta.servlet.*;
+import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.stereotype.Component;
+import java.io.IOException;
+import java.util.*;
+
+@Component
+public class UserContextFilter implements Filter {
+ private static final String[] HEADERS = {
+ "X-User-Id", "X-User-Name", "X-User-Account",
+ "X-User-Email", "X-User-Post", "X-User-Roles",
+ "X-System-Id", "X-System-Code", "X-System-Name"
+ };
+
+ @Override
+ public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
+ throws IOException, ServletException {
+ HttpServletRequest request = (HttpServletRequest) req;
+ Map<String, String> user = new HashMap<>();
+ for (String h : HEADERS) {
+ String v = request.getHeader(h);
+ if (v != null) user.put(h, v);
+ }
+ UserContext.set(user);
+ try {
+ chain.doFilter(req, res);
+ } finally {
+ UserContext.clear();
+ }
+ }
+}
+
+// ====== 业务代码中使用 ======
+@RestController
+public class OrderController {
+
+ @GetMapping("/api/orders")
+ public List<Order> list() {
+ String userId = UserContext.getUserId(); // "123"
+ String userName = UserContext.getUserName(); // "张三"
+ String systemCode = UserContext.getSystemCode(); // "msg"
+ return orderService.findByUser(userId, systemCode);
+ }
+}
+
+
+ 4.3 Python — Flask 中间件
+
+
Python
+
# ====== middleware.py ======
+from flask import request, g
+
+USER_HEADERS = [
+ 'X-User-Id', 'X-User-Name', 'X-User-Account',
+ 'X-User-Email', 'X-User-Post', 'X-User-Roles',
+ 'X-System-Id', 'X-System-Code', 'X-System-Name',
+]
+
+def user_context_middleware():
+ """将网关注入的用户信息存到 Flask g 对象"""
+ for h in USER_HEADERS:
+ val = request.headers.get(h)
+ if val:
+ setattr(g, h.replace('-', '_').lower(), val)
+
+# 注册到 app (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)
+
+
+ 4.4 Go — 中间件
+
+
Go
+
// ====== middleware.go ======
+package middleware
+
+import (
+ "context"
+ "net/http"
+)
+
+type contextKey string
+
+func UserContext(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ for _, h := range []string{
+ "X-User-Id", "X-User-Name", "X-User-Account",
+ "X-User-Email", "X-User-Roles",
+ "X-System-Id", "X-System-Code",
+ } {
+ if v := r.Header.Get(h); v != "" {
+ ctx = context.WithValue(ctx, contextKey(h), v)
+ }
+ }
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
+// 快捷函数
+func GetUserID(ctx context.Context) string {
+ return ctx.Value(contextKey("X-User-Id")).(string)
+}
+func GetSystemCode(ctx context.Context) string {
+ return ctx.Value(contextKey("X-System-Code")).(string)
+}
+
+// ====== main.go: 注册中间件 ======
+// http.Handle("/api/", middleware.UserContext(yourHandler))
+
+// ====== 业务代码 ======
+func listOrders(w http.ResponseWriter, r *http.Request) {
+ userID := middleware.GetUserID(r.Context()) // "123"
+ systemCode := middleware.GetSystemCode(r.Context()) // "msg"
+ // ...
+}
+
+
+
+
+
+ 5API 接口参考
+
+ 5.1 获取当前用户信息
+
+
GET
+
/zgapi/v1/auth/user/info
+
+
+
响应:
+
{
+ "code": 200,
+ "data": {
+ "userId": 1,
+ "username": "admin",
+ "realName": "管理员",
+ "email": "admin@zgitm.com",
+ "phone": "13800000000",
+ "avatar": null,
+ "post": "管理员"
+ }
+}
+
+
+
+ 5.2 获取系统菜单路由
+
+
GET
+
/zgapi/v1/auth/menu/router?systemCode={{ sys?.systemCode || 'YOUR_CODE' }}
+
+
+ 响应:树形菜单结构,包含 path / name / component / children / meta(含 title, icon)
+
+
+
+ 5.3 退出登录
+
+
POST
+
/zgapi/v1/auth/logout
+
+
+
+
+
+
+ 6数据库初始化
+ 在网关平台的数据库执行以下 SQL,为「{{ sys?.systemName }}」创建默认菜单和角色。
+
+
+
+
+
+ 7创建网关管理员账号
+
+ 业务系统接入后,系统管理员需要登录网关管理后台维护本系统的菜单和角色。
+ 执行以下 SQL 创建一个应用系统管理员账号(角色编码 gateway-app-admin),该管理员只能看到自己绑定系统的数据。
+
+
+
SQL — 创建应用系统管理员
+
{{ adminSql }}
+
+
+
⚠️ 注意
+
密码需用 BCrypt 加密。可使用在线工具或 Java BCrypt.hashpw("your_password", BCrypt.gensalt()) 生成密文。
+ 如果已创建过用户,跳过步骤 1,只执行步骤 2 和 3 绑定系统和角色即可。
+
+
+
+
+
+ 8对接测试清单
+
+
+
+ {{ item.text }}
+
+
+
+
+
+
+ 9开放 API — 文件上传 / 下载
+
+ 对外开放 API 使用一次性 Token 认证,可用于外部系统上传文件到文件对象存储、或从存储中下载文件。
+ 每个 Token 12 小时内有效,只能使用一次(调用任意接口即消费)。
+
+
+ 9.1 获取一次性 Token
+
+
POST
+
/zgapi/v1/open/token
+
+
请求体:
+
{
+ "systemCode": "{{ sys?.systemCode || 'YOUR_CODE' }}"
+}
+
响应:
+
{
+ "code": 200,
+ "data": {
+ "token": "eyJhbGciOiJIUzM4NCJ9...",
+ "expiresIn": 43200,
+ "systemName": "业务系统名称",
+ "usageNote": "此 Token 只能消费一次,消费后立即失效"
+ }
+}
+
+
+ 9.2 校验 Token(不消费)
+
+
POST
+
/zgapi/v1/open/token/validate
+
+
请求体:
+
{
+ "token": "eyJhbGciOiJIUzM4NCJ9..."
+}
+
成功响应(Token 有效,未被消费):
+
{
+ "code": 200,
+ "data": {
+ "valid": true,
+ "systemCode": "gateway",
+ "msg": "Token 有效,未被消费"
+ }
+}
+
⚠ 此接口不会消费 Token,校验通过后 Token 仍可用于后续操作。
+
+
+ 9.3 标记 Token 已消费
+
+
POST
+
/zgapi/v1/open/token/consume
+
+
请求体:
+
{
+ "token": "eyJhbGciOiJIUzM4NCJ9...",
+ "action": "上传文件"
+}
+
参数说明:
+
+
+
+
+
+
+
成功响应:
+
{
+ "code": 200,
+ "data": {
+ "tokenConsumed": true,
+ "action": "上传文件",
+ "msg": "Token 已标记为已消费"
+ }
+}
+
+ 适用场景:外部系统在自己的业务中消费了 Token(如上传到自有存储),调用此接口告知网关防止重复使用。
+
+
+
+ 9.4 上传文件
+
+
POST
+
/zgapi/v1/open/file/upload
+
+
+
+
+
+
+
+
curl 示例:
+
# 获取 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"
+
成功响应(Token 已消费):
+
{
+ "code": 200,
+ "data": {
+ "fileId": "a1b2c3d4e5f6",
+ "fileName": "file.png",
+ "storageFileName": "a1b2c3d4e5f6.png",
+ "fileSize": 51200,
+ "tokenConsumed": true
+ }
+}
+
+
+ 9.5 下载文件
+
+
GET
+
/zgapi/v1/open/file/{id}/download?token={token}
+
+
curl 示例:
+
# 下载文件(Token 通过 query 参数传递)
+curl -o downloaded.png \
+ "{{ gatewayUrl }}/zgapi/v1/open/file/a1b2c3d4e5f6/download?token=$TOKEN"
+
成功响应:直接返回文件二进制流,Content-Disposition: attachment 触发浏览器下载。
+
错误响应:
+
+
+
+
+
+
+ 9.6 发送邮件
+
+
POST
+
/zgapi/v1/open/email/send
+
+
请求体:
+
{
+ "to": "user@example.com",
+ "subject": "邮件主题",
+ "content": "<h1>邮件内容</h1>",
+ "contentType": "html"
+}
+
curl 示例:
+
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": 200,
+ "data": {
+ "msg": "发送成功",
+ "tokenConsumed": true
+ }
+}
+
+
+
+
⚠️ 注意事项
+
1. 每个 Token 只能使用一次,调用任意开放接口(上传/下载/发邮件)即消费。
+ 2. 调用失败不消费 Token,可重试(如邮件发送失败、文件写入失败)。
+ 3. 文件上传大小限制为 2GB,超时时间 12 小时。
+ 4. 下载链接中的 Token 通过 URL 明文传递,请勿在公开页面中暴露。
+ 5. 上传成功后返回 fileId,请妥善保存用于后续下载。
+
+
+
+
+
+
+
+
+
diff --git a/_backup_20260613/OpenApiController.java b/_backup_20260613/OpenApiController.java
new file mode 100644
index 0000000..36cbba8
--- /dev/null
+++ b/_backup_20260613/OpenApiController.java
@@ -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
+ *
+ * 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
+ *
+ * 每个 Token 只能消费一次(调用任意 /zgapi/v1/open/* 接口即为消费),消费后立即失效。
+ * Token 有效期 2 小时,过期未消费也会失效。
+ */
+ @PostMapping("/token")
+ @Operation(summary = "获取一次性API Token",
+ description = "传入 systemCode 换取 JWT Token。Token 有效期2小时,只能消费一次,消费后立即失效。")
+ public Result