508 lines
20 KiB
Java
508 lines
20 KiB
Java
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);
|
||
}
|
||
}
|