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> getToken(HttpServletRequest request) { Map 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() .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 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 是否有效(不消费) *

* 仅检查 Token 是否存在且未被消费,不做消费标记。 * 用于调用方在实际调用前预先确认 Token 可用。 */ @PostMapping("/token/validate") @Operation(summary = "校验Token是否有效(不消费)", description = "传入 token 检查是否存在且未被消费。注意:此接口不会消费 Token,仅做状态查询。") public Result> validateToken(HttpServletRequest request) { Map 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 为已消费(供外部系统回调) *

* 外部系统在自己的业务中消费了 Token 后,调用此接口告知网关标记已消费, * 防止该 Token 被重复使用。 */ @PostMapping("/token/consume") @Operation(summary = "标记Token已消费", description = "外部系统调用此接口主动标记 Token 已消费。Token 被标记后不可再使用。") public Result> markConsumed(HttpServletRequest request) { Map 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 *

* 只有邮件发送成功后才消费 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 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 *

* Token 通过 multipart form 字段传递。上传成功后消费 Token。 */ @PostMapping("/file/upload") @Operation(summary = "上传文件(消费Token)", description = "上传文件到文件对象存储。Token 作为 form 字段传递,上传成功后立即消费 Token。") public Result> 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 *

* 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 有效性(不消费) *

仅校验 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 w = new LambdaQueryWrapper<>(); w.eq(SysOpenTokenLog::getTokenId, tokenId); openTokenLogMapper.update(log, w); } catch (Exception ignored) {} } /** * 验证 Open API Token 有效性(不消费),从原始 token 字符串直接验证 *

用于 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 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('�') >= 0) { json = new String(bytes, java.nio.charset.Charset.forName("GBK")); } return JSONUtil.toBean(json, Map.class); } }