Files
mokeegateway/_backup_20260613/OpenApiController.java
2026-07-15 16:13:27 +08:00

508 lines
20 KiB
Java
Raw Blame History

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