初始化

This commit is contained in:
zg
2026-07-15 16:13:27 +08:00
parent 6d6f559793
commit c4023c1623
276 changed files with 42622 additions and 0 deletions

View 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 获取一次性 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);
}
}