初始化
This commit is contained in:
176
_backup_20260613/FileController.java
Normal file
176
_backup_20260613/FileController.java
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件管理控制器
|
||||||
|
* <p>
|
||||||
|
* 文件二进制数据存储于本地磁盘,数据库仅保存元信息。
|
||||||
|
* 向下兼容存量 BLOB 数据(file_data 非空时优先读磁盘,磁盘无文件则回退 DB BLOB)。
|
||||||
|
* <p>
|
||||||
|
* 请求路径前缀:{@code /zgapi/v1/admin/file}
|
||||||
|
*
|
||||||
|
* @author mokee
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/zgapi/v1/admin/file")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class FileController {
|
||||||
|
private final FileService fileService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件(管理后台)
|
||||||
|
* <p>
|
||||||
|
* 上传者显示名从请求头 X-User-Name 获取(由网关 HeaderInjectFilter 注入,已 URL 解码)。
|
||||||
|
*/
|
||||||
|
@OperationLog(module = "文件管理", action = "上传")
|
||||||
|
@PostMapping("/upload")
|
||||||
|
public Result<SysFile> 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<IPage<SysFile>> page(@RequestBody java.util.Map<String, Object> 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<SysFile>> list(@RequestParam(required = false) String fileType,
|
||||||
|
@RequestParam(required = false) String systemId) {
|
||||||
|
return Result.ok(fileService.list(fileType, systemId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件元信息(不含二进制数据)
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public Result<SysFile> getById(@PathVariable String id) {
|
||||||
|
return Result.ok(fileService.getMetaById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预览文件 — 直接输出文件流
|
||||||
|
* <p>
|
||||||
|
* 优先读磁盘文件;磁盘文件不存在时回退到 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载文件 — 触发浏览器下载
|
||||||
|
* <p>
|
||||||
|
* 与 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
738
_backup_20260613/FileList.vue
Normal file
738
_backup_20260613/FileList.vue
Normal file
@@ -0,0 +1,738 @@
|
|||||||
|
<!--
|
||||||
|
文件对象储存 — 表格列表页
|
||||||
|
|
||||||
|
功能:
|
||||||
|
- 表格展示所有文件(缩略图、原文件名、储存文件名、类型、大小、上传者、时间)
|
||||||
|
- 图片缩略图 50x50,点击弹窗预览大图
|
||||||
|
- 操作:下载 / 复制下载链接 / 删除
|
||||||
|
|
||||||
|
管理后台接口(Cookie 登录态):
|
||||||
|
- GET /zgapi/v1/admin/file/{id}/view 预览
|
||||||
|
- GET /zgapi/v1/admin/file/{id}/download 下载
|
||||||
|
- DELETE /zgapi/v1/admin/file/{id} 删除
|
||||||
|
- POST /zgapi/v1/admin/file/page 分页
|
||||||
|
|
||||||
|
开放 API(需外部系统自行获取 token):
|
||||||
|
{gatewayUrl}/zgapi/v1/open/file/{id}/download?token=YOUR_TOKEN
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<!-- 搜索+操作栏 -->
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header-title">
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
<span>搜索条件</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-form :model="query" inline>
|
||||||
|
<el-form-item label="原文件名">
|
||||||
|
<el-input v-model="query.fileName" placeholder="请输入文件名" clearable @keyup.enter="handleSearch" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="上传者">
|
||||||
|
<el-input v-model="query.uploadUserName" placeholder="请输入上传者" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="文件类型">
|
||||||
|
<el-select v-model="query.fileType" placeholder="全部" clearable style="width:120px">
|
||||||
|
<el-option label="头像" value="avatar" />
|
||||||
|
<el-option label="图标" value="icon" />
|
||||||
|
<el-option label="文档" value="doc" />
|
||||||
|
<el-option label="其他" value="other" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSearch">
|
||||||
|
<el-icon><Search /></el-icon>搜索
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button type="primary" @click="uploadVisible = true" v-permission="'system:file:upload'">
|
||||||
|
<el-icon><Upload /></el-icon>上传文件
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 上传弹窗 -->
|
||||||
|
<el-dialog v-model="uploadVisible" width="640px" destroy-on-close @closed="resetUpload" class="upload-dialog">
|
||||||
|
<template #header>
|
||||||
|
<div class="dialog-header-custom">
|
||||||
|
<div class="header-icon-box">
|
||||||
|
<el-icon :size="22"><UploadFilled /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="header-title">上传文件</div>
|
||||||
|
<div class="header-sub">{{ uploadQueue.length ? uploadQueue.length + ' 个文件待上传' : '支持批量选择,逐个上传' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 拖拽区域 -->
|
||||||
|
<div
|
||||||
|
class="drop-zone"
|
||||||
|
:class="{ 'drag-over': dragOver, 'has-files': uploadQueue.length > 0 }"
|
||||||
|
@click="triggerUpload"
|
||||||
|
@dragover.prevent="dragOver = true"
|
||||||
|
@dragleave="dragOver = false"
|
||||||
|
@drop.prevent="onDrop"
|
||||||
|
>
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div v-if="uploadQueue.length === 0" class="drop-empty">
|
||||||
|
<div class="drop-icon-ring">
|
||||||
|
<el-icon :size="36"><Cloudy /></el-icon>
|
||||||
|
</div>
|
||||||
|
<p class="drop-title">将文件拖拽到此处</p>
|
||||||
|
<p class="drop-hint">或点击此区域选择文件(支持多选)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文件队列 -->
|
||||||
|
<div v-else class="queue-list">
|
||||||
|
<div
|
||||||
|
v-for="(f, i) in uploadQueue" :key="i"
|
||||||
|
class="queue-card"
|
||||||
|
:class="f._status"
|
||||||
|
>
|
||||||
|
<!-- 状态图标 -->
|
||||||
|
<div class="q-icon" :class="f._status">
|
||||||
|
<el-icon v-if="f._status === 'done'" :size="22"><Check /></el-icon>
|
||||||
|
<el-icon v-else-if="f._status === 'fail'" :size="22"><Close /></el-icon>
|
||||||
|
<div v-else-if="f._status === 'uploading'" class="spinner"></div>
|
||||||
|
<el-icon v-else :size="22"><Document /></el-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文件信息 -->
|
||||||
|
<div class="q-body">
|
||||||
|
<div class="q-name">{{ f.file.name }}</div>
|
||||||
|
<div class="q-meta">
|
||||||
|
<span>{{ formatSize(f.file.size) }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- 进度条 -->
|
||||||
|
<div v-if="f._status === 'uploading'" class="q-progress">
|
||||||
|
<el-progress :percentage="uploadPercent" :stroke-width="8" :show-text="true" :color="'#6366f1'" />
|
||||||
|
<div class="q-speed">{{ uploadSpeed }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作 -->
|
||||||
|
<el-button
|
||||||
|
v-if="f._status === 'wait'"
|
||||||
|
class="q-remove" circle size="small" text
|
||||||
|
@click.stop="removeQueueItem(i)"
|
||||||
|
>
|
||||||
|
<el-icon><Close /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<el-tag v-else-if="f._status === 'done'" type="success" size="small" effect="light" round>完成</el-tag>
|
||||||
|
<el-tag v-else-if="f._status === 'fail'" type="danger" size="small" effect="light" round>失败</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input ref="fileInputRef" type="file" multiple style="display:none" @change="onFileSelected" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部操作栏 -->
|
||||||
|
<div class="upload-footer">
|
||||||
|
<div class="footer-left">
|
||||||
|
<el-select v-model="uploadForm.fileType" style="width:110px" :disabled="uploading" size="default">
|
||||||
|
<el-option label="🖼 头像" value="avatar" />
|
||||||
|
<el-option label="⭐ 图标" value="icon" />
|
||||||
|
<el-option label="📄 文档" value="doc" />
|
||||||
|
<el-option label="📦 其他" value="other" />
|
||||||
|
</el-select>
|
||||||
|
<el-button @click="triggerUpload" :disabled="uploading" size="default">
|
||||||
|
<el-icon><FolderAdd /></el-icon>添加文件
|
||||||
|
</el-button>
|
||||||
|
<el-button v-if="uploadQueue.length > 0" @click="uploadQueue = []" :disabled="uploading" size="default" text>
|
||||||
|
清空列表
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<el-button
|
||||||
|
type="primary" :loading="uploading" size="large"
|
||||||
|
:disabled="uploadQueue.length === 0 || allDone"
|
||||||
|
@click="startUpload"
|
||||||
|
class="btn-start"
|
||||||
|
>
|
||||||
|
<template v-if="!uploading">
|
||||||
|
<el-icon><Upload /></el-icon>
|
||||||
|
开始上传({{ uploadQueue.filter(f => f._status !== 'done').length }} 个文件)
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ doneCount }}/{{ uploadQueue.length }}
|
||||||
|
</template>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 表格 -->
|
||||||
|
<el-card shadow="never">
|
||||||
|
<el-table :data="fileList" border stripe v-loading="loading" style="width:100%">
|
||||||
|
<!-- 压缩图 -->
|
||||||
|
<el-table-column label="压缩图" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="thumb-box" @click="preview(row)" v-if="isImage(row.mimeType)">
|
||||||
|
<img :src="getViewUrl(row.id)" class="thumb-img" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<el-icon v-else :size="28" color="#94a3b8"><Document /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<!-- 原文件名 -->
|
||||||
|
<el-table-column prop="fileName" label="原文件名" min-width="180" show-overflow-tooltip />
|
||||||
|
|
||||||
|
<!-- 储存文件名 -->
|
||||||
|
<el-table-column prop="storageFileName" label="储存文件名" width="200" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<code class="storage-name-cell">{{ row.storageFileName || '-' }}</code>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<!-- 文件类型 -->
|
||||||
|
<el-table-column prop="fileType" label="文件类型" width="90" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="fileTypeTag(row.fileType)" size="small" effect="dark">
|
||||||
|
{{ fileTypeLabel(row.fileType) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<!-- 文件大小 -->
|
||||||
|
<el-table-column label="文件大小" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatSize(row.fileSize) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<!-- 上传者 -->
|
||||||
|
<el-table-column prop="uploadUserName" label="上传者" width="120" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.uploadUserName || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<!-- 上传时间 -->
|
||||||
|
<el-table-column label="上传时间" width="170">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.createdAt ? new Date(row.createdAt).toLocaleString('zh-CN') : '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<!-- 操作 -->
|
||||||
|
<el-table-column label="操作" width="220" align="center" class-name="ops-col" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="actions">
|
||||||
|
<el-button size="small" @click="downloadFile(row)">
|
||||||
|
<el-icon><Download /></el-icon>下载
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" @click="copyDownloadUrl(row)">
|
||||||
|
<el-icon><Link /></el-icon>复制链接
|
||||||
|
</el-button>
|
||||||
|
<el-popconfirm title="确定删除此文件?" @confirm="handleDelete(row.id)">
|
||||||
|
<template #reference>
|
||||||
|
<el-button size="small" type="danger">
|
||||||
|
<el-icon><Delete /></el-icon>删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-popconfirm>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<template #empty>
|
||||||
|
<el-empty description="暂无文件" :image-size="100" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination-wrapper" v-if="total > 0">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="page"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
background
|
||||||
|
@current-change="fetchData"
|
||||||
|
@size-change="fetchData"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 预览弹窗 -->
|
||||||
|
<el-dialog v-model="previewVisible" title="文件预览" width="720px" destroy-on-close>
|
||||||
|
<div class="preview-body">
|
||||||
|
<template v-if="previewItem && isImage(previewItem.mimeType)">
|
||||||
|
<img :src="getViewUrl(previewItem.id)" style="max-width:100%;max-height:65vh;display:block;margin:0 auto" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-empty description="非图片文件,无法预览" />
|
||||||
|
</template>
|
||||||
|
<div class="preview-meta" v-if="previewItem">
|
||||||
|
<div class="meta-line"><span class="meta-label">原文件名:</span>{{ previewItem.fileName }}</div>
|
||||||
|
<div class="meta-line"><span class="meta-label">储存文件名:</span>{{ previewItem.storageFileName || '-' }}</div>
|
||||||
|
<div class="meta-line"><span class="meta-label">类型:</span>{{ previewItem.mimeType || '-' }}</div>
|
||||||
|
<div class="meta-line"><span class="meta-label">大小:</span>{{ formatSize(previewItem.fileSize) }}</div>
|
||||||
|
<div class="meta-line"><span class="meta-label">上传者:</span>{{ previewItem.uploadUserName || '-' }}</div>
|
||||||
|
<div class="meta-line"><span class="meta-label">上传时间:</span>{{ previewItem.createdAt ? new Date(previewItem.createdAt).toLocaleString('zh-CN') : '-' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import {
|
||||||
|
Search, Document, Delete, Upload, UploadFilled, Close, Download, Link,
|
||||||
|
Cloudy, FolderAdd, Check,
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
import request from '@/utils/request'
|
||||||
|
import { useUploadStore } from '@/stores/upload'
|
||||||
|
|
||||||
|
interface FileItem {
|
||||||
|
id: string
|
||||||
|
fileName: string
|
||||||
|
storageFileName: string
|
||||||
|
fileType: string
|
||||||
|
fileSize: number
|
||||||
|
mimeType: string
|
||||||
|
md5: string
|
||||||
|
uploadUserName: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const fileList = ref<FileItem[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || ''
|
||||||
|
|
||||||
|
// 搜索条件
|
||||||
|
const query = reactive({
|
||||||
|
fileName: '',
|
||||||
|
uploadUserName: '',
|
||||||
|
fileType: '' as string,
|
||||||
|
})
|
||||||
|
|
||||||
|
const previewVisible = ref(false)
|
||||||
|
const previewItem = ref<FileItem | null>(null)
|
||||||
|
|
||||||
|
// ===== 上传队列 =====
|
||||||
|
interface QueueItem { file: File; _status: 'wait' | 'uploading' | 'done' | 'fail' }
|
||||||
|
|
||||||
|
const uploadStore = useUploadStore()
|
||||||
|
const uploadVisible = ref(false)
|
||||||
|
const uploading = ref(false)
|
||||||
|
const uploadPercent = ref(0)
|
||||||
|
const uploadSpeed = ref('')
|
||||||
|
const dragOver = ref(false)
|
||||||
|
const fileInputRef = ref<HTMLInputElement>()
|
||||||
|
const uploadQueue = ref<QueueItem[]>([])
|
||||||
|
const uploadForm = reactive({ fileType: 'other' })
|
||||||
|
let lastLoaded = 0
|
||||||
|
let lastTime = 0
|
||||||
|
|
||||||
|
const doneCount = computed(() => uploadQueue.value.filter(f => f._status === 'done').length)
|
||||||
|
const allDone = computed(() => uploadQueue.value.length > 0 && uploadQueue.value.every(f => f._status === 'done' || f._status === 'fail'))
|
||||||
|
|
||||||
|
function resetUpload() {
|
||||||
|
if (uploading.value) return // 上传中不重置
|
||||||
|
uploading.value = false
|
||||||
|
uploadPercent.value = 0
|
||||||
|
uploadSpeed.value = ''
|
||||||
|
uploadQueue.value = []
|
||||||
|
dragOver.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerUpload() { fileInputRef.value?.click() }
|
||||||
|
|
||||||
|
function addFiles(files: FileList | File[]) {
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i]
|
||||||
|
uploadQueue.value.push({ file, _status: 'wait' })
|
||||||
|
// 根据第一个文件自动判断类型
|
||||||
|
if (uploadQueue.value.length === 1) {
|
||||||
|
const ext = file.name.split('.').pop()?.toLowerCase()
|
||||||
|
if (ext && ['png','jpg','jpeg','gif','webp','svg','ico'].includes(ext)) uploadForm.fileType = 'icon'
|
||||||
|
else if (ext && ['pdf','doc','docx','xls','xlsx','txt','md'].includes(ext)) uploadForm.fileType = 'doc'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeQueueItem(i: number) {
|
||||||
|
uploadQueue.value.splice(i, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileSelected(e: Event) {
|
||||||
|
const files = (e.target as HTMLInputElement).files
|
||||||
|
if (files && files.length > 0) addFiles(files)
|
||||||
|
// 重置 input 以允许重复选同一个文件
|
||||||
|
if (fileInputRef.value) fileInputRef.value.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e: DragEvent) {
|
||||||
|
dragOver.value = false
|
||||||
|
const files = e.dataTransfer?.files
|
||||||
|
if (files && files.length > 0) addFiles(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 逐个上传队列中的文件(串行) */
|
||||||
|
async function startUpload() {
|
||||||
|
if (uploadQueue.value.length === 0) return
|
||||||
|
uploading.value = true
|
||||||
|
|
||||||
|
for (let i = 0; i < uploadQueue.value.length; i++) {
|
||||||
|
const item = uploadQueue.value[i]
|
||||||
|
if (item._status === 'done') continue
|
||||||
|
|
||||||
|
item._status = 'uploading'
|
||||||
|
uploadPercent.value = 0
|
||||||
|
uploadSpeed.value = '准备上传...'
|
||||||
|
lastLoaded = 0; lastTime = Date.now()
|
||||||
|
|
||||||
|
// 注册到全局上传面板
|
||||||
|
const taskId = Date.now().toString(36) + i
|
||||||
|
uploadStore.addTask({
|
||||||
|
id: taskId, fileName: item.file.name, fileSize: item.file.size,
|
||||||
|
fileType: uploadForm.fileType, file: item.file,
|
||||||
|
status: 'uploading', progress: 0, speed: '', startedAt: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', item.file)
|
||||||
|
fd.append('fileType', uploadForm.fileType)
|
||||||
|
|
||||||
|
const axios = (await import('axios')).default
|
||||||
|
const baseURL = import.meta.env.VITE_GATEWAY_URL || ''
|
||||||
|
const token = document.cookie.match(/(?:^|;\s*)token=([^;]*)/)?.[1] || ''
|
||||||
|
|
||||||
|
const res = await axios.post(baseURL + '/zgapi/v1/admin/file/upload', fd, {
|
||||||
|
timeout: 43200000, // 12小时
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data', ...(token ? { Authorization: 'Bearer ' + token } : {}) },
|
||||||
|
onUploadProgress: (e: any) => {
|
||||||
|
if (e.total > 0) {
|
||||||
|
uploadPercent.value = Math.round((e.loaded / e.total) * 100)
|
||||||
|
const now = Date.now()
|
||||||
|
const elapsed = (now - lastTime) / 1000
|
||||||
|
if (elapsed >= 1) {
|
||||||
|
const speed = (e.loaded - lastLoaded) / elapsed
|
||||||
|
uploadSpeed.value = formatSpeed(speed)
|
||||||
|
lastLoaded = e.loaded; lastTime = now
|
||||||
|
}
|
||||||
|
uploadStore.updateTask(taskId, { progress: uploadPercent.value, speed: uploadSpeed.value })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查业务状态码
|
||||||
|
if (res.data?.code && res.data.code !== 200) {
|
||||||
|
throw new Error(res.data.msg || '服务器返回错误')
|
||||||
|
}
|
||||||
|
|
||||||
|
item._status = 'done'
|
||||||
|
uploadStore.updateTask(taskId, { status: 'completed', progress: 100 })
|
||||||
|
} catch (e: any) {
|
||||||
|
item._status = 'fail'
|
||||||
|
// 提取错误信息
|
||||||
|
let errMsg = '上传失败'
|
||||||
|
if (e.response?.data?.msg) {
|
||||||
|
errMsg = e.response.data.msg
|
||||||
|
} else if (e.response?.status) {
|
||||||
|
errMsg = `服务器错误(${e.response.status})`
|
||||||
|
} else if (e.message && e.message !== '上传失败') {
|
||||||
|
errMsg = e.message
|
||||||
|
}
|
||||||
|
uploadStore.updateTask(taskId, { status: 'failed', error: errMsg })
|
||||||
|
console.error(`[上传失败] ${item.file.name}:`, errMsg, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uploading.value = false
|
||||||
|
ElMessage.success(`上传完成: ${doneCount.value}/${uploadQueue.value.length} 个文件成功`)
|
||||||
|
page.value = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSpeed(bytesPerSec: number) {
|
||||||
|
if (bytesPerSec < 1024) return bytesPerSec.toFixed(0) + ' B/s'
|
||||||
|
if (bytesPerSec < 1048576) return (bytesPerSec / 1024).toFixed(1) + ' KB/s'
|
||||||
|
return (bytesPerSec / 1048576).toFixed(1) + ' MB/s'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 数据 =====
|
||||||
|
function getViewUrl(id: string) { return `${gatewayUrl}/zgapi/v1/admin/file/${id}/view` }
|
||||||
|
function getDownloadUrl(id: string) { return `${gatewayUrl}/zgapi/v1/admin/file/${id}/download` }
|
||||||
|
function isImage(mime: string | null | undefined) { return mime ? mime.startsWith('image/') : false }
|
||||||
|
|
||||||
|
function formatSize(bytes: number | null) {
|
||||||
|
if (!bytes) return '0 B'
|
||||||
|
if (bytes < 1024) return bytes + ' B'
|
||||||
|
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
|
||||||
|
return (bytes / 1048576).toFixed(1) + ' MB'
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileTypeTag(type: string) {
|
||||||
|
const map: Record<string, string> = { avatar: 'success', icon: 'warning', doc: '', other: 'info' }
|
||||||
|
return map[type] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileTypeLabel(type: string) {
|
||||||
|
const map: Record<string, string> = { avatar: '头像', icon: '图标', doc: '文档', other: '其他' }
|
||||||
|
return map[type] || type || '其他'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
}
|
||||||
|
if (query.fileName) params.fileName = query.fileName
|
||||||
|
if (query.uploadUserName) params.uploadUserName = query.uploadUserName
|
||||||
|
if (query.fileType) params.fileType = query.fileType
|
||||||
|
const res = await request.post('/zgapi/v1/admin/file/page', params)
|
||||||
|
fileList.value = res.data?.records || []
|
||||||
|
total.value = res.data?.total || 0
|
||||||
|
} catch { /* ignore */ } finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
page.value = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
query.fileName = ''
|
||||||
|
query.uploadUserName = ''
|
||||||
|
query.fileType = ''
|
||||||
|
page.value = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 预览 =====
|
||||||
|
function preview(item: FileItem) {
|
||||||
|
previewItem.value = item
|
||||||
|
previewVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 下载(fetch + blob,支持跨域) =====
|
||||||
|
async function downloadFile(item: FileItem) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(getDownloadUrl(item.id), { credentials: 'include' })
|
||||||
|
if (!res.ok) { ElMessage.error('下载失败'); return }
|
||||||
|
const blob = await res.blob()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = item.fileName
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('下载失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 复制下载链接(自动获取一次性 token) =====
|
||||||
|
async function copyDownloadUrl(item: FileItem) {
|
||||||
|
try {
|
||||||
|
// 自动获取开放 API Token
|
||||||
|
const tokenRes = await request.post('/zgapi/v1/open/token', { systemCode: 'gateway' })
|
||||||
|
const token = tokenRes.data?.token
|
||||||
|
if (!token) { ElMessage.error('获取下载Token失败'); return }
|
||||||
|
|
||||||
|
const url = `${gatewayUrl}/zgapi/v1/open/file/${item.id}/download?token=${token}`
|
||||||
|
await navigator.clipboard.writeText(url)
|
||||||
|
ElMessage.success('已复制下载链接(Token 12小时有效,仅可使用一次)')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 删除 =====
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
try {
|
||||||
|
await request.delete(`/zgapi/v1/admin/file/${id}`)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
if (fileList.value.length === 1 && page.value > 1) page.value--
|
||||||
|
fetchData()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防误刷:上传中刷新/关闭页面时弹提示
|
||||||
|
function beforeUnload(e: BeforeUnloadEvent) {
|
||||||
|
if (uploading.value) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.returnValue = ''
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData()
|
||||||
|
window.addEventListener('beforeunload', beforeUnload)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('beforeunload', beforeUnload)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-wrapper { display:flex; flex-direction:column; gap:16px; }
|
||||||
|
|
||||||
|
/* 搜索卡片 */
|
||||||
|
.card-header-title {
|
||||||
|
display:flex; align-items:center; gap:6px; font-weight:600; color:#303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 工具栏 */
|
||||||
|
.toolbar { display:flex; justify-content:flex-start; margin-bottom:0; }
|
||||||
|
.toolbar-left { display:flex; gap:12px; }
|
||||||
|
.toolbar-right { display:flex; gap:8px; }
|
||||||
|
|
||||||
|
/* 缩略图 */
|
||||||
|
.thumb-box {
|
||||||
|
width:50px; height:50px; border-radius:6px; overflow:hidden;
|
||||||
|
cursor:pointer; border:1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.thumb-box:hover { border-color:var(--color-primary); }
|
||||||
|
.thumb-img {
|
||||||
|
width:100%; height:100%; object-fit:cover; display:block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 储存文件名 */
|
||||||
|
.storage-name-cell {
|
||||||
|
font-family:'SF Mono','Fira Code',monospace;
|
||||||
|
font-size:12px; color:var(--text-muted);
|
||||||
|
background:var(--bg-page); padding:2px 6px; border-radius:4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分页 */
|
||||||
|
.pagination-wrapper { display:flex; justify-content:flex-end; margin-top:20px; }
|
||||||
|
|
||||||
|
/* 操作 */
|
||||||
|
.actions { display:flex; gap:4px; flex-wrap:wrap; justify-content:center; }
|
||||||
|
|
||||||
|
/* ===== 上传弹窗 ===== */
|
||||||
|
.upload-dialog :deep(.el-dialog__header) { display:none; }
|
||||||
|
.upload-dialog :deep(.el-dialog__body) { padding:0; }
|
||||||
|
|
||||||
|
/* 自定义标题 */
|
||||||
|
.dialog-header-custom {
|
||||||
|
display:flex; align-items:center; gap:14px;
|
||||||
|
padding:20px 28px 0;
|
||||||
|
}
|
||||||
|
.header-icon-box {
|
||||||
|
width:44px; height:44px; border-radius:var(--radius-lg);
|
||||||
|
background:linear-gradient(135deg, #6366f1, #818cf8);
|
||||||
|
display:flex; align-items:center; justify-content:center; color:#fff; flex-shrink:0;
|
||||||
|
}
|
||||||
|
.header-title { font-size:17px; font-weight:700; color:var(--text-primary); }
|
||||||
|
.header-sub { font-size:12px; color:var(--text-muted); margin-top:2px; }
|
||||||
|
|
||||||
|
/* 拖拽区域 */
|
||||||
|
.drop-zone {
|
||||||
|
margin:20px 28px; padding:24px;
|
||||||
|
border:2px dashed var(--border-color); border-radius:var(--radius-xl);
|
||||||
|
cursor:pointer; transition:all 0.25s; position:relative;
|
||||||
|
min-height:160px; max-height:360px; overflow-y:auto;
|
||||||
|
}
|
||||||
|
.drop-zone:hover, .drop-zone.drag-over {
|
||||||
|
border-color:var(--color-primary); background:linear-gradient(135deg, rgba(99,102,241,0.03), rgba(129,140,248,0.05));
|
||||||
|
}
|
||||||
|
.drop-zone.drag-over { border-style:solid; box-shadow:0 0 0 4px rgba(99,102,241,0.1); }
|
||||||
|
.drop-zone.has-files { border-style:solid; border-color:var(--border-color); }
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.drop-empty { text-align:center; padding:20px 0; }
|
||||||
|
.drop-icon-ring {
|
||||||
|
width:72px; height:72px; border-radius:50%;
|
||||||
|
background:linear-gradient(135deg, #eef2ff, #e0e7ff);
|
||||||
|
display:inline-flex; align-items:center; justify-content:center;
|
||||||
|
margin-bottom:16px; color:var(--color-primary);
|
||||||
|
}
|
||||||
|
.drop-title { font-size:16px; font-weight:600; color:var(--text-primary); margin-bottom:4px; }
|
||||||
|
.drop-hint { font-size:13px; color:var(--text-muted); }
|
||||||
|
|
||||||
|
/* 文件卡片 */
|
||||||
|
.queue-list { display:flex; flex-direction:column; gap:10px; }
|
||||||
|
.queue-card {
|
||||||
|
display:flex; align-items:center; gap:14px;
|
||||||
|
padding:14px 16px; border-radius:var(--radius-lg);
|
||||||
|
background:#fff; border:1px solid var(--border-color);
|
||||||
|
transition:all 0.2s;
|
||||||
|
}
|
||||||
|
.queue-card:hover { box-shadow:var(--shadow-sm); }
|
||||||
|
.queue-card.done { border-color:#bbf7d0; background:#f0fdf4; }
|
||||||
|
.queue-card.fail { border-color:#fecaca; background:#fef2f2; }
|
||||||
|
.queue-card.uploading { border-color:#c7d2fe; background:#eef2ff; }
|
||||||
|
|
||||||
|
/* 状态图标 */
|
||||||
|
.q-icon {
|
||||||
|
width:44px; height:44px; border-radius:var(--radius-md);
|
||||||
|
display:flex; align-items:center; justify-content:center; flex-shrink:0;
|
||||||
|
background:var(--bg-page); color:var(--text-muted);
|
||||||
|
}
|
||||||
|
.q-icon.done { background:#dcfce7; color:#16a34a; }
|
||||||
|
.q-icon.fail { background:#fecaca; color:#dc2626; }
|
||||||
|
.q-icon.uploading { background:#e0e7ff; }
|
||||||
|
|
||||||
|
/* 旋转动画 */
|
||||||
|
.spinner {
|
||||||
|
width:20px; height:20px; border:2.5px solid #c7d2fe;
|
||||||
|
border-top-color:var(--color-primary); border-radius:50%;
|
||||||
|
animation:spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform:rotate(360deg); } }
|
||||||
|
|
||||||
|
/* 文件信息 */
|
||||||
|
.q-body { flex:1; min-width:0; }
|
||||||
|
.q-name { font-size:14px; font-weight:500; color:var(--text-primary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
.q-meta { font-size:12px; color:var(--text-muted); margin-top:2px; }
|
||||||
|
.q-progress { margin-top:8px; }
|
||||||
|
.q-speed { font-size:11px; color:var(--text-muted); text-align:right; margin-top:2px; }
|
||||||
|
|
||||||
|
.q-remove { flex-shrink:0; color:var(--text-muted); }
|
||||||
|
.q-remove:hover { color:var(--color-danger); background:#fef2f2; }
|
||||||
|
|
||||||
|
/* 底部栏 */
|
||||||
|
.upload-footer {
|
||||||
|
display:flex; align-items:center; justify-content:space-between;
|
||||||
|
padding:16px 28px 24px; border-top:1px solid var(--border-light);
|
||||||
|
gap:12px;
|
||||||
|
}
|
||||||
|
.footer-left { display:flex; align-items:center; gap:8px; }
|
||||||
|
.btn-start { min-width:180px; }
|
||||||
|
|
||||||
|
/* 移动端 */
|
||||||
|
@media (max-width:768px) {
|
||||||
|
.upload-dialog :deep(.el-dialog) { width:100vw !important; max-width:100vw !important; margin:0 !important; border-radius:0; }
|
||||||
|
.drop-zone { margin:12px 16px; padding:16px; min-height:120px; max-height:240px; }
|
||||||
|
.dialog-header-custom { padding:16px 16px 0; }
|
||||||
|
.upload-footer { flex-direction:column; padding:12px 16px 20px; }
|
||||||
|
.footer-left { width:100%; flex-wrap:wrap; }
|
||||||
|
.btn-start { width:100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 预览弹窗 */
|
||||||
|
.preview-meta { margin-top:20px; padding:16px; background:var(--bg-page); border-radius:var(--radius-md); }
|
||||||
|
.meta-line { font-size:13px; color:var(--text-secondary); line-height:2; }
|
||||||
|
.meta-label { color:var(--text-muted); }
|
||||||
|
|
||||||
|
/* 移动端 */
|
||||||
|
@media (max-width:768px) {
|
||||||
|
.toolbar { flex-direction:column; }
|
||||||
|
.toolbar .el-button { width:100%; }
|
||||||
|
|
||||||
|
.search-card :deep(.el-form-item) { width:100%; margin-right:0; }
|
||||||
|
.search-card :deep(.el-input),
|
||||||
|
.search-card :deep(.el-select) { width:100% !important; }
|
||||||
|
|
||||||
|
.storage-name-cell { font-size:10px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
49
_backup_20260613/FileService.java
Normal file
49
_backup_20260613/FileService.java
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件服务接口
|
||||||
|
* <p>
|
||||||
|
* 文件二进制数据存储于本地磁盘,数据库仅保存元信息。
|
||||||
|
*
|
||||||
|
* @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<SysFile> page(Integer page, Integer size, String fileType, String fileName, String uploadUserName, String systemId);
|
||||||
|
|
||||||
|
/** 列表查询(不含 file_data) */
|
||||||
|
List<SysFile> 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;
|
||||||
|
}
|
||||||
178
_backup_20260613/FileServiceImpl.java
Normal file
178
_backup_20260613/FileServiceImpl.java
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件服务实现 — 文件二进制数据存储于本地磁盘
|
||||||
|
* <p>
|
||||||
|
* 磁盘路径结构:{basePath}/{yyyy-MM}/{storageFileName}
|
||||||
|
* 例:/data/mokee/gw/file/2026-06/a1b2c3d4e5f6.png
|
||||||
|
* <p>
|
||||||
|
* 向下兼容:存量 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<SysFile> page(Integer page, Integer size, String fileType,
|
||||||
|
String fileName, String uploadUserName, String systemId) {
|
||||||
|
LambdaQueryWrapper<SysFile> 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<SysFile> list(String fileType, String systemId) {
|
||||||
|
LambdaQueryWrapper<SysFile> 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<SysFile> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
936
_backup_20260613/IntegrationDoc.vue
Normal file
936
_backup_20260613/IntegrationDoc.vue
Normal file
@@ -0,0 +1,936 @@
|
|||||||
|
<!--
|
||||||
|
系统接入文档页面(公开访问,通过 docToken 路由访问)
|
||||||
|
|
||||||
|
路由:/docs/:token
|
||||||
|
页面路由在 router 中配置为无需登录即可访问(公开文档页)
|
||||||
|
|
||||||
|
内容包含 8 个章节:
|
||||||
|
1. 当前系统配置 — 展示系统基本信息(编码、名称、前后端地址)
|
||||||
|
2. 架构概览 — ASCII 架构图 + 5 步流程说明 + 3 条关键规则
|
||||||
|
3. 前端接入 — 完整的 Axios 实例和路由守卫复制即用代码
|
||||||
|
4. 后端接入 — Java/Python/Go 三种语言的 UserContext 中间件示例
|
||||||
|
5. API 接口参考 — GET 用户信息、GET 菜单路由、POST 退出登录
|
||||||
|
6. 数据库初始化 — 生成当前系统的菜单和角色初始化 SQL
|
||||||
|
7. 创建网关管理员账号 — 生成 gateway-app-admin 账号的 SQL
|
||||||
|
8. 对接测试清单 — 6 项验收检查
|
||||||
|
|
||||||
|
数据来源:
|
||||||
|
- 系统信息:GET /zgapi/v1/admin/system/doc/:token(公开接口,无需登录)
|
||||||
|
|
||||||
|
SQL 生成逻辑:
|
||||||
|
- menuSql:根据当前系统的 systemCode 生成菜单、角色、角色-菜单关联的 INSERT 语句
|
||||||
|
- adminSql:生成应用系统管理员账号、系统绑定、角色分配的 INSERT 语句
|
||||||
|
密码使用 BCrypt 密文($2a$10$...),账号格式为 admin_{systemCode}
|
||||||
|
角色编码固定为 gateway-app-admin(仅能看到自己绑定系统的数据)
|
||||||
|
所有 INSERT 使用 NOT EXISTS 子查询防止重复执行
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div class="doc-page">
|
||||||
|
<div class="doc-container">
|
||||||
|
<!-- ====== 页头 ====== -->
|
||||||
|
<header class="doc-hero">
|
||||||
|
<div class="doc-hero-left">
|
||||||
|
<svg class="doc-logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h1>{{ sys?.systemName || '系统' }} — 接入文档</h1>
|
||||||
|
<p>Mokee Gateway 统一登录网关 · 系统对接完整指南</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="doc-hero-right">
|
||||||
|
<el-tag size="large" type="primary" effect="dark" round>系统编码: {{ sys?.systemCode }}</el-tag>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ====== 一、系统信息 ====== -->
|
||||||
|
<section class="doc-section">
|
||||||
|
<h2 class="section-h2"><span class="s-num">1</span>当前系统配置</h2>
|
||||||
|
<el-descriptions :column="2" border size="large">
|
||||||
|
<el-descriptions-item label="系统编码"><code class="inline-code">{{ sys?.systemCode }}</code></el-descriptions-item>
|
||||||
|
<el-descriptions-item label="系统名称">{{ sys?.systemName }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="前端地址"><code class="inline-code">{{ sys?.frontUrl }}</code></el-descriptions-item>
|
||||||
|
<el-descriptions-item label="后端地址"><code class="inline-code">{{ sys?.backendUrl }}</code></el-descriptions-item>
|
||||||
|
<el-descriptions-item label="系统描述" :span="2">{{ sys?.description || '-' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ====== 二、架构概览 ====== -->
|
||||||
|
<section class="doc-section">
|
||||||
|
<h2 class="section-h2"><span class="s-num">2</span>架构概览</h2>
|
||||||
|
<div class="arch-diagram">
|
||||||
|
<pre class="arch-text">┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||||
|
│ 业务前端 │────▶│ Mokee Gateway │────▶│ 业务后端 │
|
||||||
|
│ (Vue/React) │ │ gw.server.zgitm.com │ │ (任意语言) │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ 携带 Token │ │ 验证 Token │ │ 从请求头读取: │
|
||||||
|
│ + SystemCode │ │ 识别系统编码 │ │ X-User-Id │
|
||||||
|
│ │ │ 转发请求 │ │ X-User-Name │
|
||||||
|
│ │ │ 注入用户信息头 │ │ X-System-Code │
|
||||||
|
└──────────────┘ └──────────────────┘ └──────────────────┘</pre>
|
||||||
|
</div>
|
||||||
|
<div class="flow-steps">
|
||||||
|
<div class="flow-step"><b>①</b> 用户访问业务前端 → 无 Token → 跳转统一登录页</div>
|
||||||
|
<div class="flow-step"><b>②</b> 登录成功 → 获得 JWT Token → 回跳到业务前端</div>
|
||||||
|
<div class="flow-step"><b>③</b> 业务前端所有 API 请求 → 网关 (携带 Authorization + X-System-Code)</div>
|
||||||
|
<div class="flow-step"><b>④</b> 网关验证 Token → 注入用户信息请求头 → 转发到业务后端</div>
|
||||||
|
<div class="flow-step"><b>⑤</b> 业务后端从请求头读取用户信息 → 执行业务逻辑</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="arch-warnings">
|
||||||
|
<div class="warn-item">
|
||||||
|
<b>🔴 关键规则一:前端只调网关</b>
|
||||||
|
<p>业务前端的 <code>baseURL</code> 必须配置为 <b>网关地址</b>(如 <code>{{ gatewayUrl }}</code>),<b>绝对不能</b>直接调用业务后端。网关根据请求头 <code>X-System-Code</code> 自动识别并转发到对应后端。</p>
|
||||||
|
</div>
|
||||||
|
<div class="warn-item">
|
||||||
|
<b>🔴 关键规则二:后端必须开跨域 (CORS)</b>
|
||||||
|
<p>业务后端需要允许来自网关域名的跨域请求。网关转发请求时 origin 是浏览器地址,不是网关地址。</p>
|
||||||
|
<p><b>Java Spring Boot 配置:</b></p>
|
||||||
|
<div class="code-box"><pre><code>@Configuration
|
||||||
|
public class CorsConfig implements WebMvcConfigurer {
|
||||||
|
@Override
|
||||||
|
public void addCorsMappings(CorsRegistry registry) {
|
||||||
|
registry.addMapping("/**")
|
||||||
|
.allowedOriginPatterns("*")
|
||||||
|
.allowedMethods("*")
|
||||||
|
.allowedHeaders("*")
|
||||||
|
.allowCredentials(true);
|
||||||
|
}
|
||||||
|
}</code></pre></div>
|
||||||
|
</div>
|
||||||
|
<div class="warn-item">
|
||||||
|
<b>🔴 关键规则三:业务后端不再做登录/鉴权</b>
|
||||||
|
<p>Token 验证由网关完成。业务后端只需从请求头读取用户信息,不需要再验证 Token、不需要查询用户表。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ====== 三、前端接入 ====== -->
|
||||||
|
<section class="doc-section">
|
||||||
|
<h2 class="section-h2"><span class="s-num">3</span>前端接入 (完整代码)</h2>
|
||||||
|
|
||||||
|
<h3 class="section-h3">3.1 安装依赖</h3>
|
||||||
|
<div class="code-box">
|
||||||
|
<div class="code-label">Shell</div>
|
||||||
|
<pre><code>npm install axios</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="section-h3">3.2 环境变量 (.env)</h3>
|
||||||
|
<div class="code-box">
|
||||||
|
<div class="code-label">.env</div>
|
||||||
|
<pre><code>VITE_GATEWAY_URL = {{ gatewayUrl }}
|
||||||
|
VITE_LOGIN_URL = {{ loginUrl }}
|
||||||
|
VITE_SYSTEM_CODE = {{ sys?.systemCode || 'YOUR_CODE' }}</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="section-h3">3.3 Axios 实例 (src/utils/request.ts)</h3>
|
||||||
|
<div class="code-box">
|
||||||
|
<div class="code-label">TypeScript</div>
|
||||||
|
<pre><code>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</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="section-h3">3.4 路由守卫 (src/router/index.ts)</h3>
|
||||||
|
<div class="code-box">
|
||||||
|
<div class="code-label">TypeScript</div>
|
||||||
|
<pre><code>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</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="section-h3">3.5 登录成功回调处理</h3>
|
||||||
|
<p class="text-p">统一登录成功后,网关会将 Token 写入 localStorage 并跳回业务前端。业务前端无需处理登录请求,只需从 localStorage 读取 Token。</p>
|
||||||
|
<div class="code-box">
|
||||||
|
<div class="code-label">TypeScript</div>
|
||||||
|
<pre><code>// 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)
|
||||||
|
}</code></pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ====== 四、后端接入 ====== -->
|
||||||
|
<section class="doc-section">
|
||||||
|
<h2 class="section-h2"><span class="s-num">4</span>后端接入 (完整代码)</h2>
|
||||||
|
|
||||||
|
<h3 class="section-h3">4.1 网关转发到后端时注入的请求头</h3>
|
||||||
|
<el-table :data="headerTable" border stripe size="large" style="width:100%">
|
||||||
|
<el-table-column prop="name" label="请求头名称" width="200" />
|
||||||
|
<el-table-column prop="type" label="数据类型" width="120" />
|
||||||
|
<el-table-column prop="desc" label="说明" />
|
||||||
|
</el-table>
|
||||||
|
<p class="text-p" style="margin-top:16px"><b>重要:</b>这些请求头由网关注入,业务后端可以信任这些值。不需要再验证 Token 或查询用户信息。</p>
|
||||||
|
|
||||||
|
<h3 class="section-h3">4.2 Java — UserContext 工具类</h3>
|
||||||
|
<p class="text-p">用 ThreadLocal 缓存请求头,业务代码零侵入获取用户信息。</p>
|
||||||
|
<div class="code-box">
|
||||||
|
<div class="code-label">Java</div>
|
||||||
|
<pre><code>// ====== 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);
|
||||||
|
}
|
||||||
|
}</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="section-h3">4.3 Python — Flask 中间件</h3>
|
||||||
|
<div class="code-box">
|
||||||
|
<div class="code-label">Python</div>
|
||||||
|
<pre><code># ====== 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)</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="section-h3">4.4 Go — 中间件</h3>
|
||||||
|
<div class="code-box">
|
||||||
|
<div class="code-label">Go</div>
|
||||||
|
<pre><code>// ====== 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"
|
||||||
|
// ...
|
||||||
|
}</code></pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ====== 五、API 接口参考 ====== -->
|
||||||
|
<section class="doc-section">
|
||||||
|
<h2 class="section-h2"><span class="s-num">5</span>API 接口参考</h2>
|
||||||
|
|
||||||
|
<h3 class="section-h3">5.1 获取当前用户信息</h3>
|
||||||
|
<div class="api-card">
|
||||||
|
<div class="api-method get">GET</div>
|
||||||
|
<div class="api-path">/zgapi/v1/auth/user/info</div>
|
||||||
|
<div class="api-headers">
|
||||||
|
<b>请求头:</b>Authorization: Bearer {token}
|
||||||
|
</div>
|
||||||
|
<div class="api-response">
|
||||||
|
<b>响应:</b>
|
||||||
|
<div class="code-box"><pre><code>{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"userId": 1,
|
||||||
|
"username": "admin",
|
||||||
|
"realName": "管理员",
|
||||||
|
"email": "admin@zgitm.com",
|
||||||
|
"phone": "13800000000",
|
||||||
|
"avatar": null,
|
||||||
|
"post": "管理员"
|
||||||
|
}
|
||||||
|
}</code></pre></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="section-h3">5.2 获取系统菜单路由</h3>
|
||||||
|
<div class="api-card">
|
||||||
|
<div class="api-method get">GET</div>
|
||||||
|
<div class="api-path">/zgapi/v1/auth/menu/router?systemCode={{ sys?.systemCode || 'YOUR_CODE' }}</div>
|
||||||
|
<div class="api-headers">
|
||||||
|
<b>请求头:</b>Authorization: Bearer {token}
|
||||||
|
</div>
|
||||||
|
<div class="api-response">
|
||||||
|
<b>响应:</b>树形菜单结构,包含 path / name / component / children / meta(含 title, icon)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="section-h3">5.3 退出登录</h3>
|
||||||
|
<div class="api-card">
|
||||||
|
<div class="api-method post">POST</div>
|
||||||
|
<div class="api-path">/zgapi/v1/auth/logout</div>
|
||||||
|
<div class="api-headers">
|
||||||
|
<b>请求头:</b>Authorization: Bearer {token}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ====== 六、数据库初始化 ====== -->
|
||||||
|
<section class="doc-section">
|
||||||
|
<h2 class="section-h2"><span class="s-num">6</span>数据库初始化</h2>
|
||||||
|
<p class="text-p">在网关平台的数据库执行以下 SQL,为「{{ sys?.systemName }}」创建默认菜单和角色。</p>
|
||||||
|
<div class="code-box">
|
||||||
|
<div class="code-label">SQL</div>
|
||||||
|
<pre><code>{{ menuSql }}</code></pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ====== 七、创建网关管理员账号 ====== -->
|
||||||
|
<section class="doc-section">
|
||||||
|
<h2 class="section-h2"><span class="s-num">7</span>创建网关管理员账号</h2>
|
||||||
|
<p class="text-p">
|
||||||
|
业务系统接入后,系统管理员需要登录网关管理后台维护本系统的菜单和角色。
|
||||||
|
执行以下 SQL 创建一个<b>应用系统管理员</b>账号(角色编码 <code>gateway-app-admin</code>),该管理员只能看到自己绑定系统的数据。
|
||||||
|
</p>
|
||||||
|
<div class="code-box">
|
||||||
|
<div class="code-label">SQL — 创建应用系统管理员</div>
|
||||||
|
<pre><code>{{ adminSql }}</code></pre>
|
||||||
|
</div>
|
||||||
|
<div class="warn-item" style="margin-top:16px">
|
||||||
|
<b>⚠️ 注意</b>
|
||||||
|
<p>密码需用 BCrypt 加密。可使用在线工具或 Java <code>BCrypt.hashpw("your_password", BCrypt.gensalt())</code> 生成密文。<br>
|
||||||
|
如果已创建过用户,跳过步骤 1,只执行步骤 2 和 3 绑定系统和角色即可。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ====== 八、测试清单 ====== -->
|
||||||
|
<section class="doc-section">
|
||||||
|
<h2 class="section-h2"><span class="s-num">8</span>对接测试清单</h2>
|
||||||
|
<div class="checklist">
|
||||||
|
<div v-for="(item, i) in testChecklist" :key="i" class="check-item">
|
||||||
|
<el-icon :size="20" color="var(--color-primary)"><component :is="getIconComponent(item.done ? 'CircleCheckFilled' : 'CircleClose')" /></el-icon>
|
||||||
|
<span>{{ item.text }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ====== 九、开放 API(文件上传/下载) ====== -->
|
||||||
|
<section class="doc-section">
|
||||||
|
<h2 class="section-h2"><span class="s-num">9</span>开放 API — 文件上传 / 下载</h2>
|
||||||
|
<p class="text-p">
|
||||||
|
对外开放 API 使用<b>一次性 Token</b> 认证,可用于外部系统上传文件到文件对象存储、或从存储中下载文件。
|
||||||
|
每个 Token <b>12 小时</b>内有效,<b>只能使用一次</b>(调用任意接口即消费)。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 class="section-h3">9.1 获取一次性 Token</h3>
|
||||||
|
<div class="api-card">
|
||||||
|
<div class="api-method post">POST</div>
|
||||||
|
<div class="api-path">/zgapi/v1/open/token</div>
|
||||||
|
<div class="api-headers"><b>Content-Type:</b> application/json; charset=UTF-8</div>
|
||||||
|
<p style="margin:8px 0"><b>请求体:</b></p>
|
||||||
|
<div class="code-box"><pre><code>{
|
||||||
|
"systemCode": "{{ sys?.systemCode || 'YOUR_CODE' }}"
|
||||||
|
}</code></pre></div>
|
||||||
|
<p style="margin:8px 0"><b>响应:</b></p>
|
||||||
|
<div class="code-box"><pre><code>{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"token": "eyJhbGciOiJIUzM4NCJ9...",
|
||||||
|
"expiresIn": 43200,
|
||||||
|
"systemName": "业务系统名称",
|
||||||
|
"usageNote": "此 Token 只能消费一次,消费后立即失效"
|
||||||
|
}
|
||||||
|
}</code></pre></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="section-h3">9.2 校验 Token(不消费)</h3>
|
||||||
|
<div class="api-card">
|
||||||
|
<div class="api-method post">POST</div>
|
||||||
|
<div class="api-path">/zgapi/v1/open/token/validate</div>
|
||||||
|
<div class="api-headers"><b>Content-Type:</b> application/json; charset=UTF-8</div>
|
||||||
|
<p style="margin:8px 0"><b>请求体:</b></p>
|
||||||
|
<div class="code-box"><pre><code>{
|
||||||
|
"token": "eyJhbGciOiJIUzM4NCJ9..."
|
||||||
|
}</code></pre></div>
|
||||||
|
<p style="margin:8px 0"><b>成功响应(Token 有效,未被消费):</b></p>
|
||||||
|
<div class="code-box"><pre><code>{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"valid": true,
|
||||||
|
"systemCode": "gateway",
|
||||||
|
"msg": "Token 有效,未被消费"
|
||||||
|
}
|
||||||
|
}</code></pre></div>
|
||||||
|
<p style="color:var(--color-danger);font-size:13px"><b>⚠ 此接口不会消费 Token</b>,校验通过后 Token 仍可用于后续操作。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="section-h3">9.3 标记 Token 已消费</h3>
|
||||||
|
<div class="api-card">
|
||||||
|
<div class="api-method post">POST</div>
|
||||||
|
<div class="api-path">/zgapi/v1/open/token/consume</div>
|
||||||
|
<div class="api-headers"><b>Content-Type:</b> application/json; charset=UTF-8</div>
|
||||||
|
<p style="margin:8px 0"><b>请求体:</b></p>
|
||||||
|
<div class="code-box"><pre><code>{
|
||||||
|
"token": "eyJhbGciOiJIUzM4NCJ9...",
|
||||||
|
"action": "上传文件"
|
||||||
|
}</code></pre></div>
|
||||||
|
<p style="margin:8px 0"><b>参数说明:</b></p>
|
||||||
|
<el-table :data="openApiConsumeParams" border stripe size="small" style="width:100%">
|
||||||
|
<el-table-column prop="name" label="参数" width="100" />
|
||||||
|
<el-table-column prop="type" label="类型" width="80" />
|
||||||
|
<el-table-column prop="required" label="必填" width="60" />
|
||||||
|
<el-table-column prop="desc" label="说明" />
|
||||||
|
</el-table>
|
||||||
|
<p style="margin:8px 0;margin-top:12px"><b>成功响应:</b></p>
|
||||||
|
<div class="code-box"><pre><code>{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"tokenConsumed": true,
|
||||||
|
"action": "上传文件",
|
||||||
|
"msg": "Token 已标记为已消费"
|
||||||
|
}
|
||||||
|
}</code></pre></div>
|
||||||
|
<p style="color:var(--text-muted);font-size:13px">
|
||||||
|
适用场景:外部系统在自己的业务中消费了 Token(如上传到自有存储),调用此接口告知网关防止重复使用。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="section-h3">9.4 上传文件</h3>
|
||||||
|
<div class="api-card">
|
||||||
|
<div class="api-method post">POST</div>
|
||||||
|
<div class="api-path">/zgapi/v1/open/file/upload</div>
|
||||||
|
<div class="api-headers"><b>Content-Type:</b> multipart/form-data</div>
|
||||||
|
<el-table :data="openApiUploadParams" border stripe size="small" style="margin:12px 0;width:100%">
|
||||||
|
<el-table-column prop="name" label="参数" width="130" />
|
||||||
|
<el-table-column prop="type" label="类型" width="80" />
|
||||||
|
<el-table-column prop="required" label="必填" width="60" />
|
||||||
|
<el-table-column prop="desc" label="说明" />
|
||||||
|
</el-table>
|
||||||
|
<p style="margin:8px 0"><b>curl 示例:</b></p>
|
||||||
|
<div class="code-box"><pre><code># 获取 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"</code></pre></div>
|
||||||
|
<p style="margin:8px 0"><b>成功响应(Token 已消费):</b></p>
|
||||||
|
<div class="code-box"><pre><code>{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"fileId": "a1b2c3d4e5f6",
|
||||||
|
"fileName": "file.png",
|
||||||
|
"storageFileName": "a1b2c3d4e5f6.png",
|
||||||
|
"fileSize": 51200,
|
||||||
|
"tokenConsumed": true
|
||||||
|
}
|
||||||
|
}</code></pre></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="section-h3">9.5 下载文件</h3>
|
||||||
|
<div class="api-card">
|
||||||
|
<div class="api-method get">GET</div>
|
||||||
|
<div class="api-path">/zgapi/v1/open/file/{id}/download?token={token}</div>
|
||||||
|
<div class="api-headers" style="margin-bottom:8px">
|
||||||
|
<b>Token 传递方式:</b>URL query 参数 <code>?token=xxx</code>(不能用 Header,浏览器 <a> 标签下载时无法自定义 Header)
|
||||||
|
</div>
|
||||||
|
<p style="margin:8px 0"><b>curl 示例:</b></p>
|
||||||
|
<div class="code-box"><pre><code># 下载文件(Token 通过 query 参数传递)
|
||||||
|
curl -o downloaded.png \
|
||||||
|
"{{ gatewayUrl }}/zgapi/v1/open/file/a1b2c3d4e5f6/download?token=$TOKEN"</code></pre></div>
|
||||||
|
<p style="margin:8px 0"><b>成功响应:</b>直接返回文件二进制流,Content-Disposition: attachment 触发浏览器下载。</p>
|
||||||
|
<p style="margin:8px 0"><b>错误响应:</b></p>
|
||||||
|
<el-table :data="openApiErrors" border stripe size="small" style="width:100%">
|
||||||
|
<el-table-column prop="code" label="HTTP" width="80" />
|
||||||
|
<el-table-column prop="msg" label="说明" />
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="section-h3">9.6 发送邮件</h3>
|
||||||
|
<div class="api-card">
|
||||||
|
<div class="api-method post">POST</div>
|
||||||
|
<div class="api-path">/zgapi/v1/open/email/send</div>
|
||||||
|
<div class="api-headers"><b>Authorization:</b> Bearer {token} | <b>Content-Type:</b> application/json; charset=UTF-8</div>
|
||||||
|
<p style="margin:8px 0"><b>请求体:</b></p>
|
||||||
|
<div class="code-box"><pre><code>{
|
||||||
|
"to": "user@example.com",
|
||||||
|
"subject": "邮件主题",
|
||||||
|
"content": "<h1>邮件内容</h1>",
|
||||||
|
"contentType": "html"
|
||||||
|
}</code></pre></div>
|
||||||
|
<p style="margin:8px 0"><b>curl 示例:</b></p>
|
||||||
|
<div class="code-box"><pre><code>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></pre></div>
|
||||||
|
<p style="margin:8px 0"><b>成功响应:</b></p>
|
||||||
|
<div class="code-box"><pre><code>{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"msg": "发送成功",
|
||||||
|
"tokenConsumed": true
|
||||||
|
}
|
||||||
|
}</code></pre></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="warn-item" style="margin-top:16px">
|
||||||
|
<b>⚠️ 注意事项</b>
|
||||||
|
<p>1. 每个 Token 只能使用一次,调用任意开放接口(上传/下载/发邮件)即消费。<br>
|
||||||
|
2. 调用失败<b>不消费</b> Token,可重试(如邮件发送失败、文件写入失败)。<br>
|
||||||
|
3. 文件上传大小限制为 2GB,超时时间 12 小时。<br>
|
||||||
|
4. 下载链接中的 Token 通过 URL 明文传递,请勿在公开页面中暴露。<br>
|
||||||
|
5. 上传成功后返回 <code>fileId</code>,请妥善保存用于后续下载。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { CircleCheckFilled, CircleClose } from '@element-plus/icons-vue'
|
||||||
|
import { getIconComponent } from '@/utils/icons'
|
||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const sys = ref<any>(null) // 系统信息对象(systemCode, systemName, frontUrl, backendUrl, description)
|
||||||
|
// docToken 从 URL params 读取,用于请求系统文档信息
|
||||||
|
const token = route.params.token as string
|
||||||
|
|
||||||
|
// 网关地址和登录地址从环境变量读取,未配置时使用默认值
|
||||||
|
const gatewayUrl = computed(() => import.meta.env.VITE_GATEWAY_URL || 'https://gw.server.zgitm.com')
|
||||||
|
const loginUrl = computed(() => import.meta.env.VITE_LOGIN_URL || 'https://login.user.zgitm.com')
|
||||||
|
|
||||||
|
// 网关转发时注入的 HTTP 请求头列表
|
||||||
|
// 业务后端从这些请求头中读取用户信息,无需自行验证 Token
|
||||||
|
const headerTable = [
|
||||||
|
{ name: 'X-User-Id', type: 'String', desc: '当前登录用户的唯一ID' },
|
||||||
|
{ name: 'X-User-Name', type: 'String', desc: '用户真实姓名' },
|
||||||
|
{ name: 'X-User-Account', type: 'String', desc: '用户登录账号' },
|
||||||
|
{ name: 'X-User-Email', type: 'String', desc: '用户邮箱' },
|
||||||
|
{ name: 'X-User-Post', type: 'String', desc: '用户岗位' },
|
||||||
|
{ name: 'X-User-Roles', type: 'JSON', desc: '用户角色列表 [{roleId, roleName, roleCode}]' },
|
||||||
|
{ name: 'X-System-Id', type: 'String', desc: '当前业务系统的数据库ID' },
|
||||||
|
{ name: 'X-System-Code', type: 'String', desc: '当前业务系统的唯一编码' },
|
||||||
|
{ name: 'X-System-Name', type: 'String', desc: '当前业务系统的显示名称' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成菜单和角色初始化 SQL
|
||||||
|
*
|
||||||
|
* 内容:
|
||||||
|
* 1. 创建"系统管理"和"业务管理"两个顶级目录
|
||||||
|
* 2. 创建"用户管理"、"角色管理"、"订单管理"三个子菜单
|
||||||
|
* 3. 创建"管理员"和"普通用户"两个角色
|
||||||
|
* 4. 将所有菜单分配给管理员角色
|
||||||
|
*
|
||||||
|
* SQL 特点:
|
||||||
|
* - 使用 SELECT ... FROM sys_system WHERE system_code = 'xxx' 子查询获取当前系统的 system_id
|
||||||
|
* - 子菜单的 parent_id 通过自连接获取对应父菜单 ID
|
||||||
|
*/
|
||||||
|
const menuSql = computed(() => {
|
||||||
|
const sc = sys.value?.systemCode || 'YOUR_SYSTEM_CODE'
|
||||||
|
const sn = sys.value?.systemName || '系统'
|
||||||
|
return `-- =============================================
|
||||||
|
-- ${sn} 菜单 & 角色初始化 SQL
|
||||||
|
-- 系统编码: ${sc}
|
||||||
|
-- 将 system_code 替换为你的编码后直接执行
|
||||||
|
-- =============================================
|
||||||
|
|
||||||
|
-- 1. 创建顶级菜单目录 (parent_id = 0)
|
||||||
|
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status)
|
||||||
|
SELECT s.id, 0, '系统管理', '/system', 'Layout', 'Setting', 1, 1, 1
|
||||||
|
FROM sys_system s WHERE s.system_code = '${sc}';
|
||||||
|
|
||||||
|
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status)
|
||||||
|
SELECT s.id, 0, '业务管理', '/biz', 'Layout', 'Document', 1, 2, 1
|
||||||
|
FROM sys_system s WHERE s.system_code = '${sc}';
|
||||||
|
|
||||||
|
-- 2. 创建子菜单 (parent_id = 对应父菜单ID)
|
||||||
|
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status)
|
||||||
|
SELECT s.id, m.id, '用户管理', '/system/user', 'system/user/index', 'User', 2, 1, 1
|
||||||
|
FROM sys_system s, sys_menu m
|
||||||
|
WHERE s.system_code = '${sc}' AND m.menu_name = '系统管理' AND m.system_id = s.id;
|
||||||
|
|
||||||
|
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status)
|
||||||
|
SELECT s.id, m.id, '角色管理', '/system/role', 'system/role/index', 'Avatar', 2, 2, 1
|
||||||
|
FROM sys_system s, sys_menu m
|
||||||
|
WHERE s.system_code = '${sc}' AND m.menu_name = '系统管理' AND m.system_id = s.id;
|
||||||
|
|
||||||
|
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status)
|
||||||
|
SELECT s.id, m.id, '订单管理', '/biz/order', 'biz/order/index', 'Tickets', 2, 1, 1
|
||||||
|
FROM sys_system s, sys_menu m
|
||||||
|
WHERE s.system_code = '${sc}' AND m.menu_name = '业务管理' AND m.system_id = s.id;
|
||||||
|
|
||||||
|
-- 3. 创建角色
|
||||||
|
INSERT INTO sys_role (system_id, role_name, role_code, description, status)
|
||||||
|
SELECT s.id, '管理员', 'admin', '拥有全部权限', 1
|
||||||
|
FROM sys_system s WHERE s.system_code = '${sc}';
|
||||||
|
|
||||||
|
INSERT INTO sys_role (system_id, role_name, role_code, description, status)
|
||||||
|
SELECT s.id, '普通用户', 'user', '拥有基本权限', 1
|
||||||
|
FROM sys_system s WHERE s.system_code = '${sc}';
|
||||||
|
|
||||||
|
-- 4. 将菜单分配给角色
|
||||||
|
INSERT INTO sys_role_menu (role_id, menu_id)
|
||||||
|
SELECT r.id, m.id
|
||||||
|
FROM sys_role r, sys_menu m, sys_system s
|
||||||
|
WHERE r.role_code = 'admin'
|
||||||
|
AND r.system_id = s.id
|
||||||
|
AND m.system_id = s.id
|
||||||
|
AND s.system_code = '${sc}';`
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成网关管理员账号创建 SQL
|
||||||
|
*
|
||||||
|
* 步骤:
|
||||||
|
* 1. 创建管理员用户(用户名 admin_{systemCode},密码为 admin123 的 BCrypt 密文)
|
||||||
|
* 使用 NOT EXISTS 防止重复创建
|
||||||
|
* 2. 绑定用户到 gateway 系统(sys_user_system 表)
|
||||||
|
* 3. 分配 gateway-app-admin 角色(该角色只能看到自己绑定系统的数据)
|
||||||
|
*
|
||||||
|
* 注意:
|
||||||
|
* - BCrypt 密文需自行生成替换,当前示例密文对应密码 "admin123"
|
||||||
|
* - 如果用户已存在,步骤 1 跳过,只执行步骤 2、3
|
||||||
|
* - 所有 INSERT 使用 NOT EXISTS 防止重复执行
|
||||||
|
*/
|
||||||
|
const adminSql = computed(() => {
|
||||||
|
const sc = sys.value?.systemCode || 'YOUR_SYSTEM_CODE'
|
||||||
|
return `-- =============================================
|
||||||
|
-- 创建网关管理平台的应用系统管理员账号
|
||||||
|
-- 角色编码: gateway-app-admin (只能看到自己绑定系统的数据)
|
||||||
|
-- =============================================
|
||||||
|
|
||||||
|
-- 1. 创建管理员用户(密码 'admin123' 的 BCrypt 密文,建议自行生成替换)
|
||||||
|
INSERT INTO sys_user (id, username, password, real_name, email, phone, status, is_deleted)
|
||||||
|
SELECT REPLACE(UUID(),'-',''), 'admin_${sc}',
|
||||||
|
'$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi',
|
||||||
|
'${sys.value?.systemName || '系统'}管理员', 'admin@${sc}.com', NULL, 1, 0
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM sys_user WHERE username = 'admin_${sc}');
|
||||||
|
|
||||||
|
-- 2. 绑定管理员到网关系统 (gateway)
|
||||||
|
INSERT INTO sys_user_system (id, user_id, system_id)
|
||||||
|
SELECT REPLACE(UUID(),'-',''), u.id, s.id
|
||||||
|
FROM sys_user u, sys_system s
|
||||||
|
WHERE u.username = 'admin_${sc}'
|
||||||
|
AND s.system_code = 'gateway'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_user_system us
|
||||||
|
WHERE us.user_id = u.id AND us.system_id = s.id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. 分配 gateway-app-admin 角色(gateway 系统下的应用管理员角色)
|
||||||
|
INSERT INTO sys_user_role (id, user_id, role_id)
|
||||||
|
SELECT REPLACE(UUID(),'-',''), u.id, r.id
|
||||||
|
FROM sys_user u, sys_role r, sys_system s
|
||||||
|
WHERE u.username = 'admin_${sc}'
|
||||||
|
AND r.role_code = 'gateway-app-admin'
|
||||||
|
AND r.system_id = s.id
|
||||||
|
AND s.system_code = 'gateway'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_user_role ur
|
||||||
|
WHERE ur.user_id = u.id AND ur.role_id = r.id
|
||||||
|
);`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 开放 API — 标记消费参数
|
||||||
|
const openApiConsumeParams = [
|
||||||
|
{ name: 'token', type: 'String', required: '是', desc: '需要标记已消费的 Token' },
|
||||||
|
{ name: 'action', type: 'String', required: '是', desc: '消费动作:发送邮件 / 上传文件 / 下载文件' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 开放 API — 上传参数表
|
||||||
|
const openApiUploadParams = [
|
||||||
|
{ name: 'file', type: 'File', required: '是', desc: '上传的文件' },
|
||||||
|
{ name: 'token', type: 'String', required: '是', desc: '通过 /open/token 获取的一次性 Token(form 字段传递)' },
|
||||||
|
{ name: 'fileType', type: 'String', required: '否', desc: '文件类型: avatar / icon / doc / other(默认 other)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 开放 API — 错误码
|
||||||
|
const openApiErrors = [
|
||||||
|
{ code: '400', msg: '缺少 file 或 token 参数' },
|
||||||
|
{ code: '401', msg: 'Token 无效、已过期或已消费' },
|
||||||
|
{ code: '404', msg: '文件不存在或文件数据丢失' },
|
||||||
|
{ code: '500', msg: '文件上传/下载失败(失败不消费 Token,可重试)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 测试清单(全部默认未完成,由接入方自行勾选确认)
|
||||||
|
const testChecklist = computed(() => [
|
||||||
|
{ text: '访问业务前端首页 → 自动跳转到统一登录页', done: false },
|
||||||
|
{ text: '使用测试账号登录 → 成功进入业务前端', done: false },
|
||||||
|
{ text: '调用 GET /zgapi/v1/auth/user/info → 返回用户信息', done: false },
|
||||||
|
{ text: '业务后端 Controller 中 UserContext.getUserId() 能获取到用户ID', done: false },
|
||||||
|
{ text: '未放行的 API 请求被网关返回 403', done: false },
|
||||||
|
{ text: '退出登录后访问业务页面 → 重新跳转登录页', done: false },
|
||||||
|
])
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await request.get('/zgapi/v1/admin/system/doc/' + token)
|
||||||
|
sys.value = res.data
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ===== 整体 ===== */
|
||||||
|
.doc-page { min-height:100vh; background:var(--bg-page); padding:20px; }
|
||||||
|
.doc-container { max-width:960px; margin:0 auto; }
|
||||||
|
|
||||||
|
/* ===== 页头 ===== */
|
||||||
|
.doc-hero {
|
||||||
|
display:flex; align-items:center; justify-content:space-between;
|
||||||
|
padding:28px 32px; background:var(--bg-surface);
|
||||||
|
border-radius:var(--radius-xl); border:1px solid var(--border-color);
|
||||||
|
margin-bottom:24px; box-shadow:var(--shadow-sm);
|
||||||
|
}
|
||||||
|
.doc-hero-left { display:flex; align-items:center; gap:14px; }
|
||||||
|
.doc-logo { width:36px; height:36px; color:var(--color-primary); flex-shrink:0; }
|
||||||
|
.doc-hero-left h1 { font-size:22px; font-weight:700; margin:0 0 4px; color:var(--text-primary); }
|
||||||
|
.doc-hero-left p { font-size:14px; color:var(--text-muted); margin:0; }
|
||||||
|
|
||||||
|
/* ===== 章节 ===== */
|
||||||
|
.doc-section {
|
||||||
|
background:var(--bg-surface); border-radius:var(--radius-xl);
|
||||||
|
border:1px solid var(--border-color); padding:28px 32px;
|
||||||
|
margin-bottom:20px; box-shadow:var(--shadow-sm);
|
||||||
|
}
|
||||||
|
.section-h2 {
|
||||||
|
display:flex; align-items:center; gap:12px;
|
||||||
|
font-size:20px; font-weight:700; color:var(--text-primary);
|
||||||
|
margin:0 0 20px; padding-bottom:16px;
|
||||||
|
border-bottom:1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
.s-num {
|
||||||
|
display:inline-flex; align-items:center; justify-content:center;
|
||||||
|
width:32px; height:32px; border-radius:var(--radius-md);
|
||||||
|
background:var(--color-primary); color:#fff;
|
||||||
|
font-size:15px; font-weight:700; flex-shrink:0;
|
||||||
|
}
|
||||||
|
.section-h3 {
|
||||||
|
font-size:16px; font-weight:600; color:var(--text-primary);
|
||||||
|
margin:24px 0 10px;
|
||||||
|
}
|
||||||
|
.section-h3:first-of-type { margin-top:0; }
|
||||||
|
|
||||||
|
/* ===== 文字 ===== */
|
||||||
|
.text-p { font-size:14px; color:var(--text-secondary); line-height:1.7; margin:0 0 12px; }
|
||||||
|
.inline-code {
|
||||||
|
background:var(--color-primary-bg); color:var(--color-primary);
|
||||||
|
padding:2px 8px; border-radius:4px; font-size:13px;
|
||||||
|
font-family:'SF Mono','Consolas',monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 架构图 ===== */
|
||||||
|
.arch-text {
|
||||||
|
background:#1e293b; color:#e2e8f0; padding:16px 20px;
|
||||||
|
border-radius:var(--radius-md); font-size:13px;
|
||||||
|
line-height:1.7; overflow-x:auto; white-space:pre;
|
||||||
|
font-family:'SF Mono','Consolas',monospace;
|
||||||
|
}
|
||||||
|
.flow-steps { margin-top:16px; display:flex; flex-direction:column; gap:10px; }
|
||||||
|
.flow-step { font-size:14px; color:var(--text-secondary); display:flex; align-items:flex-start; gap:8px; line-height:1.6; }
|
||||||
|
.flow-step b { color:var(--color-primary); flex-shrink:0; }
|
||||||
|
|
||||||
|
/* ===== 代码块 ===== */
|
||||||
|
.code-box {
|
||||||
|
position:relative; background:#1e293b; border-radius:var(--radius-md);
|
||||||
|
margin-bottom:16px; overflow:hidden;
|
||||||
|
}
|
||||||
|
.code-label {
|
||||||
|
position:absolute; top:8px; right:12px; z-index:1;
|
||||||
|
font-size:11px; color:rgba(255,255,255,0.45); font-weight:600;
|
||||||
|
letter-spacing:0.5px; text-transform:uppercase;
|
||||||
|
}
|
||||||
|
.code-box pre {
|
||||||
|
margin:0; padding:20px; overflow-x:auto;
|
||||||
|
}
|
||||||
|
.code-box code {
|
||||||
|
font-family:'SF Mono','Fira Code','Consolas',monospace;
|
||||||
|
font-size:13px; line-height:1.75; color:#e2e8f0; white-space:pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== API 卡片 ===== */
|
||||||
|
.api-card {
|
||||||
|
border:1px solid var(--border-color); border-radius:var(--radius-lg);
|
||||||
|
padding:20px; margin-bottom:16px;
|
||||||
|
}
|
||||||
|
.api-method {
|
||||||
|
display:inline-block; padding:2px 10px; border-radius:4px;
|
||||||
|
font-size:12px; font-weight:700; color:#fff; margin-bottom:8px;
|
||||||
|
}
|
||||||
|
.api-method.get { background:#10b981; }
|
||||||
|
.api-method.post { background:#6366f1; }
|
||||||
|
.api-method.put { background:#f59e0b; }
|
||||||
|
.api-path {
|
||||||
|
font-size:15px; font-weight:600; color:var(--text-primary);
|
||||||
|
font-family:'SF Mono','Consolas',monospace; margin-bottom:8px;
|
||||||
|
}
|
||||||
|
.api-headers { font-size:14px; color:var(--text-secondary); margin-bottom:8px; }
|
||||||
|
.api-response { font-size:14px; color:var(--text-secondary); }
|
||||||
|
|
||||||
|
/* ===== 测试清单 ===== */
|
||||||
|
.checklist { display:flex; flex-direction:column; gap:14px; }
|
||||||
|
.check-item { display:flex; align-items:center; gap:10px; font-size:14px; color:var(--text-secondary); }
|
||||||
|
|
||||||
|
/* 架构警告 */
|
||||||
|
.arch-warnings { margin-top:16px; display:flex; flex-direction:column; gap:12px; }
|
||||||
|
.warn-item {
|
||||||
|
background:#fef2f2; border:1px solid #fecaca; border-radius:var(--radius-md);
|
||||||
|
padding:14px 18px;
|
||||||
|
}
|
||||||
|
.warn-item b { color:#dc2626; font-size:14px; display:block; margin-bottom:4px; }
|
||||||
|
.warn-item p { color:var(--text-secondary); font-size:14px; margin:0 0 8px; line-height:1.7; }
|
||||||
|
|
||||||
|
@media (max-width:768px) {
|
||||||
|
.doc-hero { flex-direction:column; gap:12px; align-items:flex-start; }
|
||||||
|
.doc-section { padding:20px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
507
_backup_20260613/OpenApiController.java
Normal file
507
_backup_20260613/OpenApiController.java
Normal 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 获取一次性 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
85
_backup_20260613/SystemConfig.vue
Normal file
85
_backup_20260613/SystemConfig.vue
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<!--
|
||||||
|
系统配置页
|
||||||
|
|
||||||
|
功能:配置文件对象存储的磁盘路径。
|
||||||
|
数据来源:GET /zgapi/v1/admin/config/list → POST /zgapi/v1/admin/config/save
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div class="config-page">
|
||||||
|
<!-- 文件对象存储配置 -->
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header-title">
|
||||||
|
<el-icon><FolderOpened /></el-icon>
|
||||||
|
<span>文件对象存储配置</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form ref="formRef" :model="form" label-width="120px" style="max-width: 600px">
|
||||||
|
<el-form-item label="存储磁盘路径">
|
||||||
|
<el-input
|
||||||
|
v-model="form.diskPath"
|
||||||
|
placeholder="如: /data/mokee/gw/file"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="saving" @click="handleSave">
|
||||||
|
<el-icon><Check /></el-icon>
|
||||||
|
保存配置
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { FolderOpened, Check } from '@element-plus/icons-vue'
|
||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
const form = reactive({ diskPath: '' })
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await request.get('/zgapi/v1/admin/config/list')
|
||||||
|
const list: any[] = res.data ?? []
|
||||||
|
const diskCfg = list.find((c: any) => c.configKey === 'file.storage.disk.path')
|
||||||
|
if (diskCfg) {
|
||||||
|
form.diskPath = diskCfg.configValue || ''
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await request.post('/zgapi/v1/admin/config/save', {
|
||||||
|
configKey: 'file.storage.disk.path',
|
||||||
|
configValue: form.diskPath,
|
||||||
|
description: '文件对象存储磁盘根目录',
|
||||||
|
})
|
||||||
|
ElMessage.success('保存成功')
|
||||||
|
} catch { /* 拦截器已提示 */ } finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.config-page {
|
||||||
|
max-width: 700px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
449
_backup_20260613/open-api-doc.md
Normal file
449
_backup_20260613/open-api-doc.md
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
# Mokee Gateway 对外开放 API 文档
|
||||||
|
|
||||||
|
**网关地址**: `https://gw.server.zgitm.com`
|
||||||
|
**版本**: v1
|
||||||
|
**更新时间**: 2026-06-13
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 概述
|
||||||
|
|
||||||
|
对外开放 API 用于外部业务系统接入 Mokee 网关平台,支持获取一次性 Token 并调用邮件发送等功能。
|
||||||
|
|
||||||
|
> ⚠️ **编码要求**:所有请求和响应均使用 **UTF-8** 编码。`Content-Type` 必须指定 `charset=UTF-8`,否则中文会出现乱码或解析错误。
|
||||||
|
|
||||||
|
### 认证流程
|
||||||
|
|
||||||
|
```
|
||||||
|
外部系统 ──POST /zgapi/v1/open/token──▶ 获取一次性 Token
|
||||||
|
外部系统 ──POST /zgapi/v1/open/token/validate──▶ 校验 Token(不消费)
|
||||||
|
外部系统 ──POST /zgapi/v1/open/token/consume──▶ 标记 Token 已消费
|
||||||
|
外部系统 ──POST /zgapi/v1/open/email/send──▶ 发送邮件(消费 Token)
|
||||||
|
外部系统 ──POST /zgapi/v1/open/file/upload──▶ 上传文件(消费 Token)
|
||||||
|
外部系统 ──GET /zgapi/v1/open/file/{id}/download──▶ 下载文件(消费 Token)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Token 规则
|
||||||
|
|
||||||
|
| 规则 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 获取方式 | 传入 `systemCode` 即可获取,无需密钥 |
|
||||||
|
| 有效期 | 获取后 **12 小时**内有效 |
|
||||||
|
| 消费限制 | 每个 Token **只能使用一次**,调用任意接口即消费 |
|
||||||
|
| 消费失败 | 接口调用失败(如邮件发送失败)**不消费** Token,可重试 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 获取 Token
|
||||||
|
|
||||||
|
传入系统编码,获取一次性 JWT Token。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /zgapi/v1/open/token
|
||||||
|
Content-Type: application/json; charset=UTF-8
|
||||||
|
```
|
||||||
|
|
||||||
|
> **重要**:所有请求 Body 必须使用 **UTF-8** 编码。
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| systemCode | string | 是 | 系统编码,需在网关平台已注册且启用 |
|
||||||
|
|
||||||
|
**请求示例**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://gw.server.zgitm.com/zgapi/v1/open/token" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"systemCode": "gateway"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"token": "eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOi...",
|
||||||
|
"expiresIn": 43200,
|
||||||
|
"systemName": "网关管理平台",
|
||||||
|
"usageNote": "此 Token 只能消费一次,消费后立即失效"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| code | int | 200=成功 |
|
||||||
|
| data.token | string | JWT Token,后续请求放入 Authorization 头 |
|
||||||
|
| data.expiresIn | int | 有效时间(秒),43200 = 12小时 |
|
||||||
|
| data.systemName | string | 系统名称 |
|
||||||
|
| data.usageNote | string | 使用提示 |
|
||||||
|
|
||||||
|
**错误响应**
|
||||||
|
|
||||||
|
| code | 说明 |
|
||||||
|
|:----:|------|
|
||||||
|
| 400 | 缺少 `systemCode` 参数 |
|
||||||
|
| 401 | `systemCode` 无效或系统已停用 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 校验 Token(不消费)
|
||||||
|
|
||||||
|
检查 Token 是否有效(存在且未被消费)。**此接口不会消费 Token**,仅做状态查询。调用方可在实际操作前预先确认 Token 可用。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /zgapi/v1/open/token/validate
|
||||||
|
Content-Type: application/json; charset=UTF-8
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| token | string | 是 | 需要校验的 JWT Token |
|
||||||
|
|
||||||
|
**请求示例**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://gw.server.zgitm.com/zgapi/v1/open/token/validate" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"token": "eyJhbGciOiJIUzM4NCJ9..."}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应(Token 有效,未被消费)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"valid": true,
|
||||||
|
"systemCode": "gateway",
|
||||||
|
"msg": "Token 有效,未被消费"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| data.valid | bool | true = Token 有效可用 |
|
||||||
|
| data.systemCode | string | Token 所属的系统编码 |
|
||||||
|
| data.msg | string | 状态描述 |
|
||||||
|
|
||||||
|
**错误响应**
|
||||||
|
|
||||||
|
| code | 说明 |
|
||||||
|
|:----:|------|
|
||||||
|
| 400 | 缺少 `token` 参数 |
|
||||||
|
| 401 | Token 无效、已过期、已被消费、或类型错误 |
|
||||||
|
|
||||||
|
> **关键:此接口不会消费 Token。** 校验通过后 Token 仍可用于后续操作。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 标记 Token 已消费
|
||||||
|
|
||||||
|
外部系统在自己的业务逻辑中消费了 Token 后(如上传文件到自己的存储),调用此接口告知网关标记 Token 已消费,防止被重复使用。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /zgapi/v1/open/token/consume
|
||||||
|
Content-Type: application/json; charset=UTF-8
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| token | string | 是 | 需要标记已消费的 JWT Token |
|
||||||
|
| action | string | 是 | 消费动作描述,如 `发送邮件` / `上传文件` / `下载文件` |
|
||||||
|
|
||||||
|
**请求示例**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://gw.server.zgitm.com/zgapi/v1/open/token/consume" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"token": "eyJhbGciOiJIUzM4NCJ9...", "action": "上传文件"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"tokenConsumed": true,
|
||||||
|
"action": "上传文件",
|
||||||
|
"msg": "Token 已标记为已消费"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误响应**
|
||||||
|
|
||||||
|
| code | 说明 |
|
||||||
|
|:----:|------|
|
||||||
|
| 400 | 缺少 `token` 或 `action` 参数 |
|
||||||
|
| 401 | Token 无效、已过期、或已被消费 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 发送邮件
|
||||||
|
|
||||||
|
使用 Token 认证后发送邮件。**调用此接口将消费当前 Token。**
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /zgapi/v1/open/email/send
|
||||||
|
Content-Type: application/json; charset=UTF-8
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
> **重要**:请求 Body 必须使用 **UTF-8** 编码。
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| to | string | 是 | 收件人邮箱地址 |
|
||||||
|
| subject | string | 是 | 邮件主题 |
|
||||||
|
| content | string | 是 | 邮件内容 |
|
||||||
|
| contentType | string | 否 | `html`(默认)或 `text` |
|
||||||
|
|
||||||
|
**请求示例**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 先获取 Token
|
||||||
|
TOKEN=$(curl -s -X POST "https://gw.server.zgitm.com/zgapi/v1/open/token" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"systemCode":"gateway"}' | sed 's/.*"token":"\([^"]*\)".*/\1/')
|
||||||
|
|
||||||
|
# 发送 HTML 邮件
|
||||||
|
curl -X POST "https://gw.server.zgitm.com/zgapi/v1/open/email/send" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{
|
||||||
|
"to": "user@example.com",
|
||||||
|
"subject": "通知标题",
|
||||||
|
"content": "<h1>您好</h1><p>这是邮件内容</p>",
|
||||||
|
"contentType": "html"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"msg": "发送成功",
|
||||||
|
"tokenConsumed": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误响应**
|
||||||
|
|
||||||
|
| code | 说明 |
|
||||||
|
|:----:|------|
|
||||||
|
| 400 | 缺少必填参数 `to` / `subject` / `content` |
|
||||||
|
| 401 | Token 无效、已过期或已消费 |
|
||||||
|
| 500 | 邮件发送失败(Token 未消费,可重试) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 上传文件
|
||||||
|
|
||||||
|
使用 Token 认证后上传文件到文件对象存储。**调用此接口将消费当前 Token。**
|
||||||
|
|
||||||
|
> 上传成功后返回文件 ID,可用于后续下载。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /zgapi/v1/open/file/upload
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| file | file | 是 | 上传的文件 |
|
||||||
|
| token | string | 是 | 开放 API Token(作为 form 字段传递) |
|
||||||
|
| fileType | string | 否 | 文件类型: `avatar`/`icon`/`doc`/`other`(默认 `other`) |
|
||||||
|
|
||||||
|
**请求示例**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 先获取 Token
|
||||||
|
TOKEN=$(curl -s -X POST "https://gw.server.zgitm.com/zgapi/v1/open/token" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"systemCode":"gateway"}' | sed 's/.*"token":"\([^"]*\)".*/\1/')
|
||||||
|
|
||||||
|
# 上传文件
|
||||||
|
curl -X POST "https://gw.server.zgitm.com/zgapi/v1/open/file/upload" \
|
||||||
|
-F "file=@/path/to/avatar.png" \
|
||||||
|
-F "token=$TOKEN" \
|
||||||
|
-F "fileType=avatar"
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"fileId": "a1b2c3d4e5f6",
|
||||||
|
"fileName": "avatar.png",
|
||||||
|
"storageFileName": "a1b2c3d4e5f6.png",
|
||||||
|
"fileSize": 51200,
|
||||||
|
"tokenConsumed": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| data.fileId | string | 文件唯一 ID,用于下载 |
|
||||||
|
| data.fileName | string | 原始文件名 |
|
||||||
|
| data.storageFileName | string | 磁盘存储的实际文件名 |
|
||||||
|
| data.fileSize | long | 文件大小(字节) |
|
||||||
|
| data.tokenConsumed | bool | Token 已消费 |
|
||||||
|
|
||||||
|
**错误响应**
|
||||||
|
|
||||||
|
| code | 说明 |
|
||||||
|
|:----:|------|
|
||||||
|
| 400 | 缺少 `file` 或 `token` 参数 |
|
||||||
|
| 401 | Token 无效、已过期或已消费 |
|
||||||
|
| 500 | 文件上传失败(Token 未消费,可重试) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 下载文件
|
||||||
|
|
||||||
|
使用 Token 认证后下载文件。**调用此接口将消费当前 Token。**
|
||||||
|
Token 通过 URL query 参数传递。
|
||||||
|
|
||||||
|
> **注意**: 每个下载链接的 Token 只能使用一次。如需多次下载,需每次获取新 Token。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /zgapi/v1/open/file/{id}/download?token=<token>
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| id | string | 是 | 文件 ID(路径参数) |
|
||||||
|
| token | string | 是 | 开放 API Token(query 参数) |
|
||||||
|
|
||||||
|
**请求示例**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 先获取 Token
|
||||||
|
TOKEN=$(curl -s -X POST "https://gw.server.zgitm.com/zgapi/v1/open/token" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"systemCode":"gateway"}' | sed 's/.*"token":"\([^"]*\)".*/\1/')
|
||||||
|
|
||||||
|
# 下载文件(保存到本地)
|
||||||
|
curl -o downloaded.png \
|
||||||
|
"https://gw.server.zgitm.com/zgapi/v1/open/file/a1b2c3d4e5f6/download?token=$TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应**: 直接返回文件二进制流,`Content-Type: application/octet-stream`,`Content-Disposition: attachment`。
|
||||||
|
|
||||||
|
**错误响应**
|
||||||
|
|
||||||
|
| HTTP 状态码 | code | 说明 |
|
||||||
|
|:----------:|:----:|------|
|
||||||
|
| 401 | 401 | Token 无效、已过期或已消费 |
|
||||||
|
| 404 | 404 | 文件不存在或文件数据丢失 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Java 调用示例
|
||||||
|
|
||||||
|
使用 Hutool 或 OkHttp 均可,以下为 Hutool 示例:
|
||||||
|
|
||||||
|
```java
|
||||||
|
import cn.hutool.http.HttpRequest;
|
||||||
|
import cn.hutool.http.HttpResponse;
|
||||||
|
import cn.hutool.json.JSONObject;
|
||||||
|
import cn.hutool.json.JSONUtil;
|
||||||
|
|
||||||
|
public class OpenApiDemo {
|
||||||
|
|
||||||
|
private static final String BASE = "https://gw.server.zgitm.com";
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
// 1. 获取 Token
|
||||||
|
JSONObject tokenReq = JSONUtil.createObj().set("systemCode", "gateway");
|
||||||
|
String tokenResp = HttpRequest.post(BASE + "/zgapi/v1/open/token")
|
||||||
|
.header("Content-Type", "application/json; charset=UTF-8")
|
||||||
|
.body(tokenReq.toString())
|
||||||
|
.execute()
|
||||||
|
.body();
|
||||||
|
|
||||||
|
JSONObject tokenJson = JSONUtil.parseObj(tokenResp);
|
||||||
|
String token = tokenJson.getJSONObject("data").getStr("token");
|
||||||
|
System.out.println("Token: " + token.substring(0, 30) + "...");
|
||||||
|
|
||||||
|
// 2. 上传文件
|
||||||
|
JSONObject uploadResp = HttpRequest.post(BASE + "/zgapi/v1/open/file/upload")
|
||||||
|
.form("file", new java.io.File("./avatar.png"))
|
||||||
|
.form("token", token)
|
||||||
|
.form("fileType", "avatar")
|
||||||
|
.execute()
|
||||||
|
.body();
|
||||||
|
System.out.println("上传结果: " + uploadResp);
|
||||||
|
|
||||||
|
// 3. 发送邮件
|
||||||
|
JSONObject emailReq = JSONUtil.createObj()
|
||||||
|
.set("to", "user@example.com")
|
||||||
|
.set("subject", "测试邮件")
|
||||||
|
.set("content", "<h1>Hello</h1><p>这是一封测试邮件</p>")
|
||||||
|
.set("contentType", "html");
|
||||||
|
|
||||||
|
String emailResp = HttpRequest.post(BASE + "/zgapi/v1/open/email/send")
|
||||||
|
.header("Authorization", "Bearer " + token)
|
||||||
|
.body(emailReq.toString())
|
||||||
|
.execute()
|
||||||
|
.body();
|
||||||
|
|
||||||
|
System.out.println(emailResp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Maven 依赖
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.hutool</groupId>
|
||||||
|
<artifactId>hutool-all</artifactId>
|
||||||
|
<version>5.8.28</version>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 附录
|
||||||
|
|
||||||
|
### 可用系统编码
|
||||||
|
|
||||||
|
请联系管理员获取已注册的系统编码,或登录网关管理平台查看。
|
||||||
|
|
||||||
|
### 错误码汇总
|
||||||
|
|
||||||
|
| HTTP 状态码 | code | 说明 |
|
||||||
|
|:----------:|:----:|------|
|
||||||
|
| 200 | 200 | 成功 |
|
||||||
|
| 200 | 400 | 参数错误 |
|
||||||
|
| 200 | 401 | 认证失败 |
|
||||||
|
| 200 | 500 | 服务内部错误 |
|
||||||
|
|
||||||
|
> **注意**:所有响应 HTTP 状态码均为 200,实际结果通过响应体中的 `code` 字段判断。
|
||||||
|
|
||||||
|
### 联系支持
|
||||||
|
|
||||||
|
如有问题请联系 Mokee Gateway 管理员。
|
||||||
5
add_doc_token.sql
Normal file
5
add_doc_token.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-- 系统表新增文档令牌字段
|
||||||
|
ALTER TABLE sys_system ADD COLUMN IF NOT EXISTS doc_token VARCHAR(64) DEFAULT NULL COMMENT '文档访问令牌(UUID)';
|
||||||
|
|
||||||
|
-- 为已有系统生成随机令牌(16位 hex)
|
||||||
|
UPDATE sys_system SET doc_token = SUBSTRING(MD5(UUID()), 1, 16) WHERE doc_token IS NULL;
|
||||||
29
check_docker.py
Normal file
29
check_docker.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import paramiko
|
||||||
|
|
||||||
|
HOST = '10.1.1.170'
|
||||||
|
USER = 'root'
|
||||||
|
PASS = 'mokee.'
|
||||||
|
|
||||||
|
c = paramiko.SSHClient()
|
||||||
|
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
c.connect(HOST, username=USER, password=PASS, timeout=15)
|
||||||
|
|
||||||
|
# 列出所有运行中的 Docker 容器
|
||||||
|
_, stdout, _ = c.exec_command('docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}" 2>&1')
|
||||||
|
out = stdout.read().decode()
|
||||||
|
print('=== Docker 容器列表 ===')
|
||||||
|
print(out)
|
||||||
|
|
||||||
|
# 也检查一下 /opt/mokee 目录结构
|
||||||
|
_, stdout2, _ = c.exec_command('ls -la /opt/mokee/ 2>&1')
|
||||||
|
out2 = stdout2.read().decode()
|
||||||
|
print('=== /opt/mokee 目录 ===')
|
||||||
|
print(out2)
|
||||||
|
|
||||||
|
# 查找与 mokee 相关的所有容器
|
||||||
|
_, stdout3, _ = c.exec_command('docker ps -a --format "table {{.Names}}\t{{.Image}}\t{{.Status}}" --filter name=mokee 2>&1')
|
||||||
|
out3 = stdout3.read().decode()
|
||||||
|
print('=== mokee 相关容器 ===')
|
||||||
|
print(out3)
|
||||||
|
|
||||||
|
c.close()
|
||||||
1014
docs/integration-guide.md
Normal file
1014
docs/integration-guide.md
Normal file
File diff suppressed because it is too large
Load Diff
268
docs/open-api-doc.md
Normal file
268
docs/open-api-doc.md
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
# Mokee Gateway 对外开放 API 文档
|
||||||
|
|
||||||
|
**网关地址**: `https://gw.server.zgitm.com`
|
||||||
|
**版本**: v1
|
||||||
|
**更新时间**: 2026-06-13
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 概述
|
||||||
|
|
||||||
|
对外开放 API 用于外部业务系统接入 Mokee 网关平台,支持获取一次性 Token、校验 Token、标记 Token 已消费、发送邮件等功能。
|
||||||
|
|
||||||
|
> 📦 **文件上传/下载** 已迁移至独立的文件对象存储服务 `file.wg.zgitm.com`,详见 [文件对象存储服务 API 文档](#10-文件对象存储服务)。
|
||||||
|
|
||||||
|
> ⚠️ **编码要求**:所有请求和响应均使用 **UTF-8** 编码。
|
||||||
|
|
||||||
|
### 认证流程
|
||||||
|
|
||||||
|
```
|
||||||
|
外部系统 ──POST /zgapi/v1/open/token──▶ 获取一次性 Token
|
||||||
|
外部系统 ──POST /zgapi/v1/open/token/validate──▶ 校验 Token(不消费)
|
||||||
|
外部系统 ──GET /zgapi/v1/open/token/info──▶ 查询系统信息(不消费)
|
||||||
|
外部系统 ──POST /zgapi/v1/open/token/consume──▶ 标记 Token 已消费
|
||||||
|
外部系统 ──POST /zgapi/v1/open/email/send──▶ 发送邮件(消费 Token)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Token 规则
|
||||||
|
|
||||||
|
| 规则 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 获取方式 | 传入 `systemCode` 即可获取,无需密钥 |
|
||||||
|
| 有效期 | 获取后 **12 小时**内有效 |
|
||||||
|
| 消费限制 | 每个 Token **只能使用一次**,调用任意接口即消费 |
|
||||||
|
| 消费失败 | 接口调用失败(如邮件发送失败)**不消费** Token,可重试 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 获取 Token
|
||||||
|
|
||||||
|
传入系统编码,获取一次性 JWT Token。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /zgapi/v1/open/token
|
||||||
|
Content-Type: application/json; charset=UTF-8
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| systemCode | string | 是 | 系统编码,需在网关平台已注册且启用 |
|
||||||
|
|
||||||
|
**请求示例**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://gw.server.zgitm.com/zgapi/v1/open/token" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"systemCode": "gateway"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"token": "eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOi...",
|
||||||
|
"expiresIn": 43200,
|
||||||
|
"systemName": "网关管理平台",
|
||||||
|
"usageNote": "此 Token 只能消费一次,消费后立即失效"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| code | int | 200=成功 |
|
||||||
|
| data.token | string | JWT Token,后续请求放入 Authorization 头 |
|
||||||
|
| data.expiresIn | int | 有效时间(秒),43200 = 12小时 |
|
||||||
|
| data.systemName | string | 系统名称 |
|
||||||
|
| data.usageNote | string | 使用提示 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 校验 Token(不消费)
|
||||||
|
|
||||||
|
检查 Token 是否有效。**不会消费 Token**。
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /zgapi/v1/open/token/validate
|
||||||
|
Content-Type: application/json; charset=UTF-8
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| token | string | 是 | 需要校验的 JWT Token |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://gw.server.zgitm.com/zgapi/v1/open/token/validate" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"token": "eyJhbGciOiJIUzM4NCJ9..."}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "code": 200, "data": { "valid": true, "systemCode": "gateway", "msg": "Token 有效,未被消费" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 查询 Token 系统信息(不消费)
|
||||||
|
|
||||||
|
根据 Token 查询所属系统信息。**不消费 Token**,不检查已消费状态,仅从 JWT 解析。
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /zgapi/v1/open/token/info?token=<token>
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| token | string | 是 | query 参数传递 JWT Token |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://gw.server.zgitm.com/zgapi/v1/open/token/info?token=eyJhbGciOiJIUzM4NCJ9..."
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"systemId": "30957c6264d511f18b7900e04c896f92",
|
||||||
|
"systemCode": "gateway",
|
||||||
|
"systemName": "网关管理平台"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| data.systemId | string | 系统唯一 ID |
|
||||||
|
| data.systemCode | string | 系统编码 |
|
||||||
|
| data.systemName | string | 系统名称 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 标记 Token 已消费
|
||||||
|
|
||||||
|
外部系统在自己的业务中消费了 Token 后,调用此接口告知网关。
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /zgapi/v1/open/token/consume
|
||||||
|
Content-Type: application/json; charset=UTF-8
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| token | string | 是 | 需要标记的 Token |
|
||||||
|
| action | string | 是 | 消费动作,如 `发送邮件` / `上传文件` |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://gw.server.zgitm.com/zgapi/v1/open/token/consume" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"token": "eyJ...", "action": "上传文件"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 发送邮件
|
||||||
|
|
||||||
|
使用 Token 认证后发送邮件。**调用此接口将消费当前 Token。**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /zgapi/v1/open/email/send
|
||||||
|
Content-Type: application/json; charset=UTF-8
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:--:|------|
|
||||||
|
| to | string | 是 | 收件人邮箱 |
|
||||||
|
| subject | string | 是 | 邮件主题 |
|
||||||
|
| content | string | 是 | 邮件内容 |
|
||||||
|
| contentType | string | 否 | `html`(默认)或 `text` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Java 调用示例
|
||||||
|
|
||||||
|
```java
|
||||||
|
import cn.hutool.http.HttpRequest;
|
||||||
|
|
||||||
|
public class OpenApiDemo {
|
||||||
|
private static final String BASE = "https://gw.server.zgitm.com";
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
// 1. 获取 Token
|
||||||
|
String tokenResp = HttpRequest.post(BASE + "/zgapi/v1/open/token")
|
||||||
|
.body("{\"systemCode\":\"gateway\"}").execute().body();
|
||||||
|
String token = JSONUtil.parseObj(tokenResp).getJSONObject("data").getStr("token");
|
||||||
|
|
||||||
|
// 2. 校验 Token
|
||||||
|
String validResp = HttpRequest.post(BASE + "/zgapi/v1/open/token/validate")
|
||||||
|
.body("{\"token\":\"" + token + "\"}").execute().body();
|
||||||
|
System.out.println(validResp);
|
||||||
|
|
||||||
|
// 3. 发送邮件
|
||||||
|
String emailResp = HttpRequest.post(BASE + "/zgapi/v1/open/email/send")
|
||||||
|
.header("Authorization", "Bearer " + token)
|
||||||
|
.body("{\"to\":\"user@example.com\",\"subject\":\"测试\",\"content\":\"<h1>Hello</h1>\"}")
|
||||||
|
.execute().body();
|
||||||
|
System.out.println(emailResp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 文件对象存储服务
|
||||||
|
|
||||||
|
文件上传/下载/删除功能由独立的文件对象存储服务提供。
|
||||||
|
|
||||||
|
| 项目 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 服务地址 | `https://file.wg.zgitm.com` |
|
||||||
|
| 认证方式 | Bearer Token(一次一密) |
|
||||||
|
| 最大文件 | 100MB |
|
||||||
|
| 文档 | 详见文件服务 API 文档 |
|
||||||
|
|
||||||
|
**快速示例**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 获取 Token
|
||||||
|
TOKEN=$(curl -s -X POST https://file.wg.zgitm.com/api/token \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"systemCode":"gateway"}' | jq -r '.data.token')
|
||||||
|
|
||||||
|
# 上传
|
||||||
|
curl -X POST https://file.wg.zgitm.com/api/upload \
|
||||||
|
-H "Authorization: Bearer $TOKEN" -F "file=@/path/to/file.pdf"
|
||||||
|
|
||||||
|
# 下载(两种方式等效)
|
||||||
|
# 方式1: Header 传 Token
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" -o output.pdf \
|
||||||
|
https://file.wg.zgitm.com/api/download/{fileId}
|
||||||
|
# 方式2: URL Query 参数传 Token(浏览器直链时使用)
|
||||||
|
curl -o output.pdf \
|
||||||
|
"https://file.wg.zgitm.com/api/download/{fileId}?token=$TOKEN"
|
||||||
|
|
||||||
|
# 删除
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" \
|
||||||
|
https://file.wg.zgitm.com/api/delete/{fileId}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 附录
|
||||||
|
|
||||||
|
### 错误码汇总
|
||||||
|
|
||||||
|
| HTTP | code | 说明 |
|
||||||
|
|:----:|:----:|------|
|
||||||
|
| 200 | 200 | 成功 |
|
||||||
|
| 200 | 400 | 参数错误 |
|
||||||
|
| 200 | 401 | 认证失败 |
|
||||||
|
| 200 | 500 | 服务内部错误 |
|
||||||
1293
docs/superpowers/plans/2026-06-13-user-chip-widget.md
Normal file
1293
docs/superpowers/plans/2026-06-13-user-chip-widget.md
Normal file
File diff suppressed because it is too large
Load Diff
78
init_data.sql
Normal file
78
init_data.sql
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
-- 统一登录网关平台 — 初始化测试数据
|
||||||
|
-- 创建默认管理员和测试系统
|
||||||
|
|
||||||
|
-- 1. 创建默认管理员用户(密码: admin123)
|
||||||
|
INSERT INTO sys_user (username, password, real_name, email, phone, post, status, created_at, updated_at)
|
||||||
|
VALUES ('admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi', '系统管理员', 'admin@mokee.com', '13800000000', '系统管理员', 1, NOW(), NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE username=username;
|
||||||
|
|
||||||
|
-- 2. 创建测试业务系统
|
||||||
|
INSERT INTO sys_system (system_code, system_name, front_url, backend_url, gateway_url, icon, status, description, created_at, updated_at)
|
||||||
|
VALUES ('test', '测试业务系统', 'http://127.0.0.1:5001', 'http://127.0.0.1:9000', 'http://127.0.0.1:9000', '', 1, '用于测试的业务系统', NOW(), NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE system_code=system_code;
|
||||||
|
|
||||||
|
-- 3. 绑定管理员到测试系统
|
||||||
|
INSERT INTO sys_user_system (user_id, system_id, created_at)
|
||||||
|
SELECT u.id, s.id, NOW() FROM sys_user u, sys_system s
|
||||||
|
WHERE u.username = 'admin' AND s.system_code = 'test'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM sys_user_system us WHERE us.user_id = u.id AND us.system_id = s.id);
|
||||||
|
|
||||||
|
-- 4. 创建管理员角色
|
||||||
|
INSERT INTO sys_role (system_id, role_name, role_code, description, status, created_at, updated_at)
|
||||||
|
SELECT s.id, '系统管理员', 'admin', '系统管理员角色,拥有所有权限', 1, NOW(), NOW()
|
||||||
|
FROM sys_system s WHERE s.system_code = 'test'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM sys_role r WHERE r.system_id = s.id AND r.role_code = 'admin');
|
||||||
|
|
||||||
|
-- 5. 为用户分配管理员角色
|
||||||
|
INSERT INTO sys_user_role (user_id, role_id, created_at)
|
||||||
|
SELECT u.id, r.id, NOW() FROM sys_user u, sys_role r, sys_system s
|
||||||
|
WHERE u.username = 'admin' AND r.role_code = 'admin' AND r.system_id = s.id AND s.system_code = 'test'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM sys_user_role ur WHERE ur.user_id = u.id AND ur.role_id = r.id);
|
||||||
|
|
||||||
|
-- 6. 创建默认菜单
|
||||||
|
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, permission, sort_order, status, created_at, updated_at)
|
||||||
|
SELECT s.id, 0, '系统管理', '/system', 'system/index', 'Setting', 1, '', 1, 1, NOW(), NOW()
|
||||||
|
FROM sys_system s WHERE s.system_code = 'test'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM sys_menu m WHERE m.system_id = s.id AND m.menu_name = '系统管理');
|
||||||
|
|
||||||
|
-- 7. 为管理员角色分配菜单
|
||||||
|
INSERT INTO sys_role_menu (role_id, menu_id, created_at)
|
||||||
|
SELECT r.id, m.id, NOW() FROM sys_role r, sys_menu m, sys_system s
|
||||||
|
WHERE r.role_code = 'admin' AND m.system_id = s.id AND s.system_code = 'test'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = r.id AND rm.menu_id = m.id);
|
||||||
|
|
||||||
|
-- 8. 配置放行接口(允许所有接口)
|
||||||
|
INSERT INTO sys_api (system_id, api_path, api_method, api_name, status, created_at, updated_at)
|
||||||
|
SELECT s.id, '/zgapi/v1/admin/**', 'GET', '管理接口-GET', 1, NOW(), NOW()
|
||||||
|
FROM sys_system s WHERE s.system_code = 'test'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM sys_api a WHERE a.system_id = s.id AND a.api_path = '/zgapi/v1/admin/**' AND a.api_method = 'GET');
|
||||||
|
|
||||||
|
INSERT INTO sys_api (system_id, api_path, api_method, api_name, status, created_at, updated_at)
|
||||||
|
SELECT s.id, '/zgapi/v1/admin/**', 'POST', '管理接口-POST', 1, NOW(), NOW()
|
||||||
|
FROM sys_system s WHERE s.system_code = 'test'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM sys_api a WHERE a.system_id = s.id AND a.api_path = '/zgapi/v1/admin/**' AND a.api_method = 'POST');
|
||||||
|
|
||||||
|
INSERT INTO sys_api (system_id, api_path, api_method, api_name, status, created_at, updated_at)
|
||||||
|
SELECT s.id, '/zgapi/v1/admin/**', 'PUT', '管理接口-PUT', 1, NOW(), NOW()
|
||||||
|
FROM sys_system s WHERE s.system_code = 'test'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM sys_api a WHERE a.system_id = s.id AND a.api_path = '/zgapi/v1/admin/**' AND a.api_method = 'PUT');
|
||||||
|
|
||||||
|
INSERT INTO sys_api (system_id, api_path, api_method, api_name, status, created_at, updated_at)
|
||||||
|
SELECT s.id, '/zgapi/v1/admin/**', 'DELETE', '管理接口-DELETE', 1, NOW(), NOW()
|
||||||
|
FROM sys_system s WHERE s.system_code = 'test'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM sys_api a WHERE a.system_id = s.id AND a.api_path = '/zgapi/v1/admin/**' AND a.api_method = 'DELETE');
|
||||||
|
|
||||||
|
INSERT INTO sys_api (system_id, api_path, api_method, api_name, status, created_at, updated_at)
|
||||||
|
SELECT s.id, '/zgapi/v1/auth/**', 'GET', '认证接口-GET', 1, NOW(), NOW()
|
||||||
|
FROM sys_system s WHERE s.system_code = 'test'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM sys_api a WHERE a.system_id = s.id AND a.api_path = '/zgapi/v1/auth/**' AND a.api_method = 'GET');
|
||||||
|
|
||||||
|
INSERT INTO sys_api (system_id, api_path, api_method, api_name, status, created_at, updated_at)
|
||||||
|
SELECT s.id, '/zgapi/v1/auth/**', 'POST', '认证接口-POST', 1, NOW(), NOW()
|
||||||
|
FROM sys_system s WHERE s.system_code = 'test'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM sys_api a WHERE a.system_id = s.id AND a.api_path = '/zgapi/v1/auth/**' AND a.api_method = 'POST');
|
||||||
|
|
||||||
|
INSERT INTO sys_api (system_id, api_path, api_method, api_name, status, created_at, updated_at)
|
||||||
|
SELECT s.id, '/zgapi/v1/auth/**', 'PUT', '认证接口-PUT', 1, NOW(), NOW()
|
||||||
|
FROM sys_system s WHERE s.system_code = 'test'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM sys_api a WHERE a.system_id = s.id AND a.api_path = '/zgapi/v1/auth/**' AND a.api_method = 'PUT');
|
||||||
266
init_database.py
Normal file
266
init_database.py
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
"""
|
||||||
|
统一登录网关平台 — 数据库初始化脚本
|
||||||
|
连接 MySQL db1.prod.zgitm.com 创建 11 张表
|
||||||
|
"""
|
||||||
|
import pymysql
|
||||||
|
|
||||||
|
# ===== 数据库连接配置 =====
|
||||||
|
DB_CONFIG = {
|
||||||
|
'host': 'db1.prod.zgitm.com',
|
||||||
|
'port': 3306,
|
||||||
|
'user': 'mokeegateway',
|
||||||
|
'password': 'mokee.',
|
||||||
|
'database': 'mokeegateway',
|
||||||
|
'charset': 'utf8mb4',
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===== 建表 DDL =====
|
||||||
|
DDL_STATEMENTS = [
|
||||||
|
# 1. 业务系统表
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS sys_system (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
|
system_code VARCHAR(64) NOT NULL COMMENT '系统编码,如 msg、oa',
|
||||||
|
system_name VARCHAR(128) NOT NULL COMMENT '系统名称',
|
||||||
|
front_url VARCHAR(256) NOT NULL COMMENT '系统前端地址',
|
||||||
|
backend_url VARCHAR(256) NOT NULL COMMENT '系统后端真实地址',
|
||||||
|
gateway_url VARCHAR(256) NOT NULL COMMENT '网关代理地址',
|
||||||
|
icon VARCHAR(512) DEFAULT NULL COMMENT '系统图标URL',
|
||||||
|
sort_order INT DEFAULT 0 COMMENT '排序',
|
||||||
|
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-禁用 1-启用',
|
||||||
|
description VARCHAR(512) DEFAULT NULL COMMENT '系统描述',
|
||||||
|
created_by BIGINT DEFAULT NULL COMMENT '创建人ID',
|
||||||
|
updated_by BIGINT DEFAULT NULL COMMENT '更新人ID',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-正常 1-已删除',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
-- uk_system_code/uk_gateway_url 唯一性由应用层代码校验
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务系统表';
|
||||||
|
""",
|
||||||
|
|
||||||
|
# 2. 系统放行接口表
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS sys_api (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
|
system_id BIGINT NOT NULL COMMENT '关联系统ID',
|
||||||
|
api_path VARCHAR(256) NOT NULL COMMENT '接口路径',
|
||||||
|
api_method VARCHAR(10) NOT NULL COMMENT '请求方式:GET/POST/PUT/DELETE',
|
||||||
|
api_name VARCHAR(128) DEFAULT NULL COMMENT '接口名称',
|
||||||
|
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-禁用 1-启用',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_system_id (system_id),
|
||||||
|
UNIQUE KEY uk_system_path_method (system_id, api_path, api_method)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统放行接口表';
|
||||||
|
""",
|
||||||
|
|
||||||
|
# 3. 用户表
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS sys_user (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
|
username VARCHAR(64) NOT NULL COMMENT '登录账号',
|
||||||
|
password VARCHAR(256) NOT NULL COMMENT 'BCrypt加密密码',
|
||||||
|
real_name VARCHAR(64) DEFAULT NULL COMMENT '真实姓名',
|
||||||
|
email VARCHAR(128) DEFAULT NULL COMMENT '邮箱',
|
||||||
|
phone VARCHAR(32) DEFAULT NULL COMMENT '手机号',
|
||||||
|
avatar VARCHAR(512) DEFAULT NULL COMMENT '头像URL',
|
||||||
|
post VARCHAR(64) DEFAULT NULL COMMENT '岗位',
|
||||||
|
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-禁用 1-正常 2-锁定',
|
||||||
|
lock_until DATETIME DEFAULT NULL COMMENT '锁定截止时间',
|
||||||
|
login_fail_count INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数',
|
||||||
|
last_login_ip VARCHAR(64) DEFAULT NULL COMMENT '最后登录IP',
|
||||||
|
last_login_time DATETIME DEFAULT NULL COMMENT '最后登录时间',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-正常 1-已删除',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
-- 用户名/邮箱唯一性由应用层代码校验
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
|
||||||
|
""",
|
||||||
|
|
||||||
|
# 4. 用户系统绑定表
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS sys_user_system (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
|
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||||||
|
system_id BIGINT NOT NULL COMMENT '系统ID',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '绑定时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_user_system (user_id, system_id),
|
||||||
|
KEY idx_system_id (system_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户系统绑定表';
|
||||||
|
""",
|
||||||
|
|
||||||
|
# 5. 角色表
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS sys_role (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
|
system_id BIGINT NOT NULL COMMENT '所属系统ID',
|
||||||
|
role_name VARCHAR(64) NOT NULL COMMENT '角色名称',
|
||||||
|
role_code VARCHAR(64) NOT NULL COMMENT '角色编码',
|
||||||
|
description VARCHAR(256) DEFAULT NULL COMMENT '角色描述',
|
||||||
|
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-禁用 1-启用',
|
||||||
|
created_by BIGINT DEFAULT NULL COMMENT '创建人ID',
|
||||||
|
updated_by BIGINT DEFAULT NULL COMMENT '更新人ID',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-正常 1-已删除',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
-- uk_system_role_code 唯一性由应用层代码校验
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色表';
|
||||||
|
""",
|
||||||
|
|
||||||
|
# 6. 用户角色表
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS sys_user_role (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
|
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||||||
|
role_id BIGINT NOT NULL COMMENT '角色ID',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '绑定时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_user_role (user_id, role_id),
|
||||||
|
KEY idx_role_id (role_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户角色表';
|
||||||
|
""",
|
||||||
|
|
||||||
|
# 7. 菜单表
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS sys_menu (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
|
system_id BIGINT NOT NULL COMMENT '所属系统ID',
|
||||||
|
parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父菜单ID,0表示顶级',
|
||||||
|
menu_name VARCHAR(64) NOT NULL COMMENT '菜单名称',
|
||||||
|
menu_path VARCHAR(256) DEFAULT NULL COMMENT '路由路径',
|
||||||
|
component VARCHAR(256) DEFAULT NULL COMMENT '前端组件路径',
|
||||||
|
icon VARCHAR(128) DEFAULT NULL COMMENT '菜单图标',
|
||||||
|
menu_type TINYINT NOT NULL COMMENT '菜单类型:1-目录 2-菜单 3-按钮',
|
||||||
|
permission VARCHAR(128) DEFAULT NULL COMMENT '权限标识(按钮用)',
|
||||||
|
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序',
|
||||||
|
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-禁用 1-启用',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_system_id (system_id),
|
||||||
|
KEY idx_parent_id (parent_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='菜单表';
|
||||||
|
""",
|
||||||
|
|
||||||
|
# 8. 角色菜单表
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS sys_role_menu (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
|
role_id BIGINT NOT NULL COMMENT '角色ID',
|
||||||
|
menu_id BIGINT NOT NULL COMMENT '菜单ID',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_role_menu (role_id, menu_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色菜单表';
|
||||||
|
""",
|
||||||
|
|
||||||
|
# 9. 登录日志表
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS sys_login_log (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
|
user_id BIGINT DEFAULT NULL COMMENT '用户ID',
|
||||||
|
username VARCHAR(64) NOT NULL COMMENT '登录账号',
|
||||||
|
system_id BIGINT DEFAULT NULL COMMENT '登录目标系统ID',
|
||||||
|
login_ip VARCHAR(64) DEFAULT NULL COMMENT '登录IP',
|
||||||
|
user_agent VARCHAR(512) DEFAULT NULL COMMENT '浏览器User-Agent',
|
||||||
|
status TINYINT NOT NULL COMMENT '登录状态:0-失败 1-成功',
|
||||||
|
fail_reason VARCHAR(256) DEFAULT NULL COMMENT '失败原因',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '登录时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_user_id (user_id),
|
||||||
|
KEY idx_created_at (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='登录日志表';
|
||||||
|
""",
|
||||||
|
|
||||||
|
# 10. 操作日志表
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS sys_operation_log (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
|
user_id BIGINT DEFAULT NULL COMMENT '操作人ID',
|
||||||
|
username VARCHAR(64) DEFAULT NULL COMMENT '操作人名称',
|
||||||
|
system_id BIGINT DEFAULT NULL COMMENT '所属系统ID',
|
||||||
|
module VARCHAR(64) DEFAULT NULL COMMENT '操作模块',
|
||||||
|
action VARCHAR(32) DEFAULT NULL COMMENT '操作类型:CREATE/UPDATE/DELETE/QUERY/IMPORT/EXPORT',
|
||||||
|
request_url VARCHAR(256) DEFAULT NULL COMMENT '请求地址',
|
||||||
|
request_method VARCHAR(10) DEFAULT NULL COMMENT '请求方式',
|
||||||
|
request_body TEXT DEFAULT NULL COMMENT '请求体',
|
||||||
|
response_status INT DEFAULT NULL COMMENT '响应状态码',
|
||||||
|
error_msg TEXT DEFAULT NULL COMMENT '错误信息',
|
||||||
|
cost_time INT DEFAULT NULL COMMENT '耗时(毫秒)',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_user_id (user_id),
|
||||||
|
KEY idx_created_at (created_at),
|
||||||
|
KEY idx_system_id (system_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='操作日志表';
|
||||||
|
""",
|
||||||
|
|
||||||
|
# 11. 数据字典表
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS sys_dict (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
|
dict_type VARCHAR(64) NOT NULL COMMENT '字典类型',
|
||||||
|
dict_label VARCHAR(128) NOT NULL COMMENT '字典标签',
|
||||||
|
dict_value VARCHAR(128) NOT NULL COMMENT '字典值',
|
||||||
|
sort_order INT DEFAULT 0 COMMENT '排序',
|
||||||
|
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-禁用 1-启用',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_type_value (dict_type, dict_value)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='数据字典表';
|
||||||
|
""",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""连接数据库,执行建表"""
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
print(f"[INFO] Connecting to MySQL {DB_CONFIG['host']}:{DB_CONFIG['port']} ...")
|
||||||
|
conn = pymysql.connect(**DB_CONFIG)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
print(f"[OK] Connected, database: {DB_CONFIG['database']}")
|
||||||
|
|
||||||
|
# 先查现有表
|
||||||
|
cursor.execute("SHOW TABLES")
|
||||||
|
existing = {row[0] for row in cursor.fetchall()}
|
||||||
|
print(f"\n[INFO] Existing tables: {existing if existing else '(empty)'}")
|
||||||
|
|
||||||
|
# 逐个执行 DDL
|
||||||
|
created = 0
|
||||||
|
skipped = 0
|
||||||
|
for ddl in DDL_STATEMENTS:
|
||||||
|
# 提取表名
|
||||||
|
table_name = ddl.split("TABLE IF NOT EXISTS")[1].strip().split("(")[0].strip().strip('`')
|
||||||
|
if table_name in existing:
|
||||||
|
print(f" [SKIP] {table_name} already exists")
|
||||||
|
skipped += 1
|
||||||
|
else:
|
||||||
|
cursor.execute(ddl.strip())
|
||||||
|
conn.commit()
|
||||||
|
print(f" [OK] {table_name} created")
|
||||||
|
created += 1
|
||||||
|
|
||||||
|
print(f"\n===== Done: {created} created, {skipped} skipped =====")
|
||||||
|
|
||||||
|
# 展示所有表
|
||||||
|
cursor.execute("SHOW TABLES")
|
||||||
|
tables = [row[0] for row in cursor.fetchall()]
|
||||||
|
print(f"Current tables ({len(tables)}): {tables}")
|
||||||
|
|
||||||
|
except pymysql.Error as e:
|
||||||
|
print(f"[ERROR] Database error: {e}")
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
print("\n[INFO] Connection closed.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
252
init_gateway_system.py
Normal file
252
init_gateway_system.py
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
"""
|
||||||
|
为 gateway 系统初始化数据库:系统、菜单、角色、角色菜单绑定、用户绑定、放行接口
|
||||||
|
"""
|
||||||
|
import pymysql
|
||||||
|
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host='db1.prod.zgitm.com', port=3306,
|
||||||
|
user='mokeegateway', password='mokee.',
|
||||||
|
database='mokeegateway', charset='utf8mb4'
|
||||||
|
)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
sc = 'gateway'
|
||||||
|
|
||||||
|
# ========== 1.1 插入 gateway 系统 ==========
|
||||||
|
# 使用 INSERT ... ON DUPLICATE KEY UPDATE 防止重复
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO sys_system (system_code, system_name, front_url, backend_url, icon, sort_order, status, description, doc_token)
|
||||||
|
VALUES ('gateway', '网关管理平台', 'http://127.0.0.1:5001', 'http://127.0.0.1:9000', '', 0, 1, '统一登录网关管理后台', 'gateway1234567890')
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
system_name = VALUES(system_name),
|
||||||
|
front_url = VALUES(front_url),
|
||||||
|
backend_url = VALUES(backend_url),
|
||||||
|
icon = VALUES(icon),
|
||||||
|
sort_order = VALUES(sort_order),
|
||||||
|
status = VALUES(status),
|
||||||
|
description = VALUES(description),
|
||||||
|
doc_token = VALUES(doc_token)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
print("[1.1] gateway 系统已创建/更新")
|
||||||
|
|
||||||
|
# 获取 gateway 系统 ID
|
||||||
|
cur.execute("SELECT id FROM sys_system WHERE system_code = %s", (sc,))
|
||||||
|
system_id = cur.fetchone()[0]
|
||||||
|
print(f" gateway system_id = {system_id}")
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 1.2 创建菜单 ==========
|
||||||
|
# 先删除 gateway 系统已有的菜单(重新创建)
|
||||||
|
cur.execute("DELETE FROM sys_role_menu WHERE menu_id IN (SELECT id FROM sys_menu WHERE system_id = %s)", (system_id,))
|
||||||
|
cur.execute("DELETE FROM sys_menu WHERE system_id = %s", (system_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# 1.2.1 顶级菜单(目录)
|
||||||
|
top_menus = [
|
||||||
|
('首页', '/admin/dashboard', '', 'HomeFilled', 1, 1, 0), # parent_id=0, 先插再用子查询
|
||||||
|
('系统管理', '/admin/system', '', 'Monitor', 1, 2, 0),
|
||||||
|
('用户权限', None, '', 'User', 1, 3, 0),
|
||||||
|
('日志审计', None, '', 'Notebook', 1, 4, 0),
|
||||||
|
]
|
||||||
|
for menu_name, menu_path, component, icon, menu_type, sort_order, parent_name in top_menus:
|
||||||
|
# 对于 parent_name=0,直接传当前菜单名(后续用子查询)
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status)
|
||||||
|
SELECT %s, 0, %s, %s, %s, %s, %s, %s, 1
|
||||||
|
""", (system_id, menu_name, menu_path, component, icon, menu_type, sort_order))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# 获取父级菜单 ID
|
||||||
|
def get_menu_id(menu_name):
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id FROM sys_menu WHERE menu_name = %s AND system_id = %s ORDER BY id LIMIT 1",
|
||||||
|
(menu_name, system_id)
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
sys_pid = get_menu_id('系统管理')
|
||||||
|
user_pid = get_menu_id('用户权限')
|
||||||
|
log_pid = get_menu_id('日志审计')
|
||||||
|
|
||||||
|
# 子菜单
|
||||||
|
sub_menus = [
|
||||||
|
# 系统管理子菜单
|
||||||
|
('系统列表', '/admin/system', '', 'List', 2, 1, sys_pid),
|
||||||
|
('接口管理', '/admin/api', '', 'Link', 2, 2, sys_pid),
|
||||||
|
('数据字典', '/admin/dict', '', 'Collection', 2, 3, sys_pid),
|
||||||
|
# 用户权限子菜单
|
||||||
|
('用户管理', '/admin/user', '', 'UserFilled', 2, 1, user_pid),
|
||||||
|
('角色管理', '/admin/role', '', 'Avatar', 2, 2, user_pid),
|
||||||
|
('菜单管理', '/admin/menu', '', 'Menu', 2, 3, user_pid),
|
||||||
|
# 日志审计子菜单
|
||||||
|
('登录日志', '/admin/log/login', '', 'Document', 2, 1, log_pid),
|
||||||
|
('操作日志', '/admin/log/operation', '', 'Tickets', 2, 2, log_pid),
|
||||||
|
('Token日志', '/admin/log/token', '', 'Key', 2, 3, log_pid),
|
||||||
|
]
|
||||||
|
for menu_name, menu_path, component, icon, menu_type, sort_order, parent_id in sub_menus:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status)
|
||||||
|
SELECT %s, %s, %s, %s, %s, %s, %s, %s, 1
|
||||||
|
""", (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order))
|
||||||
|
conn.commit()
|
||||||
|
print("[1.2] 菜单已创建")
|
||||||
|
|
||||||
|
# 验证菜单数量
|
||||||
|
cur.execute("SELECT COUNT(*) FROM sys_menu WHERE system_id = %s", (system_id,))
|
||||||
|
menu_count = cur.fetchone()[0]
|
||||||
|
print(f" 菜单数量: {menu_count}")
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 1.3 创建角色 ==========
|
||||||
|
cur.execute("DELETE FROM sys_user_role WHERE role_id IN (SELECT id FROM sys_role WHERE system_id = %s)", (system_id,))
|
||||||
|
cur.execute("DELETE FROM sys_role_menu WHERE role_id IN (SELECT id FROM sys_role WHERE system_id = %s)", (system_id,))
|
||||||
|
cur.execute("DELETE FROM sys_role WHERE system_id = %s", (system_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO sys_role (system_id, role_name, role_code, description, status)
|
||||||
|
VALUES (%s, '超级管理员', 'super_admin', '网关平台超级管理员,拥有全部权限', 1)
|
||||||
|
""", (system_id,))
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO sys_role (system_id, role_name, role_code, description, status)
|
||||||
|
VALUES (%s, '普通管理员', 'admin', '网管平台普通管理员', 1)
|
||||||
|
""", (system_id,))
|
||||||
|
conn.commit()
|
||||||
|
print("[1.3] 角色已创建")
|
||||||
|
|
||||||
|
# 验证角色数量
|
||||||
|
cur.execute("SELECT COUNT(*) FROM sys_role WHERE system_id = %s", (system_id,))
|
||||||
|
role_count = cur.fetchone()[0]
|
||||||
|
print(f" 角色数量: {role_count}")
|
||||||
|
|
||||||
|
# 获取角色 ID
|
||||||
|
def get_role_id(role_code):
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id FROM sys_role WHERE role_code = %s AND system_id = %s",
|
||||||
|
(role_code, system_id)
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
super_admin_id = get_role_id('super_admin')
|
||||||
|
admin_id = get_role_id('admin')
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 1.4 角色菜单绑定 ==========
|
||||||
|
# super_admin 绑定所有菜单
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO sys_role_menu (role_id, menu_id)
|
||||||
|
SELECT %s, m.id FROM sys_menu m WHERE m.system_id = %s
|
||||||
|
""", (super_admin_id, system_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# admin 也绑定所有菜单(简化)
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO sys_role_menu (role_id, menu_id)
|
||||||
|
SELECT %s, m.id FROM sys_menu m WHERE m.system_id = %s
|
||||||
|
""", (admin_id, system_id))
|
||||||
|
conn.commit()
|
||||||
|
print("[1.4] 角色菜单已绑定")
|
||||||
|
|
||||||
|
# 验证绑定数
|
||||||
|
cur.execute("SELECT COUNT(*) FROM sys_role_menu WHERE role_id IN (%s, %s)", (super_admin_id, admin_id))
|
||||||
|
binding_count = cur.fetchone()[0]
|
||||||
|
print(f" 角色菜单绑定数: {binding_count}")
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 1.5 绑定 admin 用户到 gateway 系统 + 分配 super_admin 角色 ==========
|
||||||
|
# 获取 admin 用户 ID
|
||||||
|
cur.execute("SELECT id FROM sys_user WHERE username = 'admin'")
|
||||||
|
admin_user = cur.fetchone()
|
||||||
|
if admin_user:
|
||||||
|
admin_user_id = admin_user[0]
|
||||||
|
|
||||||
|
# 绑定用户到 gateway 系统
|
||||||
|
cur.execute("""
|
||||||
|
INSERT IGNORE INTO sys_user_system (user_id, system_id)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
""", (admin_user_id, system_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# 给 admin 用户分配 super_admin 角色
|
||||||
|
cur.execute("""
|
||||||
|
INSERT IGNORE INTO sys_user_role (user_id, role_id)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
""", (admin_user_id, super_admin_id))
|
||||||
|
conn.commit()
|
||||||
|
print(f"[1.5] admin 用户(user_id={admin_user_id})已绑定到 gateway 系统 + super_admin 角色")
|
||||||
|
else:
|
||||||
|
print("[1.5] 警告:未找到 admin 用户!")
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 1.6 创建 gateway 系统的放行接口 ==========
|
||||||
|
cur.execute("DELETE FROM sys_api WHERE system_id = %s", (system_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
gateway_apis = [
|
||||||
|
# admin 接口(全部 HTTP 方法)
|
||||||
|
('/zgapi/v1/admin/**', 'GET', '管理接口-GET'),
|
||||||
|
('/zgapi/v1/admin/**', 'POST', '管理接口-POST'),
|
||||||
|
('/zgapi/v1/admin/**', 'PUT', '管理接口-PUT'),
|
||||||
|
('/zgapi/v1/admin/**', 'DELETE', '管理接口-DELETE'),
|
||||||
|
# auth 接口
|
||||||
|
('/zgapi/v1/auth/**', 'GET', '认证接口-GET'),
|
||||||
|
('/zgapi/v1/auth/**', 'POST', '认证接口-POST'),
|
||||||
|
('/zgapi/v1/auth/**', 'PUT', '认证接口-PUT'),
|
||||||
|
]
|
||||||
|
for api_path, api_method, api_name in gateway_apis:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO sys_api (system_id, api_path, api_method, api_name, status)
|
||||||
|
VALUES (%s, %s, %s, %s, 1)
|
||||||
|
""", (system_id, api_path, api_method, api_name))
|
||||||
|
conn.commit()
|
||||||
|
print("[1.6] gateway 放行接口已创建")
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 验证 ==========
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(" 验 证 结 果")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
cur.execute("SELECT COUNT(*) FROM sys_menu WHERE system_id = %s", (system_id,))
|
||||||
|
print(f" 菜单数量: {cur.fetchone()[0]}")
|
||||||
|
|
||||||
|
cur.execute("SELECT COUNT(*) FROM sys_role WHERE system_id = %s", (system_id,))
|
||||||
|
print(f" 角色数量: {cur.fetchone()[0]}")
|
||||||
|
|
||||||
|
cur.execute("SELECT COUNT(*) FROM sys_role_menu rm JOIN sys_role r ON rm.role_id = r.id WHERE r.system_id = %s", (system_id,))
|
||||||
|
print(f" 角色菜单绑定数: {cur.fetchone()[0]}")
|
||||||
|
|
||||||
|
cur.execute("SELECT COUNT(*) FROM sys_user_system WHERE system_id = %s", (system_id,))
|
||||||
|
print(f" admin用户绑定系统数: {cur.fetchone()[0]}")
|
||||||
|
|
||||||
|
cur.execute("SELECT COUNT(*) FROM sys_api WHERE system_id = %s", (system_id,))
|
||||||
|
print(f" 放行接口数: {cur.fetchone()[0]}")
|
||||||
|
|
||||||
|
# 列出菜单
|
||||||
|
print("\n--- 菜单列表 ---")
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, parent_id, menu_name, menu_path, menu_type, sort_order FROM sys_menu WHERE system_id = %s ORDER BY sort_order, id",
|
||||||
|
(system_id,)
|
||||||
|
)
|
||||||
|
for r in cur.fetchall():
|
||||||
|
print(f" id={r[0]} parent={r[1]} {r[2]:8s} path={r[3]:25s} type={r[4]} sort={r[5]}")
|
||||||
|
|
||||||
|
print("\n--- 角色列表 ---")
|
||||||
|
cur.execute("SELECT id, role_name, role_code FROM sys_role WHERE system_id = %s", (system_id,))
|
||||||
|
for r in cur.fetchall():
|
||||||
|
print(f" id={r[0]} {r[1]} ({r[2]})")
|
||||||
|
|
||||||
|
print("\n--- 放行接口列表 ---")
|
||||||
|
cur.execute("SELECT api_path, api_method, api_name FROM sys_api WHERE system_id = %s", (system_id,))
|
||||||
|
for r in cur.fetchall():
|
||||||
|
print(f" {r[0]:30s} {r[1]:8s} {r[2]}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("初始化完成!")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
48
init_menu.py
Normal file
48
init_menu.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""为 test 系统初始化菜单和角色"""
|
||||||
|
import pymysql
|
||||||
|
conn = pymysql.connect(host='db1.prod.zgitm.com', port=3306, user='mokeegateway', password='mokee.',
|
||||||
|
database='mokeegateway', charset='utf8mb4')
|
||||||
|
cur = conn.cursor()
|
||||||
|
sc = 'test'
|
||||||
|
|
||||||
|
# 1. 顶级菜单
|
||||||
|
cur.execute(f"INSERT INTO sys_menu (system_id,parent_id,menu_name,menu_path,component,icon,menu_type,sort_order,status) SELECT s.id,0,'系统管理','/system','Layout','Setting',1,1,1 FROM sys_system s WHERE s.system_code='{sc}'")
|
||||||
|
cur.execute(f"INSERT INTO sys_menu (system_id,parent_id,menu_name,menu_path,component,icon,menu_type,sort_order,status) SELECT s.id,0,'业务管理','/biz','Layout','Document',1,2,1 FROM sys_system s WHERE s.system_code='{sc}'")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# 获取父ID
|
||||||
|
cur.execute(f"SELECT id FROM sys_menu WHERE menu_name='系统管理' AND system_id=(SELECT id FROM sys_system WHERE system_code='{sc}')")
|
||||||
|
sys_pid = cur.fetchone()[0]
|
||||||
|
cur.execute(f"SELECT id FROM sys_menu WHERE menu_name='业务管理' AND system_id=(SELECT id FROM sys_system WHERE system_code='{sc}')")
|
||||||
|
biz_pid = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# 子菜单
|
||||||
|
subs = [
|
||||||
|
('用户管理', '/system/user', 'system/user/index', 'User', 2, 1, sys_pid),
|
||||||
|
('角色管理', '/system/role', 'system/role/index', 'Avatar', 2, 2, sys_pid),
|
||||||
|
('订单管理', '/biz/order', 'biz/order/index', 'Tickets', 2, 1, biz_pid),
|
||||||
|
('报表查询', '/biz/report', 'biz/report/index', 'TrendCharts', 2, 2, biz_pid),
|
||||||
|
]
|
||||||
|
for nm, path, comp, icon, mtype, sort, pid in subs:
|
||||||
|
cur.execute(f"INSERT INTO sys_menu (system_id,parent_id,menu_name,menu_path,component,icon,menu_type,sort_order,status) SELECT s.id,{pid},'{nm}','{path}','{comp}','{icon}',{mtype},{sort},1 FROM sys_system s WHERE s.system_code='{sc}'")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# 2. 角色
|
||||||
|
cur.execute(f"INSERT IGNORE INTO sys_role (system_id,role_name,role_code,description,status) SELECT s.id,'管理员','admin','系统管理员',1 FROM sys_system s WHERE s.system_code='{sc}'")
|
||||||
|
cur.execute(f"INSERT IGNORE INTO sys_role (system_id,role_name,role_code,description,status) SELECT s.id,'普通用户','user','普通用户',1 FROM sys_system s WHERE s.system_code='{sc}'")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# 3. 角色菜单绑定
|
||||||
|
cur.execute(f"INSERT INTO sys_role_menu (role_id,menu_id) SELECT r.id,m.id FROM sys_role r,sys_menu m,sys_system s WHERE r.role_code='admin' AND r.system_id=s.id AND m.system_id=s.id AND s.system_code='{sc}'")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
print("=== Menus ===")
|
||||||
|
cur.execute(f"SELECT menu_name,menu_path,menu_type FROM sys_menu WHERE system_id=(SELECT id FROM sys_system WHERE system_code='{sc}') ORDER BY sort_order,id")
|
||||||
|
for r in cur.fetchall(): print(f" {r[0]:10s} {r[1]:20s} type={r[2]}")
|
||||||
|
print("=== Roles ===")
|
||||||
|
cur.execute(f"SELECT role_name,role_code FROM sys_role WHERE system_id=(SELECT id FROM sys_system WHERE system_code='{sc}')")
|
||||||
|
for r in cur.fetchall(): print(f" {r[0]} ({r[1]})")
|
||||||
|
cur.execute(f"SELECT COUNT(*) FROM sys_role_menu rm JOIN sys_role r ON rm.role_id=r.id WHERE r.system_id=(SELECT id FROM sys_system WHERE system_code='{sc}')")
|
||||||
|
print(f"=== Role-Menu bindings: {cur.fetchone()[0]} ===")
|
||||||
|
cur.close(); conn.close()
|
||||||
|
print("Done")
|
||||||
364
init_qa_database.py
Normal file
364
init_qa_database.py
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
"""
|
||||||
|
QA 数据库初始化脚本 (幂等版本)
|
||||||
|
数据库: mokeegatewayqa / 用户: mokeegatewayqa / 密码: mokee.
|
||||||
|
"""
|
||||||
|
import pymysql
|
||||||
|
|
||||||
|
DB = {
|
||||||
|
'host': 'db1.qa.zgitm.com',
|
||||||
|
'port': 3306,
|
||||||
|
'user': 'mokeegatewayqa',
|
||||||
|
'password': 'mokee.',
|
||||||
|
'database': 'mokeegatewayqa',
|
||||||
|
'charset': 'utf8mb4',
|
||||||
|
}
|
||||||
|
|
||||||
|
DDL = [
|
||||||
|
# 1
|
||||||
|
"""CREATE TABLE IF NOT EXISTS sys_system (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
|
system_code VARCHAR(64) NOT NULL, system_name VARCHAR(128) NOT NULL,
|
||||||
|
front_url VARCHAR(256) NOT NULL, backend_url VARCHAR(256) NOT NULL,
|
||||||
|
gateway_url VARCHAR(256) NOT NULL, icon VARCHAR(512) DEFAULT NULL,
|
||||||
|
sort_order INT DEFAULT 0, status TINYINT NOT NULL DEFAULT 1,
|
||||||
|
description VARCHAR(512) DEFAULT NULL, doc_token VARCHAR(128) DEFAULT NULL,
|
||||||
|
created_by BIGINT DEFAULT NULL, updated_by BIGINT DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
is_deleted TINYINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务系统表';""",
|
||||||
|
# 2
|
||||||
|
"""CREATE TABLE IF NOT EXISTS sys_api (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT, system_id BIGINT NOT NULL,
|
||||||
|
api_path VARCHAR(256) NOT NULL, api_method VARCHAR(10) NOT NULL,
|
||||||
|
api_name VARCHAR(128) DEFAULT NULL, api_desc VARCHAR(512) DEFAULT NULL,
|
||||||
|
status TINYINT NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id), KEY idx_system_id (system_id),
|
||||||
|
UNIQUE KEY uk_system_path_method (system_id, api_path, api_method)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统放行接口表';""",
|
||||||
|
# 3
|
||||||
|
"""CREATE TABLE IF NOT EXISTS sys_user (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(64) NOT NULL,
|
||||||
|
password VARCHAR(256) NOT NULL, real_name VARCHAR(64) DEFAULT NULL,
|
||||||
|
email VARCHAR(128) DEFAULT NULL, phone VARCHAR(32) DEFAULT NULL,
|
||||||
|
avatar VARCHAR(512) DEFAULT NULL, post VARCHAR(64) DEFAULT NULL,
|
||||||
|
status TINYINT NOT NULL DEFAULT 1, lock_until DATETIME DEFAULT NULL,
|
||||||
|
login_fail_count INT NOT NULL DEFAULT 0, last_login_ip VARCHAR(64) DEFAULT NULL,
|
||||||
|
last_login_time DATETIME DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
is_deleted TINYINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';""",
|
||||||
|
# 4
|
||||||
|
"""CREATE TABLE IF NOT EXISTS sys_user_system (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, system_id BIGINT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id), UNIQUE KEY uk_user_system (user_id, system_id), KEY idx_system_id (system_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户系统绑定表';""",
|
||||||
|
# 5
|
||||||
|
"""CREATE TABLE IF NOT EXISTS sys_role (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT, system_id BIGINT NOT NULL,
|
||||||
|
role_name VARCHAR(64) NOT NULL, role_code VARCHAR(64) NOT NULL,
|
||||||
|
description VARCHAR(256) DEFAULT NULL, status TINYINT NOT NULL DEFAULT 1,
|
||||||
|
created_by BIGINT DEFAULT NULL, updated_by BIGINT DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
is_deleted TINYINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色表';""",
|
||||||
|
# 6
|
||||||
|
"""CREATE TABLE IF NOT EXISTS sys_user_role (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, role_id BIGINT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id), UNIQUE KEY uk_user_role (user_id, role_id), KEY idx_role_id (role_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户角色表';""",
|
||||||
|
# 7
|
||||||
|
"""CREATE TABLE IF NOT EXISTS sys_menu (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT, system_id BIGINT NOT NULL,
|
||||||
|
parent_id BIGINT NOT NULL DEFAULT 0, menu_name VARCHAR(64) NOT NULL,
|
||||||
|
menu_path VARCHAR(256) DEFAULT NULL, component VARCHAR(256) DEFAULT NULL,
|
||||||
|
icon VARCHAR(128) DEFAULT NULL, menu_type TINYINT NOT NULL,
|
||||||
|
permission VARCHAR(128) DEFAULT NULL, sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
status TINYINT NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id), KEY idx_system_id (system_id), KEY idx_parent_id (parent_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='菜单表';""",
|
||||||
|
# 8
|
||||||
|
"""CREATE TABLE IF NOT EXISTS sys_role_menu (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT, role_id BIGINT NOT NULL, menu_id BIGINT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id), UNIQUE KEY uk_role_menu (role_id, menu_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色菜单表';""",
|
||||||
|
# 9
|
||||||
|
"""CREATE TABLE IF NOT EXISTS sys_login_log (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT DEFAULT NULL,
|
||||||
|
username VARCHAR(64) NOT NULL, system_id BIGINT DEFAULT NULL,
|
||||||
|
login_ip VARCHAR(64) DEFAULT NULL, user_agent VARCHAR(512) DEFAULT NULL,
|
||||||
|
browser VARCHAR(64) DEFAULT NULL, os VARCHAR(64) DEFAULT NULL,
|
||||||
|
status TINYINT NOT NULL, fail_reason VARCHAR(256) DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_created_at (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='登录日志表';""",
|
||||||
|
# 10
|
||||||
|
"""CREATE TABLE IF NOT EXISTS sys_operation_log (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT DEFAULT NULL,
|
||||||
|
username VARCHAR(64) DEFAULT NULL, system_id BIGINT DEFAULT NULL,
|
||||||
|
module VARCHAR(64) DEFAULT NULL, action VARCHAR(32) DEFAULT NULL,
|
||||||
|
request_url VARCHAR(256) DEFAULT NULL, request_method VARCHAR(10) DEFAULT NULL,
|
||||||
|
request_body TEXT DEFAULT NULL, response_status INT DEFAULT NULL,
|
||||||
|
error_msg TEXT DEFAULT NULL, cost_time INT DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_created_at (created_at), KEY idx_system_id (system_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='操作日志表';""",
|
||||||
|
# 11
|
||||||
|
"""CREATE TABLE IF NOT EXISTS sys_dict (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT, dict_type VARCHAR(64) NOT NULL,
|
||||||
|
dict_label VARCHAR(128) NOT NULL, dict_value VARCHAR(128) NOT NULL,
|
||||||
|
sort_order INT DEFAULT 0, status TINYINT NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id), UNIQUE KEY uk_type_value (dict_type, dict_value)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='数据字典表';""",
|
||||||
|
# 12
|
||||||
|
"""CREATE TABLE IF NOT EXISTS sys_file (
|
||||||
|
id VARCHAR(64) NOT NULL COMMENT 'UUID主键',
|
||||||
|
system_id VARCHAR(64) DEFAULT NULL COMMENT '所属系统ID',
|
||||||
|
file_name VARCHAR(256) NOT NULL COMMENT '原始文件名',
|
||||||
|
file_type VARCHAR(64) DEFAULT 'other' COMMENT '文件类型(icon/avatar/doc/other)',
|
||||||
|
file_size BIGINT DEFAULT 0 COMMENT '文件大小(字节)',
|
||||||
|
mime_type VARCHAR(128) DEFAULT NULL COMMENT 'MIME类型',
|
||||||
|
md5 VARCHAR(64) DEFAULT NULL COMMENT '文件MD5摘要',
|
||||||
|
upload_user_id VARCHAR(64) DEFAULT NULL COMMENT '上传用户ID',
|
||||||
|
file_data LONGBLOB COMMENT '文件二进制数据',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
INDEX idx_system_id (system_id),
|
||||||
|
INDEX idx_file_type (file_type),
|
||||||
|
INDEX idx_upload_user_id (upload_user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文件表';""",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def init():
|
||||||
|
conn = pymysql.connect(**DB)
|
||||||
|
cur = conn.cursor()
|
||||||
|
print(f"已连接: {DB['host']}:{DB['port']}/{DB['database']}")
|
||||||
|
|
||||||
|
# ===== 阶段0: 清空所有数据(按外键顺序) =====
|
||||||
|
print("\n===== 阶段0: 清空旧数据 =====")
|
||||||
|
truncate_order = [
|
||||||
|
'sys_operation_log', 'sys_login_log',
|
||||||
|
'sys_role_menu', 'sys_user_role', 'sys_user_system',
|
||||||
|
'sys_api', 'sys_menu', 'sys_role',
|
||||||
|
'sys_dict', 'sys_file',
|
||||||
|
'sys_user', 'sys_system',
|
||||||
|
]
|
||||||
|
for tbl in truncate_order:
|
||||||
|
cur.execute(f"DELETE FROM {tbl}")
|
||||||
|
conn.commit()
|
||||||
|
# 重置自增
|
||||||
|
for tbl in truncate_order:
|
||||||
|
cur.execute(f"ALTER TABLE {tbl} AUTO_INCREMENT = 1")
|
||||||
|
conn.commit()
|
||||||
|
print(" 所有表已清空并重置自增")
|
||||||
|
|
||||||
|
# ===== 阶段1: 建表 =====
|
||||||
|
print("\n===== 阶段1: 建表 =====")
|
||||||
|
for ddl in DDL:
|
||||||
|
tbl = ddl.split("TABLE IF NOT EXISTS")[1].strip().split("(")[0].strip().strip('`')
|
||||||
|
cur.execute(ddl.strip())
|
||||||
|
conn.commit()
|
||||||
|
print(f" OK {tbl}")
|
||||||
|
print(" 建表完成")
|
||||||
|
|
||||||
|
# ===== 阶段2: 插入管理员 =====
|
||||||
|
print("\n===== 阶段2: 管理员用户 =====")
|
||||||
|
cur.execute("""INSERT INTO sys_user (username, password, real_name, email, phone, post, status)
|
||||||
|
VALUES ('admin', '$2b$10$ffAZHF6lmUuNV6OJITADXeduD5mMTS0.7kElPI7zno2zGn/RF5uC6', '管理员',
|
||||||
|
'admin@mokee.com', '13800000000', '管理员', 1)""")
|
||||||
|
conn.commit()
|
||||||
|
cur.execute("SELECT id FROM sys_user WHERE username='admin'")
|
||||||
|
admin_uid = cur.fetchone()[0]
|
||||||
|
print(f" 管理员: id={admin_uid}")
|
||||||
|
|
||||||
|
# ===== 阶段3: 测试系统 =====
|
||||||
|
print("\n===== 阶段3: 测试系统 (test) =====")
|
||||||
|
cur.execute("""INSERT INTO sys_system (system_code, system_name, front_url, backend_url, gateway_url, status, description)
|
||||||
|
VALUES ('test', '测试系统', 'http://127.0.0.1:5001', 'http://127.0.0.1:9000', 'http://127.0.0.1:9000/test', 1, '测试用业务系统')""")
|
||||||
|
conn.commit()
|
||||||
|
test_sid = cur.lastrowid
|
||||||
|
print(f" 系统: id={test_sid}")
|
||||||
|
|
||||||
|
# 绑定管理员
|
||||||
|
cur.execute("INSERT INTO sys_user_system (user_id, system_id) VALUES (%s, %s)", (admin_uid, test_sid))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# 测试系统角色
|
||||||
|
cur.execute("INSERT INTO sys_role (system_id, role_name, role_code, description, status) VALUES (%s, '管理员', 'admin', '系统管理员', 1)", (test_sid,))
|
||||||
|
cur.execute("INSERT INTO sys_role (system_id, role_name, role_code, description, status) VALUES (%s, '普通用户', 'user', '普通用户', 1)", (test_sid,))
|
||||||
|
conn.commit()
|
||||||
|
cur.execute("SELECT id FROM sys_role WHERE role_code='admin' AND system_id=%s", (test_sid,))
|
||||||
|
test_admin_role = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# 测试系统菜单
|
||||||
|
cur.execute("INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status) VALUES (%s, 0, '系统管理', '/system', 'Layout', 'Setting', 1, 1, 1)", (test_sid,))
|
||||||
|
cur.execute("INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status) VALUES (%s, 0, '业务管理', '/biz', 'Layout', 'Document', 1, 2, 1)", (test_sid,))
|
||||||
|
conn.commit()
|
||||||
|
cur.execute("SELECT id FROM sys_menu WHERE menu_name='系统管理' AND system_id=%s", (test_sid,))
|
||||||
|
sys_pid = cur.fetchone()[0]
|
||||||
|
cur.execute("SELECT id FROM sys_menu WHERE menu_name='业务管理' AND system_id=%s", (test_sid,))
|
||||||
|
biz_pid = cur.fetchone()[0]
|
||||||
|
|
||||||
|
test_subs = [
|
||||||
|
('用户管理', '/system/user', 'system/user/index', 'User', 2, 1, sys_pid),
|
||||||
|
('角色管理', '/system/role', 'system/role/index', 'Avatar', 2, 2, sys_pid),
|
||||||
|
('订单管理', '/biz/order', 'biz/order/index', 'Tickets', 2, 1, biz_pid),
|
||||||
|
('报表查询', '/biz/report', 'biz/report/index', 'TrendCharts', 2, 2, biz_pid),
|
||||||
|
]
|
||||||
|
for nm, path, comp, icon, mtype, sort, pid in test_subs:
|
||||||
|
cur.execute("INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 1)",
|
||||||
|
(test_sid, pid, nm, path, comp, icon, mtype, sort))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# 管理员角色绑定所有菜单
|
||||||
|
cur.execute("INSERT INTO sys_role_menu (role_id, menu_id) SELECT %s, m.id FROM sys_menu m WHERE m.system_id=%s", (test_admin_role, test_sid))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# 分配角色
|
||||||
|
cur.execute("INSERT INTO sys_user_role (user_id, role_id) VALUES (%s, %s)", (admin_uid, test_admin_role))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# 测试系统放行接口
|
||||||
|
for method, path in [('GET', '/zgapi/v1/admin/**'), ('POST', '/zgapi/v1/admin/**'),
|
||||||
|
('PUT', '/zgapi/v1/admin/**'), ('DELETE', '/zgapi/v1/admin/**'),
|
||||||
|
('GET', '/zgapi/v1/auth/**'), ('POST', '/zgapi/v1/auth/**'),
|
||||||
|
('PUT', '/zgapi/v1/auth/**')]:
|
||||||
|
cur.execute("INSERT INTO sys_api (system_id, api_path, api_method, api_name, status) VALUES (%s, %s, %s, '放行接口', 1)",
|
||||||
|
(test_sid, path, method))
|
||||||
|
conn.commit()
|
||||||
|
print(f" 测试系统完成 (1管理员 + 2角色 + 6菜单 + 7接口)")
|
||||||
|
|
||||||
|
# ===== 阶段4: Gateway 系统 =====
|
||||||
|
print("\n===== 阶段4: 网关管理平台 (gateway) =====")
|
||||||
|
cur.execute("""INSERT INTO sys_system (system_code, system_name, front_url, backend_url, gateway_url, icon, sort_order, status, description, doc_token)
|
||||||
|
VALUES ('gateway', '网关管理平台', 'http://127.0.0.1:5001', 'http://127.0.0.1:9000', 'http://127.0.0.1:9000/gateway', '', 0, 1, '统一登录网关管理后台', 'gateway1234567890')""")
|
||||||
|
conn.commit()
|
||||||
|
gw_sid = cur.lastrowid
|
||||||
|
print(f" 系统: id={gw_sid}")
|
||||||
|
|
||||||
|
# 绑定管理员
|
||||||
|
cur.execute("INSERT INTO sys_user_system (user_id, system_id) VALUES (%s, %s)", (admin_uid, gw_sid))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# 角色
|
||||||
|
cur.execute("INSERT INTO sys_role (system_id, role_name, role_code, description, status) VALUES (%s, '超级管理员', 'super_admin', '网关平台超级管理员', 1)", (gw_sid,))
|
||||||
|
cur.execute("INSERT INTO sys_role (system_id, role_name, role_code, description, status) VALUES (%s, '普通管理员', 'admin', '网关平台普通管理员', 1)", (gw_sid,))
|
||||||
|
conn.commit()
|
||||||
|
cur.execute("SELECT id FROM sys_role WHERE role_code='super_admin' AND system_id=%s", (gw_sid,))
|
||||||
|
gw_super_admin = cur.fetchone()[0]
|
||||||
|
cur.execute("SELECT id FROM sys_role WHERE role_code='admin' AND system_id=%s", (gw_sid,))
|
||||||
|
gw_admin = cur.fetchone()[0]
|
||||||
|
|
||||||
|
# 分配 super_admin
|
||||||
|
cur.execute("INSERT INTO sys_user_role (user_id, role_id) VALUES (%s, %s)", (admin_uid, gw_super_admin))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# 顶级菜单
|
||||||
|
for name, path, comp, icon, mtype, sort in [
|
||||||
|
('首页', '/admin/dashboard', '', 'HomeFilled', 1, 1),
|
||||||
|
('系统管理', None, '', 'Monitor', 1, 2),
|
||||||
|
('用户权限', None, '', 'User', 1, 3),
|
||||||
|
('日志审计', None, '', 'Notebook', 1, 4),
|
||||||
|
]:
|
||||||
|
cur.execute("INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status) VALUES (%s, 0, %s, %s, %s, %s, %s, %s, 1)",
|
||||||
|
(gw_sid, name, path, comp, icon, mtype, sort))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def get_menu_id(name, sid):
|
||||||
|
cur.execute("SELECT id FROM sys_menu WHERE menu_name=%s AND system_id=%s ORDER BY id LIMIT 1", (name, sid))
|
||||||
|
r = cur.fetchone()
|
||||||
|
return r[0] if r else None
|
||||||
|
|
||||||
|
gw_sys_pid = get_menu_id('系统管理', gw_sid)
|
||||||
|
gw_user_pid = get_menu_id('用户权限', gw_sid)
|
||||||
|
gw_log_pid = get_menu_id('日志审计', gw_sid)
|
||||||
|
|
||||||
|
for name, path, comp, icon, mtype, sort, pid in [
|
||||||
|
('系统列表', '/admin/system', '', 'List', 2, 1, gw_sys_pid),
|
||||||
|
('接口管理', '/admin/api', '', 'Link', 2, 2, gw_sys_pid),
|
||||||
|
('数据字典', '/admin/dict', '', 'Collection', 2, 3, gw_sys_pid),
|
||||||
|
('用户管理', '/admin/user', '', 'UserFilled', 2, 1, gw_user_pid),
|
||||||
|
('角色管理', '/admin/role', '', 'Avatar', 2, 2, gw_user_pid),
|
||||||
|
('菜单管理', '/admin/menu', '', 'Menu', 2, 3, gw_user_pid),
|
||||||
|
('登录日志', '/admin/log/login', '', 'Document', 2, 1, gw_log_pid),
|
||||||
|
('操作日志', '/admin/log/operation', '', 'Tickets', 2, 2, gw_log_pid),
|
||||||
|
]:
|
||||||
|
cur.execute("INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 1)",
|
||||||
|
(gw_sid, pid, name, path, comp, icon, mtype, sort))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# 角色绑定所有菜单
|
||||||
|
cur.execute("INSERT INTO sys_role_menu (role_id, menu_id) SELECT %s, m.id FROM sys_menu m WHERE m.system_id=%s", (gw_super_admin, gw_sid))
|
||||||
|
cur.execute("INSERT INTO sys_role_menu (role_id, menu_id) SELECT %s, m.id FROM sys_menu m WHERE m.system_id=%s", (gw_admin, gw_sid))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# 放行接口
|
||||||
|
for path, method in [('/zgapi/v1/admin/**', 'GET'), ('/zgapi/v1/admin/**', 'POST'),
|
||||||
|
('/zgapi/v1/admin/**', 'PUT'), ('/zgapi/v1/admin/**', 'DELETE'),
|
||||||
|
('/zgapi/v1/auth/**', 'GET'), ('/zgapi/v1/auth/**', 'POST'),
|
||||||
|
('/zgapi/v1/auth/**', 'PUT')]:
|
||||||
|
cur.execute("INSERT INTO sys_api (system_id, api_path, api_method, api_name, status) VALUES (%s, %s, %s, '放行接口', 1)",
|
||||||
|
(gw_sid, path, method))
|
||||||
|
conn.commit()
|
||||||
|
print(f" Gateway系统完成 (2角色 + 12菜单 + 7接口)")
|
||||||
|
|
||||||
|
# ===== 验证 =====
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(" 验 证 结 果")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
stats = [
|
||||||
|
("系统数", "sys_system"),
|
||||||
|
("用户数", "sys_user"),
|
||||||
|
("菜单数", "sys_menu"),
|
||||||
|
("角色数", "sys_role"),
|
||||||
|
("角色菜单绑定", "sys_role_menu"),
|
||||||
|
("放行接口", "sys_api"),
|
||||||
|
("用户系统绑定", "sys_user_system"),
|
||||||
|
("用户角色绑定", "sys_user_role"),
|
||||||
|
]
|
||||||
|
for label, tbl in stats:
|
||||||
|
cur.execute(f"SELECT COUNT(*) FROM {tbl}")
|
||||||
|
print(f" {label}: {cur.fetchone()[0]}")
|
||||||
|
|
||||||
|
print("\n--- 系统列表 ---")
|
||||||
|
cur.execute("SELECT id, system_code, system_name FROM sys_system")
|
||||||
|
for r in cur.fetchall():
|
||||||
|
print(f" id={r[0]} {r[1]:10s} {r[2]}")
|
||||||
|
|
||||||
|
print("\n--- 角色列表 ---")
|
||||||
|
cur.execute("SELECT r.id, s.system_code, r.role_name, r.role_code FROM sys_role r JOIN sys_system s ON r.system_id=s.id")
|
||||||
|
for r in cur.fetchall():
|
||||||
|
print(f" id={r[0]} {r[1]:10s} {r[2]:12s} ({r[3]})")
|
||||||
|
|
||||||
|
print("\n--- Gateway 菜单 ---")
|
||||||
|
cur.execute("SELECT id, parent_id, menu_name, menu_path, menu_type FROM sys_menu WHERE system_id=%s ORDER BY sort_order, id", (gw_sid,))
|
||||||
|
for r in cur.fetchall():
|
||||||
|
print(f" id={r[0]:2d} pid={r[1]:2d} {r[2]:8s} path={(r[3] or ''):25s} type={r[4]}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("QA 数据库初始化完成!")
|
||||||
|
print(f"管理员账号: admin / admin123")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
init()
|
||||||
68
init_test_data.py
Normal file
68
init_test_data.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""初始化测试数据:管理员账号 + 测试系统"""
|
||||||
|
import pymysql
|
||||||
|
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host='db1.prod.zgitm.com', port=3306,
|
||||||
|
user='mokeegateway', password='mokee.',
|
||||||
|
database='mokeegateway', charset='utf8mb4'
|
||||||
|
)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# 1. 管理员用户 (密码: admin123, BCrypt加密)
|
||||||
|
sql = """INSERT IGNORE INTO sys_user (username, password, real_name, email, phone, post, status)
|
||||||
|
VALUES ('admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi', '管理员', 'admin@mokee.com', '13800000000', '管理员', 1)"""
|
||||||
|
cur.execute(sql)
|
||||||
|
conn.commit()
|
||||||
|
print("1. 管理员用户 OK")
|
||||||
|
|
||||||
|
# 2. 测试系统
|
||||||
|
sql = """INSERT IGNORE INTO sys_system (system_code, system_name, front_url, backend_url, gateway_url, status, description)
|
||||||
|
VALUES ('test', '测试系统', 'http://127.0.0.1:5001', 'http://127.0.0.1:9000', 'http://127.0.0.1:9000', 1, '测试用业务系统')"""
|
||||||
|
cur.execute(sql)
|
||||||
|
conn.commit()
|
||||||
|
print("2. 测试系统 OK")
|
||||||
|
|
||||||
|
# 3. 绑定管理员到测试系统
|
||||||
|
sql = """INSERT IGNORE INTO sys_user_system (user_id, system_id)
|
||||||
|
SELECT u.id, s.id FROM sys_user u, sys_system s
|
||||||
|
WHERE u.username='admin' AND s.system_code='test'"""
|
||||||
|
cur.execute(sql)
|
||||||
|
conn.commit()
|
||||||
|
print("3. 用户系统绑定 OK")
|
||||||
|
|
||||||
|
# 4. 管理员角色
|
||||||
|
sql = """INSERT IGNORE INTO sys_role (system_id, role_name, role_code, description, status)
|
||||||
|
SELECT s.id, '管理员', 'admin', '系统管理员', 1 FROM sys_system s WHERE s.system_code='test'"""
|
||||||
|
cur.execute(sql)
|
||||||
|
conn.commit()
|
||||||
|
print("4. 管理员角色 OK")
|
||||||
|
|
||||||
|
# 5. 分配角色给用户
|
||||||
|
sql = """INSERT IGNORE INTO sys_user_role (user_id, role_id)
|
||||||
|
SELECT u.id, r.id FROM sys_user u, sys_role r, sys_system s
|
||||||
|
WHERE u.username='admin' AND r.role_code='admin' AND r.system_id=s.id AND s.system_code='test'"""
|
||||||
|
cur.execute(sql)
|
||||||
|
conn.commit()
|
||||||
|
print("5. 用户角色分配 OK")
|
||||||
|
|
||||||
|
# 6. 放行所有接口
|
||||||
|
apis = [
|
||||||
|
('GET', '/zgapi/v1/admin/**'),
|
||||||
|
('POST', '/zgapi/v1/admin/**'),
|
||||||
|
('PUT', '/zgapi/v1/admin/**'),
|
||||||
|
('DELETE', '/zgapi/v1/admin/**'),
|
||||||
|
('GET', '/zgapi/v1/auth/**'),
|
||||||
|
('POST', '/zgapi/v1/auth/**'),
|
||||||
|
('PUT', '/zgapi/v1/auth/**'),
|
||||||
|
]
|
||||||
|
for method, path in apis:
|
||||||
|
sql = """INSERT IGNORE INTO sys_api (system_id, api_path, api_method, api_name, status)
|
||||||
|
SELECT s.id, %s, %s, '放行接口', 1 FROM sys_system s WHERE s.system_code='test'"""
|
||||||
|
cur.execute(sql, (path, method))
|
||||||
|
conn.commit()
|
||||||
|
print(f"6. 放行接口 OK ({len(apis)} 条)")
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
print("\n===== 测试数据初始化完成 =====")
|
||||||
|
print("管理员账号: admin / admin123")
|
||||||
1
memory/.session-last
Normal file
1
memory/.session-last
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"projectPath":"E:\\AI\\claude\\new\\mokeeGateway","endedAt":"2026-06-20T10:55:00+08:00","sessionId":"session-20260620-2"}
|
||||||
24
memory/MEMORY.md
Normal file
24
memory/MEMORY.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
- [项目概览](project-overview.md) — 统一登录网关平台完整信息、架构、状态、已知问题
|
||||||
|
- [2026-06-28 会话-1](session-20260628-1.md) — 修复登录后 /open/token 和文件下载接口重复调用
|
||||||
|
- [2026-06-09 会话](session-log.md) — 完整开发过程、决策、Bug修复、待办
|
||||||
|
- [2026-06-10 会话-1](session-20260610-1.md) — 数据库地址变更、前端环境变量整理
|
||||||
|
- [2026-06-10 会话-2](session-20260610-2.md) — UUID迁移、权限系统、UI翻新、数据库对齐、Cookie跨域修复
|
||||||
|
- [2026-06-11 会话-1](session-20260611-1.md) — 头像上传修复、用户信息显示修复、文件存DB、系统Bug全面检查
|
||||||
|
- [2026-06-12 会话-1](session-20260612-1.md) — 域名迁移zgitm.com、对外开放API、邮件服务、Token一次消费、移动端适配
|
||||||
|
- [2026-06-12 会话-2](session-20260612-2.md) — VPN网络带宽诊断、前端加载慢根因定位(Nginx代理丢失gzip)
|
||||||
|
- [2026-06-13 会话-1](session-20260613-1.md) — 数据库IP切换、11个bug修复、42文件加注释、assignRoles session修复
|
||||||
|
- [2026-06-13 会话-2](session-20260613-2.md) — UI移动端优化、登录跳转、操作人乱码修复、文件存储迁移磁盘、系统配置页、开放文件API
|
||||||
|
- [2026-06-13 会话-3](session-20260613-3.md) — 头像401、菜单check-strictly、移动端全面优化、token/info接口、唯一键校验、数据权限
|
||||||
|
- [2026-06-13 会话-4](session-20260613-4.md) — Widget实施 + 10+Bug修复(菜单动态化/数据权限/ApiFilter/网关转发/ElMessage等)+ 接入文档完善
|
||||||
|
- [2026-06-13 会话-5](session-20260613-5.md) — 5个前端修复(ElMessage弹窗/双首页/服务器卡片/图表数据/头像上传)
|
||||||
|
- [2026-06-15 会话-1](session-20260615-1.md) — 前端UI统一优化:分页/布局/CSS变量/下载修复,12文件改动
|
||||||
|
- [2026-06-15 会话-2](session-20260615-2.md) — sys_user唯一索引删除,修复逻辑删除与uk_username冲突
|
||||||
|
- [2026-06-17 会话-1](session-20260617-1.md) — 前端日期时间格式统一为 yyyy-MM-dd HH:mm:ss
|
||||||
|
- [2026-06-18 会话-1](session-20260618-1.md) — 网关SocketTimeout日志分析 + Widget接入指南补全(后端文档+前端页面)
|
||||||
|
- [2026-06-18 会话-2](session-20260618-2.md) — SQL执行26权限 + 7页面查看按钮 + 3页面列宽填满 + 个人中心权限 + ICP备案号
|
||||||
|
- [2026-06-19 会话-1](session-20260619-1.md) — 接入文档全面审查修复:permission字段、字段说明表、v-permission指令、Widget修复、废弃放行API、删除登录API
|
||||||
|
- [2026-06-20 会话-1](session-20260620-1.md) — menu/router接口改造、user/info权限过滤、文档清理、数据库IP迁移域名、Redis Key前缀
|
||||||
|
- [2026-06-20 会话-2](session-20260620-2.md) — 修复TokenAuthFilter Redis Key前缀遗漏导致登录后Token找不到会话
|
||||||
|
- [2026-06-21 会话-1](session-20260621-1.md) — 接口性能排查(/auth/system/list + /open/token) + sys_system.system_code加索引
|
||||||
|
- [2026-06-23 会话-1](session-20260623-1.md) — Cookie Token兜底确认 + API白名单跳过鉴权方案设计(待实施)
|
||||||
|
- [部署审批](deploy-approval-required.md) — 部署 10.1.1.170 必须经用户明确授权
|
||||||
102
memory/session-20260611-1.md
Normal file
102
memory/session-20260611-1.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
---
|
||||||
|
name: session-20260611-1
|
||||||
|
description: 2026-06-11 会话 — 头像上传修复、用户信息显示修复、文件存DB、系统Bug全面检查
|
||||||
|
metadata:
|
||||||
|
type: session
|
||||||
|
date: 2026-06-11
|
||||||
|
sessionNumber: 1
|
||||||
|
startTime: 2026-06-11T01:00:00+08:00
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2026-06-11 会话
|
||||||
|
|
||||||
|
## 会话摘要
|
||||||
|
修复了上次会话遗留的2个已知问题(头像上传、用户信息不显示),将文件存储从磁盘改为数据库,并对整个系统进行了全面Bug检查,修复了8个高/中优先级问题。
|
||||||
|
|
||||||
|
## 关键操作
|
||||||
|
|
||||||
|
### 一、sys_file 表重构(文件存数据库)
|
||||||
|
- **做了什么**:
|
||||||
|
- 重新设计 sys_file 表,添加 `file_data LONGBLOB` 字段存储二进制
|
||||||
|
- 统一 Entity 和 DDL:新增 `system_id`, `mime_type`, `upload_user_id`, `md5` 字段
|
||||||
|
- 删除旧磁盘存储字段 `file_path`(→file_data), `upload_by`(→upload_user_id)
|
||||||
|
- **原因**: 用户要求图片文件存入数据库而非磁盘
|
||||||
|
- **结果**: QA 数据库已更新,PROD 迁移脚本已准备(`sql/prod_migrate_sys_file.sql`)
|
||||||
|
|
||||||
|
### 二、FileServiceImpl 重写
|
||||||
|
- **做了什么**: upload()改为读取文件字节存入DB;page()/list()排除file_data字段;新增getMetaById();新增文件输出流
|
||||||
|
- **原因**: 配合数据库存储方案
|
||||||
|
- **结果**: 编译通过
|
||||||
|
|
||||||
|
### 三、FileController 更新
|
||||||
|
- **做了什么**: upload返回时清除fileData;新增 `GET /{id}/view` 端点直接输出文件流(支持浏览器 `<img>` 标签加载)
|
||||||
|
- **原因**: 文件存DB后需要HTTP接口访问
|
||||||
|
|
||||||
|
### 四、PersonalView 头像上传修复
|
||||||
|
- **做了什么**:
|
||||||
|
- 修正 `res.data?.url` → 通过 `res.data?.id` 构建访问URL
|
||||||
|
- 上传时添加 `fd.append('fileType', 'avatar')`
|
||||||
|
- 头像地址格式: `{VITE_GATEWAY_URL}/zgapi/v1/admin/file/{id}/view`
|
||||||
|
- 更新后调用 `authStore.fetchUserInfo()` 刷新store
|
||||||
|
- **原因**: Entity字段名不匹配+文件访问方式变更
|
||||||
|
|
||||||
|
### 五、AdminLayout 用户信息修复
|
||||||
|
- **做了什么**: onMounted中调用 `authStore.fetchUserInfo()`
|
||||||
|
- **原因**: 跨域名跳转(login.user.mokee.com→web.gw.mokee.com)后Pinia状态丢失,需要重新加载
|
||||||
|
- **结果**: 顶栏头像和用户名正确显示
|
||||||
|
|
||||||
|
### 六、Redis TokenInfo 同步机制
|
||||||
|
- **做了什么**:
|
||||||
|
- 登录时建立反向索引 `auth:user_session:{userId}` → tokenId
|
||||||
|
- 登出时清除反向索引
|
||||||
|
- UserServiceImpl.update()后同步更新Redis中的TokenInfo(username/realName/email/phone/avatar/post)
|
||||||
|
- UserServiceImpl.assignRoles()后清除用户Redis会话强制重新登录
|
||||||
|
- RoleServiceImpl.assignMenus()后清除所有受影响用户的Redis会话
|
||||||
|
- **原因**: 用户信息/角色/权限变更后前端fetchUserInfo读到过期数据
|
||||||
|
|
||||||
|
### 七、SysSystem 实体补充
|
||||||
|
- **做了什么**: 添加 `gatewayUrl` 字段映射 `gateway_url` 列
|
||||||
|
- **原因**: DDL中有此列但Entity缺少映射,INSERT可能失败
|
||||||
|
|
||||||
|
### 八、前端错误处理修复
|
||||||
|
- **做了什么**: LoginView和UserForm的catch块添加具体错误提示
|
||||||
|
- **原因**: 登录失败/保存失败时用户看不到任何反馈
|
||||||
|
|
||||||
|
### 九、系统全面检查发现(已记录,部分待处理)
|
||||||
|
- A-2: SysMenu.menuType注释错误(0-based vs 1-based) — LOW
|
||||||
|
- D-3~D-6: 多处catch块无用户反馈 — MEDIUM(拦截器已提供基线处理)
|
||||||
|
- E-1: Cookie domain硬编码.mokee.com导致localhost开发不可用 — HIGH(已知限制)
|
||||||
|
- E-2: auth.ts和request.ts重复实现Token Cookie操作 — MEDIUM
|
||||||
|
- F-3: bindSystems/unbindSystems不同步Redis — MEDIUM
|
||||||
|
- F-5: RoleServiceImpl.update不同步Redis — LOW
|
||||||
|
|
||||||
|
## 修改文件清单
|
||||||
|
**后端 (7个文件)**:
|
||||||
|
- `mokee-gateway-common/.../entity/SysFile.java` — 重构字段
|
||||||
|
- `mokee-gateway-common/.../entity/SysSystem.java` — 添加gatewayUrl
|
||||||
|
- `mokee-gateway-admin/.../service/FileService.java` — 新增getMetaById
|
||||||
|
- `mokee-gateway-admin/.../service/impl/FileServiceImpl.java` — 完全重写(DB存储)
|
||||||
|
- `mokee-gateway-admin/.../controller/FileController.java` — 新增view端点
|
||||||
|
- `mokee-gateway-admin/.../service/impl/UserServiceImpl.java` — Redis同步+会话失效
|
||||||
|
- `mokee-gateway-admin/.../service/impl/RoleServiceImpl.java` — 角色变更会话失效
|
||||||
|
- `mokee-gateway-auth/.../service/impl/AuthServiceImpl.java` — 反向索引
|
||||||
|
|
||||||
|
**前端 (3个文件)**:
|
||||||
|
- `mokee-gateway-web/src/views/admin/PersonalView.vue` — 头像上传修复
|
||||||
|
- `mokee-gateway-web/src/views/admin/AdminLayout.vue` — fetchUserInfo onMount
|
||||||
|
- `mokee-gateway-web/src/views/login/LoginView.vue` — 错误提示
|
||||||
|
- `mokee-gateway-web/src/views/admin/user/UserForm.vue` — 错误提示
|
||||||
|
|
||||||
|
**SQL (4个文件)**:
|
||||||
|
- `sql/qa_ddl.sql` — sys_file新结构
|
||||||
|
- `sql/prod_align_qa.sql` — sys_file新结构
|
||||||
|
- `sql/prod_migrate_sys_file.sql` — PROD迁移(新建)
|
||||||
|
- `init_qa_database.py` — sys_file新结构
|
||||||
|
|
||||||
|
## 待处理
|
||||||
|
- [x] **PROD 数据库 sys_file 表迁移** — 已完成,QA和PROD结构一致
|
||||||
|
- [ ] 部署前端到PROD(Nginx 10.10.1.160)
|
||||||
|
- [ ] 部署后端到PROD(10.10.1.170)
|
||||||
|
- [ ] 验证头像上传端到端流程
|
||||||
|
- [ ] E-1 Cookie域localhost问题(开发环境限制,暂不处理)
|
||||||
|
- [ ] F-3 bindSystems/unbindSystems Redis同步(低优先级)
|
||||||
133
memory/session-20260612-1.md
Normal file
133
memory/session-20260612-1.md
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
---
|
||||||
|
name: session-20260612-1
|
||||||
|
description: 2026-06-12 会话 — 域名迁移mokee.com→zgitm.com、对外开放API、邮件服务、Token一次消费、文件管理、移动端适配
|
||||||
|
metadata:
|
||||||
|
type: session
|
||||||
|
date: 2026-06-12
|
||||||
|
sessionNumber: 1
|
||||||
|
startTime: 2026-06-12T00:00:00+08:00
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2026-06-12 会话
|
||||||
|
|
||||||
|
## 会话摘要
|
||||||
|
域名从 mokee.com 迁移到 zgitm.com,新增对外开放API(Token获取+邮件发送+一次消费),新增邮件服务(阿里企业邮箱),新增Token日志和文件管理页面,全员移动端适配,修复多个关键Bug。
|
||||||
|
|
||||||
|
## 关键操作
|
||||||
|
|
||||||
|
### 一、域名迁移 mokee.com → zgitm.com
|
||||||
|
- `.env.production` 所有 URL 改为 `zgitm.com`
|
||||||
|
- `auth.ts` Cookie domain `.zgitm.com`(环境变量 `VITE_COOKIE_DOMAIN`)
|
||||||
|
- `request.ts` Cookie domain 环境变量化
|
||||||
|
- `IntegrationDoc.vue` 备用 URL 更新
|
||||||
|
- **Bug**: Cookie domain 拼写错误 `.zgtim.com` → 修正为 `.zgitm.com`
|
||||||
|
- **Bug**: 登录不跳转 → Cookie domain 不匹配导致跨子域丢失
|
||||||
|
- Nginx 需要配置 `zgitm.com` 域名
|
||||||
|
|
||||||
|
### 二、对外开放 API
|
||||||
|
- **获取Token**: `POST /zgapi/v1/open/token` 只需传 `systemCode`
|
||||||
|
- **发送邮件**: `POST /zgapi/v1/open/email/send` 带 Bearer Token
|
||||||
|
- **一次消费**: 每个 Token 只能用一次,Redis `open:token:{id}:consumed` 标记
|
||||||
|
- **30分钟有效期**
|
||||||
|
- 网关路由 `/zgapi/v1/open/` → admin
|
||||||
|
- TokenAuthFilter 放行 `/zgapi/v1/open/token`,其他 open 路径验证 open token
|
||||||
|
|
||||||
|
### 三、邮件服务
|
||||||
|
- SMTP: `smtp.qiye.aliyun.com:465` SSL, `admin@zgitm.com` / `mokee2016.`
|
||||||
|
- `EmailService.java` — 支持纯文本/HTML/附件,自动加 `[QA]`/`[PROD]` 前缀
|
||||||
|
- `EmailService.setFrom()` 设发件人(阿里企业邮箱要求发件人=认证账号)
|
||||||
|
- `EmailController.java` — 后台发送+记录+分页查询+Token日志
|
||||||
|
- `sys_email_log` 表 — 发送记录
|
||||||
|
- `@ConditionalOnBean` 问题 → 改为 `@Autowired(required = false)` + null检查
|
||||||
|
- **Bug**: 网关模块无 mail 配置导致 `@Value("${spring.mail.username}")` 启动失败 → 加默认值 `:}`
|
||||||
|
|
||||||
|
### 四、编码兼容
|
||||||
|
- **Bug**: 外部系统发 GBK 编码 JSON → Jackson 按 UTF-8 解析失败 `Invalid UTF-8 start byte 0xb2`
|
||||||
|
- 网关方案被拒绝 → 在 `OpenApiController` 手动读字节流,检测 GBK→UTF-8 转换
|
||||||
|
- API 文档要求 `Content-Type: application/json; charset=UTF-8`
|
||||||
|
|
||||||
|
### 五、文件管理
|
||||||
|
- 文件二进制存入数据库 `file_data LONGBLOB`
|
||||||
|
- `sys_file` 表结构调整(QA+PROD)
|
||||||
|
- `FileController.{id}/view` 端点输出图片流
|
||||||
|
- `FileList.vue` — 文件列表+上传+预览
|
||||||
|
- **Bug**: 图片显示 `ERR_BLOCKED_BY_ORB` → view 端点加 CORS 头
|
||||||
|
- **Bug**: 上传报 `MissingServletRequestPartException` → 网关 `spring.servlet.multipart.enabled=false`
|
||||||
|
- **Bug**: 图片 `<img>` 不带 Authorization 头 → TokenAuthFilter 支持 Cookie 读取 token
|
||||||
|
|
||||||
|
### 六、系统Bug全面检查
|
||||||
|
- A-1: `SysSystem` 缺 `gatewayUrl` 字段 → 已添加
|
||||||
|
- D-1/D-2: LoginView/UserForm 错误静默吞没 → 加错误提示
|
||||||
|
- E-1: Cookie domain 硬编码 → 改为环境变量 `VITE_COOKIE_DOMAIN`
|
||||||
|
- F-1/F-2: assignRoles/assignMenus 不同步 Redis → 清除会话强制重登
|
||||||
|
- F-4: username 不同步 Redis → 已加同步
|
||||||
|
|
||||||
|
### 七、移动端适配(全部22个Vue页面)
|
||||||
|
- AdminLayout: 汉堡菜单 + 侧边栏滑出 + 遮罩
|
||||||
|
- 全局: 表格横向滚动、弹窗95vw、按钮缩小
|
||||||
|
- 所有列表页: 工具栏垂直排列、select/input 100%宽
|
||||||
|
- 所有表单页: max-width→100%、label缩到70px、按钮全宽
|
||||||
|
- 操作列: 去掉 `fixed="right"`,手机端只显示图标
|
||||||
|
|
||||||
|
### 八、Token日志
|
||||||
|
- `sys_open_token_log` 表(QA+PROD)
|
||||||
|
- `OpenTokenLogList.vue` — Token日志页面
|
||||||
|
- 菜单 "Token日志" 在日志审计下
|
||||||
|
- **Bug**: Token日志跳转登录 → 接口路径从 `/zgapi/v1/open/` 移到 `/zgapi/v1/admin/email/token-log/page`
|
||||||
|
|
||||||
|
### 九、API文档
|
||||||
|
- `docs/open-api-doc.md` — 独立API文档,使用生产地址 `gw.server.zgitm.com`
|
||||||
|
- Java(Hutool)调用示例
|
||||||
|
- UTF-8 编码要求醒目提示
|
||||||
|
|
||||||
|
### 十、数据库变更(QA+PROD均已执行)
|
||||||
|
- `sys_file` 表重建(file_data LONGBLOB + 新结构)
|
||||||
|
- `sys_file` 添加 `file_url` 兼容列
|
||||||
|
- `sys_open_token_log` 表创建
|
||||||
|
- `sys_email_log` 表创建
|
||||||
|
- 文件管理菜单+权限 `system:file:*`
|
||||||
|
- 邮件管理菜单+权限 `system:email:*`
|
||||||
|
- Token日志菜单+权限 `system:token:list`
|
||||||
|
|
||||||
|
## 修改文件清单
|
||||||
|
**后端**:
|
||||||
|
- `SysSystem.java` — 添加 gatewayUrl
|
||||||
|
- `SysFile.java` — 重构(file_data)
|
||||||
|
- `SysEmailLog.java` — 新建
|
||||||
|
- `SysOpenTokenLog.java` — 新建
|
||||||
|
- `EmailService.java` — 新建(setFrom修复)
|
||||||
|
- `JwtUtil.java` — generateOpenToken()
|
||||||
|
- `FileService.java` / `FileServiceImpl.java` — DB存储重写
|
||||||
|
- `FileController.java` — view端点+CORS
|
||||||
|
- `EmailController.java` — 发送+记录+Token日志
|
||||||
|
- `OpenApiController.java` — Token获取+邮件发送+编码兼容+自主解析tokenId
|
||||||
|
- `UserServiceImpl.java` — Redis同步+会话失效
|
||||||
|
- `RoleServiceImpl.java` — 角色变更会话失效
|
||||||
|
- `AuthServiceImpl.java` — 反向索引
|
||||||
|
- `TokenAuthFilter.java` — Cookie token + Open API认证
|
||||||
|
- `GatewayController.java` — open路由+multipart修复
|
||||||
|
- 8个 application-*.yml — 日志级别+邮件配置+multipart禁用
|
||||||
|
|
||||||
|
**前端**:
|
||||||
|
- `.env.production` / `.env.development` — VITE_COOKIE_DOMAIN
|
||||||
|
- `auth.ts` — Cookie domain环境变量化
|
||||||
|
- `request.ts` — Cookie domain环境变量化
|
||||||
|
- `LoginView.vue` — 错误提示+移动端
|
||||||
|
- `AdminLayout.vue` — fetchUserInfo+汉堡菜单+移动端+文件/邮件/Token菜单
|
||||||
|
- `PersonalView.vue` — 头像上传修复+移动端
|
||||||
|
- `FileList.vue` — 新建(上传+预览+移动端)
|
||||||
|
- `EmailList.vue` — 新建(发送+记录+移动端)
|
||||||
|
- `OpenTokenLogList.vue` — 新建(移动端)
|
||||||
|
- `IconPicker.vue` — 已上传图标标签页
|
||||||
|
- 15个页面 — 移动端@media适配
|
||||||
|
- 7个表格 — 去掉fixed="right"
|
||||||
|
- `router/index.ts` — 路由+标题favicon
|
||||||
|
- `global.css` — 全局移动端CSS
|
||||||
|
|
||||||
|
**文档**:
|
||||||
|
- `docs/open-api-doc.md` — 对外开放API文档
|
||||||
|
|
||||||
|
## 已知问题
|
||||||
|
- E-1: Cookie domain localhost开发不可用(开发环境VITE_COOKIE_DOMAIN为空)
|
||||||
|
- PROD Nginx 需要配 `zgitm.com` 域名
|
||||||
|
- Docker部署需要重新构建镜像
|
||||||
57
memory/session-20260612-2.md
Normal file
57
memory/session-20260612-2.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
name: session-20260612-2
|
||||||
|
description: 2026-06-12 第2次会话 — VPN网络带宽诊断、Nginx代理丢失gzip修复、共享静态资源域名解决双域重复加载
|
||||||
|
metadata:
|
||||||
|
type: session
|
||||||
|
date: 2026-06-12
|
||||||
|
sessionNumber: 2
|
||||||
|
startTime: 2026-06-12T19:30:00+08:00
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2026-06-12 第2次会话
|
||||||
|
|
||||||
|
## 会话摘要
|
||||||
|
用户反馈 Vue 前端通过 VPN 访问非常慢(JS/CSS 加载要 1 分钟)。诊断发现根因是 Nginx 代理层(10.10.1.160)丢失了 gzip 压缩,导致 1MB+ 的文件通过 VPN 窄带宽原样传输。用户自行修复。
|
||||||
|
|
||||||
|
## 关键操作
|
||||||
|
|
||||||
|
### VPN 网络诊断 — 前端加载慢
|
||||||
|
- **问题现象**: 通过域名 `web.gw.zgitm.com` 访问前端,JS/CSS 加载要 40 秒~1 分钟
|
||||||
|
- **网络拓扑**: 用户电脑(10.10.1.5) → 公网VPN → 阿里云VPN(10.10.1.1) → Nginx代理(10.10.1.160) → 前端服务(10.10.1.170:5001)
|
||||||
|
- **域名解析**: `web.gw.zgitm.com` 和 `login.user.zgitm.com` 均解析到 10.10.1.160
|
||||||
|
- **实测数据**:
|
||||||
|
|
||||||
|
| 路径 | el.js (1.08MB) 耗时 | gzip |
|
||||||
|
|------|---------------------|------|
|
||||||
|
| 内网 10.1.1.170:5001 | 19ms | ✅ |
|
||||||
|
| VPN直连 10.10.1.170:5001 | 3-10秒 | ✅ |
|
||||||
|
| Nginx代理 web.gw.zgitm.com | 20-40秒 | ❌ 丢失 |
|
||||||
|
|
||||||
|
- **根因**: 10.10.1.170 上的 Nginx 返回 gzip 压缩内容,但 10.10.1.160 Nginx 代理时丢失了 gzip 压缩,导致 1MB+ 文件通过窄带宽 VPN 原样传输
|
||||||
|
- **Ping延迟**: 5-15ms(正常),**不是延迟问题是带宽+压缩问题**
|
||||||
|
- **修复方案**: 在 10.10.1.160 Nginx 配置中添加 `proxy_set_header Accept-Encoding "gzip"` 和/或开启本地 gzip — 用户自行修复
|
||||||
|
|
||||||
|
## 重要信息
|
||||||
|
- VPN 网关: 10.10.1.1(阿里云)
|
||||||
|
- VPN 子网: 10.10.1.x
|
||||||
|
- 前端服务: 10.10.1.170:5001(内网 10.1.1.170:5001)
|
||||||
|
- Nginx 代理: 10.10.1.160
|
||||||
|
- DNS 服务器: 10.10.1.150
|
||||||
|
- 公网 IP: zgitm.com → 8.133.197.85
|
||||||
|
- 生产前端域名: web.gw.zgitm.com, login.user.zgitm.com
|
||||||
|
- Nginx 版本: 160 上是 1.14.1,170 上是 1.30.2
|
||||||
|
|
||||||
|
### 共享静态资源域名 — 解决 login/web 双域名重复加载
|
||||||
|
- **问题**: login.user.zgitm.com 登录后跳转 web.gw.zgitm.com,同一份 JS/CSS 被两个域名各自下载一次
|
||||||
|
- **方案**: Vite 构建设置 `base` 为统一静态域名,HTML 中资源 URL 用绝对路径
|
||||||
|
- **第一版**: `http://web.wg.static.zgitm.com:5001/` — 遇到 Mixed Content 问题(HTTPS 页面加载 HTTP 资源被浏览器拦截)
|
||||||
|
- **最终版**: `https://web.gw.zgitm.com/` — 复用已有 HTTPS 域名,零额外配置
|
||||||
|
- **改动文件**:
|
||||||
|
- `vite.config.ts` — 引入 loadEnv,build 时 base 读取 VITE_STATIC_URL,dev 回退 '/'
|
||||||
|
- `.env.production` — 新增 `VITE_STATIC_URL=https://web.gw.zgitm.com/`
|
||||||
|
- **效果**: login 域名首次下载资源后,跳转到 web 域名时浏览器缓存直接命中,0 网络请求
|
||||||
|
|
||||||
|
## 待处理
|
||||||
|
- [ ] 重新 `npm run build` + 部署后验证效果
|
||||||
|
- [ ] 如果后续还慢,考虑 Vite 拆包 Element Plus 按需加载
|
||||||
|
- [ ] 考虑 160 Nginx 升级 gzip 压缩级别
|
||||||
73
memory/session-20260613-1.md
Normal file
73
memory/session-20260613-1.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
name: session-20260613-1
|
||||||
|
description: 2026-06-13 会话 — 数据库地址变更、11个前后端字段不匹配bug修复、42个文件加注释、5个新bug修复(图标/操作日志/登录日志/选择系统/用户编辑跳登录)
|
||||||
|
metadata:
|
||||||
|
type: session
|
||||||
|
date: 2026-06-13
|
||||||
|
sessionNumber: 1
|
||||||
|
startTime: 2026-06-13T00:00:00+08:00
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2026-06-13 会话
|
||||||
|
|
||||||
|
## 会话摘要
|
||||||
|
数据库地址从VPN网段切换到内网,全项目前后端字段匹配审计修复11个bug,42个Java/Vue文件加中文注释,修复5个功能bug(图标不显示、操作日志缺报文、登录日志缺字段、选择系统未记录、用户编辑跳登录)。
|
||||||
|
|
||||||
|
## 关键操作
|
||||||
|
|
||||||
|
### 一、数据库地址变更
|
||||||
|
- MySQL + Redis 地址: `10.10.1.122` → `10.1.1.122`
|
||||||
|
- 14个文件全局替换(yml/py/md)
|
||||||
|
|
||||||
|
### 二、前后端字段匹配审计 + Bug修复
|
||||||
|
共发现11个问题,修复了9个:
|
||||||
|
- BUG1: ApiList/ApiForm `s.id` → `s.systemId` (SystemVO无id字段)
|
||||||
|
- BUG2: OperationLogList `operateTime` → `createdAt`
|
||||||
|
- BUG3: MenuRouterVO 加 `sortOrder`/`status`
|
||||||
|
- BUG4: UserController加email过滤
|
||||||
|
- BUG5: RoleServiceImpl联表填充systemName + SysRole加transient systemName
|
||||||
|
- BUG6: ApiForm编辑补apiDesc
|
||||||
|
- BUG7: LoginLogList接口 `loginTime` → `createdAt`
|
||||||
|
- BUG9: DictController补全CRUD (listTypes/add/update/delete/getById)
|
||||||
|
- 登录日志systemName: `WHERE system_id=?` → `WHERE id=?`
|
||||||
|
|
||||||
|
### 三、PUT路由405修复
|
||||||
|
前端 `PUT /admin/role/{id}` 后端 `@PutMapping` 不匹配。
|
||||||
|
SystemController/UserController: `@PutMapping` (id在body)
|
||||||
|
RoleController/MenuController/SystemApiController: `@PutMapping("/{id}")` (id在URL)
|
||||||
|
MenuList.vue统一用URL传id
|
||||||
|
|
||||||
|
### 四、42个文件加中文注释
|
||||||
|
- 后端23个: 10 Controller + 10 Service + 3 Entity/VO
|
||||||
|
- 前端19个: 4配置 + 15页面
|
||||||
|
|
||||||
|
### 五、5个新Bug修复
|
||||||
|
1. **图标不显示**: icons.ts改 `import * as Icons` 全量支持动态图标
|
||||||
|
2. **用户编辑跳登录**: `assignRoles` 的 `invalidateUserSession` 删除Redis会话,admin编辑自己时自己的token被清理。改为同步角色到Redis不删session
|
||||||
|
3. **操作日志**: actionTag加中文映射(新增/编辑/删除), 加"查看"弹窗显示传入/返回报文, 加responseBody字段
|
||||||
|
4. **登录日志**: 加realName字段 (实体+DDL+AuthService+前端)
|
||||||
|
5. **选择系统**: selectSystem更新最近登录日志的systemId
|
||||||
|
|
||||||
|
### 六、服务器状态
|
||||||
|
- TCP端口检测改为HTTP健康检查
|
||||||
|
- 每个微服务独立JVM信息
|
||||||
|
|
||||||
|
### 七、前端拦截器
|
||||||
|
- request.ts 401还原直接跳登录
|
||||||
|
|
||||||
|
### 八、系统列表权限
|
||||||
|
- 新增 `GET /zgapi/v1/admin/system/list`
|
||||||
|
- super_admin@gateway → 全部系统
|
||||||
|
- 其他 → 仅绑定的系统 (JOIN sys_user_system)
|
||||||
|
|
||||||
|
### 九、数据库
|
||||||
|
- QA+PROD执行: sys_login_log加real_name, sys_operation_log加response_body
|
||||||
|
|
||||||
|
## 修改文件清单
|
||||||
|
详见会话日志中各Bug修复记录
|
||||||
|
|
||||||
|
## 重要信息
|
||||||
|
- 数据库: 10.1.1.122:3306
|
||||||
|
- 微服务端口: gateway:9000, auth:9001, admin:9002
|
||||||
|
- 前端构建: `npm run build:prod` (跳过vue-tsc类型检查)
|
||||||
|
- Node版本: 服务器Node 18,Vite 5.4兼容
|
||||||
138
memory/session-20260613-2.md
Normal file
138
memory/session-20260613-2.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
---
|
||||||
|
name: session-20260613-2
|
||||||
|
description: 2026-06-13 第2次会话 — UI移动端优化、登录跳转修复、操作人乱码修复、操作详情弹窗重设计、文件对象存储从DB迁移到磁盘、系统配置页、开放文件API
|
||||||
|
metadata:
|
||||||
|
type: session
|
||||||
|
date: 2026-06-13
|
||||||
|
sessionNumber: 2
|
||||||
|
startTime: 2026-06-13T00:00:00+08:00
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2026-06-13 第2次会话
|
||||||
|
|
||||||
|
## 会话摘要
|
||||||
|
|
||||||
|
完成前两轮累积的全部需求:UI移动端适配、登录页跳转优化、操作人乱码BUG修复(Header URL编解码)、操作日志查看弹窗重新设计、文件存储从MySQL BLOB迁移到本地磁盘、系统配置管理页、文件对象储存列表重设计、开放API文件上传/下载接口、open-api-doc.md文档更新。
|
||||||
|
|
||||||
|
## 关键操作
|
||||||
|
|
||||||
|
### 一、前端UI优化
|
||||||
|
|
||||||
|
**登录页 (LoginView.vue)**:
|
||||||
|
- 登录成功后按钮持续转圈(loading不关闭),显示"正在跳转..."
|
||||||
|
- `navigating` 标志防重复跳转
|
||||||
|
- 支持二次点击(已登录成功卡住时重新触发跳转)
|
||||||
|
- 8秒超时自动提示"网络较慢"
|
||||||
|
|
||||||
|
**操作日志 (OperationLogList.vue)**:
|
||||||
|
- 查看弹窗完全重设计:el-descriptions 展示操作人/时间/接口/方法/成功/耗时
|
||||||
|
- 深色代码块 + 折叠面板展示传入/返回报文
|
||||||
|
- 一键复制按钮
|
||||||
|
|
||||||
|
**移动端全局适配 (global.css)**:
|
||||||
|
- 操作列按钮 480px 以上保留文字,480px 以下只显示图标
|
||||||
|
- 搜索卡片 inline 表单移动端垂直排列
|
||||||
|
- 分页移动端隐藏 sizes 和 jumper
|
||||||
|
- 弹窗移动端 95vw
|
||||||
|
|
||||||
|
**列表页移动端适配**: SystemList/UserList/RoleList/DictList/LoginLogList 全部更新
|
||||||
|
|
||||||
|
### 二、操作人乱码BUG修复
|
||||||
|
|
||||||
|
**根因**: HeaderInjectFilter 将中文 realName(如"张三")注入 HTTP 头 `X-User-Name`。HTTP 头只支持 ASCII,跨服务转发时中文字符损坏。
|
||||||
|
|
||||||
|
**修复**:
|
||||||
|
- `HeaderInjectFilter.java`: `X-User-Name` 值写入前 `URLEncoder.encode(realName, UTF_8)`
|
||||||
|
- `OperationLogAspect.java`: 读取后 `URLDecoder.decode(value, UTF_8)` 还原
|
||||||
|
- 新增 `decodeHeader()` 辅助方法
|
||||||
|
|
||||||
|
### 三、文件对象存储(DB BLOB → 本地磁盘)
|
||||||
|
|
||||||
|
**核心原则**: 文件二进制数据一律存磁盘,sys_file 表只存元信息。
|
||||||
|
|
||||||
|
**数据库变更**:
|
||||||
|
- 新建 `sys_config` 表(系统配置键值对)
|
||||||
|
- `sys_file` 加 `storage_file_name`、`upload_user_name` 字段
|
||||||
|
- 保留 `file_data` 列用于存量 BLOB 向后兼容
|
||||||
|
|
||||||
|
**后端新增**:
|
||||||
|
- `SysConfig.java` 实体 + `SysConfigMapper` + `ConfigService`(含 ConcurrentHashMap 缓存) + `ConfigController`
|
||||||
|
- `FileServiceImpl.upload()` 改为写磁盘:`{basePath}/{yyyy-MM}/{uuid}.{ext}`
|
||||||
|
- `FileController.view()` 优先读磁盘,兜底读 BLOB(存量兼容)
|
||||||
|
- `FileController.download()` 新增下载端点(Content-Disposition: attachment)
|
||||||
|
- `uploadUserName` 逻辑:管理后台上传→用户姓名,API上传→系统名称
|
||||||
|
|
||||||
|
**开放 API(OpenApiController)**:
|
||||||
|
- `POST /zgapi/v1/open/file/upload` — form 字段传 token,上传成功消费
|
||||||
|
- `GET /zgapi/v1/open/file/{id}/download?token=xxx` — query 参数传 token,下载成功消费
|
||||||
|
- 新增 `validateOpenTokenFromValue(String)` 支持非 Header 传 token
|
||||||
|
|
||||||
|
**网关 (TokenAuthFilter)**:
|
||||||
|
- `authenticateOpenApi()` 无 Bearer Header 时从 `request.getParameter("token")` 兜底
|
||||||
|
|
||||||
|
**前端**:
|
||||||
|
- `SystemConfig.vue` — 系统配置页(存储磁盘路径输入框 + 保存)
|
||||||
|
- `FileList.vue` — 完全重写:卡片网格、缩略图点击预览、储存文件名、上传者、下载/复制链接/删除
|
||||||
|
- 菜单"文件管理"→"文件对象储存"
|
||||||
|
- 新增"系统配置"菜单(Setting 图标)
|
||||||
|
|
||||||
|
**菜单 SQL**:
|
||||||
|
- `add_file_storage.sql`: sys_config DDL + sys_file ALTER + 菜单改名+新增
|
||||||
|
- `add_file_menu.sql`: 菜单名更新
|
||||||
|
|
||||||
|
### 四、文档更新
|
||||||
|
|
||||||
|
`docs/open-api-doc.md` 新增:
|
||||||
|
- §4 上传文件(multipart/form-data 示例、响应字段说明)
|
||||||
|
- §5 下载文件(query param token 示例)
|
||||||
|
- Java 调用示例增加文件上传
|
||||||
|
- 附录重新编号为 §7
|
||||||
|
|
||||||
|
## 修改文件清单
|
||||||
|
|
||||||
|
| 文件 | 操作 |
|
||||||
|
|------|------|
|
||||||
|
| `LoginView.vue` | 修改 |
|
||||||
|
| `OperationLogList.vue` | 修改 |
|
||||||
|
| `global.css` | 修改 |
|
||||||
|
| `SystemList.vue` | 修改 |
|
||||||
|
| `UserList.vue` | 修改 |
|
||||||
|
| `RoleList.vue` | 修改 |
|
||||||
|
| `DictList.vue` | 修改 |
|
||||||
|
| `LoginLogList.vue` | 修改 |
|
||||||
|
| `HeaderInjectFilter.java` | 修改(URL编码) |
|
||||||
|
| `OperationLogAspect.java` | 修改(URL解码) |
|
||||||
|
| `SysConfig.java` | **新增** |
|
||||||
|
| `SysConfigMapper.java` | **新增** |
|
||||||
|
| `ConfigService.java` | **新增** |
|
||||||
|
| `ConfigServiceImpl.java` | **新增** |
|
||||||
|
| `ConfigController.java` | **新增** |
|
||||||
|
| `SysFile.java` | 修改(+2字段) |
|
||||||
|
| `FileService.java` | 修改(接口更新) |
|
||||||
|
| `FileServiceImpl.java` | 重写(磁盘存储) |
|
||||||
|
| `FileController.java` | 重写(+download,view改磁盘优先) |
|
||||||
|
| `OpenApiController.java` | 修改(+2文件端点) |
|
||||||
|
| `TokenAuthFilter.java` | 修改(query param token) |
|
||||||
|
| `SystemConfig.vue` | **新增** |
|
||||||
|
| `FileList.vue` | 重写 |
|
||||||
|
| `router/index.ts` | 修改 |
|
||||||
|
| `AdminLayout.vue` | 修改 |
|
||||||
|
| `open-api-doc.md` | 修改 |
|
||||||
|
| `add_file_storage.sql` | **新增** |
|
||||||
|
| `add_file_menu.sql` | 修改 |
|
||||||
|
|
||||||
|
## 重要信息
|
||||||
|
|
||||||
|
- 文件存储路径: 默认 `/data/mokee/gw/file`,可在系统配置页修改
|
||||||
|
- 磁盘文件结构: `{basePath}/{yyyy-MM}/{uuid}.{ext}`
|
||||||
|
- 存量 BLOB 文件仍可正常读取(view/download 兜底逻辑)
|
||||||
|
- 开放 API Token 消费规则: 上传/下载成功后消费,失败不消费
|
||||||
|
- Token 在 download 通过 query 参数传递(浏览器 `<a>` 标签无法设 Header)
|
||||||
|
|
||||||
|
## 待处理
|
||||||
|
|
||||||
|
- [ ] 执行 `add_file_storage.sql` 到 QA/PROD 数据库
|
||||||
|
- [ ] 服务器上创建存储目录 `/data/mokee/gw/file` 并设置写权限
|
||||||
|
- [ ] 执行 QA/PROD 菜单更新(文件管理→文件对象储存 + 系统配置菜单)
|
||||||
|
- [ ] 构建部署:`mvn clean package` + `npm run build:prod`
|
||||||
|
- [ ] 原有迁移建议:现有文件数据过大时,可编写迁移脚本将 `file_data` BLOB 导出到磁盘
|
||||||
142
memory/session-20260613-3.md
Normal file
142
memory/session-20260613-3.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
---
|
||||||
|
name: session-20260613-3
|
||||||
|
description: 2026-06-13 第3次会话 — 头像401、菜单check-strictly、移动端全面优化、token/info接口、唯一键校验、数据权限、文档更新
|
||||||
|
metadata:
|
||||||
|
type: session
|
||||||
|
date: 2026-06-13
|
||||||
|
sessionNumber: 3
|
||||||
|
startTime: 2026-06-13T00:00:00+08:00
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2026-06-13 第3次会话 — 完整记录
|
||||||
|
|
||||||
|
## 一、头像 401 修复
|
||||||
|
|
||||||
|
**根因**:文件服务 Token 一次一密,PersonalView.vue 上传和下载共用一个 Token,带 Token 的 URL 存入数据库 → 永久 401。
|
||||||
|
|
||||||
|
**修复**:
|
||||||
|
- `auth.ts`:fetchUserInfo() 和 login() 先刷新 Token 再设 userInfo
|
||||||
|
- `PersonalView.vue`:上传用 Token1(消费),下载用 Token2(新获取),DB 只存基础 URL
|
||||||
|
|
||||||
|
## 二、分配菜单 check-strictly
|
||||||
|
|
||||||
|
**RoleList.vue**:el-tree 加 `check-strictly`,节点独立勾选,父子不联动。
|
||||||
|
- 可以只勾"系统列表"不给新增/编辑/删除
|
||||||
|
|
||||||
|
## 三、移动端全面优化
|
||||||
|
|
||||||
|
### 3.1 操作列 → 桌面按钮行 / 手机卡片按钮
|
||||||
|
不再使用 CSS dropdown hack,改用 `useIsMobile()` composable + `v-if`
|
||||||
|
|
||||||
|
**composable**: `src/composables/useIsMobile.ts` — 响应式检测屏幕 ≤768px
|
||||||
|
|
||||||
|
**已改造**:RoleList, UserList, SystemList, MenuList, DictList, ApiList, FileList, EmailList, LoginLogList, OperationLogList, OpenTokenLogList
|
||||||
|
|
||||||
|
### 3.2 表单按钮全局统一
|
||||||
|
`global.css`:移动端 `.form-footer { flex-direction: column }` 取消/保存按钮等宽上下排列
|
||||||
|
|
||||||
|
### 3.3 分页滚动
|
||||||
|
`main.ts`:监听分页点击 → `window.scrollTo({ top: 0, behavior: 'smooth' })`
|
||||||
|
|
||||||
|
### 3.4 卡片样式
|
||||||
|
`global.css`:`.mobile-cards` / `.mobile-card` / `.card-field` / `.card-actions`
|
||||||
|
|
||||||
|
## 四、新增 GET /token/info 接口
|
||||||
|
|
||||||
|
- `OpenApiController.java`:`GET /zgapi/v1/open/token/info?token=xxx`
|
||||||
|
- 不消费 Token,从 JWT 解析 systemId/systemCode/systemName
|
||||||
|
- `TokenAuthFilter.java`:放行 `/zgapi/v1/open/token/info`
|
||||||
|
- `open-api-doc.md`:新增 §4
|
||||||
|
|
||||||
|
## 五、所有新增接口唯一键校验
|
||||||
|
|
||||||
|
| 接口 | 唯一约束 | 修复 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 新增系统 | uk_system_code + uk_gateway_url | ✅ gatewayUrl 自动生成唯一值 |
|
||||||
|
| 新增用户 | uk_username + uk_email | ✅ 空邮箱→null,重复提示中文 |
|
||||||
|
| 新增角色 | uk_system_role_code | ✅ 系统+编码重复提示 |
|
||||||
|
| 新增字典 | uk_type_value | ✅ 类型+值重复提示 |
|
||||||
|
| 新增接口 | uk_system_path_method | ✅ 系统+路径+方法重复提示 |
|
||||||
|
|
||||||
|
## 六、全局数据权限
|
||||||
|
|
||||||
|
**架构**:`UserContextInterceptor` → `UserContextHolder`(ThreadLocal) → Service 层过滤
|
||||||
|
|
||||||
|
**规则**:roleCode=super_admin 看全部;其他角色只看绑定系统(sys_user_system)的数据
|
||||||
|
|
||||||
|
**新增文件**:
|
||||||
|
- `config/UserContextHolder.java` — ThreadLocal
|
||||||
|
- `config/UserContextInterceptor.java` — 拦截器,读 X-User-Roles 头
|
||||||
|
- `config/WebMvcConfig.java` — 注册拦截器
|
||||||
|
|
||||||
|
**修改的 Service**:
|
||||||
|
- RoleServiceImpl.page() — in(systemIds)
|
||||||
|
- SystemApiServiceImpl.page() — in(systemIds)
|
||||||
|
- LogServiceImpl.loginLogPage() — in(systemIds)
|
||||||
|
- UserServiceImpl.page() — 通过 sys_user_system 关联过滤
|
||||||
|
- SystemServiceImpl.page() — in(systemIds)
|
||||||
|
- SystemServiceImpl.add() — gatewayUrl 唯一值
|
||||||
|
|
||||||
|
**不做过滤**:
|
||||||
|
- 操作日志、Token日志(用户指定不要)
|
||||||
|
- 字典、邮件(无 system_id)
|
||||||
|
|
||||||
|
## 七、FileList 系统筛选
|
||||||
|
|
||||||
|
- 搜索栏新增"所属系统"下拉(systemCode / systemName)
|
||||||
|
- 表格新增"所属系统"列
|
||||||
|
- systemId 传给后端过滤
|
||||||
|
|
||||||
|
## 八、接入文档更新
|
||||||
|
|
||||||
|
### IntegrationDoc.vue(页面内文档)
|
||||||
|
- **§9.3** 下载文件:Token 支持 Header 和 Query 参数两种方式
|
||||||
|
- **§10** 新增对外开放 API 章节(Token 获取 + 邮件发送)
|
||||||
|
- **§3** 前端接入:localStorage → Cookie 读取 Token,说明跨域 Cookie 机制
|
||||||
|
- **§3.5** 登录回调:完整说明 Cookie Token 流程
|
||||||
|
|
||||||
|
### open-api-doc.md(独立文档)
|
||||||
|
- 下载文件 curl 示例增加 Query 参数方式
|
||||||
|
- 所有文档章节重新编号
|
||||||
|
|
||||||
|
## 修改文件总清单
|
||||||
|
|
||||||
|
**后端新增**:
|
||||||
|
- `config/UserContextHolder.java`
|
||||||
|
- `config/UserContextInterceptor.java`
|
||||||
|
- `config/WebMvcConfig.java`
|
||||||
|
|
||||||
|
**后端修改**:
|
||||||
|
- `OpenApiController.java` — +GET /token/info
|
||||||
|
- `TokenAuthFilter.java` — 放行 /token/info
|
||||||
|
- `UserServiceImpl.java` — 重复校验 + 数据权限 + 空邮箱→null
|
||||||
|
- `SystemServiceImpl.java` — systemCode/gatewayUrl 唯一性 + 数据权限
|
||||||
|
- `RoleServiceImpl.java` — 角色编码唯一性 + 数据权限
|
||||||
|
- `DictServiceImpl.java` — 字典值唯一性
|
||||||
|
- `SystemApiServiceImpl.java` — 接口唯一性 + 数据权限
|
||||||
|
- `LogServiceImpl.java` — 登录日志数据权限(操作日志不过滤)
|
||||||
|
- `FileController.java` — systemId 过滤已支持
|
||||||
|
|
||||||
|
**前端新增**:
|
||||||
|
- `composables/useIsMobile.ts`
|
||||||
|
|
||||||
|
**前端修改**:
|
||||||
|
- `auth.ts` — 头像 Token 刷新
|
||||||
|
- `PersonalView.vue` — 头像双 Token
|
||||||
|
- `global.css` — 移动端卡片 + 表单按钮
|
||||||
|
- `main.ts` — 分页滚动
|
||||||
|
- `RoleList.vue` — check-strictly + 手机卡片
|
||||||
|
- `UserList.vue` — 手机卡片
|
||||||
|
- `SystemList.vue` — 手机卡片
|
||||||
|
- `MenuList.vue` — 手机卡片
|
||||||
|
- `DictList.vue` — 手机卡片
|
||||||
|
- `ApiList.vue` — 手机卡片
|
||||||
|
- `FileList.vue` — 系统下拉过滤
|
||||||
|
- `EmailList.vue` — 手机卡片 + confirmDel
|
||||||
|
- `LoginLogList.vue` — 手机卡片
|
||||||
|
- `OperationLogList.vue` — 手机卡片
|
||||||
|
- `OpenTokenLogList.vue` — 手机卡片
|
||||||
|
|
||||||
|
**文档修改**:
|
||||||
|
- `docs/open-api-doc.md` — +token/info,下载 Query 参数
|
||||||
|
- `IntegrationDoc.vue` — Cookie Token + 邮件发送 + 下载方式
|
||||||
137
memory/session-20260613-4.md
Normal file
137
memory/session-20260613-4.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
---
|
||||||
|
name: session-20260613-4
|
||||||
|
description: 2026-06-13 第4次会话 — Widget实施 + 大量Bug修复 + 接入文档完善
|
||||||
|
metadata:
|
||||||
|
type: session
|
||||||
|
date: 2026-06-13
|
||||||
|
sessionNumber: 4
|
||||||
|
startTime: 2026-06-13
|
||||||
|
endTime: 2026-06-13
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2026-06-13 第4次会话 — 完整记录
|
||||||
|
|
||||||
|
## 会话摘要
|
||||||
|
实施了用户头像下拉 Widget,修复了 10+ 个 Bug(菜单权限、数据过滤、API转发、错误提示、SQL修复等),完善了接入文档。
|
||||||
|
|
||||||
|
## 一、UserChip Widget 实施
|
||||||
|
|
||||||
|
### 新建文件(6个)
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `src/widgets/user-chip/cookie.ts` | Cookie Token 读写 |
|
||||||
|
| `src/widgets/user-chip/api.ts` | API 调用层(fetch/原生) |
|
||||||
|
| `src/widgets/user-chip/UserChip.vue` | 核心组件(BEM 命名空间) |
|
||||||
|
| `src/widgets/user-chip/main.ts` | 自动扫描 #mokee-user-chip 挂载 |
|
||||||
|
| `vite.widget.config.ts` | 独立 IIFE 构建(→ dist-widget/) |
|
||||||
|
| `public/demo-widget.html` | 接入演示 |
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|------|------|
|
||||||
|
| `package.json` | +build:widget + build:prod 合并 |
|
||||||
|
| `IntegrationDoc.vue` | §3.6 Widget 一键接入 + widgetConfigTable |
|
||||||
|
|
||||||
|
### 关键设计
|
||||||
|
- loginUrl/selectUrl 从脚本自身 URL 推断(不用 window.location.origin)
|
||||||
|
- 登录页: https://web.gw.zgitm.com/login
|
||||||
|
- 切换系统: https://web.gw.zgitm.com/select
|
||||||
|
- API 网关: https://gw.server.zgitm.com
|
||||||
|
|
||||||
|
## 二、重要 Bug 修复
|
||||||
|
|
||||||
|
### 2.1 AdminLayout 菜单动态化(硬编码→API 加载)
|
||||||
|
- **原因**: 硬编码 HTML,所有用户看到全部菜单
|
||||||
|
- **修复**: 改为 `fetchMenuRouter('gateway')` 动态加载,按角色过滤
|
||||||
|
- **问题**: API 返回字段和前端预期不一致 → MenuRouterVO 缺少字段
|
||||||
|
- **修复**: buildMenuTree 补填 id/menuName/menuType/icon/permission/sortOrder/status
|
||||||
|
|
||||||
|
### 2.2 ApiPermissionFilter 过于严格
|
||||||
|
- **问题**: 业务系统选系统后调 /zgapi/v1/auth/menu/router 被 403
|
||||||
|
- **修复**: `/zgapi/v1/auth/*` 和 `/zgapi/v1/admin/*` 全部放行,不查 sys_api 白名单
|
||||||
|
|
||||||
|
### 2.3 网关不转发非 /zgapi/ 路径
|
||||||
|
- **问题**: 业务系统请求 /api/rules 带 X-System-Code,网关返回 200 空响应
|
||||||
|
- **修复**: 非 /zgapi/ 路径有 X-System-Code 头 → 转发到业务后端
|
||||||
|
|
||||||
|
### 2.4 RestTemplate 吞业务后端错误
|
||||||
|
- **问题**: RestTemplate 默认对 4xx/5xx 抛异常,网关 catch 后返回 502 吞掉真实错误
|
||||||
|
- **修复**: 设置自定义 ResponseErrorHandler,hasError() 永远返回 false
|
||||||
|
|
||||||
|
### 2.5 assignRoles 角色覆盖
|
||||||
|
- **问题**: tokenInfo.setRoles(当前系统角色) 覆盖掉其他系统角色 → super_admin 丢失 → 403
|
||||||
|
- **修复**: 改为从 DB 重新查询用户全部角色再写入 Redis
|
||||||
|
|
||||||
|
### 2.6 选择系统页不应该隐藏 gateway
|
||||||
|
- **初始修复(错误)**: AuthServiceImpl 过滤 gateway,导致用户无法进入管理后台
|
||||||
|
- **正确的修复**: 还原 AuthServiceImpl,选择系统页显示 gateway 卡片(入口),数据层面由 UserContextInterceptor 过滤 gateway 数据
|
||||||
|
|
||||||
|
### 2.7 非 super_admin 看到 gateway 数据
|
||||||
|
- **问题**: adminnginxserver 绑定 gateway+nginxServer,数据权限范围包含 gateway
|
||||||
|
- **修复**: UserContextInterceptor 加 isGatewaySystem() 过滤,排除 gateway 系统 ID
|
||||||
|
|
||||||
|
### 2.8 MenuServiceImpl 无权限校验
|
||||||
|
- **问题**: getMenuTree/add/update/delete 都不检查 UserContextHolder
|
||||||
|
- **修复**: 新增 checkSystemScope(),所有方法校验 systemId 是否在绑定范围内
|
||||||
|
|
||||||
|
### 2.9 两个首页菜单
|
||||||
|
- **问题**: 过滤逻辑只查顶级节点,子节点中的首页漏网
|
||||||
|
- **修复**: 递归 removeDashboard() 函数,过滤所有层级
|
||||||
|
|
||||||
|
### 2.10 ElMessage 弹窗位置错误
|
||||||
|
- **问题**: CSS 导入路径不对,el-message.css 未加载 → 消息显示在底部
|
||||||
|
- **修复**: main.ts 显式 import 'element-plus/theme-chalk/el-message.css',global.css 去冲突 !important
|
||||||
|
|
||||||
|
### 2.11 登录错误双重弹窗
|
||||||
|
- **修复**: LoginView.vue catch 块去掉重复的 ElMessage.error
|
||||||
|
|
||||||
|
### 2.12 SystemServiceImpl 排除 gateway
|
||||||
|
- **修复**: 非 super_admin 时 wrapper.ne(SysSystem::getSystemCode, "gateway")
|
||||||
|
|
||||||
|
### 2.13 接入文档 SQL 修复
|
||||||
|
- 所有 INSERT 加 id 列 + REPLACE(UUID(),'-','')
|
||||||
|
- 用户创建 SQL 加步骤 4(绑定自己系统)+ 步骤 5(分配自己系统 admin 角色)
|
||||||
|
- 菜单初始化 SQL 改用 MySQL 变量显式持有 UUID
|
||||||
|
|
||||||
|
### 2.14 RestTemplate 吞业务后端错误
|
||||||
|
- **问题**: RestTemplate 默认对 4xx/5xx 抛异常,网关 catch 返回 502 吞掉真实错误
|
||||||
|
- **修复**: RestTemplateConfig 设置自定义 ResponseErrorHandler,hasError() 永远返回 false
|
||||||
|
|
||||||
|
### 2.15 FileServiceImpl 无数据权限
|
||||||
|
- **问题**: 文件对象储存页能看到 gateway 系统的文件
|
||||||
|
- **修复**: FileServiceImpl.page() 加 UserContextHolder 检查
|
||||||
|
|
||||||
|
### 2.16 用户列表排除 super_admin 账号
|
||||||
|
- **问题**: 非 super_admin 用户能看到 admin 等超管账号
|
||||||
|
- **修复**: UserServiceImpl.page() 加 NOT IN (拥有 super_admin 角色的用户ID)
|
||||||
|
|
||||||
|
## 三、接入文档完善
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `IntegrationDoc.vue` §5 | 重写 API 章节:7 个接口 + 速查表 + 完整请求/响应 JSON |
|
||||||
|
| `docs/integration-guide.md` | 新建独立的完整 Markdown 接入文档 |
|
||||||
|
|
||||||
|
## 四、所有修改文件清单
|
||||||
|
|
||||||
|
**后端修改**:
|
||||||
|
- gateway-auth: AuthServiceImpl.java (systemList过滤 + buildMenuTree补字段)
|
||||||
|
- gateway-admin: UserContextInterceptor.java (排除gateway), MenuServiceImpl.java (权限校验), SystemServiceImpl.java (排除gateway), UserServiceImpl.java (assignRoles修复 + resetPassword新增)
|
||||||
|
- gateway-gateway: ApiPermissionFilter.java (放行auth/admin), GatewayController.java (转发非zgapi路径), RestTemplateConfig.java (不吞错误)
|
||||||
|
|
||||||
|
**前端修改**:
|
||||||
|
- AdminLayout.vue (动态菜单 + 递归过滤首页)
|
||||||
|
- LoginView.vue (去掉重复ElMessage)
|
||||||
|
- UserForm.vue (super_admin重置密码)
|
||||||
|
- IntegrationDoc.vue (§3.6 Widget + §5 API完善 + SQL修复)
|
||||||
|
- main.ts (ElMessage CSS显式引入)
|
||||||
|
- global.css (ElMessage样式修复)
|
||||||
|
- package.json (build:widget脚本)
|
||||||
|
|
||||||
|
**新建文件**:
|
||||||
|
- 6个Widget文件 + 1个文档
|
||||||
|
|
||||||
|
## 待处理
|
||||||
|
- [ ] 部署到 web.gw.zgitm.com(Nginx /widgets/ location)
|
||||||
|
- [ ] 给 gateway-app-admin/gateway-app-user 角色分配菜单
|
||||||
|
- [ ] 生产环境验证
|
||||||
90
memory/session-20260613-5.md
Normal file
90
memory/session-20260613-5.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
name: session-20260613-5
|
||||||
|
description: 2026-06-13 第5次会话 — 前端5问题 + 系统下拉框过滤 + Token失效 + ECharts修复 + Token日志复制
|
||||||
|
metadata:
|
||||||
|
type: session
|
||||||
|
date: 2026-06-13
|
||||||
|
sessionNumber: 5
|
||||||
|
startTime: 2026-06-13
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2026-06-13 第5次会话 — 完整记录
|
||||||
|
|
||||||
|
## 会话摘要
|
||||||
|
修复了前端 5 个问题 + 系统下拉框 gateway 过滤 + 角色菜单保存后 token 失效 + ECharts 图表空指针 + Token 日志列点击复制。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. ElMessage 错误提示显示在左下角(非弹窗)
|
||||||
|
- **尝试**: 改为全量 `element-plus/dist/index.css` → 与 unplugin-vue-components 按需加载冲突导致首页空白
|
||||||
|
- **回退**: 保持 3 个独立 CSS 引入(el-message.css/message-box.css/notification.css)
|
||||||
|
- **文件**: `mokee-gateway-web/src/main.ts`
|
||||||
|
|
||||||
|
## 2. 登录后显示两个首页
|
||||||
|
- **修复**: 删除 AdminLayout 侧边栏硬编码"首页"菜单项 + 删除 `removeDashboard()` 函数
|
||||||
|
- 菜单完全由 API 动态提供,需确保 DB 中"首页"菜单已分配角色
|
||||||
|
- **文件**: `mokee-gateway-web/src/views/admin/AdminLayout.vue`
|
||||||
|
|
||||||
|
## 3. 去掉首页服务器状态卡片
|
||||||
|
- 删除 server-panel 块(微服务状态 + JVM 信息)及关联 CSS
|
||||||
|
- 布局简化:统计卡片 + 登录趋势图表全宽
|
||||||
|
- **文件**: `mokee-gateway-web/src/views/admin/Dashboard.vue`
|
||||||
|
|
||||||
|
## 4. 近30天登录趋势图表 — 两次修复
|
||||||
|
### 4a. 执行顺序 bug
|
||||||
|
- `chartLoading=false` 原在 finally 块,renderChart() 在 try 块 → chartRef 未挂载
|
||||||
|
- 修复: chartLoading=false 移到 nextTick 前
|
||||||
|
### 4b. ECharts init(null) 崩溃
|
||||||
|
- 根因: `v-if/v-else` 导致 ref div 按需创建/销毁,nextTick 后 chartRef.value 仍为 null
|
||||||
|
- 修复: 改为 `v-show`,div 始终在 DOM 中。renderChart 增加动态 import 前后双重 null 检查
|
||||||
|
- **文件**: `mokee-gateway-web/src/views/admin/Dashboard.vue`
|
||||||
|
|
||||||
|
## 5. 用户编辑页添加头像上传
|
||||||
|
- `v-if="isSelf"` — 仅 `authStore.userInfo.userId === editId` 时显示
|
||||||
|
- 复用 PersonalView 上传逻辑: 获取Token → 上传 → 保存baseUrl
|
||||||
|
- loadData 时自动刷新文件服务 Token(避免 401)
|
||||||
|
- **文件**: `mokee-gateway-web/src/views/admin/user/UserForm.vue`
|
||||||
|
|
||||||
|
## 6. Token日志菜单后端不返回
|
||||||
|
- 根因: 初始化脚本是破坏性的(DELETE+重建),Token日志菜单从未被加入
|
||||||
|
- 修复: 3 个脚本补充 Token日志菜单 INSERT
|
||||||
|
- **文件**: `init_gateway_system.py`, `sql/init_qa.py`, `sql/qa_data.sql`
|
||||||
|
|
||||||
|
## 7. 系统下拉框过滤 gateway(非 super_admin)
|
||||||
|
- 需求: admin 页面系统下拉框不显示"网关管理平台"
|
||||||
|
- 初次方案: `listByUserId()` SQL 加 `AND system_code != 'gateway'` → 用户指出会误伤 super_admin
|
||||||
|
- 最终方案: 过滤移到 `SystemController.listAll()`,Stream 加 `.filter(s -> isSuperAdmin || !"gateway".equals(s.getSystemCode()))`
|
||||||
|
- 编译错误修复: `isSuperAdmin` 需声明 `final`
|
||||||
|
- **文件**: `mokee-gateway-admin/.../controller/SystemController.java`
|
||||||
|
|
||||||
|
## 8. 编辑角色菜单保存后 token 失效跳登录
|
||||||
|
- 根因: `RoleServiceImpl.invalidateSessionsForRole()` 删除所有拥有该角色的用户的 Redis 会话,包括操作者自己
|
||||||
|
- 修复: 加 `.filter(uid -> !uid.equals(currentUserId))` 跳过当前操作用户
|
||||||
|
- **文件**: `mokee-gateway-admin/.../service/impl/RoleServiceImpl.java`
|
||||||
|
|
||||||
|
## 9. Token日志列点击复制
|
||||||
|
- 显示: `tokenSuffix()` 只显示 `...` 后缀部分(如 `...dQwNJ9HG0E`)
|
||||||
|
- 点击: `navigator.clipboard.writeText()` 复制完整 tokenPreview
|
||||||
|
- 注意: 完整 JWT token 不存库,只有 tokenPreview(前20+...+后10字符)
|
||||||
|
- **文件**: `mokee-gateway-web/src/views/admin/log/OpenTokenLogList.vue`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 涉及文件清单
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|------|------|
|
||||||
|
| `mokee-gateway-web/src/main.ts` | CSS 引入(保持 3 个独立引入) |
|
||||||
|
| `mokee-gateway-web/src/views/admin/AdminLayout.vue` | 去硬编码首页 + removeDashboard |
|
||||||
|
| `mokee-gateway-web/src/views/admin/Dashboard.vue` | 去服务器卡片 + 图表v-show + 双重null检查 |
|
||||||
|
| `mokee-gateway-web/src/views/admin/user/UserForm.vue` | 新增头像上传(仅编辑自己) |
|
||||||
|
| `mokee-gateway-web/src/views/admin/log/OpenTokenLogList.vue` | Token列点击复制 + 后缀展示 |
|
||||||
|
| `init_gateway_system.py` | 新增 Token日志菜单 |
|
||||||
|
| `sql/init_qa.py` | 新增 Token日志菜单 |
|
||||||
|
| `sql/qa_data.sql` | 新增 Token日志菜单 |
|
||||||
|
| `mokee-gateway-admin/.../controller/SystemController.java` | 非super_admin过滤gateway |
|
||||||
|
| `mokee-gateway-admin/.../service/impl/RoleServiceImpl.java` | invalidateSessions跳过当前用户 |
|
||||||
|
|
||||||
|
## 待处理
|
||||||
|
- [ ] 重新构建部署前后端验证所有修复
|
||||||
|
- [ ] 问题1(ElMessage 左下角)需进一步排查 — CSS 引入正确但可能浏览器缓存
|
||||||
89
memory/session-20260615-1.md
Normal file
89
memory/session-20260615-1.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
name: session-20260615-1
|
||||||
|
description: 2026-06-15 第1次会话 — 前端UI统一优化:分页/布局/CSS变量/下载修复,12文件改动
|
||||||
|
metadata:
|
||||||
|
type: session
|
||||||
|
date: 2026-06-15
|
||||||
|
sessionNumber: 1
|
||||||
|
startTime: 2026-06-15
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2026-06-15 第1次会话
|
||||||
|
|
||||||
|
## 会话摘要
|
||||||
|
全面统一了前端 11 个列表页面的 UI 风格。核心工作:提取 4 个公共 CSS 类到 global.css,修复 3 个不规范的页面(EmailList、OpenTokenLogList、FileList),清理 7 个标准页面的重复样式。
|
||||||
|
|
||||||
|
## 关键操作
|
||||||
|
|
||||||
|
### 1. global.css 添加公共布局类
|
||||||
|
- **做了什么**: 在 global.css 新增 `.page-wrapper`、`.card-header-title`、`.toolbar`、`.pagination-wrapper` 四个全局类,统一所有列表页的布局结构
|
||||||
|
- **原因**: 8+ 个文件各自重复定义相同的 CSS 规则,维护困难且容易产生不一致
|
||||||
|
- **结果**: 一处定义,处处使用;同时统一了 `card-header-title` 的 `color` 为 `var(--text-primary)`(之前大部分页面用硬编码 `#303133`)
|
||||||
|
|
||||||
|
### 2. EmailList.vue 全面翻新
|
||||||
|
- **做了什么**: 根类名 email-page→page-wrapper,分页改为标准配置(background + 完整 layout),新增 v-if/v-else 移动端卡片列表,搜索卡片加 search-card 类,操作栏简化,删除了大量冗余 CSS
|
||||||
|
- **原因**: 分页只有 prev/pager/next 且 small 模式,无移动端适配,结构与其他页面完全不同
|
||||||
|
- **结果**: 与其他列表页样式完全统一
|
||||||
|
|
||||||
|
### 3. OpenTokenLogList.vue 统一
|
||||||
|
- **做了什么**: 根类名 tokenlog-page→page-wrapper,page-footer→pagination-wrapper,分页改为标准配置,el-card 包裹表格
|
||||||
|
- **原因**: 分页居中且不完整,容器类名不标准
|
||||||
|
- **结果**: 与其他列表页样式完全统一
|
||||||
|
|
||||||
|
### 4. FileList.vue 结构调整
|
||||||
|
- **做了什么**: 搜索卡片加 search-card 类,上传按钮从搜索卡片内移出到独立 toolbar,去掉分页 v-if="total>0",删除重复 CSS 规则
|
||||||
|
- **原因**: 搜索+操作栏混在同一卡片不符合标准模式
|
||||||
|
- **结果**: 结构对齐标准模式
|
||||||
|
|
||||||
|
### 5. 7 个标准页面清理
|
||||||
|
- **做了什么**: UserList, RoleList, SystemList, ApiList, LoginLogList, OperationLogList, DictList 删除已全局化的 4 个 CSS 规则
|
||||||
|
- **额外**: UserList 还删除了 .search-card scoped 规则(全局 el-card 规则已覆盖);DictList 保留 `.toolbar { gap: 8px }` 特有样式
|
||||||
|
- **结果**: 每个文件平均减少 ~20 行冗余 CSS
|
||||||
|
|
||||||
|
## 6. 文件对象存储下载修复
|
||||||
|
- **做了什么**: 将 `downloadFile()` 从 `fetch blob → createObjectURL → <a> click` 改为 `隐藏 iframe 直接加载下载 URL`
|
||||||
|
- **原因**: 旧方式先把文件全量下载到浏览器内存,等全部下完才弹保存对话框,大文件体验极差。新方式浏览器直接接管下载,立即弹出保存对话框,后台下载有进度条
|
||||||
|
- **结果**: FileList.vue 的 `downloadFile` 函数重写,用户体验显著提升
|
||||||
|
- **文件**: `mokee-gateway-web/src/views/admin/file/FileList.vue`
|
||||||
|
|
||||||
|
## 涉及文件清单
|
||||||
|
|
||||||
|
| 文件 | 改动类型 |
|
||||||
|
|------|---------|
|
||||||
|
| `src/styles/global.css` | 新增 4 个公共类 + 移动端适配 |
|
||||||
|
| `src/views/admin/email/EmailList.vue` | 模板+脚本+样式全面翻新 |
|
||||||
|
| `src/views/admin/log/OpenTokenLogList.vue` | 模板+样式翻新 |
|
||||||
|
| `src/views/admin/file/FileList.vue` | 结构调整 + 样式清理 |
|
||||||
|
| `src/views/admin/user/UserList.vue` | 样式清理 |
|
||||||
|
| `src/views/admin/role/RoleList.vue` | 样式清理 |
|
||||||
|
| `src/views/admin/system/SystemList.vue` | 样式清理 |
|
||||||
|
| `src/views/admin/system/ApiList.vue` | 样式清理 |
|
||||||
|
| `src/views/admin/log/LoginLogList.vue` | 样式清理 |
|
||||||
|
| `src/views/admin/log/OperationLogList.vue` | 样式清理 |
|
||||||
|
| `src/views/admin/dict/DictList.vue` | 样式清理 |
|
||||||
|
|
||||||
|
## 统一后的分页组件标准
|
||||||
|
```html
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="page"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
background
|
||||||
|
@current-change="fetchData"
|
||||||
|
@size-change="fetchData"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 统一后的页面结构标准
|
||||||
|
```
|
||||||
|
.page-wrapper
|
||||||
|
├── el-card.search-card → 搜索条件
|
||||||
|
├── div.toolbar → 操作按钮
|
||||||
|
└── el-card → 表格(v-if=!isMobile) + 卡片(v-else) + 分页
|
||||||
|
```
|
||||||
|
|
||||||
|
## 待处理
|
||||||
|
- [ ] 启动 dev server 手动验证所有页面的分页和移动端效果
|
||||||
|
- [ ] 如有需要,部署到测试环境验证
|
||||||
56
memory/session-20260615-2.md
Normal file
56
memory/session-20260615-2.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
name: session-20260615-2
|
||||||
|
description: 2026-06-15 第2次会话 — sys_user唯一索引删除,修复逻辑删除与uk_username冲突
|
||||||
|
metadata:
|
||||||
|
type: session
|
||||||
|
date: 2026-06-15
|
||||||
|
sessionNumber: 2
|
||||||
|
startTime: 2026-06-15
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2026-06-15 第2次会话
|
||||||
|
|
||||||
|
## 会话摘要
|
||||||
|
修复了 `sys_user` 表逻辑删除(`is_deleted`)与唯一索引 `uk_username`/`uk_email` 冲突的问题。删除了数据库唯一索引,改为应用层代码校验。
|
||||||
|
|
||||||
|
## 问题描述
|
||||||
|
- **现象**:创建用户时报 `Duplicate entry 'nginxadmin' for key 'sys_user.uk_username'`
|
||||||
|
- **根因**:MyBatis-Plus `@TableLogic` 自动在查询时过滤 `is_deleted=1`,但数据库唯一索引不区分逻辑删除标记。已删用户仍占着 username → 新用户无法创建同名 → 插入时索引冲突
|
||||||
|
|
||||||
|
## 关键操作
|
||||||
|
|
||||||
|
### 删除唯一索引,改应用层校验
|
||||||
|
- **做了什么**:从 `sys_user` 表删除 `uk_username` 和 `uk_email` 两个唯一索引,用户名/邮箱唯一性交给 `UserServiceImpl.add()` 中的 `selectCount` 校验
|
||||||
|
- **原因**:唯一索引不区分 `is_deleted`,逻辑删除的记录仍阻塞同名用户创建
|
||||||
|
- **结果**:已删除的用户不再占坑,可创建同名新用户;活跃用户的唯一性由应用层保证
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|------|------|
|
||||||
|
| `sql/qa_ddl.sql` | 删除 uk_username、uk_email 索引定义 |
|
||||||
|
| `init_database.py` | 同上 |
|
||||||
|
| `init_qa_database.py` | 同上 |
|
||||||
|
| QA 数据库 `mokeegatewayqa` | DROP INDEX uk_username, uk_email |
|
||||||
|
| PROD 数据库 `mokeegateway` | DROP INDEX uk_username, uk_email |
|
||||||
|
|
||||||
|
## 代码层无需改动
|
||||||
|
`UserServiceImpl.add()` 已有校验:`selectCount` + MyBatis-Plus `@TableLogic` 自动过滤已删除记录,重复用户名会提示"用户名已存在"。
|
||||||
|
|
||||||
|
## 待处理
|
||||||
|
- [ ] 验证:创建一个用户 → 删除 → 再创建同名用户,确认不再报错
|
||||||
|
|
||||||
|
### 2. 首页仪表盘数据权限
|
||||||
|
- **做了什么**:DashboardController.stats() 增加数据权限过滤。非 super_admin 用户首页统计卡片和登录趋势图只看自己绑定系统的数据(排除网关平台)
|
||||||
|
- **原因**:之前首页显示全部系统/全部用户的全局统计数据,业务管理员不应看到平台全局数据
|
||||||
|
- **结果**:
|
||||||
|
- 系统总数 → 用户绑定系统数(拦截器已排除网关)
|
||||||
|
- 用户总数 → JOIN sys_user_system 统计绑定系统下的用户
|
||||||
|
- 今日登录/在线/30天趋势 → system_id IN (用户绑定系统) 过滤
|
||||||
|
- super_admin 行为不变
|
||||||
|
- 复用 UserContextInterceptor 已有的 systemIds(已排除网关),不重复查库
|
||||||
|
- **涉及文件**: `DashboardController.java`
|
||||||
|
|
||||||
|
## 待处理
|
||||||
|
- [ ] 验证:创建一个用户 → 删除 → 再创建同名用户,确认不再报错
|
||||||
|
- [ ] 部署后验证首页数据权限:用非 super_admin 账号登录,确认统计数据只显示绑定系统范围内
|
||||||
79
memory/session-20260617-1.md
Normal file
79
memory/session-20260617-1.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
name: session-20260617-1
|
||||||
|
description: 2026-06-17 第1次会话 — 前端日期时间格式统一 + 全局权限控制修复 + DB权限条目补全
|
||||||
|
metadata:
|
||||||
|
type: session
|
||||||
|
date: 2026-06-17
|
||||||
|
sessionNumber: 1
|
||||||
|
startTime: 2026-06-17
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2026-06-17 第1次会话
|
||||||
|
|
||||||
|
## 会话摘要
|
||||||
|
1. 前端日期时间格式统一为 `yyyy-MM-dd HH:mm:ss`
|
||||||
|
2. 全局排查并修复了前端 v-permission 权限控制缺失问题(9个页面少了几十个权限指令)
|
||||||
|
3. 补充了数据库初始化脚本中缺失的权限条目(menu/delete, api/add/edit/delete, dict/add/edit/delete)
|
||||||
|
4. 重写了日期工具函数,用手动解析替代 `new Date()` 避免跨浏览器兼容问题
|
||||||
|
|
||||||
|
## 关键操作
|
||||||
|
|
||||||
|
### 一、创建/重写日期格式化工具
|
||||||
|
- **做了什么**: 创建 `src/utils/date.ts`,导出 `formatDateTime()` 和 `formatDate()`
|
||||||
|
- **原因**: 统一格式 + 解决 `new Date("yyyy-MM-dd HH:mm:ss")` 在 Firefox/Safari 中返回 Invalid Date 的浏览器兼容问题
|
||||||
|
- **结果**: 使用手动正则解析替代 `new Date()` 解析字符串,兼容 ISO 8601、时间戳、后端标准格式
|
||||||
|
- **涉及文件**: `src/utils/date.ts`(新建)
|
||||||
|
|
||||||
|
### 二、替换所有日期格式化调用
|
||||||
|
- **做了什么**: 将7个Vue文件中所有 `toLocaleString('zh-CN')` 替换为 `formatDateTime()`,并为之前裸字符串显示日期的页面添加格式化
|
||||||
|
- **涉及文件**: PersonalView, FileList, UserList, OperationLogList, LoginLogList, EmailList, OpenTokenLogList
|
||||||
|
|
||||||
|
### 三、全局修复 v-permission 权限控制
|
||||||
|
- **问题根因**: 之前 90% 的操作按钮没有 `v-permission` 指令,导致即使用户没有勾选编辑/删除权限按钮,按钮仍然显示并可操作
|
||||||
|
- **做了什么**: 为所有列表页的操作按钮添加了完整的 `v-permission` 指令
|
||||||
|
|
||||||
|
**前端修改(9个文件):**
|
||||||
|
|
||||||
|
| 文件 | 新增按钮 | 编辑按钮 | 删除按钮 | 其他 |
|
||||||
|
|------|:--:|:--:|:--:|------|
|
||||||
|
| SystemList.vue | ✅已有 | ✅新增 | ✅新增 | |
|
||||||
|
| UserList.vue | ✅新增 | ✅新增 | ✅新增 | |
|
||||||
|
| RoleList.vue | ✅新增 | ✅新增 | ✅新增 | 分配菜单 ✅新增 |
|
||||||
|
| MenuList.vue | ✅新增 | ✅新增 | ✅新增 | 新增子级 ✅新增 |
|
||||||
|
| DictList.vue | ✅新增 | ✅新增 | ✅新增 | 新增类型 ✅新增 |
|
||||||
|
| ApiList.vue | ✅新增 | ✅新增 | ✅新增 | |
|
||||||
|
| FileList.vue | N/A | N/A | ✅新增 | 上传 ✅新增 |
|
||||||
|
| EmailList.vue | ✅已有 | N/A | ✅新增 | |
|
||||||
|
| SystemConfig.vue | N/A | N/A | N/A | 保存 ✅新增 |
|
||||||
|
|
||||||
|
### 四、补充数据库权限条目
|
||||||
|
- **做了什么**: 在 DB 初始化脚本中新增以下权限按钮(之前 DB 中没有这些记录,即使前端加了 v-permission,super_admin 也会因权限列表不含该字符串而看不到按钮)
|
||||||
|
- **新增权限**: `system:menu:delete`, `system:api:add/edit/delete`, `system:dict:add/edit/delete`
|
||||||
|
- **涉及文件**: `sql/qa_data.sql`, `sql/init_qa.py`, `sql/prod_add_permissions.sql`, `sql/prod_fix_data.sql`
|
||||||
|
|
||||||
|
### 权限字符串对照表
|
||||||
|
|
||||||
|
| 模块 | 列表权限 | 新增 | 编辑 | 删除 | 其他 |
|
||||||
|
|------|---------|------|------|------|------|
|
||||||
|
| 系统管理 | system:system:list | system:system:add | system:system:edit | system:system:delete | |
|
||||||
|
| 用户管理 | system:user:list | system:user:add | system:user:edit | system:user:delete | |
|
||||||
|
| 角色管理 | system:role:list | system:role:add | system:role:edit | system:role:delete | system:role:assignMenu |
|
||||||
|
| 菜单管理 | system:menu:list | system:menu:add | system:menu:edit | system:menu:delete | |
|
||||||
|
| 接口管理 | system:api:list | system:api:add | system:api:edit | system:api:delete | |
|
||||||
|
| 数据字典 | system:dict:list | system:dict:add | system:dict:edit | system:dict:delete | |
|
||||||
|
| 文件管理 | system:file:list | system:file:upload | | system:file:delete | |
|
||||||
|
| 邮件管理 | system:email:list | system:email:send | | system:email:delete | |
|
||||||
|
| 系统配置 | | system:config:save | | | |
|
||||||
|
|
||||||
|
### 五、网关转发注入用户信息头
|
||||||
|
- **问题根因**: 非 `/zgapi/` 路径的请求(如 `/dashboard/stats`),TokenAuthFilter 直接放行不解析 token → HeaderInjectFilter 拿不到 tokenInfo → 转发到业务系统后端时请求头中没有 X-User-Id/Name 等用户信息
|
||||||
|
- **做了什么**:
|
||||||
|
1. `TokenAuthFilter.java`: 非 `/zgapi/` 路径也尝试解析 token(Cookie / Authorization 头),有则设置 tokenInfo,无/过期则放行不阻塞。`/zgapi/` 路径行为不变
|
||||||
|
2. `HeaderInjectFilter.java`: 系统信息头(X-System-Id/Code/Name)原始请求已有则保留原始值,避免用 tokenInfo.currentSystemCode 覆盖业务系统前端设的 `x-system-code`
|
||||||
|
- **涉及文件**: `TokenAuthFilter.java`, `HeaderInjectFilter.java`
|
||||||
|
|
||||||
|
## 待处理
|
||||||
|
- [ ] 部署后验证:用非 super_admin 角色登录,确认未授权的按钮不显示
|
||||||
|
- [ ] 在生产数据库执行 `prod_add_permissions.sql`(INSERT IGNORE 安全幂等)
|
||||||
|
- [ ] 验证日期时间显示格式在各个浏览器中一致
|
||||||
|
- [ ] 部署网关后验证:非 `/zgapi/` 业务系统请求能正确转发并携带用户信息头
|
||||||
55
memory/session-20260618-1.md
Normal file
55
memory/session-20260618-1.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
name: session-20260618-1
|
||||||
|
description: 2026-06-18 第1次会话 — 网关SocketTimeout日志分析 + Widget接入指南补全(后端文档+前端页面)
|
||||||
|
metadata:
|
||||||
|
type: session
|
||||||
|
date: 2026-06-18
|
||||||
|
sessionNumber: 1
|
||||||
|
startTime: 2026-06-18T01:18:00+08:00
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2026-06-18 第1次会话
|
||||||
|
|
||||||
|
## 会话摘要
|
||||||
|
1. 分析网关日志中频繁出现的 `SocketTimeoutException` / `Broken pipe` 错误,确认是下游 `devops.server.zgitm.com` 响应太慢导致客户端超时断开,不是网关 Bug
|
||||||
|
2. 将 Widget 接入详细指南补充到 deployServer 文档体系(新建 `06-gateway-widget.md`)
|
||||||
|
3. 将 Widget 接入详细内容更新到网关前端公网文档页面 `IntegrationDoc.vue`(`https://web.gw.zgitm.com/docs/:token`)
|
||||||
|
|
||||||
|
## 关键操作
|
||||||
|
|
||||||
|
### 一、网关 SocketTimeout/Broken pipe 日志分析
|
||||||
|
- **做了什么**: 分析了 `mokee-gateway-gateway` 服务日志中大量 `AsyncRequestNotUsableException` → `ClientAbortException` → `SocketTimeoutException` / `Broken pipe` 异常栈
|
||||||
|
- **根因**: 下游 `devops.server.zgitm.com` 的 `/api/apps/{8,9,10}/builds` 接口响应时间 >3秒,浏览器/前端超时主动断开连接,网关尝试写响应时发现 socket 已关闭
|
||||||
|
- **结论**:
|
||||||
|
- 🔴 主要问题:下游 devops 服务响应太慢
|
||||||
|
- ⚠️ 网关问题:GlobalExceptionHandler 把客户端断开当成 ERROR 记录,应降级为 WARN 或静默忽略
|
||||||
|
- 🟡 前端问题:超时设置过短(~3秒),并发请求多导致日志刷屏
|
||||||
|
|
||||||
|
### 二、deployServer 文档体系补充 Widget 接入指南
|
||||||
|
- **做了什么**: 将 `mokee-gateway-widget-integration.md` 内容格式化后新建为编号文档 `docs/06-gateway-widget.md`
|
||||||
|
- **原因**: 文档目录中原只有 01-05 号文档,Widget 接入指南是独立的未编号文件,需要纳入体系
|
||||||
|
- **涉及文件**:
|
||||||
|
- 新建:`deployServer/docs/06-gateway-widget.md`
|
||||||
|
- 更新:`deployServer/README.md`(文档导航新增链接)
|
||||||
|
- 更新:`deployServer/docs/05-implementation-plan.md`(文件清单树形图)
|
||||||
|
|
||||||
|
### 三、IntegrationDoc.vue 第 3.6 节 Widget 接入大幅扩展
|
||||||
|
- **做了什么**: 将原来 3 行简略 HTML 代码替换为完整接入指南
|
||||||
|
- **新增内容**:
|
||||||
|
- 🚫 常见错误说明:`process is not defined` 的原因(Vue 3.x 打包引用 process.env,浏览器无此全局变量)
|
||||||
|
- 步骤 1:index.html 引入 CSS(不引入 JS、不放占位 div)
|
||||||
|
- 步骤 2:TopBar.vue 中放置占位 div(必须在 Vue 模板中,不在 index.html)
|
||||||
|
- 步骤 3:main.js 中 mount 后动态加载脚本 + `window.process = { env: {} }` polyfill
|
||||||
|
- 原理说明:ASCII 流程图展示加载时序
|
||||||
|
- 排查清单:5 项常见问题对照表(Widget 不显示 / process 报错 / 登录不识别 / 样式错乱 / 跨域报错)
|
||||||
|
- CDN 资源地址
|
||||||
|
- **涉及文件**: `mokee-gateway-web/src/views/docs/IntegrationDoc.vue`
|
||||||
|
- 注释头更新:8 个章节 → 10 个章节
|
||||||
|
- 模板:第 3.6 节完全重写
|
||||||
|
- Script:新增 `widgetTroubleshooting` 数据
|
||||||
|
|
||||||
|
## 待处理
|
||||||
|
- [ ] `IntegrationDoc.vue` 修改需要重新构建部署 `mokee-gateway-web` 前端才能在 `https://web.gw.zgitm.com/docs/:token` 看到更新
|
||||||
|
- [ ] 网关 `GlobalExceptionHandler` 需要优化:`ClientAbortException` / `AsyncRequestNotUsableException` 降级为 WARN 或静默处理
|
||||||
|
- [ ] 排查 `devops.server.zgitm.com` 的 `/api/apps/{8,9,10}/builds` 接口性能,优化响应时间
|
||||||
|
- [ ] 前端可考虑增加超时时间或减少并发请求数
|
||||||
72
memory/session-20260618-2.md
Normal file
72
memory/session-20260618-2.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
name: session-20260618-2
|
||||||
|
description: 2026-06-18 第2次会话 — 权限修复SQL执行 + 7页面查看按钮 + 3页面列宽填满 + 个人中心权限 + ICP备案号
|
||||||
|
metadata:
|
||||||
|
type: session
|
||||||
|
date: 2026-06-18
|
||||||
|
sessionNumber: 2
|
||||||
|
startTime: 2026-06-18T02:00:00+08:00
|
||||||
|
endTime: 2026-06-18T05:00:00+08:00
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2026-06-18 第2次会话
|
||||||
|
|
||||||
|
## 会话摘要
|
||||||
|
1. 数据库权限修复:发现 PROD 缺失 10 条按钮权限(接口管理/数据字典/菜单删除/个人中心),已执行 SQL 补充并绑定 super_admin
|
||||||
|
2. 7个列表页新增查看按钮(无需权限),含详情弹窗
|
||||||
|
3. 修复 DictList/MenuList/TokenLog 表格列宽不填满屏幕的问题(width → min-width)
|
||||||
|
4. 个人中心 3 个按钮绑定 v-permission
|
||||||
|
5. 所有页面底部添加 ICP备案号 湘ICP备19021539号
|
||||||
|
6. 纠正了之前的"全屏按钮"错误理解,删除 useFullscreen composable
|
||||||
|
|
||||||
|
## 关键操作
|
||||||
|
|
||||||
|
### 一、数据库权限诊断与修复
|
||||||
|
- **做了什么**: 连接 PROD 数据库 10.1.1.122,查询发现缺少 10 条按钮权限:
|
||||||
|
- `system:api:add/edit/delete`(接口管理)← 导致超级管理员看不到新增接口按钮
|
||||||
|
- `system:dict:add/edit/delete`(数据字典)
|
||||||
|
- `system:menu:delete`(菜单管理缺少删除)
|
||||||
|
- `system:profile:edit/password/avatar`(个人中心)
|
||||||
|
- **执行方式**: Python pymysql 逐条 INSERT IGNORE + 全量 super_admin 绑定
|
||||||
|
- **结果**: 26 个按钮权限全部绑定到 super_admin,需重新登录刷新 Redis TokenInfo
|
||||||
|
- **涉及文件**: `sql/fix_all_permissions.sql`, 数据库 `mokeegateway@10.1.1.122`
|
||||||
|
- **数据库账号**: mokeegateway / mokee.
|
||||||
|
|
||||||
|
### 二、查看按钮 — 6个页面新增
|
||||||
|
- **做了什么**: 在 ApiList/UserList/SystemList/RoleList/DictList/OpenTokenLogList 添加"查看"按钮
|
||||||
|
- **查看按钮无 v-permission**,所有人可见,点击弹出 el-descriptions 详情弹窗
|
||||||
|
- **Token日志特别处理**: 弹窗展示完整 Token(可复制)、消费状态、所有时间字段
|
||||||
|
- **涉及文件**: 6 个 list Vue 文件
|
||||||
|
|
||||||
|
### 三、表格列宽填满屏幕修复
|
||||||
|
- **问题**: DictList/ MenuList/ OpenTokenLogList 表格列使用固定 width,不能填满屏幕
|
||||||
|
- **修复**: 文本列 width="xxx" → min-width="xxx",固定宽度列(排序/状态/操作)保持不变
|
||||||
|
- **清理**: 删除了之前错误添加的 useFullscreen composable、全屏按钮、card-fullscreen CSS
|
||||||
|
- **涉及文件**: DictList.vue, MenuList.vue, OpenTokenLogList.vue
|
||||||
|
|
||||||
|
### 四、个人中心权限绑定
|
||||||
|
- **做了什么**: PersonalView.vue 三个按钮添加 v-permission:
|
||||||
|
- "保存信息" → `system:profile:edit`
|
||||||
|
- "修改密码" → `system:profile:password`
|
||||||
|
- "更换头像" → `system:profile:avatar`
|
||||||
|
- **SQL**: 3 条独立权限条目(parent_id='0'),已执行到 PROD
|
||||||
|
|
||||||
|
### 五、ICP备案号
|
||||||
|
- **做了什么**: 所有页面底部添加 `湘ICP备19021539号`,链接到 `https://beian.miit.gov.cn/`
|
||||||
|
- **涉及页面**: AdminLayout.vue(footer-icp)、LoginView.vue(login-footer 追加)、SystemSelectView.vue(select-footer)、IntegrationDoc.vue(doc-footer)
|
||||||
|
- **样式**: 灰色小字、居中、hover 变主题色
|
||||||
|
|
||||||
|
### 六、构建部署
|
||||||
|
- **构建命令**: `npm run build:prod`(跳过 vue-tsc 类型检查,避免 pre-existing TS 错误)
|
||||||
|
- **构建结果**: 成功,2437 + 1581 modules transformed
|
||||||
|
|
||||||
|
## 重要信息
|
||||||
|
- 数据库: 10.1.1.122:3306, db=mokeegateway, user=mokeegateway, pass=mokee.
|
||||||
|
- 查看按钮无需 v-permission,不入 sys_menu
|
||||||
|
- 表格列宽用 min-width 替代 width 可自动填满屏幕
|
||||||
|
- build:prod 跳过 vue-tsc,pre-existing TS 类型错误不影响部署
|
||||||
|
|
||||||
|
## 待处理
|
||||||
|
- [ ] super_admin 重新登录验证所有 26 个按钮权限可见
|
||||||
|
- [ ] 网关 `GlobalExceptionHandler` 优化(ClientAbortException 降级为 WARN)
|
||||||
|
- [ ] 排查 `devops.server.zgitm.com` 接口性能
|
||||||
59
memory/session-20260619-1.md
Normal file
59
memory/session-20260619-1.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
name: session-20260619-1
|
||||||
|
description: 2026-06-19 第1次会话 — 接入文档全面审查和修复:permission字段补充、字段说明表、v-permission指令、Widget修复、废弃放行API、删除登录API
|
||||||
|
metadata:
|
||||||
|
type: session
|
||||||
|
date: 2026-06-19
|
||||||
|
sessionNumber: 1
|
||||||
|
startTime: 2026-06-19T14:00:00+08:00
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2026-06-19 第1次会话
|
||||||
|
|
||||||
|
## 会话摘要
|
||||||
|
用户指出的接入文档(`docs/integration-guide.md` 和 `IntegrationDoc.vue`)中 INSERT SQL 缺少 `permission` 字段、字段含义未解释等问题,要求以"接入方开发者"视角全面审查文档,确保人家(非AI)能看懂并照着接入。进行了大范围审查和修复。
|
||||||
|
|
||||||
|
## 关键操作
|
||||||
|
|
||||||
|
### 1. 初始化SQL补充 permission 字段 + 按钮权限示例
|
||||||
|
- **做了什么**:两个文件的菜单初始化 INSERT SQL 加上 `permission` 列,目录=NULL、菜单=页面权限标识、新增按钮(menu_type=3)示例(新增/编辑/删除)
|
||||||
|
- **原因**:接入系统需要知道怎么创建按钮权限
|
||||||
|
- **结果**:SQL 完整覆盖目录→菜单→按钮三级结构,含注释说明
|
||||||
|
|
||||||
|
### 2. 新增字段说明表
|
||||||
|
- **做了什么**:第六章新增 6.1 节,含 sys_menu / sys_role / sys_role_menu 三张表的字段说明表格 + permission 命名规范示例表
|
||||||
|
- **原因**:用户指出"字段意思没解释,人家一脸懵"
|
||||||
|
- **结果**:每个字段的类型、必填、不同 menu_type 下填什么都写清楚了
|
||||||
|
|
||||||
|
### 3. 新增 v-permission 指令章节
|
||||||
|
- **做了什么**:Markdown 3.6 / Vue 3.7 新增按钮权限控制章节,含 directive 代码、全局注册、模板用法
|
||||||
|
- **原因**:接入方不知道怎么用 permissions 控制前端按钮显隐
|
||||||
|
- **结果**:完整可复制的代码示例
|
||||||
|
|
||||||
|
### 4. Widget 接入章节大修
|
||||||
|
- **做了什么**:从简单三行 HTML 改为正确的 polyfill 三步接入(process polyfill + mount后动态加载),加了排查清单
|
||||||
|
- **原因**:原方式在浏览器会报 `process is not defined`,且说"如果不想手写代码"语气不对——这是必须这样做的步骤
|
||||||
|
- **结果**:与 Vue 组件中 3.6 节一致,接入方不会踩坑
|
||||||
|
|
||||||
|
### 5. 废弃放行 API 章节
|
||||||
|
- **做了什么**:新增 sys_api 章节后又根据用户指示完全移除(该功能已废弃)
|
||||||
|
- **原因**:用户说"放行 API 配置废弃了,可以去掉"
|
||||||
|
|
||||||
|
### 6. 删除登录 API 章节
|
||||||
|
- **做了什么**:删除 Markdown 5.1 / Vue 5.1 登录接口章节(请求体/响应体/字段说明全移除),API 章节重新编号
|
||||||
|
- **原因**:用户指出"登录是网关的登录页面,业务系统没有登录页",所以不应该文档里出现登录API
|
||||||
|
|
||||||
|
### 7. 其他修复
|
||||||
|
- 3.2 环境变量加说明表(各变量从哪获取)
|
||||||
|
- 3.5 多系统场景改为跳转URL带 systemCode 参数(不再调用登录API)
|
||||||
|
- 5.0 新增错误码表(200/401/403/500)
|
||||||
|
- 管理员BCrypt改为 Python/Java/在线工具三种具体方式
|
||||||
|
- 普通用户角色说明(为何初始化不给菜单、怎么手动分配)
|
||||||
|
- 测试清单移除"未放行API"项
|
||||||
|
|
||||||
|
## 修改文件
|
||||||
|
- `docs/integration-guide.md`
|
||||||
|
- `mokee-gateway-web/src/views/docs/IntegrationDoc.vue`
|
||||||
|
|
||||||
|
## 待处理
|
||||||
|
- [ ] 部署前端到服务器使线上文档页生效
|
||||||
71
memory/session-20260620-1.md
Normal file
71
memory/session-20260620-1.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
name: session-20260620-1
|
||||||
|
description: 2026-06-20 第1次会话 — menu/router接口改造、user/info权限过滤、文档清理、数据库IP迁移域名、Redis Key加前缀
|
||||||
|
metadata:
|
||||||
|
type: session
|
||||||
|
date: 2026-06-20
|
||||||
|
sessionNumber: 1
|
||||||
|
startTime: 2026-06-20T00:00:00+08:00
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2026-06-20 第1次会话
|
||||||
|
|
||||||
|
## 会话摘要
|
||||||
|
本次会话主要做了5件事:1) `/menu/router` 接口响应结构调整,新增 `permission` 字段;2) `/user/info` 的 `permissions` 仅 gateway 系统返回;3) 接入文档清理删除冗余接口章节;4) 数据库/Redis IP 全部迁移到域名;5) Redis Key 加统一前缀 `GATEWAY:`。
|
||||||
|
|
||||||
|
## 关键操作
|
||||||
|
|
||||||
|
### 1. /menu/router 接口返回结构调整
|
||||||
|
- **做了什么**: 新建 `MenuRouterResponse` DTO(含 `menu` + `permission` 字段),修改 AuthService/AuthServiceImpl/AuthController 返回新结构,前端 auth.ts 和 AdminLayout.vue 适配新结构
|
||||||
|
- **原因**: 业务系统需要一次性获取菜单路由树 + 该系统所有权限标识,用于前端 v-permission 指令
|
||||||
|
- **结果**: 返回从 `[{...}]` 变为 `{"menu":[{...}], "permission":["...","..."]}`
|
||||||
|
- **涉及文件**: `MenuRouterResponse.java`(新建), `AuthService.java`, `AuthServiceImpl.java`, `AuthController.java`, `auth.ts`, `AdminLayout.vue`, `integration-guide.md`, `IntegrationDoc.vue`
|
||||||
|
|
||||||
|
### 2. /user/info 的 permissions 条件返回
|
||||||
|
- **做了什么**: `getUserInfo()` 中判断 `currentSystemCode`,仅 gateway 系统返回 permissions,业务系统返回 null
|
||||||
|
- **原因**: user/info 的 permissions 是网关平台权限,业务系统用不到,应通过 /menu/router 获取自身权限
|
||||||
|
- **结果**: 文档 5.1 节完全去掉 permissions 字段
|
||||||
|
|
||||||
|
### 3. 接入文档删除 system/select 和 logout 接口
|
||||||
|
- **做了什么**: 删除 Markdown 和 Vue 文档中 5.4 (system/select) 和 5.5 (logout) 章节,5.6→5.4 重新编号,API 速查表同步更新
|
||||||
|
- **原因**: 这两个接口 Widget 已内置(切换系统→跳转系统选择页,退出登录→Widget 内部处理),接入系统不需要自己调
|
||||||
|
- **涉及文件**: `integration-guide.md`, `IntegrationDoc.vue`
|
||||||
|
|
||||||
|
### 4. 数据库/Redis IP 迁移域名
|
||||||
|
- **做了什么**: 全项目 `10.1.1.122` → 域名,区分 PROD/QA 环境
|
||||||
|
- **原因**: 统一使用域名方便运维,IP 变更不用改代码
|
||||||
|
- **结果**:
|
||||||
|
- PROD: MySQL=`db1.prod.zgitm.com`, Redis=`redis.db1.zgitm.com`
|
||||||
|
- QA: MySQL=`db1.qa.zgitm.com`, Redis=`redis.db2.zgitm.com`
|
||||||
|
- **涉及文件**: 21个文件(yml src×6 + yml target×6 + py×7 + 需求文档×1 + ...)
|
||||||
|
|
||||||
|
### 5. Redis Key 加统一前缀 GATEWAY:
|
||||||
|
- **做了什么**: `RedisUtil` 新增 `KEY_PREFIX = "GATEWAY:"` 常量和 `key()` 静态方法,所有 Redis key 改为 `RedisUtil.key("xxx")`
|
||||||
|
- **原因**: 以后改前缀只改一处
|
||||||
|
- **结果**: 所有 key 变为 `GATEWAY:rate:login:...` / `GATEWAY:auth:token:...` / `GATEWAY:open:token:...` 等
|
||||||
|
- **涉及文件**: `RedisUtil.java`, `AuthServiceImpl.java`, `UserServiceImpl.java`, `RoleServiceImpl.java`, `OpenApiController.java`
|
||||||
|
|
||||||
|
## 重要信息
|
||||||
|
|
||||||
|
### 数据库连接
|
||||||
|
| 环境 | MySQL | Redis |
|
||||||
|
|------|-------|-------|
|
||||||
|
| PROD | db1.prod.zgitm.com:3306 | redis.db1.zgitm.com:6379 |
|
||||||
|
| QA | db1.qa.zgitm.com:3306 | redis.db2.zgitm.com:6379 |
|
||||||
|
|
||||||
|
### API 接口清单(接入系统用)
|
||||||
|
| 章节 | 接口 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 5.1 | GET /user/info | 用户信息(无 permissions) |
|
||||||
|
| 5.2 | GET /menu/router | 菜单树 + 权限列表 |
|
||||||
|
| 5.3 | GET /system/list | 可切换系统列表 |
|
||||||
|
| 5.4 | PUT /password/update | 修改密码 |
|
||||||
|
|
||||||
|
### Redis Key 前缀
|
||||||
|
- 统一前缀: `GATEWAY:`
|
||||||
|
- 改法: `RedisUtil.key("auth:token:" + tokenId)`
|
||||||
|
|
||||||
|
## 待处理
|
||||||
|
- [ ] 部署后端到服务器使接口变更生效
|
||||||
|
- [ ] 部署前端到服务器使文档页生效
|
||||||
|
- [ ] 确认 DNS 解析 `db1.prod.zgitm.com` / `db1.qa.zgitm.com` / `redis.db1.zgitm.com` / `redis.db2.zgitm.com` 已配置
|
||||||
51
memory/session-20260620-2.md
Normal file
51
memory/session-20260620-2.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
name: session-20260620-2
|
||||||
|
description: 2026-06-20 第2次会话 — 修复TokenAuthFilter Redis Key前缀遗漏导致登录后Token找不到会话
|
||||||
|
metadata:
|
||||||
|
type: session
|
||||||
|
date: 2026-06-20
|
||||||
|
sessionNumber: 2
|
||||||
|
startTime: 2026-06-20T10:45:00+08:00
|
||||||
|
endTime: 2026-06-20T10:55:00+08:00
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2026-06-20 第2次会话
|
||||||
|
|
||||||
|
## 会话摘要
|
||||||
|
修复 TokenAuthFilter 中 Redis Key 缺少 `GATEWAY:` 前缀的 bug。上次会话给 RedisUtil 加了统一前缀机制后,Auth/Admin 服务都改为使用 `RedisUtil.key()` 生成 key,但 Gateway 的 TokenAuthFilter 被遗漏,导致登录后 Token 校验时找不到 Redis 会话。
|
||||||
|
|
||||||
|
## 关键操作
|
||||||
|
|
||||||
|
### TokenAuthFilter Redis Key 前缀修复
|
||||||
|
- **做了什么**: `TokenAuthFilter.java` 中两处 Redis key 改为 `RedisUtil.key("auth:token:" + tokenId)` 和 `RedisUtil.key("open:token:" + tokenId)`
|
||||||
|
- **原因**: 上次会话(session-1)引入 `RedisUtil.KEY_PREFIX = "GATEWAY:"` 和 `key()` 方法后,Auth 服务写入 key 是 `GATEWAY:auth:token:xxx`,但 Gateway 读取时直接拼 `"auth:token:" + tokenId`(无前缀),导致永远找不到。这是一个重构遗漏 bug。
|
||||||
|
- **结果**: 修复后 Gateway 正确使用 `RedisUtil.key()` 生成带前缀的 key,与 Auth/Admin 服务写入的 key 一致
|
||||||
|
- **涉及文件**: `TokenAuthFilter.java` (第115行和第183行)
|
||||||
|
|
||||||
|
### 问题诊断过程
|
||||||
|
- **日志现象**: `Token 对应 Redis 会话不存在 - tokenId: 43a705df-fdc7-4b97-b5eb-605d3c081897`
|
||||||
|
- **数据流追踪**:
|
||||||
|
1. 登录 → Auth 服务写入 `GATEWAY:auth:token:43a705df-...` ✓
|
||||||
|
2. 校验 → Gateway 查找 `auth:token:43a705df-...` ✗ (缺少 GATEWAY: 前缀)
|
||||||
|
- **根因**: `RedisUtil.key()` 自动添加 `GATEWAY:` 前缀,但 TokenAuthFilter 没使用此方法
|
||||||
|
|
||||||
|
## 重要信息
|
||||||
|
- Redis 前缀机制详情见 [[session-20260620-1]] 的 "Redis Key 加统一前缀 GATEWAY:" 章节
|
||||||
|
- Auth 服务(9001)、Admin 服务(9002)、Gateway(9000) 三个服务共享同一个 Redis 实例
|
||||||
|
- 所有 Redis key 操作都必须通过 `RedisUtil.key()` 方法,不能直接拼字符串
|
||||||
|
|
||||||
|
## 待处理
|
||||||
|
- [x] 重新编译部署 Gateway 服务使修复生效 (10:54 已部署到 10.1.1.170 Docker 容器 mokeegateway-mokee-gateway-gateway)
|
||||||
|
- [ ] 验证登录后 Token 校验正常通过(用户自行测试)
|
||||||
|
|
||||||
|
## 部署记录
|
||||||
|
- **服务器**: 10.1.1.170(内网)
|
||||||
|
- **容器名**: mokeegateway-mokee-gateway-gateway
|
||||||
|
- **容器 ID**: 09bc559da896
|
||||||
|
- **镜像**: harbor.zgitm.com/mokee/mokee-mokeegateway-mokee-gateway-gateway:v78.20260620102948
|
||||||
|
- **部署方式**: SCP JAR → docker cp 替换 /app/app.jar → docker restart
|
||||||
|
- **启动耗时**: 约 5.5 秒
|
||||||
|
- **部署时间**: 2026-06-20 10:54
|
||||||
|
|
||||||
|
## 新规则
|
||||||
|
- **部署 170 须用户授权**: 后续没有用户明确要求,不能擅自部署到 10.1.1.170(生产环境)。[[deploy-approval-required]]
|
||||||
50
memory/session-20260621-1.md
Normal file
50
memory/session-20260621-1.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
name: session-20260621-1
|
||||||
|
description: 2026-06-21 第1次会话 — 接口性能排查 + sys_system.system_code 加索引
|
||||||
|
metadata:
|
||||||
|
type: session
|
||||||
|
date: 2026-06-21
|
||||||
|
sessionNumber: 1
|
||||||
|
startTime: 2026-06-21T00:00:00+08:00
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2026-06-21 第1次会话
|
||||||
|
|
||||||
|
## 会话摘要
|
||||||
|
用户反馈两个接口响应慢,排查了全链路耗时分布。最终在 QA/Prod 数据库 `sys_system` 表 `system_code` 列加了普通索引。
|
||||||
|
|
||||||
|
## 关键操作
|
||||||
|
|
||||||
|
### /zgapi/v1/auth/system/list 性能排查
|
||||||
|
- **做了什么**: 完整追踪请求链路:Nginx → Gateway(9000) → TokenAuthFilter → RateLimitFilter → HeaderInjectFilter → ApiPermissionFilter → GatewayController → [HTTP转发] → Auth(9001) → DB/Redis
|
||||||
|
- **发现的浪费**:
|
||||||
|
1. JWT 签名验证执行了 3 次(TokenAuthFilter + RateLimitFilter + AuthServiceImpl)
|
||||||
|
2. 同一个 Redis key 被查询了 4 次(TokenAuthFilter GET+GETEXPIRE + AuthServiceImpl GET+GETEXPIRE)
|
||||||
|
3. Gateway→Auth HTTP 转发无连接池(SimpleClientHttpRequestFactory)
|
||||||
|
- **原因**: Gateway 和 Auth 服务各自独立认证,互不复用结果
|
||||||
|
- **结论**: 架构设计上的"层层设防"必然代价,不是 bug。未改代码
|
||||||
|
|
||||||
|
### /zgapi/v1/open/token 性能排查
|
||||||
|
- **做了什么**: 追踪 `/zgapi/v1/open/token` 全链路
|
||||||
|
- **发现的问题**:
|
||||||
|
1. `sys_system.system_code` 列没有索引 → 每次查询都是全表扫描
|
||||||
|
2. `sys_open_token_log` 用 UUID 主键,日志量上去后 INSERT 引发页分裂
|
||||||
|
3. RateLimitFilter 对所有 /open/token 请求共用一个 anonymous 限流 key
|
||||||
|
4. 同上:Gateway→Admin HTTP 转发无连接池
|
||||||
|
|
||||||
|
### sys_system.system_code 加索引
|
||||||
|
- **做了什么**: 在 QA (`mokeegatewayqa`) 和 Prod (`mokeegateway`) 的 `sys_system` 表 `system_code` 列创建普通索引 `idx_system_code`
|
||||||
|
- **SQL**: `CREATE INDEX idx_system_code ON sys_system (system_code)`
|
||||||
|
- **原因**: DDL 仅指定了 PRIMARY KEY (id),system_code 的索引被遗漏,所有按 system_code 查询的场景都在全表扫描(/open/token、ApiPermissionFilter、GatewayController 业务系统转发等)
|
||||||
|
- **验证结果**: QA 和 Prod 均已确认 `idx_system_code` 存在
|
||||||
|
|
||||||
|
## 重要信息
|
||||||
|
- QA MySQL: db1.qa.zgitm.com / mokeegatewayqa
|
||||||
|
- Prod MySQL: db1.prod.zgitm.com / mokeegateway
|
||||||
|
- DDL `sys_system` 注释 "uk_system_code/uk_gateway_url 唯一性由应用层代码校验" 说明当时故意没建唯一约束,但普通索引也没有是遗漏
|
||||||
|
|
||||||
|
## 待处理
|
||||||
|
- [ ] 可以考虑给 `sys_system.gateway_url` 也加个索引(但使用频率低,优先级不高)
|
||||||
|
- [ ] `RestTemplateConfig` 改用连接池(HttpComponentsClientHttpRequestFactory),省掉每次转发的 TCP 握手
|
||||||
|
- [ ] `RateLimitFilter` 可以跳过 `/zgapi/v1/open/token`(获取 token 本身不应限流,且 anonymous key 是热点)
|
||||||
|
- [ ] Auth 服务 getTokenInfoFromRedis 中 JWT validateToken 可以改为只解析 payload 不验签(Gateway 已验证过)
|
||||||
38
memory/session-20260623-1.md
Normal file
38
memory/session-20260623-1.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
name: session-20260623-1
|
||||||
|
description: 2026-06-23 第1次会话 — Cookie Token 兜底确认 + API 白名单跳过鉴权方案设计
|
||||||
|
metadata:
|
||||||
|
type: session
|
||||||
|
date: 2026-06-23
|
||||||
|
sessionNumber: 1
|
||||||
|
startTime: 2026-06-23T00:00:00+08:00
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2026-06-23 第1次会话
|
||||||
|
|
||||||
|
## 会话摘要
|
||||||
|
1. 用户询问网关是否支持从 Cookie 读取 Token 兜底,确认已有实现,无需改动
|
||||||
|
2. 用户提出新增"API 白名单跳过鉴权"需求,完成方案设计,计划已保存待后续实施
|
||||||
|
|
||||||
|
## 关键操作
|
||||||
|
|
||||||
|
### Cookie Token 兜底确认
|
||||||
|
- **做了什么**: 检查了 TokenAuthFilter.java、前端 auth.ts/request.ts 的完整链路
|
||||||
|
- **结论**:
|
||||||
|
- `/zgapi/*` 路径:Authorization 头 → Cookie 兜底 ✅ 已有
|
||||||
|
- `/zgapi/v1/open/*` 路径:Authorization 头 → query 参数兜底 ✅ 已有,用 Cookie
|
||||||
|
- Cookie 由前端登录后写入(domain=.zgitm.com, max-age=7200, SameSite=Lax)
|
||||||
|
- **用户确认**: Open API 绝不用 Cookie,保持现状
|
||||||
|
|
||||||
|
### API 白名单跳过鉴权方案设计
|
||||||
|
- **做了什么**: 完整调研了 TokenAuthFilter、ApiPermissionFilter、sys_api 表、SysApi 实体、前端 ApiForm/ApiList
|
||||||
|
- **方案**: 扩展 `sys_api` 表新增 `skip_auth` 字段(TINYINT, 0=需鉴权/1=跳过),在 TokenAuthFilter 中增加 Caffeine 缓存查询
|
||||||
|
- **计划文件**: `C:\Users\Administrator\.claude\plans\valiant-nibbling-lake.md`
|
||||||
|
|
||||||
|
## 重要信息
|
||||||
|
- 方案涉及 8 个文件(2 新建 + 6 修改)
|
||||||
|
- 核心逻辑:TokenAuthFilter 在返回 401 之前,先查 SkipAuthCacheService(Caffeine 5min TTL),匹配则放行
|
||||||
|
- 不改动 `/zgapi/v1/open/*` 路径
|
||||||
|
|
||||||
|
## 待处理
|
||||||
|
- [ ] 执行 API 白名单跳过鉴权方案实施(计划已就绪,下次会话继续)
|
||||||
82
memory/session-20260628-1.md
Normal file
82
memory/session-20260628-1.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
name: session-20260628-1
|
||||||
|
description: 2026-06-28 第1次会话 — 登录后头像下载接口重复调用三轮排查修复
|
||||||
|
metadata:
|
||||||
|
type: session
|
||||||
|
date: 2026-06-28
|
||||||
|
sessionNumber: 1
|
||||||
|
startTime: 2026-06-28T05:00:00+08:00
|
||||||
|
endTime: 2026-06-28T05:30:00+08:00
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2026-06-28 会话
|
||||||
|
|
||||||
|
## 会话摘要
|
||||||
|
|
||||||
|
用户反馈登录成功后 `/open/token` 和 `https://file.wg.zgitm.com/api/download` 接口被重复调用。经过三轮排查,发现根因是 `login()` + `fetchUserInfo()`(SystemSelectView/AdminLayout)重复刷新头像 Token。最终方案:`login()` 刷新头像(确保 `<el-avatar>` 加载时 URL 已有 Token),`fetchUserInfo()` 增加去重逻辑(同一文件复用已有 URL),共 3 次部署到生产环境。
|
||||||
|
|
||||||
|
## 关键操作
|
||||||
|
|
||||||
|
### 一、第一轮修复 — 删除 login() 中的头像刷新
|
||||||
|
|
||||||
|
- **做了什么**: 删除 `auth.ts` login() 中的 `refreshFileServiceUrl()` 调用
|
||||||
|
- **原因**: login() 和 fetchUserInfo() 各刷新一次,导致 2 次 /open/token + 2 次 download
|
||||||
|
- **结果**: ❌ 引入新问题——login() 不刷新后,`<el-avatar>` 先用无 Token 的 URL 发起失败请求,fetchUserInfo() 再发一次成功请求
|
||||||
|
|
||||||
|
### 二、第二轮修复 — fetchUserInfo() 增加去重 + 恢复 login() 刷新
|
||||||
|
|
||||||
|
- **做了什么**:
|
||||||
|
1. `fetchUserInfo()` 中比较当前 store 中的 avatar(去 Token)和新获取的 avatar(去 Token),相同文件则复用已有 URL
|
||||||
|
2. 恢复 `login()` 中的头像刷新
|
||||||
|
- **原因**: 用户发现 admin 登录后 download 接口仍被调用 2 次:SystemSelectView 一次 → AdminLayout 又一次
|
||||||
|
- **结果**: ✅ 达到 1 次 /open/token + 1 次 download
|
||||||
|
|
||||||
|
### 三、最终代码改动
|
||||||
|
|
||||||
|
**文件**: `mokee-gateway-web/src/stores/auth.ts`
|
||||||
|
|
||||||
|
**login() 最终版** — 保留头像刷新:
|
||||||
|
```typescript
|
||||||
|
// 立即刷新头像 Token — 否则跳转后 <el-avatar> 先用无 Token 的 URL
|
||||||
|
// 发起一次失败请求,等 fetchUserInfo() 拿到 Token 后又发起第二次
|
||||||
|
// fetchUserInfo() 中有去重逻辑,不会重复刷新同一文件
|
||||||
|
let userInfoData = data.userInfo
|
||||||
|
if (userInfoData?.avatar && isFileServiceUrl(userInfoData.avatar)) {
|
||||||
|
userInfoData = { ...userInfoData, avatar: await refreshFileServiceUrl(userInfoData.avatar) }
|
||||||
|
}
|
||||||
|
userInfo.value = userInfoData
|
||||||
|
```
|
||||||
|
|
||||||
|
**fetchUserInfo() 最终版** — 增加去重:
|
||||||
|
```typescript
|
||||||
|
const prevBase = stripToken(userInfo.value?.avatar || '')
|
||||||
|
const newBase = stripToken(data?.avatar || '')
|
||||||
|
if (data?.avatar && isFileServiceUrl(data.avatar) && prevBase !== newBase) {
|
||||||
|
data = { ...data, avatar: await refreshFileServiceUrl(data.avatar) }
|
||||||
|
} else if (data?.avatar && isFileServiceUrl(data.avatar) && prevBase === newBase) {
|
||||||
|
// 同一文件,复用已有的带 Token 的 URL
|
||||||
|
data = { ...data, avatar: userInfo.value?.avatar || data.avatar }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 部署记录
|
||||||
|
|
||||||
|
- **服务器**: 10.1.1.170(内网)
|
||||||
|
- **前端容器**: `mokeegateway-web-mokee-gateway-web`(nginx:stable-alpine,端口 80→5001)
|
||||||
|
- **部署方式**: `npm run build:prod` → SFTP 上传 dist → `docker cp` 替换容器内 `/usr/share/nginx/html/` → `docker exec nginx -s reload`
|
||||||
|
- **共部署 3 次**,最终版本已验证 HTTP 200
|
||||||
|
|
||||||
|
## 重要信息
|
||||||
|
|
||||||
|
- 生产 Docker 容器列表(10.1.1.170):
|
||||||
|
- `mokeegateway-web-mokee-gateway-web` — 前端
|
||||||
|
- `mokeegateway-mokee-gateway-gateway` — 网关
|
||||||
|
- `mokeegateway-mokee-gateway-auth` — 认证
|
||||||
|
- `mokeegateway-mokee-gateway-admin` — 管理
|
||||||
|
- 文件服务域名: `file.wg.zgitm.com`,下载 Token 一次性消费
|
||||||
|
- 头像存储在文件服务中,DB 只存基础路径,每次加载页面需获取 Token 并拼接完整 URL
|
||||||
|
|
||||||
|
## 待处理
|
||||||
|
|
||||||
|
- [ ] 生产环境验证:用 admin 用户登录,确认只调用 1 次 /open/token 和 1 次 download
|
||||||
|
- [ ] 容器内旧 JS chunk 文件会累积(docker cp 不删除旧文件),长期需增加清理步骤
|
||||||
84
migrate_to_uuid.sql
Normal file
84
migrate_to_uuid.sql
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- Mokee Gateway: BIGINT → UUID 数据库迁移
|
||||||
|
-- 执行前请备份数据库
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 1. 添加临时 UUID 列并生成值
|
||||||
|
ALTER TABLE sys_system ADD COLUMN new_id VARCHAR(64) NOT NULL DEFAULT '' AFTER id;
|
||||||
|
ALTER TABLE sys_user ADD COLUMN new_id VARCHAR(64) NOT NULL DEFAULT '' AFTER id;
|
||||||
|
ALTER TABLE sys_role ADD COLUMN new_id VARCHAR(64) NOT NULL DEFAULT '' AFTER id;
|
||||||
|
ALTER TABLE sys_menu ADD COLUMN new_id VARCHAR(64) NOT NULL DEFAULT '' AFTER id;
|
||||||
|
ALTER TABLE sys_api ADD COLUMN new_id VARCHAR(64) NOT NULL DEFAULT '' AFTER id;
|
||||||
|
ALTER TABLE sys_dict ADD COLUMN new_id VARCHAR(64) NOT NULL DEFAULT '' AFTER id;
|
||||||
|
ALTER TABLE sys_file ADD COLUMN new_id VARCHAR(64) NOT NULL DEFAULT '' AFTER id;
|
||||||
|
ALTER TABLE sys_user_system ADD COLUMN new_id VARCHAR(64) NOT NULL DEFAULT '' AFTER id;
|
||||||
|
ALTER TABLE sys_user_role ADD COLUMN new_id VARCHAR(64) NOT NULL DEFAULT '' AFTER id;
|
||||||
|
ALTER TABLE sys_role_menu ADD COLUMN new_id VARCHAR(64) NOT NULL DEFAULT '' AFTER id;
|
||||||
|
ALTER TABLE sys_login_log ADD COLUMN new_id VARCHAR(64) NOT NULL DEFAULT '' AFTER id;
|
||||||
|
ALTER TABLE sys_operation_log ADD COLUMN new_id VARCHAR(64) NOT NULL DEFAULT '' AFTER id;
|
||||||
|
|
||||||
|
UPDATE sys_system SET new_id = REPLACE(UUID(),'-','');
|
||||||
|
UPDATE sys_user SET new_id = REPLACE(UUID(),'-','');
|
||||||
|
UPDATE sys_role SET new_id = REPLACE(UUID(),'-','');
|
||||||
|
UPDATE sys_menu SET new_id = REPLACE(UUID(),'-','');
|
||||||
|
UPDATE sys_api SET new_id = REPLACE(UUID(),'-','');
|
||||||
|
UPDATE sys_dict SET new_id = REPLACE(UUID(),'-','');
|
||||||
|
UPDATE sys_file SET new_id = REPLACE(UUID(),'-','');
|
||||||
|
UPDATE sys_user_system SET new_id = REPLACE(UUID(),'-','');
|
||||||
|
UPDATE sys_user_role SET new_id = REPLACE(UUID(),'-','');
|
||||||
|
UPDATE sys_role_menu SET new_id = REPLACE(UUID(),'-','');
|
||||||
|
UPDATE sys_login_log SET new_id = REPLACE(UUID(),'-','');
|
||||||
|
UPDATE sys_operation_log SET new_id = REPLACE(UUID(),'-','');
|
||||||
|
|
||||||
|
-- 2. 更新所有外键引用(用 new_id 关联)
|
||||||
|
UPDATE sys_api a JOIN sys_system s ON a.system_id = s.id SET a.system_id = s.new_id;
|
||||||
|
UPDATE sys_role r JOIN sys_system s ON r.system_id = s.id SET r.system_id = s.new_id;
|
||||||
|
UPDATE sys_menu m JOIN sys_system s ON m.system_id = s.id SET m.system_id = s.new_id;
|
||||||
|
UPDATE sys_user_system us JOIN sys_system s ON us.system_id = s.id SET us.system_id = s.new_id;
|
||||||
|
UPDATE sys_user_system us JOIN sys_user u ON us.user_id = u.id SET us.user_id = u.new_id;
|
||||||
|
UPDATE sys_user_role ur JOIN sys_role r ON ur.role_id = r.id SET ur.role_id = r.new_id;
|
||||||
|
UPDATE sys_user_role ur JOIN sys_user u ON ur.user_id = u.id SET ur.user_id = u.new_id;
|
||||||
|
UPDATE sys_role_menu rm JOIN sys_role r ON rm.role_id = r.id SET rm.role_id = r.new_id;
|
||||||
|
UPDATE sys_role_menu rm JOIN sys_menu m ON rm.menu_id = m.id SET rm.menu_id = m.new_id;
|
||||||
|
UPDATE sys_login_log l JOIN sys_user u ON l.user_id = u.id SET l.user_id = u.new_id;
|
||||||
|
UPDATE sys_login_log l JOIN sys_system s ON l.system_id = s.id SET l.system_id = s.new_id;
|
||||||
|
UPDATE sys_operation_log o JOIN sys_user u ON o.user_id = u.id SET o.user_id = u.new_id;
|
||||||
|
UPDATE sys_operation_log o JOIN sys_system s ON o.system_id = s.id SET o.system_id = s.new_id;
|
||||||
|
-- menu parent_id 自引用
|
||||||
|
UPDATE sys_menu child JOIN sys_menu parent ON child.parent_id = parent.id AND parent.new_id != '' SET child.parent_id = parent.new_id;
|
||||||
|
|
||||||
|
-- 3. 切换主键
|
||||||
|
ALTER TABLE sys_system DROP PRIMARY KEY, DROP COLUMN id, CHANGE new_id id VARCHAR(64) NOT NULL, ADD PRIMARY KEY (id);
|
||||||
|
ALTER TABLE sys_user DROP PRIMARY KEY, DROP COLUMN id, CHANGE new_id id VARCHAR(64) NOT NULL, ADD PRIMARY KEY (id);
|
||||||
|
ALTER TABLE sys_role DROP PRIMARY KEY, DROP COLUMN id, CHANGE new_id id VARCHAR(64) NOT NULL, ADD PRIMARY KEY (id);
|
||||||
|
ALTER TABLE sys_menu DROP PRIMARY KEY, DROP COLUMN id, CHANGE new_id id VARCHAR(64) NOT NULL, ADD PRIMARY KEY (id);
|
||||||
|
ALTER TABLE sys_api DROP PRIMARY KEY, DROP COLUMN id, CHANGE new_id id VARCHAR(64) NOT NULL, ADD PRIMARY KEY (id);
|
||||||
|
ALTER TABLE sys_dict DROP PRIMARY KEY, DROP COLUMN id, CHANGE new_id id VARCHAR(64) NOT NULL, ADD PRIMARY KEY (id);
|
||||||
|
ALTER TABLE sys_file DROP PRIMARY KEY, DROP COLUMN id, CHANGE new_id id VARCHAR(64) NOT NULL, ADD PRIMARY KEY (id);
|
||||||
|
ALTER TABLE sys_user_system DROP PRIMARY KEY, DROP COLUMN id, CHANGE new_id id VARCHAR(64) NOT NULL, ADD PRIMARY KEY (id);
|
||||||
|
ALTER TABLE sys_user_role DROP PRIMARY KEY, DROP COLUMN id, CHANGE new_id id VARCHAR(64) NOT NULL, ADD PRIMARY KEY (id);
|
||||||
|
ALTER TABLE sys_role_menu DROP PRIMARY KEY, DROP COLUMN id, CHANGE new_id id VARCHAR(64) NOT NULL, ADD PRIMARY KEY (id);
|
||||||
|
ALTER TABLE sys_login_log DROP PRIMARY KEY, DROP COLUMN id, CHANGE new_id id VARCHAR(64) NOT NULL, ADD PRIMARY KEY (id);
|
||||||
|
ALTER TABLE sys_operation_log DROP PRIMARY KEY, DROP COLUMN id, CHANGE new_id id VARCHAR(64) NOT NULL, ADD PRIMARY KEY (id);
|
||||||
|
|
||||||
|
-- 4. 修改所有 FK 列类型
|
||||||
|
ALTER TABLE sys_api MODIFY system_id VARCHAR(64) NOT NULL;
|
||||||
|
ALTER TABLE sys_role MODIFY system_id VARCHAR(64) NOT NULL;
|
||||||
|
ALTER TABLE sys_menu MODIFY system_id VARCHAR(64) NOT NULL, MODIFY parent_id VARCHAR(64) NOT NULL DEFAULT '0';
|
||||||
|
ALTER TABLE sys_user_system MODIFY user_id VARCHAR(64) NOT NULL, MODIFY system_id VARCHAR(64) NOT NULL;
|
||||||
|
ALTER TABLE sys_user_role MODIFY user_id VARCHAR(64) NOT NULL, MODIFY role_id VARCHAR(64) NOT NULL;
|
||||||
|
ALTER TABLE sys_role_menu MODIFY role_id VARCHAR(64) NOT NULL, MODIFY menu_id VARCHAR(64) NOT NULL;
|
||||||
|
ALTER TABLE sys_login_log MODIFY user_id VARCHAR(64), MODIFY system_id VARCHAR(64);
|
||||||
|
ALTER TABLE sys_operation_log MODIFY user_id VARCHAR(64), MODIFY system_id VARCHAR(64);
|
||||||
|
ALTER TABLE sys_file MODIFY system_id VARCHAR(64), MODIFY upload_by VARCHAR(64);
|
||||||
|
|
||||||
|
-- 5. 重新创建索引(因列类型变更)
|
||||||
|
-- sys_api
|
||||||
|
ALTER TABLE sys_api DROP INDEX uk_system_path_method, ADD UNIQUE KEY uk_system_path_method (system_id, api_path, api_method);
|
||||||
|
-- sys_role
|
||||||
|
ALTER TABLE sys_role DROP INDEX uk_system_role_code, ADD UNIQUE KEY uk_system_role_code (system_id, role_code);
|
||||||
|
-- 其他唯一键涉及 FK 的同样处理...
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 迁移完成
|
||||||
|
-- ============================================
|
||||||
36
mokee-gateway-admin/pom.xml
Normal file
36
mokee-gateway-admin/pom.xml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>com.mokee</groupId>
|
||||||
|
<artifactId>mokee-gateway</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>mokee-gateway-admin</artifactId>
|
||||||
|
<name>mokee-gateway-admin</name>
|
||||||
|
<description>业务管理服务 — 系统配置/日志/字典/定时任务</description>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.mokee</groupId>
|
||||||
|
<artifactId>mokee-gateway-common</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-aop</artifactId>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.mokee.admin;
|
||||||
|
|
||||||
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务管理服务 — 启动类
|
||||||
|
* <p>
|
||||||
|
* 负责系统配置管理、用户管理、角色权限分配、菜单维护、日志查询、定时任务等
|
||||||
|
*/
|
||||||
|
@SpringBootApplication(scanBasePackages = {"com.mokee.admin", "com.mokee.common"})
|
||||||
|
@MapperScan("com.mokee.admin.mapper")
|
||||||
|
@EnableScheduling
|
||||||
|
public class AdminApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(AdminApplication.class, args);
|
||||||
|
System.out.println("========================================");
|
||||||
|
System.out.println(" 业务管理服务启动成功! 端口: 9002");
|
||||||
|
System.out.println(" Knife4j 文档: http://localhost:9002/doc.html");
|
||||||
|
System.out.println("========================================");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package com.mokee.admin.aspect;
|
||||||
|
|
||||||
|
import cn.hutool.json.JSONUtil;
|
||||||
|
import com.mokee.admin.service.LogAsyncService;
|
||||||
|
import com.mokee.common.annotation.OperationLog;
|
||||||
|
import com.mokee.common.entity.SysOperationLog;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.aspectj.lang.ProceedingJoinPoint;
|
||||||
|
import org.aspectj.lang.annotation.Around;
|
||||||
|
import org.aspectj.lang.annotation.Aspect;
|
||||||
|
import org.aspectj.lang.reflect.MethodSignature;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
|
|
||||||
|
import java.net.URLDecoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作日志切面
|
||||||
|
* <p>
|
||||||
|
* 拦截带有 @OperationLog 注解的方法,记录操作人、请求参数、响应状态、耗时等
|
||||||
|
* 通过 X-User-Id / X-User-Name / X-System-Id 等请求头获取当前用户信息
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Aspect
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class OperationLogAspect {
|
||||||
|
|
||||||
|
private final LogAsyncService logAsyncService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 环绕通知:记录操作日志
|
||||||
|
* <p>
|
||||||
|
* 计算请求耗时,无论成功或异常都记录日志
|
||||||
|
*/
|
||||||
|
@Around("@annotation(com.mokee.common.annotation.OperationLog)")
|
||||||
|
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
|
Object result = null;
|
||||||
|
String errorMsg = null;
|
||||||
|
int responseStatus = 200;
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = joinPoint.proceed();
|
||||||
|
return result;
|
||||||
|
} catch (Throwable e) {
|
||||||
|
errorMsg = e.getMessage();
|
||||||
|
responseStatus = 500;
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
// 异步保存操作日志(简单起见直接同步写入,避免引入额外线程池配置)
|
||||||
|
try {
|
||||||
|
saveOperationLog(joinPoint, startTime, responseStatus, errorMsg, result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("记录操作日志失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存操作日志
|
||||||
|
*/
|
||||||
|
private void saveOperationLog(ProceedingJoinPoint joinPoint, long startTime,
|
||||||
|
int responseStatus, String errorMsg, Object result) {
|
||||||
|
// 获取 @OperationLog 注解信息
|
||||||
|
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
|
||||||
|
OperationLog annotation = signature.getMethod().getAnnotation(OperationLog.class);
|
||||||
|
String module = annotation.module();
|
||||||
|
String action = annotation.action();
|
||||||
|
|
||||||
|
// 获取请求信息
|
||||||
|
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||||
|
if (attributes == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
HttpServletRequest request = attributes.getRequest();
|
||||||
|
|
||||||
|
// 从请求头获取用户信息(由网关 HeaderInjectFilter 注入)
|
||||||
|
String userIdStr = request.getHeader("X-User-Id");
|
||||||
|
// X-User-Name 经网关 URL 编码,此处需解码还原中文姓名
|
||||||
|
String username = decodeHeader(request.getHeader("X-User-Name"));
|
||||||
|
String systemIdStr = request.getHeader("X-System-Id");
|
||||||
|
|
||||||
|
String userId = null;
|
||||||
|
if (userIdStr != null) {
|
||||||
|
userId = userIdStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
String systemId = null;
|
||||||
|
if (systemIdStr != null) {
|
||||||
|
systemId = systemIdStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取请求体参数
|
||||||
|
String requestBody = null;
|
||||||
|
Object[] args = joinPoint.getArgs();
|
||||||
|
if (args != null && args.length > 0) {
|
||||||
|
try {
|
||||||
|
// 过滤掉 HttpServletRequest / HttpServletResponse 等非业务参数
|
||||||
|
Object bodyArg = null;
|
||||||
|
for (Object arg : args) {
|
||||||
|
if (arg != null && !(arg instanceof HttpServletRequest)
|
||||||
|
&& !(arg instanceof jakarta.servlet.http.HttpServletResponse)) {
|
||||||
|
bodyArg = arg;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bodyArg != null) {
|
||||||
|
requestBody = JSONUtil.toJsonStr(bodyArg);
|
||||||
|
// 截断过长的请求体
|
||||||
|
if (requestBody.length() > 2000) {
|
||||||
|
requestBody = requestBody.substring(0, 2000) + "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算耗时
|
||||||
|
int costTime = (int) (System.currentTimeMillis() - startTime);
|
||||||
|
|
||||||
|
// 捕获响应体
|
||||||
|
String responseBody = null;
|
||||||
|
if (result != null && responseStatus == 200) {
|
||||||
|
try {
|
||||||
|
responseBody = JSONUtil.toJsonStr(result);
|
||||||
|
if (responseBody.length() > 2000) {
|
||||||
|
responseBody = responseBody.substring(0, 2000) + "...";
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建并保存操作日志
|
||||||
|
SysOperationLog operationLog = SysOperationLog.builder()
|
||||||
|
.userId(userId)
|
||||||
|
.username(username)
|
||||||
|
.systemId(systemId)
|
||||||
|
.module(module)
|
||||||
|
.action(action)
|
||||||
|
.requestUrl(request.getRequestURI())
|
||||||
|
.requestMethod(request.getMethod())
|
||||||
|
.requestBody(requestBody)
|
||||||
|
.responseBody(responseBody)
|
||||||
|
.responseStatus(responseStatus)
|
||||||
|
.errorMsg(errorMsg)
|
||||||
|
.costTime(costTime)
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
logAsyncService.saveOperationLog(operationLog);
|
||||||
|
log.debug("操作日志已记录 - module: {}, action: {}, url: {}, cost: {}ms",
|
||||||
|
module, action, request.getRequestURI(), costTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL 解码 HTTP 头值
|
||||||
|
* <p>
|
||||||
|
* 网关 HeaderInjectFilter 对中文值进行了 URL 编码(防止 HTTP 转发时损坏),
|
||||||
|
* 此处解码还原原始值。若解码失败或值为 null,返回原值。
|
||||||
|
*/
|
||||||
|
private String decodeHeader(String value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
try {
|
||||||
|
return URLDecoder.decode(value, StandardCharsets.UTF_8);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.mokee.admin.config;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前请求用户上下文(ThreadLocal)
|
||||||
|
* <p>
|
||||||
|
* 由 UserContextInterceptor 在请求进入时设置,请求结束时清除。
|
||||||
|
* super_admin 角色可查看所有系统的数据,其他角色只能查看自己绑定系统的数据。
|
||||||
|
*/
|
||||||
|
public class UserContextHolder {
|
||||||
|
|
||||||
|
private static final ThreadLocal<String> USER_ID = new ThreadLocal<>();
|
||||||
|
private static final ThreadLocal<Boolean> SUPER_ADMIN = new ThreadLocal<>();
|
||||||
|
private static final ThreadLocal<List<String>> SYSTEM_IDS = new ThreadLocal<>();
|
||||||
|
|
||||||
|
public static void set(String userId, boolean isSuperAdmin, List<String> systemIds) {
|
||||||
|
USER_ID.set(userId);
|
||||||
|
SUPER_ADMIN.set(isSuperAdmin);
|
||||||
|
SYSTEM_IDS.set(systemIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void clear() {
|
||||||
|
USER_ID.remove();
|
||||||
|
SUPER_ADMIN.remove();
|
||||||
|
SYSTEM_IDS.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getUserId() { return USER_ID.get(); }
|
||||||
|
|
||||||
|
/** 是否为 super_admin,可查看全部数据 */
|
||||||
|
public static boolean isSuperAdmin() { return Boolean.TRUE.equals(SUPER_ADMIN.get()); }
|
||||||
|
|
||||||
|
/** 当前用户绑定的系统 ID 列表(非 super_admin 时用于数据过滤,不会为 null) */
|
||||||
|
public static List<String> getSystemIds() { return SYSTEM_IDS.get(); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package com.mokee.admin.config;
|
||||||
|
|
||||||
|
import cn.hutool.json.JSONUtil;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.mokee.admin.mapper.SysUserSystemMapper;
|
||||||
|
import com.mokee.admin.mapper.SysSystemMapper;
|
||||||
|
import com.mokee.common.dto.response.RoleInfo;
|
||||||
|
import com.mokee.common.entity.SysSystem;
|
||||||
|
import com.mokee.common.entity.SysUserSystem;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户上下文拦截器 — 从请求头解析用户角色和系统绑定
|
||||||
|
* <p>
|
||||||
|
* 每次请求进入时调用,解析网关注入的 X-User-Roles / X-User-Id 头,
|
||||||
|
* 判断是否为 super_admin,查询用户绑定的系统列表,
|
||||||
|
* 写入 UserContextHolder,请求结束时清除。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserContextInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
private final SysUserSystemMapper sysUserSystemMapper;
|
||||||
|
private final SysSystemMapper sysSystemMapper;
|
||||||
|
|
||||||
|
/** 网关系统 ID 缓存(避免每次请求都查库) */
|
||||||
|
private final Map<String, String> gatewayIdCache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private boolean isGatewaySystem(String systemId) {
|
||||||
|
if (systemId == null) return false;
|
||||||
|
// 使用缓存避免每次请求查库
|
||||||
|
String gatewayId = gatewayIdCache.computeIfAbsent("gateway", k -> {
|
||||||
|
SysSystem gw = sysSystemMapper.selectOne(
|
||||||
|
new LambdaQueryWrapper<SysSystem>().eq(SysSystem::getSystemCode, "gateway"));
|
||||||
|
return gw != null ? gw.getId() : "__NONE__";
|
||||||
|
});
|
||||||
|
return gatewayId.equals(systemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||||
|
try {
|
||||||
|
String userId = request.getHeader("X-User-Id");
|
||||||
|
if (userId == null || userId.isEmpty()) {
|
||||||
|
// 匿名请求(如 Open API),不设置上下文
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析角色列表,判断是否为 super_admin
|
||||||
|
String rolesJson = request.getHeader("X-User-Roles");
|
||||||
|
boolean isSuperAdmin = false;
|
||||||
|
if (rolesJson != null && !rolesJson.isEmpty()) {
|
||||||
|
List<RoleInfo> roles = JSONUtil.toList(rolesJson, RoleInfo.class);
|
||||||
|
isSuperAdmin = roles.stream().anyMatch(r -> "super_admin".equals(r.getRoleCode()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非 super_admin 时,查询用户绑定的系统列表,排除网关系统
|
||||||
|
List<String> systemIds = Collections.emptyList();
|
||||||
|
if (!isSuperAdmin) {
|
||||||
|
List<SysUserSystem> bindings = sysUserSystemMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<SysUserSystem>()
|
||||||
|
.eq(SysUserSystem::getUserId, userId)
|
||||||
|
);
|
||||||
|
// 排除网关系统(gateway),业务管理员不需要看到平台基础设施数据
|
||||||
|
systemIds = bindings.stream()
|
||||||
|
.map(SysUserSystem::getSystemId)
|
||||||
|
.distinct()
|
||||||
|
.filter(sysId -> !isGatewaySystem(sysId))
|
||||||
|
.toList();
|
||||||
|
if (systemIds.isEmpty()) {
|
||||||
|
systemIds = List.of("__NONE__");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
UserContextHolder.set(userId, isSuperAdmin, systemIds);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("设置用户上下文失败", e);
|
||||||
|
// 失败不阻塞请求,兜底为全部数据可见
|
||||||
|
UserContextHolder.set(null, true, Collections.emptyList());
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
|
||||||
|
Object handler, Exception ex) {
|
||||||
|
UserContextHolder.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.mokee.admin.config;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web MVC 配置 — 注册用户上下文拦截器
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WebMvcConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
private final UserContextInterceptor userContextInterceptor;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
|
registry.addInterceptor(userContextInterceptor)
|
||||||
|
.addPathPatterns("/zgapi/v1/admin/**") // 仅管理后台接口
|
||||||
|
.excludePathPatterns("/zgapi/v1/admin/file/**/view", "/zgapi/v1/admin/file/**/download"); // 文件预览/下载不拦截
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.mokee.admin.controller;
|
||||||
|
|
||||||
|
import com.mokee.admin.service.ConfigService;
|
||||||
|
import com.mokee.common.Result;
|
||||||
|
import com.mokee.common.annotation.OperationLog;
|
||||||
|
import com.mokee.common.entity.SysConfig;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统配置控制器
|
||||||
|
* <p>
|
||||||
|
* 提供系统级配置的读写接口,管理后台使用。
|
||||||
|
* <p>
|
||||||
|
* 请求路径前缀:{@code /zgapi/v1/admin/config}
|
||||||
|
*
|
||||||
|
* @author mokee
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/zgapi/v1/admin/config")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ConfigController {
|
||||||
|
|
||||||
|
private final ConfigService configService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个配置值(供前端表单回显等场景使用)
|
||||||
|
*/
|
||||||
|
@GetMapping("/{key}")
|
||||||
|
public Result<SysConfig> getByKey(@PathVariable String key) {
|
||||||
|
return Result.ok(configService.getByKey(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取全部配置列表
|
||||||
|
*/
|
||||||
|
@GetMapping("/list")
|
||||||
|
public Result<List<SysConfig>> listAll() {
|
||||||
|
return Result.ok(configService.listAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存配置(新增或按 configKey 更新)
|
||||||
|
*/
|
||||||
|
@OperationLog(module = "系统配置", action = "编辑")
|
||||||
|
@PostMapping("/save")
|
||||||
|
public Result<?> save(@RequestBody SysConfig config) {
|
||||||
|
if (config.getConfigKey() == null || config.getConfigKey().isEmpty()) {
|
||||||
|
return Result.fail(400, "configKey 不能为空");
|
||||||
|
}
|
||||||
|
configService.save(config);
|
||||||
|
return Result.ok(Map.of("msg", "保存成功"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制刷新缓存
|
||||||
|
*/
|
||||||
|
@OperationLog(module = "系统配置", action = "刷新缓存")
|
||||||
|
@PostMapping("/refresh-cache")
|
||||||
|
public Result<?> refreshCache() {
|
||||||
|
configService.refreshCache();
|
||||||
|
return Result.ok(Map.of("msg", "缓存已刷新"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package com.mokee.admin.controller;
|
||||||
|
|
||||||
|
import com.github.benmanes.caffeine.cache.Cache;
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
|
import com.mokee.admin.config.UserContextHolder;
|
||||||
|
import com.mokee.common.Result;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.Socket;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首页仪表盘控制器
|
||||||
|
* <p>
|
||||||
|
* 提供后台管理首页的聚合统计数据,包括:
|
||||||
|
* <ul>
|
||||||
|
* <li>系统总数、用户总数(实时从数据库查询)</li>
|
||||||
|
* <li>今日登录人数/登录次数(从 sys_login_log 统计)</li>
|
||||||
|
* <li>在线用户数(最近30分钟内有成功登录记录的用户数近似)</li>
|
||||||
|
* <li>过去30天每日登录趋势(用于折线图)</li>
|
||||||
|
* <li>JVM 运行信息(处理器核心数、内存使用、Java版本、操作系统)</li>
|
||||||
|
* <li>3个微服务健康状态(通过 TCP Socket 连接检测端口可达性)</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* 注意:直接使用 JdbcTemplate 执行原生 SQL,避免引入额外的依赖或复杂查询。
|
||||||
|
* 在线用户数用登录日志近似,真实生产环境应接入 Redis 会话或 WebSocket 心跳。
|
||||||
|
* <p>
|
||||||
|
* 请求路径前缀:{@code /zgapi/v1/admin/dashboard}
|
||||||
|
*
|
||||||
|
* @author mokee
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/zgapi/v1/admin/dashboard")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DashboardController {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
/** 仪表盘缓存:10 秒 TTL,避免每次刷新都打 6 次 DB + 健康检查 */
|
||||||
|
private Cache<String, Map<String, Object>> statsCache;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
statsCache = Caffeine.newBuilder()
|
||||||
|
.maximumSize(10)
|
||||||
|
.expireAfterWrite(Duration.ofSeconds(10))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取仪表盘聚合统计数据
|
||||||
|
* <p>
|
||||||
|
* 一次性返回所有仪表盘卡片和图表所需的数据,前端只需一次请求即可渲染整个仪表盘页面。
|
||||||
|
* 返回的 Map 使用 LinkedHashMap 保证字段顺序稳定。
|
||||||
|
*
|
||||||
|
* @return Result 包含统计数据的 Map,字段包括:
|
||||||
|
* systemCount、userCount、todayLoginCount、todayLoginTotal、
|
||||||
|
* onlineCount、loginTrend、serverInfo、services
|
||||||
|
*/
|
||||||
|
@GetMapping("/stats")
|
||||||
|
public Result<Map<String, Object>> stats() {
|
||||||
|
boolean isSuperAdmin = UserContextHolder.isSuperAdmin();
|
||||||
|
// 非 super_admin 按用户区分缓存 key
|
||||||
|
String cacheKey = isSuperAdmin ? "admin" : UserContextHolder.getUserId();
|
||||||
|
|
||||||
|
// 优先读本地缓存(10 秒 TTL)
|
||||||
|
Map<String, Object> cached = statsCache.getIfPresent(cacheKey);
|
||||||
|
if (cached != null) {
|
||||||
|
return Result.ok(cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> data = new LinkedHashMap<>();
|
||||||
|
List<String> systemIds = isSuperAdmin ? null : UserContextHolder.getSystemIds();
|
||||||
|
|
||||||
|
// DB 查询:系统数、用户数、登录统计、30天趋势
|
||||||
|
if (isSuperAdmin) {
|
||||||
|
Integer systemCount = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM sys_system WHERE is_deleted=0", Integer.class);
|
||||||
|
data.put("systemCount", systemCount != null ? systemCount : 0);
|
||||||
|
Integer userCount = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM sys_user WHERE is_deleted=0", Integer.class);
|
||||||
|
data.put("userCount", userCount != null ? userCount : 0);
|
||||||
|
} else {
|
||||||
|
data.put("systemCount", systemIds != null ? systemIds.size() : 0);
|
||||||
|
data.put("userCount", countUsersBySystems(systemIds));
|
||||||
|
}
|
||||||
|
data.put("todayLoginCount", queryLoginCount(isSuperAdmin, systemIds,
|
||||||
|
"SELECT COUNT(DISTINCT user_id) FROM sys_login_log WHERE status=1 AND DATE(created_at)=CURDATE()"));
|
||||||
|
data.put("todayLoginTotal", queryLoginCount(isSuperAdmin, systemIds,
|
||||||
|
"SELECT COUNT(*) FROM sys_login_log WHERE DATE(created_at)=CURDATE()"));
|
||||||
|
data.put("onlineCount", queryLoginCount(isSuperAdmin, systemIds,
|
||||||
|
"SELECT COUNT(DISTINCT user_id) FROM sys_login_log WHERE status=1 AND created_at >= DATE_SUB(NOW(), INTERVAL 30 MINUTE)"));
|
||||||
|
data.put("loginTrend", queryLoginTrend(isSuperAdmin, systemIds));
|
||||||
|
|
||||||
|
// JVM 信息
|
||||||
|
Runtime rt = Runtime.getRuntime();
|
||||||
|
Map<String, Object> serverInfo = new LinkedHashMap<>();
|
||||||
|
serverInfo.put("availableProcessors", rt.availableProcessors());
|
||||||
|
serverInfo.put("totalMemory", rt.totalMemory() / 1024 / 1024 + " MB");
|
||||||
|
serverInfo.put("freeMemory", rt.freeMemory() / 1024 / 1024 + " MB");
|
||||||
|
serverInfo.put("maxMemory", rt.maxMemory() / 1024 / 1024 + " MB");
|
||||||
|
serverInfo.put("javaVersion", System.getProperty("java.version"));
|
||||||
|
serverInfo.put("osName", System.getProperty("os.name"));
|
||||||
|
data.put("serverInfo", serverInfo);
|
||||||
|
|
||||||
|
// 微服务健康状态:用 TCP 端口检测替代 HTTP(快 10 倍)
|
||||||
|
List<Map<String, Object>> services = new ArrayList<>();
|
||||||
|
services.add(checkTcpPort("网关服务 (Gateway)", "127.0.0.1", 9000));
|
||||||
|
services.add(checkTcpPort("认证服务 (Auth)", "127.0.0.1", 9001));
|
||||||
|
Map<String, Object> adminSvc = new LinkedHashMap<>();
|
||||||
|
adminSvc.put("name", "管理服务 (Admin)");
|
||||||
|
adminSvc.put("port", 9002);
|
||||||
|
adminSvc.put("status", "UP");
|
||||||
|
adminSvc.put("responseTime", 0);
|
||||||
|
services.add(adminSvc);
|
||||||
|
data.put("services", services);
|
||||||
|
|
||||||
|
statsCache.put(cacheKey, data);
|
||||||
|
return Result.ok(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 非 super_admin 时统计绑定系统下的用户数(排除网关平台) */
|
||||||
|
private Integer countUsersBySystems(List<String> systemIds) {
|
||||||
|
if (systemIds == null || systemIds.isEmpty()) return 0;
|
||||||
|
String inClause = String.join(",", Collections.nCopies(systemIds.size(), "?"));
|
||||||
|
return jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(DISTINCT u.id) FROM sys_user u " +
|
||||||
|
"INNER JOIN sys_user_system us ON u.id = us.user_id " +
|
||||||
|
"WHERE u.is_deleted=0 AND us.system_id IN (" + inClause + ")",
|
||||||
|
Integer.class, systemIds.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录统计类查询:非 super_admin 追加 system_id IN 过滤 */
|
||||||
|
private Integer queryLoginCount(boolean isSuperAdmin, List<String> systemIds, String baseSql) {
|
||||||
|
if (isSuperAdmin) {
|
||||||
|
return jdbcTemplate.queryForObject(baseSql, Integer.class);
|
||||||
|
}
|
||||||
|
if (systemIds == null || systemIds.isEmpty()) return 0;
|
||||||
|
String inClause = String.join(",", Collections.nCopies(systemIds.size(), "?"));
|
||||||
|
// 在 WHERE 后追加 AND system_id IN (...)
|
||||||
|
String sql = baseSql + " AND system_id IN (" + inClause + ")";
|
||||||
|
return jdbcTemplate.queryForObject(sql, Integer.class, systemIds.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 30天登录趋势:非 super_admin 按 system_id 过滤 */
|
||||||
|
private List<Map<String, Object>> queryLoginTrend(boolean isSuperAdmin, List<String> systemIds) {
|
||||||
|
List<Map<String, Object>> loginTrend = new ArrayList<>();
|
||||||
|
String baseSql = "SELECT DATE(created_at) as day, COUNT(DISTINCT user_id) as cnt " +
|
||||||
|
"FROM sys_login_log WHERE status=1 AND created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)";
|
||||||
|
List<Map<String, Object>> rows;
|
||||||
|
if (isSuperAdmin) {
|
||||||
|
rows = jdbcTemplate.queryForList(baseSql + " GROUP BY DATE(created_at) ORDER BY day");
|
||||||
|
} else {
|
||||||
|
if (systemIds == null || systemIds.isEmpty()) return loginTrend;
|
||||||
|
String inClause = String.join(",", Collections.nCopies(systemIds.size(), "?"));
|
||||||
|
rows = jdbcTemplate.queryForList(
|
||||||
|
baseSql + " AND system_id IN (" + inClause + ") GROUP BY DATE(created_at) ORDER BY day",
|
||||||
|
systemIds.toArray());
|
||||||
|
}
|
||||||
|
for (Map<String, Object> row : rows) {
|
||||||
|
Map<String, Object> point = new LinkedHashMap<>();
|
||||||
|
point.put("date", row.get("day") != null ? row.get("day").toString() : "");
|
||||||
|
point.put("count", row.get("cnt"));
|
||||||
|
loginTrend.add(point);
|
||||||
|
}
|
||||||
|
return loginTrend;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TCP 端口检测:连接成功即 UP(比 HTTP 快 10 倍,不走网关转发) */
|
||||||
|
private Map<String, Object> checkTcpPort(String name, String host, int port) {
|
||||||
|
Map<String, Object> svc = new LinkedHashMap<>();
|
||||||
|
svc.put("name", name);
|
||||||
|
svc.put("port", port);
|
||||||
|
long start = System.currentTimeMillis();
|
||||||
|
try (Socket socket = new Socket()) {
|
||||||
|
socket.connect(new InetSocketAddress(host, port), 2000);
|
||||||
|
svc.put("status", "UP");
|
||||||
|
} catch (Exception e) {
|
||||||
|
svc.put("status", "DOWN");
|
||||||
|
}
|
||||||
|
svc.put("responseTime", System.currentTimeMillis() - start);
|
||||||
|
return svc;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.mokee.admin.controller;
|
||||||
|
|
||||||
|
import com.mokee.admin.service.DictService;
|
||||||
|
import com.mokee.common.Result;
|
||||||
|
import com.mokee.common.annotation.OperationLog;
|
||||||
|
import com.mokee.common.entity.SysDict;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字典管理控制器
|
||||||
|
* <p>
|
||||||
|
* 管理数据字典的完整 CRUD 操作及按类型查询。
|
||||||
|
* <p>
|
||||||
|
* 设计说明:
|
||||||
|
* <ul>
|
||||||
|
* <li>字典是全局配置数据,所有业务系统共享(不区分 systemId)</li>
|
||||||
|
* <li>通过 dictType 字段对字典项进行分组(如 "user_status"、"order_state" 等)</li>
|
||||||
|
* <li>查询字典列表时只返回启用状态(status=1)的字典项</li>
|
||||||
|
* <li>支持按排序号(sortOrder)控制字典项在前端下拉框中的显示顺序</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* 请求路径前缀:{@code /zgapi/v1/admin/dict}
|
||||||
|
*
|
||||||
|
* @author mokee
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/zgapi/v1/admin/dict")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DictController {
|
||||||
|
|
||||||
|
private final DictService dictService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有字典类型列表(去重后按字母排序)
|
||||||
|
* <p>
|
||||||
|
* 遍历全部字典记录,提取唯一的 dictType 值。
|
||||||
|
* 用于前端字典管理页面的左侧类型树或下拉筛选。
|
||||||
|
*
|
||||||
|
* @return 去重后的字典类型字符串列表
|
||||||
|
*/
|
||||||
|
@GetMapping("/type/list")
|
||||||
|
public Result<List<String>> listTypes() {
|
||||||
|
return Result.ok(dictService.listTypes());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按字典类型查询字典项列表
|
||||||
|
* <p>
|
||||||
|
* 只返回启用状态(status=1)且未删除的字典项,按排序号升序排列。
|
||||||
|
* 用于前端下拉框、单选框、多选框等组件的选项数据源。
|
||||||
|
*
|
||||||
|
* @param type 字典类型代码(如 "user_status")
|
||||||
|
* @return 该类型下所有启用的字典项列表,按 sortOrder 升序
|
||||||
|
*/
|
||||||
|
@GetMapping("/type/{type}")
|
||||||
|
public Result<List<SysDict>> listByType(@PathVariable String type) {
|
||||||
|
return Result.ok(dictService.listByType(type));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询单个字典项详情
|
||||||
|
*
|
||||||
|
* @param id 字典项主键ID
|
||||||
|
* @return 字典项实体
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public Result<SysDict> getById(@PathVariable String id) {
|
||||||
|
return Result.ok(dictService.getById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增字典项
|
||||||
|
* <p>
|
||||||
|
* 注意:需确保同一 dictType 下 dictValue 唯一(业务层面约束)。
|
||||||
|
*
|
||||||
|
* @param dict 字典项实体(请求体 JSON)
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@OperationLog(module = "字典管理", action = "新增")
|
||||||
|
@PostMapping
|
||||||
|
public Result<?> add(@RequestBody SysDict dict) {
|
||||||
|
dictService.add(dict);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑字典项
|
||||||
|
* <p>
|
||||||
|
* 路径变量 id 用于指定要更新的字典项,同时将 id 设置到请求体中保证一致性。
|
||||||
|
*
|
||||||
|
* @param id 要更新的字典项主键ID
|
||||||
|
* @param dict 字典项实体(请求体 JSON,更新的字段)
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@OperationLog(module = "字典管理", action = "编辑")
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public Result<?> update(@PathVariable String id, @RequestBody SysDict dict) {
|
||||||
|
// 确保路径中的 id 与实体一致,防止前端传错
|
||||||
|
dict.setId(id);
|
||||||
|
dictService.update(dict);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除字典项(物理删除)
|
||||||
|
* <p>
|
||||||
|
* 注意:字典表使用物理删除而非逻辑删除,删除后不可恢复。
|
||||||
|
*
|
||||||
|
* @param id 字典项主键ID
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@OperationLog(module = "字典管理", action = "删除")
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public Result<?> delete(@PathVariable String id) {
|
||||||
|
dictService.delete(id);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package com.mokee.admin.controller;
|
||||||
|
|
||||||
|
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.service.LogAsyncService;
|
||||||
|
import com.mokee.common.Result;
|
||||||
|
import com.mokee.common.annotation.OperationLog;
|
||||||
|
import com.mokee.common.entity.SysEmailLog;
|
||||||
|
import com.mokee.common.entity.SysOpenTokenLog;
|
||||||
|
import com.mokee.common.service.EmailService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邮件管理控制器
|
||||||
|
* <p>
|
||||||
|
* 提供邮件发送和邮件日志查询功能,同时管理 Open API Token 的获取/消费日志。
|
||||||
|
* <p>
|
||||||
|
* 核心功能:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>发送邮件</b>:支持 HTML 和纯文本两种格式,发送结果记录到 sys_email_log 表</li>
|
||||||
|
* <li><b>邮件日志查询</b>:分页查询历史发送记录,支持按关键词(收件人/主题)和状态过滤</li>
|
||||||
|
* <li><b>Token 日志查询</b>:查询 Open API Token 的获取和消费记录,支持按系统编码和消费状态过滤</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* 所有写操作均记录操作日志(通过 @OperationLog 注解标注)。
|
||||||
|
* <p>
|
||||||
|
* 请求路径前缀:{@code /zgapi/v1/admin/email}
|
||||||
|
*
|
||||||
|
* @author mokee
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/zgapi/v1/admin/email")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class EmailController {
|
||||||
|
|
||||||
|
private final EmailService emailService;
|
||||||
|
private final SysEmailLogMapper emailLogMapper;
|
||||||
|
private final SysOpenTokenLogMapper openTokenLogMapper;
|
||||||
|
private final LogAsyncService logAsyncService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送邮件
|
||||||
|
* <p>
|
||||||
|
* 支持 HTML(默认)和纯文本两种内容类型。
|
||||||
|
* 发送流程:
|
||||||
|
* <ol>
|
||||||
|
* <li>根据请求参数创建邮件日志实体</li>
|
||||||
|
* <li>调用 EmailService 发送邮件</li>
|
||||||
|
* <li>发送成功后记录日志(status=1)</li>
|
||||||
|
* <li>发送失败后记录错误信息(status=0, errorMsg)并返回错误提示</li>
|
||||||
|
* </ol>
|
||||||
|
* 操作通过 @OperationLog 记录到 sys_operation_log 表。
|
||||||
|
*
|
||||||
|
* @param body 请求体,包含字段:to(收件人)、subject(主题)、content(内容)、contentType(html/text,默认html)
|
||||||
|
* @return Result 成功或失败信息
|
||||||
|
*/
|
||||||
|
@OperationLog(module = "邮件管理", action = "发送")
|
||||||
|
@PostMapping("/send")
|
||||||
|
public Result<?> send(@RequestBody Map<String, String> body) {
|
||||||
|
// 解析请求参数
|
||||||
|
String to = body.get("to");
|
||||||
|
String subject = body.get("subject");
|
||||||
|
String content = body.get("content");
|
||||||
|
// contentType 默认为 html
|
||||||
|
String contentType = body.getOrDefault("contentType", "html");
|
||||||
|
|
||||||
|
// 构建邮件日志实体
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
// 发送成功:记录状态为 1
|
||||||
|
log.setStatus(1);
|
||||||
|
logAsyncService.saveEmailLog(log);
|
||||||
|
return Result.ok();
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 发送失败:记录状态为 0,并保存错误信息
|
||||||
|
log.setStatus(0);
|
||||||
|
log.setErrorMsg(e.getMessage());
|
||||||
|
logAsyncService.saveEmailLog(log);
|
||||||
|
return Result.fail(500, "发送失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询邮件发送记录
|
||||||
|
* <p>
|
||||||
|
* 支持按关键词(模糊匹配收件人地址或邮件主题)和发送状态过滤。
|
||||||
|
* 使用 MyBatis-Plus LambdaQueryWrapper 构建动态查询条件。
|
||||||
|
*
|
||||||
|
* @param params 请求参数 Map,支持字段:page, pageSize, keyword(收件人/主题模糊查询), status(0失败/1成功)
|
||||||
|
* @return 分页结果,按创建时间倒序
|
||||||
|
*/
|
||||||
|
@PostMapping("/page")
|
||||||
|
public Result<IPage<SysEmailLog>> page(@RequestBody Map<String, Object> 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 keyword = params.get("keyword") != null ? params.get("keyword").toString() : null;
|
||||||
|
Integer status = params.get("status") != null ? ((Number) params.get("status")).intValue() : null;
|
||||||
|
|
||||||
|
// 构建动态查询条件
|
||||||
|
LambdaQueryWrapper<SysEmailLog> w = new LambdaQueryWrapper<>();
|
||||||
|
if (keyword != null && !keyword.isEmpty()) {
|
||||||
|
// 关键词同时匹配收件人地址和邮件主题(OR 条件)
|
||||||
|
w.and(wr -> wr.like(SysEmailLog::getToAddress, keyword)
|
||||||
|
.or().like(SysEmailLog::getSubject, keyword));
|
||||||
|
}
|
||||||
|
if (status != null) {
|
||||||
|
// 按发送状态过滤:0-失败,1-成功
|
||||||
|
w.eq(SysEmailLog::getStatus, status);
|
||||||
|
}
|
||||||
|
// 按创建时间倒序排列,最新记录在前
|
||||||
|
w.orderByDesc(SysEmailLog::getCreatedAt);
|
||||||
|
return Result.ok(emailLogMapper.selectPage(new Page<>(page, size), w));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查看邮件详情
|
||||||
|
*
|
||||||
|
* @param id 邮件日志主键ID
|
||||||
|
* @return 邮件日志实体(包含完整的邮件内容和错误信息)
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public Result<SysEmailLog> getById(@PathVariable String id) {
|
||||||
|
return Result.ok(emailLogMapper.selectById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除邮件日志记录
|
||||||
|
*
|
||||||
|
* @param id 邮件日志主键ID
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public Result<?> delete(@PathVariable String id) {
|
||||||
|
emailLogMapper.deleteById(id);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询 Open API Token 获取/消费日志
|
||||||
|
* <p>
|
||||||
|
* 用于审计第三方系统通过 Open API 获取和消费 Token 的情况。
|
||||||
|
* 支持按系统编码(精确匹配)和消费状态(consumed: 0-未消费, 1-已消费)过滤。
|
||||||
|
*
|
||||||
|
* @param params 请求参数 Map,支持字段:page, pageSize, systemCode, consumed
|
||||||
|
* @return 分页结果,按获取时间倒序
|
||||||
|
*/
|
||||||
|
@PostMapping("/token-log/page")
|
||||||
|
public Result<IPage<SysOpenTokenLog>> tokenLogPage(@RequestBody Map<String, Object> 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 systemCode = params.get("systemCode") != null ? params.get("systemCode").toString() : null;
|
||||||
|
Integer consumed = params.get("consumed") != null ? ((Number) params.get("consumed")).intValue() : null;
|
||||||
|
|
||||||
|
// 构建动态查询条件
|
||||||
|
LambdaQueryWrapper<SysOpenTokenLog> w = new LambdaQueryWrapper<>();
|
||||||
|
// 按系统编码精确过滤
|
||||||
|
if (systemCode != null && !systemCode.isEmpty()) w.eq(SysOpenTokenLog::getSystemCode, systemCode);
|
||||||
|
// 按消费状态过滤
|
||||||
|
if (consumed != null) w.eq(SysOpenTokenLog::getConsumed, consumed);
|
||||||
|
// 按获取时间倒序排列
|
||||||
|
w.orderByDesc(SysOpenTokenLog::getAcquiredAt);
|
||||||
|
return Result.ok(openTokenLogMapper.selectPage(new Page<>(page, size), w));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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.entity.SysFile;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件控制器 — 仅提供元信息查询
|
||||||
|
* <p>
|
||||||
|
* 文件上传/下载/删除已迁移至外部文件对象存储服务(file.wg.zgitm.com),
|
||||||
|
* 前端直调外部服务,本控制器不再提供这些端点。
|
||||||
|
*
|
||||||
|
* @author mokee
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/zgapi/v1/admin/file")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class FileController {
|
||||||
|
private final FileService fileService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询文件列表(仅元信息,不含二进制数据)
|
||||||
|
*/
|
||||||
|
@PostMapping("/page")
|
||||||
|
public Result<IPage<SysFile>> page(@RequestBody java.util.Map<String, Object> 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件列表查询(不分页)
|
||||||
|
*/
|
||||||
|
@GetMapping("/list")
|
||||||
|
public Result<List<SysFile>> list(@RequestParam(required = false) String fileType,
|
||||||
|
@RequestParam(required = false) String systemId) {
|
||||||
|
return Result.ok(fileService.list(fileType, systemId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件元信息(不含二进制数据)
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public Result<SysFile> getById(@PathVariable String id) {
|
||||||
|
return Result.ok(fileService.getMetaById(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.mokee.admin.controller;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.mokee.admin.service.LogService;
|
||||||
|
import com.mokee.common.Result;
|
||||||
|
import com.mokee.common.entity.SysLoginLog;
|
||||||
|
import com.mokee.common.entity.SysOperationLog;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志管理控制器
|
||||||
|
* <p>
|
||||||
|
* 提供登录日志和操作日志的分页查询接口。
|
||||||
|
* <p>
|
||||||
|
* 数据来源:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>登录日志</b>:由网关在用户登录成功/失败时写入 sys_login_log 表</li>
|
||||||
|
* <li><b>操作日志</b>:由 AOP 切面拦截 @OperationLog 注解自动记录到 sys_operation_log 表</li>
|
||||||
|
* </ul>
|
||||||
|
* 查询条件通过请求体 Map 传递,支持用户名、状态、时间范围等过滤条件。
|
||||||
|
* <p>
|
||||||
|
* 请求路径前缀:{@code /zgapi/v1/admin/log}
|
||||||
|
*
|
||||||
|
* @author mokee
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/zgapi/v1/admin/log")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class LogController {
|
||||||
|
|
||||||
|
private final LogService logService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录日志分页查询
|
||||||
|
* <p>
|
||||||
|
* 支持按用户名精确匹配、登录状态过滤(0失败/1成功)、时间范围过滤。
|
||||||
|
* 查询结果中会通过联表查询填充 systemName 字段(SysLoginLog.systemName 为 @TableField(exist=false) 的 transient 字段)。
|
||||||
|
*
|
||||||
|
* @param params 请求参数 Map,支持字段:page, pageSize, username, status, startTime, endTime
|
||||||
|
* @return 分页结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/login/page")
|
||||||
|
public Result<IPage<SysLoginLog>> loginLogPage(@RequestBody Map<String, Object> params) {
|
||||||
|
return Result.ok(logService.loginLogPage(params));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作日志分页查询
|
||||||
|
* <p>
|
||||||
|
* 支持按操作人用户名精确匹配、操作模块模糊查询、操作类型精确匹配、时间范围过滤。
|
||||||
|
*
|
||||||
|
* @param params 请求参数 Map,支持字段:page, pageSize, username, module, action, startTime, endTime
|
||||||
|
* @return 分页结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/operation/page")
|
||||||
|
public Result<IPage<SysOperationLog>> operationLogPage(@RequestBody Map<String, Object> params) {
|
||||||
|
return Result.ok(logService.operationLogPage(params));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package com.mokee.admin.controller;
|
||||||
|
|
||||||
|
import com.mokee.admin.service.MenuService;
|
||||||
|
import com.mokee.common.Result;
|
||||||
|
import com.mokee.common.annotation.OperationLog;
|
||||||
|
import com.mokee.common.dto.response.MenuRouterVO;
|
||||||
|
import com.mokee.common.entity.SysMenu;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 菜单管理控制器
|
||||||
|
* <p>
|
||||||
|
* 管理各业务系统的菜单树,支持树形查询和 CRUD 操作。
|
||||||
|
* <p>
|
||||||
|
* 设计说明:
|
||||||
|
* <ul>
|
||||||
|
* <li>菜单通过 parentId 字段形成树形结构(根节点 parentId 为 "0")</li>
|
||||||
|
* <li>菜单类型:1-目录(无路由路径)、2-菜单(有路由路径)、3-按钮(权限控制点)</li>
|
||||||
|
* <li>前端使用 MenuRouterVO 构建动态路由,包含名称、路径、组件、图标、排序号、状态等字段</li>
|
||||||
|
* <li>菜单的 sortOrder 控制同级菜单在侧边栏中的显示顺序</li>
|
||||||
|
* <li>菜单的 status 控制是否在前端显示(0-隐藏,1-显示)</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* PUT 方法已修复:使用 {@code @PutMapping("/{id}")} + 手动 setter。
|
||||||
|
* <p>
|
||||||
|
* 请求路径前缀:{@code /zgapi/v1/admin/menu}
|
||||||
|
*
|
||||||
|
* @author mokee
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/zgapi/v1/admin/menu")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MenuController {
|
||||||
|
|
||||||
|
private final MenuService menuService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询指定系统的完整菜单树
|
||||||
|
* <p>
|
||||||
|
* 从数据库查询该系统的所有菜单记录,在内存中递归构建树形结构。
|
||||||
|
* 返回的 MenuRouterVO 包含前端路由所需的全部字段(name, path, component, meta 等)。
|
||||||
|
*
|
||||||
|
* @param systemId 业务系统ID
|
||||||
|
* @return 树形菜单列表(根节点集合),包含嵌套的 children
|
||||||
|
*/
|
||||||
|
@GetMapping("/tree")
|
||||||
|
public Result<List<MenuRouterVO>> getMenuTree(@RequestParam String systemId) {
|
||||||
|
return Result.ok(menuService.getMenuTree(systemId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询菜单详情
|
||||||
|
*
|
||||||
|
* @param id 菜单主键ID
|
||||||
|
* @return 菜单实体
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public Result<SysMenu> getById(@PathVariable String id) {
|
||||||
|
return Result.ok(menuService.getById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增菜单节点
|
||||||
|
* <p>
|
||||||
|
* 自动设置状态为启用,创建和更新时间自动填充。
|
||||||
|
*
|
||||||
|
* @param sysMenu 菜单实体(请求体 JSON)
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@OperationLog(module = "菜单管理", action = "新增")
|
||||||
|
@PostMapping
|
||||||
|
public Result<?> add(@RequestBody SysMenu sysMenu) {
|
||||||
|
menuService.add(sysMenu);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑菜单节点
|
||||||
|
* <p>
|
||||||
|
* 【已修复】使用 {@code @PutMapping("/{id}")} 明确接收路径参数,
|
||||||
|
* 并将 id 手动设置到实体中,避免 MyBatis-Plus updateById 因 id 为 null 而失败。
|
||||||
|
*
|
||||||
|
* @param id 要编辑的菜单主键ID(路径参数)
|
||||||
|
* @param sysMenu 菜单实体(请求体 JSON,包含要更新的字段)
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@OperationLog(module = "菜单管理", action = "编辑")
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public Result<?> update(@PathVariable String id, @RequestBody SysMenu sysMenu) {
|
||||||
|
// 确保实体 id 与路径参数一致
|
||||||
|
sysMenu.setId(id);
|
||||||
|
menuService.update(sysMenu);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除菜单节点
|
||||||
|
* <p>
|
||||||
|
* 注意:删除父菜单不会自动级联删除子菜单,需业务层面保证。
|
||||||
|
*
|
||||||
|
* @param id 菜单主键ID
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@OperationLog(module = "菜单管理", action = "删除")
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public Result<?> delete(@PathVariable String id) {
|
||||||
|
menuService.delete(id);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
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.service.LogAsyncService;
|
||||||
|
import com.mokee.admin.service.SystemCacheService;
|
||||||
|
import com.mokee.common.Result;
|
||||||
|
import com.mokee.common.entity.SysEmailLog;
|
||||||
|
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 lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
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 SystemCacheService systemCacheService;
|
||||||
|
private final EmailService emailService;
|
||||||
|
private final LogAsyncService logAsyncService;
|
||||||
|
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) {
|
||||||
|
long t0 = System.currentTimeMillis();
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
long t1 = System.currentTimeMillis();
|
||||||
|
|
||||||
|
SysSystem system = systemCacheService.getByCode(systemCode);
|
||||||
|
if (system == null) {
|
||||||
|
return Result.fail(401, "systemCode 无效或系统已停用");
|
||||||
|
}
|
||||||
|
long t2 = System.currentTimeMillis();
|
||||||
|
|
||||||
|
String tokenId = UUID.randomUUID().toString();
|
||||||
|
String token = jwtUtil.generateOpenToken(system.getId(), system.getSystemCode(), tokenId, OPEN_TOKEN_TTL_MINUTES * 60L);
|
||||||
|
long t3 = System.currentTimeMillis();
|
||||||
|
|
||||||
|
// 缓存到 Redis
|
||||||
|
Map<String, Object> tokenInfo = Map.of(
|
||||||
|
"systemId", system.getId(),
|
||||||
|
"systemCode", system.getSystemCode(),
|
||||||
|
"systemName", system.getSystemName(),
|
||||||
|
"tokenId", tokenId,
|
||||||
|
"type", "open_api"
|
||||||
|
);
|
||||||
|
redisUtil.set(RedisUtil.key("open:token:" + tokenId), JSONUtil.toJsonStr(tokenInfo), OPEN_TOKEN_TTL_MINUTES, TimeUnit.MINUTES);
|
||||||
|
long t4 = System.currentTimeMillis();
|
||||||
|
|
||||||
|
// 记录获取日志
|
||||||
|
SysOpenTokenLog tokenLog = 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();
|
||||||
|
logAsyncService.saveOpenTokenLog(tokenLog);
|
||||||
|
long t5 = System.currentTimeMillis();
|
||||||
|
|
||||||
|
long total = t5 - t0;
|
||||||
|
log.info("open/token 耗时分解 - 读JSON:{}ms DB:{}ms JWT:{}ms Redis:{}ms 日志:{}ms 总计:{}ms",
|
||||||
|
t1 - t0, t2 - t1, t3 - t2, t4 - t3, t5 - t4, total);
|
||||||
|
|
||||||
|
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(RedisUtil.key("open:token:" + tokenId))) {
|
||||||
|
return Result.fail(401, "Token 不存在或已过期");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已消费
|
||||||
|
if (redisUtil.exists(RedisUtil.key("open:token:" + tokenId) + ":consumed")) {
|
||||||
|
return Result.fail(401, "Token 已被消费");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 Redis 获取 Token 信息
|
||||||
|
String redisJson = redisUtil.get(RedisUtil.key("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)
|
||||||
|
* <p>
|
||||||
|
* 仅从 JWT 中解析 systemId/systemCode,从 Redis 补充 systemName。
|
||||||
|
* 不检查是否已消费,不消耗 Token。用于外部系统拿到 Token 后查询其归属。
|
||||||
|
*/
|
||||||
|
@GetMapping("/token/info")
|
||||||
|
@Operation(summary = "查询Token中的系统信息(不消费)",
|
||||||
|
description = "传入 token 返回所属系统的 systemId/systemCode/systemName。不消费 Token,不检查已消费状态。")
|
||||||
|
public Result<Map<String, Object>> getTokenInfo(@RequestParam("token") String token) {
|
||||||
|
if (token == null || token.isEmpty()) {
|
||||||
|
return Result.fail(400, "缺少 token 参数");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 解析 JWT,获取 systemId/systemCode
|
||||||
|
String systemId;
|
||||||
|
String systemCode;
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
systemId = claims.get("systemId", String.class);
|
||||||
|
systemCode = claims.get("systemCode", String.class);
|
||||||
|
if (systemId == null || systemCode == null) {
|
||||||
|
return Result.fail(401, "Token 中缺少系统信息");
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
return Result.fail(401, "Token 无效或已过期");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 从 Redis 补充 systemName(如有)
|
||||||
|
// tokenId 从 claims 提取,但这里不强制要求 Redis 中存在(已消费的 token Redis 可能已被清理)
|
||||||
|
String systemName = "";
|
||||||
|
try {
|
||||||
|
String tokenId = jwtUtil.getTokenId(
|
||||||
|
jwtUtil.validateToken(token));
|
||||||
|
if (tokenId != null) {
|
||||||
|
String redisJson = redisUtil.get(RedisUtil.key("open:token:" + tokenId));
|
||||||
|
if (redisJson != null) {
|
||||||
|
cn.hutool.json.JSONObject info = cn.hutool.json.JSONUtil.parseObj(redisJson);
|
||||||
|
systemName = info.getStr("systemName", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// Redis 查询失败不影响主流程
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Redis 中无 systemName 时,从数据库查询
|
||||||
|
if (systemName.isEmpty()) {
|
||||||
|
SysSystem sys = systemCacheService.getById(systemId);
|
||||||
|
if (sys != null) {
|
||||||
|
systemName = sys.getSystemName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.ok(Map.of(
|
||||||
|
"systemId", systemId,
|
||||||
|
"systemCode", systemCode,
|
||||||
|
"systemName", systemName
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 标记 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(RedisUtil.key("open:token:" + tokenId))) {
|
||||||
|
return Result.fail(401, "Token 不存在或已过期");
|
||||||
|
}
|
||||||
|
if (redisUtil.exists(RedisUtil.key("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);
|
||||||
|
logAsyncService.saveEmailLog(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());
|
||||||
|
logAsyncService.saveEmailLog(log);
|
||||||
|
// 发送失败不消费 token,允许重试
|
||||||
|
return Result.fail(500, "发送失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ==================== 私有方法 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 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(RedisUtil.key("open:token:" + tokenId))) return null;
|
||||||
|
|
||||||
|
// 检查是否已消费
|
||||||
|
String consumedKey = RedisUtil.key("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(RedisUtil.key("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);
|
||||||
|
logAsyncService.updateOpenTokenLog(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(RedisUtil.key("open:token:" + tokenId))) return null;
|
||||||
|
if (redisUtil.exists(RedisUtil.key("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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package com.mokee.admin.controller;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.mokee.admin.service.RoleService;
|
||||||
|
import com.mokee.common.Result;
|
||||||
|
import com.mokee.common.annotation.OperationLog;
|
||||||
|
import com.mokee.common.entity.SysRole;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色管理控制器
|
||||||
|
* <p>
|
||||||
|
* 管理各业务系统的角色定义,支持 CRUD 和菜单权限分配。
|
||||||
|
* <p>
|
||||||
|
* 重要设计:
|
||||||
|
* <ul>
|
||||||
|
* <li>角色隶属于具体的业务系统(通过 systemId 关联),不同系统的角色相互隔离</li>
|
||||||
|
* <li>角色包含 role_code(如 "super_admin"、"admin"、"user"),用于网关的权限判断</li>
|
||||||
|
* <li>角色通过 sys_role_menu 中间表与菜单关联,实现 RBAC 权限模型</li>
|
||||||
|
* <li>分配菜单权限后会自动清除拥有该角色用户的 Redis 会话,强制重新登录刷新权限</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* PUT 方法已修复:使用 {@code @PutMapping("/{id}")} + 手动 setter,确保路径参数与实体一致。
|
||||||
|
* <p>
|
||||||
|
* 请求路径前缀:{@code /zgapi/v1/admin/role}
|
||||||
|
*
|
||||||
|
* @author mokee
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/zgapi/v1/admin/role")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RoleController {
|
||||||
|
|
||||||
|
private final RoleService roleService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询角色列表
|
||||||
|
* <p>
|
||||||
|
* 支持按 systemId(精确匹配)和 roleName(模糊查询)过滤。
|
||||||
|
* 查询结果中会通过联表查询填充 systemName 字段。
|
||||||
|
*
|
||||||
|
* @param params 请求参数 Map,支持字段:page, pageSize, systemId, roleName
|
||||||
|
* @return 分页结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/page")
|
||||||
|
public Result<IPage<SysRole>> page(@RequestBody Map<String, Object> params) {
|
||||||
|
Integer page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
|
||||||
|
Integer pageSize = params.get("pageSize") != null ? ((Number) params.get("pageSize")).intValue() : 10;
|
||||||
|
String systemId = params.get("systemId") != null ? params.get("systemId").toString() : null;
|
||||||
|
String roleName = params.get("roleName") != null ? params.get("roleName").toString() : null;
|
||||||
|
return Result.ok(roleService.page(page, pageSize, systemId, roleName));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询角色详情
|
||||||
|
*
|
||||||
|
* @param id 角色主键ID
|
||||||
|
* @return 角色实体
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public Result<SysRole> getById(@PathVariable String id) {
|
||||||
|
return Result.ok(roleService.getById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增角色
|
||||||
|
* <p>
|
||||||
|
* 自动设置状态为启用,创建和更新时间自动填充。
|
||||||
|
*
|
||||||
|
* @param sysRole 角色实体(请求体 JSON)
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@OperationLog(module = "角色管理", action = "新增")
|
||||||
|
@PostMapping
|
||||||
|
public Result<?> add(@RequestBody SysRole sysRole) {
|
||||||
|
roleService.add(sysRole);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑角色
|
||||||
|
* <p>
|
||||||
|
* 【已修复】使用 {@code @PutMapping("/{id}")} 明确接收路径参数,
|
||||||
|
* 并将 id 手动设置到实体中,避免 MyBatis-Plus updateById 因 id 为 null 而失败。
|
||||||
|
*
|
||||||
|
* @param id 要编辑的角色主键ID(路径参数)
|
||||||
|
* @param sysRole 角色实体(请求体 JSON,包含要更新的字段)
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@OperationLog(module = "角色管理", action = "编辑")
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public Result<?> update(@PathVariable String id, @RequestBody SysRole sysRole) {
|
||||||
|
// 确保实体 id 与路径参数一致
|
||||||
|
sysRole.setId(id);
|
||||||
|
roleService.update(sysRole);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除角色(逻辑删除,is_deleted 标记为 1)
|
||||||
|
*
|
||||||
|
* @param id 角色主键ID
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@OperationLog(module = "角色管理", action = "删除")
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public Result<?> delete(@PathVariable String id) {
|
||||||
|
roleService.delete(id);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询角色已分配的菜单ID列表
|
||||||
|
* <p>
|
||||||
|
* 用于前端角色编辑页面中回显已选中的菜单树节点。
|
||||||
|
*
|
||||||
|
* @param id 角色主键ID
|
||||||
|
* @return 该角色拥有的菜单ID集合
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}/menu")
|
||||||
|
public Result<List<String>> getRoleMenus(@PathVariable String id) {
|
||||||
|
return Result.ok(roleService.getMenuIdsByRoleId(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为角色分配菜单权限
|
||||||
|
* <p>
|
||||||
|
* 采用全量替换策略:先删除角色所有旧菜单关联,再批量插入新关联。
|
||||||
|
* 分配完成后会清除拥有该角色用户的 Redis 会话,强制重新登录以获取最新菜单权限。
|
||||||
|
*
|
||||||
|
* @param id 角色主键ID
|
||||||
|
* @param menuIds 菜单ID列表(前端提交的完整选中集合)
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@OperationLog(module = "角色管理", action = "分配菜单")
|
||||||
|
@PostMapping("/{id}/menu")
|
||||||
|
public Result<?> assignMenus(@PathVariable String id, @RequestBody List<String> menuIds) {
|
||||||
|
roleService.assignMenus(id, menuIds);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package com.mokee.admin.controller;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.mokee.admin.service.SystemApiService;
|
||||||
|
import com.mokee.common.Result;
|
||||||
|
import com.mokee.common.annotation.OperationLog;
|
||||||
|
import com.mokee.common.entity.SysApi;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统 API 管理控制器
|
||||||
|
* <p>
|
||||||
|
* 管理各业务系统的放行接口列表,支持分页查询和 CRUD。
|
||||||
|
* <p>
|
||||||
|
* 设计说明:
|
||||||
|
* <ul>
|
||||||
|
* <li>每个业务系统可以配置自己的公开 API 列表(无需鉴权的接口)</li>
|
||||||
|
* <li>这些 API 路径存储在 sys_api 表中,由网关在请求匹配时读取</li>
|
||||||
|
* <li>网关会检查请求路径是否在白名单中,在则跳过鉴权直接放行</li>
|
||||||
|
* <li>systemId 可选:不传时查询所有系统的 API,传了则只查指定系统的</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* PUT 方法已修复:使用 {@code @PutMapping("/{id}")} + 手动 setter。
|
||||||
|
* <p>
|
||||||
|
* 请求路径前缀:{@code /zgapi/v1/admin/system/api}
|
||||||
|
*
|
||||||
|
* @author mokee
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/zgapi/v1/admin/system/api")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SystemApiController {
|
||||||
|
|
||||||
|
private final SystemApiService systemApiService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询 API 列表
|
||||||
|
* <p>
|
||||||
|
* systemId 为可选参数:不传时查询所有系统的 API,传了则只查指定系统的。
|
||||||
|
* 用于配合前端系统选择器动态过滤。
|
||||||
|
*
|
||||||
|
* @param params 请求参数 Map,支持字段:page, pageSize, systemId(可选)
|
||||||
|
* @return 分页结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/page")
|
||||||
|
public Result<IPage<SysApi>> page(@RequestBody Map<String, Object> params) {
|
||||||
|
Integer page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
|
||||||
|
Integer pageSize = params.get("pageSize") != null ? ((Number) params.get("pageSize")).intValue() : 10;
|
||||||
|
String systemId = params.get("systemId") != null ? params.get("systemId").toString() : null;
|
||||||
|
return Result.ok(systemApiService.page(page, pageSize, systemId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询单个 API 详情
|
||||||
|
*
|
||||||
|
* @param id API 主键ID
|
||||||
|
* @return API 实体
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public Result<SysApi> getById(@PathVariable String id) {
|
||||||
|
return Result.ok(systemApiService.getById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增 API 放行接口
|
||||||
|
* <p>
|
||||||
|
* 新增后网关会在下次请求匹配时生效(网关从数据库加载白名单)。
|
||||||
|
*
|
||||||
|
* @param sysApi API 实体(请求体 JSON,需包含 systemId、apiPath 等)
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@OperationLog(module = "接口管理", action = "新增")
|
||||||
|
@PostMapping
|
||||||
|
public Result<?> add(@RequestBody SysApi sysApi) {
|
||||||
|
systemApiService.add(sysApi);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑 API 接口
|
||||||
|
* <p>
|
||||||
|
* 【已修复】使用 {@code @PutMapping("/{id}")} 明确接收路径参数,
|
||||||
|
* 并将 id 手动设置到实体中,避免 MyBatis-Plus updateById 因 id 为 null 而失败。
|
||||||
|
*
|
||||||
|
* @param id 要编辑的 API 主键ID(路径参数)
|
||||||
|
* @param sysApi API 实体(请求体 JSON,包含要更新的字段)
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@OperationLog(module = "接口管理", action = "编辑")
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public Result<?> update(@PathVariable String id, @RequestBody SysApi sysApi) {
|
||||||
|
// 确保实体 id 与路径参数一致
|
||||||
|
sysApi.setId(id);
|
||||||
|
systemApiService.update(sysApi);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除 API 接口
|
||||||
|
* <p>
|
||||||
|
* 删除后该路径将不再被网关白名单匹配,下次请求将需要鉴权。
|
||||||
|
*
|
||||||
|
* @param id API 主键ID
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@OperationLog(module = "接口管理", action = "删除")
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public Result<?> delete(@PathVariable String id) {
|
||||||
|
systemApiService.delete(id);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package com.mokee.admin.controller;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import cn.hutool.json.JSONUtil;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.mokee.admin.service.SystemService;
|
||||||
|
import com.mokee.common.Result;
|
||||||
|
import com.mokee.common.annotation.OperationLog;
|
||||||
|
import com.mokee.common.dto.response.RoleInfo;
|
||||||
|
import com.mokee.common.dto.response.SystemVO;
|
||||||
|
import com.mokee.common.entity.SysSystem;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务系统管理控制器
|
||||||
|
* <p>
|
||||||
|
* 管理可接入 Mokee 网关的业务系统,提供 CRUD 和分页查询功能。
|
||||||
|
* <p>
|
||||||
|
* 权限模型:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>super_admin @ gateway</b>:可以查看全部未删除的业务系统(调用 {@link #listAll()})</li>
|
||||||
|
* <li><b>普通管理员 / 应用管理员</b>:只能看到自己绑定的业务系统(调用 {@link #listByUserId(String)})</li>
|
||||||
|
* <li>权限判断依据网关在请求头中注入的 X-System-Code、X-User-Id、X-User-Roles</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* 请求路径前缀:{@code /zgapi/v1/admin/system}
|
||||||
|
*
|
||||||
|
* @author mokee
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/zgapi/v1/admin/system")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SystemController {
|
||||||
|
|
||||||
|
private final SystemService systemService;
|
||||||
|
private final HttpServletRequest request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询系统列表(按角色分级过滤)
|
||||||
|
* <p>
|
||||||
|
* 这是系统选择器/下拉菜单的核心接口。根据当前登录用户的角色决定返回哪些系统:
|
||||||
|
* <ol>
|
||||||
|
* <li>从请求头中解析 X-System-Code、X-User-Id、X-User-Roles</li>
|
||||||
|
* <li>如果当前在 gateway 系统下 且 用户拥有 super_admin 角色 → 返回全部未删除的系统</li>
|
||||||
|
* <li>否则 → 仅返回用户通过 sys_user_system 关联表绑定的系统</li>
|
||||||
|
* </ol>
|
||||||
|
* <p>
|
||||||
|
* 请求头来源:网关在请求转发时,从 Redis TokenInfo 中解析用户信息并注入到请求头。
|
||||||
|
*
|
||||||
|
* @return 包含系统列表的 Result,列表项为 {@link SystemVO}
|
||||||
|
*/
|
||||||
|
@GetMapping("/list")
|
||||||
|
public Result<List<SystemVO>> listAll() {
|
||||||
|
// 从网关注入的请求头中获取权限上下文
|
||||||
|
String systemCode = request.getHeader("X-System-Code");
|
||||||
|
String userId = request.getHeader("X-User-Id");
|
||||||
|
String rolesJson = request.getHeader("X-User-Roles");
|
||||||
|
|
||||||
|
// 判断当前用户是否为 super_admin
|
||||||
|
final boolean isSuperAdmin;
|
||||||
|
if ("gateway".equals(systemCode) && StrUtil.isNotBlank(rolesJson)) {
|
||||||
|
List<RoleInfo> roles = JSONUtil.toList(rolesJson, RoleInfo.class);
|
||||||
|
isSuperAdmin = roles != null && roles.stream()
|
||||||
|
.anyMatch(r -> "super_admin".equals(r.getRoleCode()));
|
||||||
|
} else {
|
||||||
|
isSuperAdmin = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SysSystem> systems;
|
||||||
|
if (isSuperAdmin) {
|
||||||
|
// super_admin → 查询全部未删除的系统
|
||||||
|
systems = systemService.listAll();
|
||||||
|
} else {
|
||||||
|
// 普通用户 → 仅查询绑定的系统
|
||||||
|
systems = StrUtil.isNotBlank(userId) ? systemService.listByUserId(userId) : List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 SysSystem 实体转换为前端需要的 SystemVO(精简字段,不暴露敏感信息)
|
||||||
|
// 非 super_admin 排除网关管理平台(系统下拉框中不应出现 gateway)
|
||||||
|
List<SystemVO> voList = systems.stream()
|
||||||
|
.filter(s -> isSuperAdmin || !"gateway".equals(s.getSystemCode()))
|
||||||
|
.map(s -> new SystemVO(s.getId(), s.getSystemCode(), s.getSystemName(),
|
||||||
|
s.getFrontUrl(), s.getIcon(), s.getDescription()))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
return Result.ok(voList);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过文档令牌查询系统信息(公开接口,无需登录认证)
|
||||||
|
* <p>
|
||||||
|
* 用于文档页面通过 URL 中的 token 参数获取对应的业务系统信息。
|
||||||
|
*
|
||||||
|
* @param token 文档访问令牌(系统创建时自动生成的 16 位 UUID 子串)
|
||||||
|
* @return 系统实体,找不到时返回 null
|
||||||
|
*/
|
||||||
|
@GetMapping("/doc/{token}")
|
||||||
|
public Result<SysSystem> getByDocToken(@PathVariable String token) {
|
||||||
|
return Result.ok(systemService.getByDocToken(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询业务系统列表(支持按名称模糊查询和编码精确查询)
|
||||||
|
*
|
||||||
|
* @param params 请求参数 Map,包含 page(页码)、pageSize(每页条数)、
|
||||||
|
* systemName(系统名称,可选)、systemCode(系统编码,可选)
|
||||||
|
* @return 分页结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/page")
|
||||||
|
public Result<IPage<SysSystem>> page(@RequestBody Map<String, Object> params) {
|
||||||
|
// 安全地从 Map 中提取各参数,提供默认值
|
||||||
|
Integer page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
|
||||||
|
Integer pageSize = params.get("pageSize") != null ? ((Number) params.get("pageSize")).intValue() : 10;
|
||||||
|
String systemName = params.get("systemName") != null ? params.get("systemName").toString() : null;
|
||||||
|
String systemCode = params.get("systemCode") != null ? params.get("systemCode").toString() : null;
|
||||||
|
return Result.ok(systemService.page(page, pageSize, systemName, systemCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询系统详情
|
||||||
|
*
|
||||||
|
* @param id 系统主键ID
|
||||||
|
* @return 系统实体
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public Result<SysSystem> getById(@PathVariable String id) {
|
||||||
|
return Result.ok(systemService.getById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增业务系统
|
||||||
|
* <p>
|
||||||
|
* 系统创建时自动生成文档令牌(docToken),默认状态为启用。
|
||||||
|
*
|
||||||
|
* @param sysSystem 系统实体(请求体 JSON)
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@OperationLog(module = "系统管理", action = "新增")
|
||||||
|
@PostMapping
|
||||||
|
public Result<?> add(@RequestBody SysSystem sysSystem) {
|
||||||
|
systemService.add(sysSystem);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑业务系统
|
||||||
|
* <p>
|
||||||
|
* 使用 PUT 无路径参数方式,id 包含在请求体中。
|
||||||
|
*
|
||||||
|
* @param sysSystem 系统实体(请求体 JSON,id 必填)
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@OperationLog(module = "系统管理", action = "编辑")
|
||||||
|
@PutMapping
|
||||||
|
public Result<?> update(@RequestBody SysSystem sysSystem) {
|
||||||
|
systemService.update(sysSystem);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除业务系统(MyBatis-Plus 逻辑删除,is_deleted 标记为 1)
|
||||||
|
*
|
||||||
|
* @param id 系统主键ID
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@OperationLog(module = "系统管理", action = "删除")
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public Result<?> delete(@PathVariable String id) {
|
||||||
|
systemService.delete(id);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package com.mokee.admin.controller;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.mokee.admin.config.UserContextHolder;
|
||||||
|
import com.mokee.admin.service.UserService;
|
||||||
|
import com.mokee.common.Result;
|
||||||
|
import com.mokee.common.annotation.OperationLog;
|
||||||
|
import com.mokee.common.entity.SysUser;
|
||||||
|
import com.mokee.common.exception.BusinessException;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户管理控制器
|
||||||
|
* <p>
|
||||||
|
* 管理平台用户的完整生命周期,包括 CRUD、绑定/解绑业务系统、分配角色等操作。
|
||||||
|
* <p>
|
||||||
|
* 核心功能:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>分页查询</b>:支持按用户名、真实姓名、邮箱模糊查询</li>
|
||||||
|
* <li><b>新增用户</b>:密码使用 BCrypt 加密存储,默认状态启用</li>
|
||||||
|
* <li><b>编辑用户</b>:不更新密码字段(密码通过独立的密码修改接口处理)</li>
|
||||||
|
* <li><b>绑定系统</b>:用户与业务系统的多对多关联(sys_user_system 中间表)</li>
|
||||||
|
* <li><b>分配角色</b>:用户在某系统下的角色分配(sys_user_role 中间表),先删后增全量替换</li>
|
||||||
|
* <li><b>Redis 同步</b>:编辑用户信息后同步更新 Redis 中的 TokenInfo,确保前端实时获取最新数据</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* 请求路径前缀:{@code /zgapi/v1/admin/user}
|
||||||
|
*
|
||||||
|
* @author mokee
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/zgapi/v1/admin/user")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserController {
|
||||||
|
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询用户列表
|
||||||
|
* <p>
|
||||||
|
* 支持按用户名、真实姓名、邮箱三个字段进行模糊查询,按创建时间倒序排列。
|
||||||
|
* 邮箱过滤参数(email)为扩展的查询条件,用于支持按邮箱搜索用户。
|
||||||
|
*
|
||||||
|
* @param params 请求参数 Map,支持字段:page, pageSize, username, realName, email
|
||||||
|
* @return 分页结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/page")
|
||||||
|
public Result<IPage<SysUser>> page(@RequestBody Map<String, Object> params) {
|
||||||
|
Integer page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
|
||||||
|
Integer pageSize = params.get("pageSize") != null ? ((Number) params.get("pageSize")).intValue() : 10;
|
||||||
|
String username = params.get("username") != null ? params.get("username").toString() : null;
|
||||||
|
String realName = params.get("realName") != null ? params.get("realName").toString() : null;
|
||||||
|
// email 过滤:支持按邮箱搜索用户(例如找回账号场景)
|
||||||
|
String email = params.get("email") != null ? params.get("email").toString() : null;
|
||||||
|
return Result.ok(userService.page(page, pageSize, username, realName, email));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户详情
|
||||||
|
*
|
||||||
|
* @param id 用户主键ID
|
||||||
|
* @return 用户实体
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public Result<SysUser> getById(@PathVariable String id) {
|
||||||
|
return Result.ok(userService.getById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增用户
|
||||||
|
* <p>
|
||||||
|
* 密码在 Service 层使用 BCrypt 加密后存储,不会保存明文密码。
|
||||||
|
* 默认状态为启用(status=1),登录失败次数为 0。
|
||||||
|
*
|
||||||
|
* @param sysUser 用户实体(请求体 JSON,password 为明文,由 Service 层加密)
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@OperationLog(module = "用户管理", action = "新增")
|
||||||
|
@PostMapping
|
||||||
|
public Result<?> add(@RequestBody SysUser sysUser) {
|
||||||
|
String id = userService.add(sysUser);
|
||||||
|
return Result.ok(java.util.Collections.singletonMap("id", id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑用户信息
|
||||||
|
* <p>
|
||||||
|
* 不更新密码字段(密码通过单独的密码修改接口处理)。
|
||||||
|
* 编辑后同步刷新 Redis 中的 TokenInfo,确保前端 fetchUserInfo 拿到最新数据。
|
||||||
|
*
|
||||||
|
* @param sysUser 用户实体(请求体 JSON,id 必填)
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@OperationLog(module = "用户管理", action = "编辑")
|
||||||
|
@PutMapping
|
||||||
|
public Result<?> update(@RequestBody SysUser sysUser) {
|
||||||
|
userService.update(sysUser);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除用户(逻辑删除,is_deleted 标记为 1)
|
||||||
|
*
|
||||||
|
* @param id 用户主键ID
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@OperationLog(module = "用户管理", action = "删除")
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public Result<?> delete(@PathVariable String id) {
|
||||||
|
userService.delete(id);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户绑定业务系统(全量替换)
|
||||||
|
* <p>
|
||||||
|
* 先删除用户所有旧系统绑定,再批量插入新绑定。
|
||||||
|
* 使用数据库事务保证原子性。
|
||||||
|
*
|
||||||
|
* @param id 用户主键ID
|
||||||
|
* @param systemIds 要绑定的系统ID列表
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@OperationLog(module = "用户管理", action = "绑定系统")
|
||||||
|
@PostMapping("/{id}/system")
|
||||||
|
public Result<?> bindSystems(@PathVariable String id, @RequestBody List<String> systemIds) {
|
||||||
|
userService.bindSystems(id, systemIds);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户已绑定的系统ID列表
|
||||||
|
* <p>
|
||||||
|
* 用于前端用户编辑页面回显已绑定的系统。
|
||||||
|
*
|
||||||
|
* @param id 用户主键ID
|
||||||
|
* @return 用户绑定的系统ID列表
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}/system")
|
||||||
|
public Result<List<String>> getUserSystems(@PathVariable String id) {
|
||||||
|
return Result.ok(userService.getUserSystemIds(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户在指定系统下的角色ID列表
|
||||||
|
* <p>
|
||||||
|
* 用于前端用户编辑页面中回显用户在特定系统下已分配的角色。
|
||||||
|
*
|
||||||
|
* @param id 用户主键ID
|
||||||
|
* @param systemId 系统ID
|
||||||
|
* @return 用户在该系统下拥有的角色ID列表
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}/role/{systemId}")
|
||||||
|
public Result<List<String>> getUserRoles(@PathVariable String id, @PathVariable String systemId) {
|
||||||
|
return Result.ok(userService.getUserRoleIds(id, systemId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户解绑所有业务系统
|
||||||
|
* <p>
|
||||||
|
* 清除该用户在 sys_user_system 表中的所有关联记录。
|
||||||
|
* 用于将用户从所有业务系统中移除。
|
||||||
|
*
|
||||||
|
* @param id 用户主键ID
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/{id}/system")
|
||||||
|
public Result<?> unbindSystems(@PathVariable String id) {
|
||||||
|
userService.unbindSystems(id);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员重置用户密码
|
||||||
|
* <p>
|
||||||
|
* 仅 super_admin 角色可调用。接收目标用户 ID 和新密码,
|
||||||
|
* 使用 BCrypt 加密后存储,同时清除目标用户的 Redis 会话使其重新登录。
|
||||||
|
*
|
||||||
|
* @param params 请求参数 Map,包含 id(用户ID)和 password(新密码,明文)
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@OperationLog(module = "用户管理", action = "重置密码")
|
||||||
|
@PutMapping("/password")
|
||||||
|
public Result<?> resetPassword(@RequestBody Map<String, Object> params) {
|
||||||
|
// 仅 super_admin 可重置他人密码
|
||||||
|
if (!UserContextHolder.isSuperAdmin()) {
|
||||||
|
throw new BusinessException(403, "仅超级管理员可重置用户密码");
|
||||||
|
}
|
||||||
|
String userId = params.get("id") != null ? params.get("id").toString() : null;
|
||||||
|
String password = params.get("password") != null ? params.get("password").toString() : null;
|
||||||
|
if (userId == null || password == null || password.isEmpty()) {
|
||||||
|
throw new BusinessException(400, "用户ID和新密码不能为空");
|
||||||
|
}
|
||||||
|
userService.resetPassword(userId, password);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为指定用户在指定系统中分配角色
|
||||||
|
* <p>
|
||||||
|
* 采用先删后增的全量替换策略:删除用户所有旧角色关联,再插入新角色。
|
||||||
|
* 分配后清除用户 Redis 会话,强制重新登录以应用新权限。
|
||||||
|
*
|
||||||
|
* @param id 用户主键ID
|
||||||
|
* @param systemId 系统ID(作为请求参数传递)
|
||||||
|
* @param roleIds 角色ID列表
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/{id}/role")
|
||||||
|
public Result<?> assignRoles(@PathVariable String id,
|
||||||
|
@RequestParam String systemId,
|
||||||
|
@RequestBody List<String> roleIds) {
|
||||||
|
userService.assignRoles(id, systemId, roleIds);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.mokee.admin.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.mokee.common.entity.SysApi;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统 API Mapper
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface SysApiMapper extends BaseMapper<SysApi> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.mokee.admin.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.mokee.common.entity.SysConfig;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统配置 Mapper
|
||||||
|
*
|
||||||
|
* @author mokee
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface SysConfigMapper extends BaseMapper<SysConfig> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.mokee.admin.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.mokee.common.entity.SysDict;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字典 Mapper
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface SysDictMapper extends BaseMapper<SysDict> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.mokee.admin.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.mokee.common.entity.SysEmailLog;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface SysEmailLogMapper extends BaseMapper<SysEmailLog> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.mokee.admin.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.mokee.common.entity.SysFile;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/** 文件 Mapper */
|
||||||
|
@Mapper
|
||||||
|
public interface SysFileMapper extends BaseMapper<SysFile> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.mokee.admin.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.mokee.common.entity.SysLoginLog;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录日志 Mapper
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface SysLoginLogMapper extends BaseMapper<SysLoginLog> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.mokee.admin.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.mokee.common.entity.SysMenu;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 菜单 Mapper
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface SysMenuMapper extends BaseMapper<SysMenu> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.mokee.admin.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.mokee.common.entity.SysOpenTokenLog;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface SysOpenTokenLogMapper extends BaseMapper<SysOpenTokenLog> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.mokee.admin.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.mokee.common.entity.SysOperationLog;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作日志 Mapper
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface SysOperationLogMapper extends BaseMapper<SysOperationLog> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.mokee.admin.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.mokee.common.entity.SysRole;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色 Mapper
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface SysRoleMapper extends BaseMapper<SysRole> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.mokee.admin.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.mokee.common.entity.SysRoleMenu;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色菜单关联 Mapper
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface SysRoleMenuMapper extends BaseMapper<SysRoleMenu> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.mokee.admin.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.mokee.common.entity.SysSystem;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统信息 Mapper
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface SysSystemMapper extends BaseMapper<SysSystem> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.mokee.admin.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.mokee.common.entity.SysUser;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户 Mapper
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface SysUserMapper extends BaseMapper<SysUser> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.mokee.admin.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.mokee.common.entity.SysUserRole;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户角色关联 Mapper
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface SysUserRoleMapper extends BaseMapper<SysUserRole> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.mokee.admin.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.mokee.common.entity.SysUserSystem;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户系统关联 Mapper
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface SysUserSystemMapper extends BaseMapper<SysUserSystem> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.mokee.admin.service;
|
||||||
|
|
||||||
|
import com.mokee.common.entity.SysConfig;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统配置服务接口
|
||||||
|
* <p>
|
||||||
|
* 提供键值对形式的系统配置读写,核心方法 getValue() 从内存缓存读取(O(1)),避免每次查库。
|
||||||
|
*
|
||||||
|
* @author mokee
|
||||||
|
*/
|
||||||
|
public interface ConfigService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取配置值(从内存缓存)
|
||||||
|
*
|
||||||
|
* @param key 配置键
|
||||||
|
* @param defaultValue 默认值(key 不存在时返回)
|
||||||
|
* @return 配置值
|
||||||
|
*/
|
||||||
|
String getValue(String key, String defaultValue);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 key 查询完整实体
|
||||||
|
*/
|
||||||
|
SysConfig getByKey(String key);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询全部配置列表
|
||||||
|
*/
|
||||||
|
List<SysConfig> listAll();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增或更新配置(按 configKey upsert)
|
||||||
|
*/
|
||||||
|
void save(SysConfig config);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除配置
|
||||||
|
*/
|
||||||
|
void delete(String id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新内存缓存(查询全表重新加载)
|
||||||
|
*/
|
||||||
|
void refreshCache();
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.mokee.admin.service;
|
||||||
|
|
||||||
|
import com.mokee.common.entity.SysDict;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字典服务接口
|
||||||
|
* <p>
|
||||||
|
* 提供数据字典的完整 CRUD 操作及按类型查询功能。
|
||||||
|
* <p>
|
||||||
|
* 数据字典是全局配置数据,不区分业务系统。典型应用场景:
|
||||||
|
* <ul>
|
||||||
|
* <li>前端下拉框、单选框、复选框的选项数据源</li>
|
||||||
|
* <li>状态码与显示名称的映射(如 user_status: 0=禁用, 1=启用)</li>
|
||||||
|
* <li>业务类型枚举值的动态配置</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* 查询逻辑:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>listTypes()</b> — 从全表提取去重的 dictType 值,用于前端类型筛选</li>
|
||||||
|
* <li><b>listByType(String)</b> — 按类型查询启用的字典项,按 sortOrder 升序排列</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @author mokee
|
||||||
|
* @see com.mokee.admin.service.impl.DictServiceImpl
|
||||||
|
*/
|
||||||
|
public interface DictService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有字典类型列表(去重后按字母排序)
|
||||||
|
* <p>
|
||||||
|
* 遍历全部字典记录提取唯一的 dictType 值。
|
||||||
|
* 用于前端字典管理的类型筛选下拉框。
|
||||||
|
*
|
||||||
|
* @return 去重并排序后的字典类型字符串列表
|
||||||
|
*/
|
||||||
|
List<String> listTypes();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按字典类型查询启用的字典项列表
|
||||||
|
* <p>
|
||||||
|
* 过滤条件:dictType 匹配 + status=1(启用)。
|
||||||
|
* 排序:按 sortOrder 升序,确保前端下拉框选项顺序可控。
|
||||||
|
*
|
||||||
|
* @param type 字典类型代码(如 "user_status")
|
||||||
|
* @return 该类型下所有启用的字典项,按排序号升序
|
||||||
|
*/
|
||||||
|
List<SysDict> listByType(String type);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据主键ID查询字典项
|
||||||
|
*
|
||||||
|
* @param id 字典项ID
|
||||||
|
* @return 字典项实体,不存在返回 null
|
||||||
|
*/
|
||||||
|
SysDict getById(String id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增字典项
|
||||||
|
* <p>
|
||||||
|
* 注意:需在业务层面确保同一 dictType 下 dictValue 唯一。
|
||||||
|
*
|
||||||
|
* @param dict 字典项实体
|
||||||
|
*/
|
||||||
|
void add(SysDict dict);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑字典项
|
||||||
|
*
|
||||||
|
* @param dict 字典项实体(id 必填)
|
||||||
|
*/
|
||||||
|
void update(SysDict dict);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除字典项(物理删除,不可恢复)
|
||||||
|
* <p>
|
||||||
|
* 注意:与用户/角色/系统的逻辑删除不同,字典表使用物理删除。
|
||||||
|
*
|
||||||
|
* @param id 字典项ID
|
||||||
|
*/
|
||||||
|
void delete(String id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.mokee.admin.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.mokee.common.entity.SysFile;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件服务接口 — 仅提供元信息查询
|
||||||
|
* <p>
|
||||||
|
* 文件上传/下载/删除已迁移至外部文件对象存储服务。
|
||||||
|
*
|
||||||
|
* @author mokee
|
||||||
|
*/
|
||||||
|
public interface FileService {
|
||||||
|
|
||||||
|
/** 分页查询(不含 file_data),支持 fileName/uploadUserName 模糊搜索 */
|
||||||
|
IPage<SysFile> page(Integer page, Integer size, String fileType, String fileName, String uploadUserName, String systemId);
|
||||||
|
|
||||||
|
/** 列表查询(不含 file_data) */
|
||||||
|
List<SysFile> list(String fileType, String systemId);
|
||||||
|
|
||||||
|
/** 查询完整记录(含 file_data 兜底,存量兼容) */
|
||||||
|
SysFile getById(String id);
|
||||||
|
|
||||||
|
/** 查询元信息(不含 file_data) */
|
||||||
|
SysFile getMetaById(String id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.mokee.admin.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.mokee.admin.mapper.SysEmailLogMapper;
|
||||||
|
import com.mokee.admin.mapper.SysOpenTokenLogMapper;
|
||||||
|
import com.mokee.admin.mapper.SysOperationLogMapper;
|
||||||
|
import com.mokee.common.entity.SysEmailLog;
|
||||||
|
import com.mokee.common.entity.SysOpenTokenLog;
|
||||||
|
import com.mokee.common.entity.SysOperationLog;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步日志写入服务
|
||||||
|
* <p>
|
||||||
|
* 将审计日志(Token日志、邮件日志、操作日志)的写入从主业务线程剥离,
|
||||||
|
* 避免数据库日志写入阻塞接口响应,提升接口性能。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class LogAsyncService {
|
||||||
|
|
||||||
|
private final SysOpenTokenLogMapper openTokenLogMapper;
|
||||||
|
private final SysEmailLogMapper emailLogMapper;
|
||||||
|
private final SysOperationLogMapper operationLogMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步写入 Open API Token 获取日志
|
||||||
|
*/
|
||||||
|
@Async("logTaskExecutor")
|
||||||
|
public void saveOpenTokenLog(SysOpenTokenLog logEntity) {
|
||||||
|
try {
|
||||||
|
openTokenLogMapper.insert(logEntity);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("异步写入 Token 日志失败 - tokenId: {}", logEntity.getTokenId(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步更新 Open API Token 日志(标记消费状态)
|
||||||
|
*/
|
||||||
|
@Async("logTaskExecutor")
|
||||||
|
public void updateOpenTokenLog(SysOpenTokenLog logEntity, LambdaQueryWrapper<SysOpenTokenLog> wrapper) {
|
||||||
|
try {
|
||||||
|
openTokenLogMapper.update(logEntity, wrapper);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("异步更新 Token 日志失败 - tokenId: {}", logEntity.getTokenId(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步写入邮件发送日志
|
||||||
|
*/
|
||||||
|
@Async("logTaskExecutor")
|
||||||
|
public void saveEmailLog(SysEmailLog logEntity) {
|
||||||
|
try {
|
||||||
|
emailLogMapper.insert(logEntity);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("异步写入邮件日志失败 - to: {}", logEntity.getToAddress(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步写入操作日志
|
||||||
|
*/
|
||||||
|
@Async("logTaskExecutor")
|
||||||
|
public void saveOperationLog(SysOperationLog logEntity) {
|
||||||
|
try {
|
||||||
|
operationLogMapper.insert(logEntity);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("异步写入操作日志失败 - url: {}", logEntity.getRequestUrl(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.mokee.admin.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.mokee.common.entity.SysLoginLog;
|
||||||
|
import com.mokee.common.entity.SysOperationLog;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志服务接口
|
||||||
|
* <p>
|
||||||
|
* 提供登录日志和操作日志的分页查询功能。
|
||||||
|
* <p>
|
||||||
|
* 设计说明:
|
||||||
|
* <ul>
|
||||||
|
* <li>参数统一使用 {@code Map<String, Object>} 传递,便于前端动态组合查询条件</li>
|
||||||
|
* <li>登录日志查询完成后,需填充 systemName 字段(SysLoginLog.systemName 为数据库不存在的 transient 字段)</li>
|
||||||
|
* <li>操作日志的 module 字段支持模糊查询,action 字段支持精确匹配</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* 日志写入侧:
|
||||||
|
* <ul>
|
||||||
|
* <li>登录日志由网关的登录过滤器写入(不经过此 Service)</li>
|
||||||
|
* <li>操作日志由 AOP 切面(@OperationLog 注解)自动写入</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @author mokee
|
||||||
|
* @see com.mokee.admin.service.impl.LogServiceImpl
|
||||||
|
*/
|
||||||
|
public interface LogService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录日志分页查询
|
||||||
|
* <p>
|
||||||
|
* 支持的查询参数(全部可选):
|
||||||
|
* <ul>
|
||||||
|
* <li>page / pageSize — 分页参数</li>
|
||||||
|
* <li>username — 用户名精确匹配</li>
|
||||||
|
* <li>status — 登录状态过滤(0=失败, 1=成功)</li>
|
||||||
|
* <li>startTime / endTime — 时间范围过滤(格式:yyyy-MM-dd HH:mm:ss)</li>
|
||||||
|
* </ul>
|
||||||
|
* 结果按 created_at 倒序排列(最新登录记录在前)。
|
||||||
|
* 每条记录的 systemName 字段会通过联表查询自动填充。
|
||||||
|
*
|
||||||
|
* @param params 查询参数 Map
|
||||||
|
* @return 分页结果,包含已填充 systemName 的 SysLoginLog 列表
|
||||||
|
*/
|
||||||
|
IPage<SysLoginLog> loginLogPage(Map<String, Object> params);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作日志分页查询
|
||||||
|
* <p>
|
||||||
|
* 支持的查询参数(全部可选):
|
||||||
|
* <ul>
|
||||||
|
* <li>page / pageSize — 分页参数</li>
|
||||||
|
* <li>username — 操作人用户名精确匹配</li>
|
||||||
|
* <li>module — 操作模块模糊查询(LIKE '%module%')</li>
|
||||||
|
* <li>action — 操作类型精确匹配(如 "新增"、"编辑"、"删除")</li>
|
||||||
|
* <li>startTime / endTime — 时间范围过滤</li>
|
||||||
|
* </ul>
|
||||||
|
* 结果按 created_at 倒序排列。
|
||||||
|
*
|
||||||
|
* @param params 查询参数 Map
|
||||||
|
* @return 分页结果
|
||||||
|
*/
|
||||||
|
IPage<SysOperationLog> operationLogPage(Map<String, Object> params);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.mokee.admin.service;
|
||||||
|
|
||||||
|
import com.mokee.common.dto.response.MenuRouterVO;
|
||||||
|
import com.mokee.common.entity.SysMenu;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 菜单管理服务接口
|
||||||
|
* <p>
|
||||||
|
* 管理各业务系统的菜单树,支持树形查询和 CRUD 操作
|
||||||
|
*/
|
||||||
|
public interface MenuService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询指定系统的完整菜单树(递归构建)
|
||||||
|
*
|
||||||
|
* @param systemId 系统ID
|
||||||
|
* @return 菜单路由树列表(根节点为 parentId=0 的菜单)
|
||||||
|
*/
|
||||||
|
List<MenuRouterVO> getMenuTree(String systemId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据主键ID查询菜单详情
|
||||||
|
*
|
||||||
|
* @param id 菜单ID
|
||||||
|
* @return 菜单实体
|
||||||
|
*/
|
||||||
|
SysMenu getById(String id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增菜单(默认状态为启用)
|
||||||
|
*
|
||||||
|
* @param sysMenu 菜单实体
|
||||||
|
*/
|
||||||
|
void add(SysMenu sysMenu);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑菜单信息
|
||||||
|
*
|
||||||
|
* @param sysMenu 菜单实体(根据ID更新)
|
||||||
|
*/
|
||||||
|
void update(SysMenu sysMenu);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除菜单
|
||||||
|
*
|
||||||
|
* @param id 菜单ID
|
||||||
|
*/
|
||||||
|
void delete(String id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.mokee.admin.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.mokee.common.entity.SysRole;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色管理服务接口
|
||||||
|
* <p>
|
||||||
|
* 管理各业务系统的角色,支持分页查询、CRUD 和菜单权限分配
|
||||||
|
*/
|
||||||
|
public interface RoleService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询角色列表
|
||||||
|
*
|
||||||
|
* @param page 当前页码
|
||||||
|
* @param size 每页条数
|
||||||
|
* @param systemId 系统ID(可选)
|
||||||
|
* @param roleName 角色名称(模糊查询,可选)
|
||||||
|
* @return 分页结果
|
||||||
|
*/
|
||||||
|
IPage<SysRole> page(Integer page, Integer size, String systemId, String roleName);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据主键ID查询角色详情
|
||||||
|
*
|
||||||
|
* @param id 角色ID
|
||||||
|
* @return 角色实体
|
||||||
|
*/
|
||||||
|
SysRole getById(String id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增角色(默认状态为启用)
|
||||||
|
*
|
||||||
|
* @param sysRole 角色实体
|
||||||
|
*/
|
||||||
|
void add(SysRole sysRole);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑角色信息
|
||||||
|
*
|
||||||
|
* @param sysRole 角色实体(根据ID更新)
|
||||||
|
*/
|
||||||
|
void update(SysRole sysRole);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除角色(逻辑删除)
|
||||||
|
*
|
||||||
|
* @param id 角色ID
|
||||||
|
*/
|
||||||
|
void delete(String id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为角色分配菜单权限
|
||||||
|
* <p>
|
||||||
|
* 先清除旧菜单关联再插入新关联,实现全量替换
|
||||||
|
*
|
||||||
|
* @param roleId 角色ID
|
||||||
|
* @param menuIds 菜单ID列表
|
||||||
|
*/
|
||||||
|
void assignMenus(String roleId, List<String> menuIds);
|
||||||
|
|
||||||
|
/** 查询角色已分配的菜单ID列表 */
|
||||||
|
List<String> getMenuIdsByRoleId(String roleId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.mokee.admin.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.mokee.common.entity.SysApi;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统 API 服务接口
|
||||||
|
* <p>
|
||||||
|
* 管理各业务系统在网关中放行的 API 接口列表,支持按系统ID查询和 CRUD 操作
|
||||||
|
*/
|
||||||
|
public interface SystemApiService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询指定系统的 API 列表
|
||||||
|
*
|
||||||
|
* @param page 当前页码
|
||||||
|
* @param size 每页条数
|
||||||
|
* @param systemId 系统ID
|
||||||
|
* @return 分页结果
|
||||||
|
*/
|
||||||
|
IPage<SysApi> page(Integer page, Integer size, String systemId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 ID 查询单个 API
|
||||||
|
*/
|
||||||
|
SysApi getById(String id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增 API 接口(默认状态为启用)
|
||||||
|
*
|
||||||
|
* @param sysApi API实体
|
||||||
|
*/
|
||||||
|
void add(SysApi sysApi);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑 API 接口信息
|
||||||
|
*
|
||||||
|
* @param sysApi API实体(根据ID更新)
|
||||||
|
*/
|
||||||
|
void update(SysApi sysApi);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除 API 接口
|
||||||
|
*
|
||||||
|
* @param id API ID
|
||||||
|
*/
|
||||||
|
void delete(String id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.mokee.admin.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.github.benmanes.caffeine.cache.Cache;
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
|
import com.mokee.admin.mapper.SysSystemMapper;
|
||||||
|
import com.mokee.common.entity.SysSystem;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统信息本地缓存服务
|
||||||
|
* <p>
|
||||||
|
* 用 Caffeine 缓存 systemCode → SysSystem 映射,避免每次 Token 请求都跨子网查 DB。
|
||||||
|
* TTL 5 分钟,系统信息变更后可通过 {@link #evict(String)} 主动失效。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SystemCacheService {
|
||||||
|
|
||||||
|
private final SysSystemMapper sysSystemMapper;
|
||||||
|
|
||||||
|
private Cache<String, SysSystem> cache;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
cache = Caffeine.newBuilder()
|
||||||
|
.maximumSize(200)
|
||||||
|
.expireAfterWrite(Duration.ofMinutes(5))
|
||||||
|
.recordStats()
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 systemCode 获取系统信息(优先缓存,缓存未命中时查 DB 并回填)
|
||||||
|
*/
|
||||||
|
public SysSystem getByCode(String systemCode) {
|
||||||
|
SysSystem system = cache.getIfPresent(systemCode);
|
||||||
|
if (system != null) {
|
||||||
|
return system;
|
||||||
|
}
|
||||||
|
system = sysSystemMapper.selectOne(
|
||||||
|
new LambdaQueryWrapper<SysSystem>()
|
||||||
|
.eq(SysSystem::getSystemCode, systemCode)
|
||||||
|
.eq(SysSystem::getStatus, 1)
|
||||||
|
);
|
||||||
|
if (system != null) {
|
||||||
|
cache.put(systemCode, system);
|
||||||
|
}
|
||||||
|
return system;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 ID 获取系统信息(直接查 DB,低频调用不做缓存)
|
||||||
|
*/
|
||||||
|
public SysSystem getById(String systemId) {
|
||||||
|
return sysSystemMapper.selectById(systemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主动失效指定 systemCode 的缓存(系统信息变更后调用)
|
||||||
|
*/
|
||||||
|
public void evict(String systemCode) {
|
||||||
|
cache.invalidate(systemCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.mokee.admin.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.mokee.common.entity.SysSystem;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务系统服务接口
|
||||||
|
* <p>
|
||||||
|
* 定义业务系统的增删改查及分页查询方法。
|
||||||
|
* <p>
|
||||||
|
* 关键方法说明:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>listAll()</b> — 查询全部未删除的系统,仅供 super_admin 角色使用</li>
|
||||||
|
* <li><b>listByUserId(String)</b> — 通过 sys_user_system 中间表 JOIN 查询用户绑定的系统,供普通管理员使用</li>
|
||||||
|
* <li><b>getByDocToken(String)</b> — 公开接口,无需认证,通过文档令牌查询系统信息</li>
|
||||||
|
* <li><b>add(SysSystem)</b> — 新增时自动生成 16 位文档令牌和设置默认状态</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* 权限模型:Controller 层根据用户角色决定调用 listAll 还是 listByUserId,
|
||||||
|
* Service 层不感知权限逻辑,只负责数据查询。
|
||||||
|
*
|
||||||
|
* @author mokee
|
||||||
|
* @see com.mokee.admin.service.impl.SystemServiceImpl
|
||||||
|
*/
|
||||||
|
public interface SystemService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询业务系统列表
|
||||||
|
*
|
||||||
|
* @param page 当前页码(从 1 开始)
|
||||||
|
* @param size 每页条数
|
||||||
|
* @param systemName 系统名称(模糊查询,可选,传 null 则不筛选)
|
||||||
|
* @param systemCode 系统编码(精确查询,可选,传 null 则不筛选)
|
||||||
|
* @return 分页结果,按 sort_order 升序排列
|
||||||
|
*/
|
||||||
|
IPage<SysSystem> page(Integer page, Integer size, String systemName, String systemCode);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据主键ID查询系统详情
|
||||||
|
*
|
||||||
|
* @param id 系统ID(UUID 字符串)
|
||||||
|
* @return 系统实体,不存在返回 null
|
||||||
|
*/
|
||||||
|
SysSystem getById(String id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增业务系统
|
||||||
|
* <p>
|
||||||
|
* 自动设置创建/更新时间、启用状态(status=1)、生成文档访问令牌。
|
||||||
|
*
|
||||||
|
* @param sysSystem 系统实体(id 可为空,由 MyBatis-Plus 自动生成)
|
||||||
|
*/
|
||||||
|
void add(SysSystem sysSystem);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑业务系统信息
|
||||||
|
* <p>
|
||||||
|
* 根据实体中的 id 更新对应记录,自动刷新 updatedAt 时间戳。
|
||||||
|
*
|
||||||
|
* @param sysSystem 系统实体(id 必填,其余字段为要更新的值)
|
||||||
|
*/
|
||||||
|
void update(SysSystem sysSystem);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过文档令牌查询系统信息(公开接口,无需登录认证)
|
||||||
|
* <p>
|
||||||
|
* 只返回启用状态(status=1)的系统。
|
||||||
|
*
|
||||||
|
* @param token 文档访问令牌(系统创建时自动生成的 16 位字符串)
|
||||||
|
* @return 系统实体,不存在或已停用返回 null
|
||||||
|
*/
|
||||||
|
SysSystem getByDocToken(String token);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询全部未删除的系统
|
||||||
|
* <p>
|
||||||
|
* 仅 super_admin 角色使用,返回所有 is_deleted=0 的系统记录,
|
||||||
|
* 按 system_code 升序排列。
|
||||||
|
*
|
||||||
|
* @return 全部未删除的系统列表
|
||||||
|
*/
|
||||||
|
java.util.List<SysSystem> listAll();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户绑定的系统列表
|
||||||
|
* <p>
|
||||||
|
* 通过 INNER JOIN sys_user_system 表查询当前用户有权限访问的系统。
|
||||||
|
* 使用原生 SQL 执行 JOIN 查询,因为 MyBatis-Plus 的关联查询能力有限。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @return 用户绑定的系统列表(按 system_code 升序)
|
||||||
|
*/
|
||||||
|
java.util.List<SysSystem> listByUserId(String userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除业务系统(MyBatis-Plus 逻辑删除)
|
||||||
|
* <p>
|
||||||
|
* 将 is_deleted 标记为 1,不会物理删除记录。
|
||||||
|
*
|
||||||
|
* @param id 系统ID
|
||||||
|
*/
|
||||||
|
void delete(String id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package com.mokee.admin.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.mokee.common.entity.SysUser;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户管理服务接口
|
||||||
|
* <p>
|
||||||
|
* 管理平台用户的完整生命周期,包括分页查询、CRUD、绑定/解绑业务系统、分配角色等操作。
|
||||||
|
* <p>
|
||||||
|
* 核心功能概要:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>分页查询</b>:支持按 username、realName、email 三个字段模糊查询,按创建时间倒序</li>
|
||||||
|
* <li><b>新增用户</b>:密码使用 BCrypt 加密后存储,不会保存明文</li>
|
||||||
|
* <li><b>编辑用户</b>:不更新密码字段(密码通过独立的密码修改接口处理),编辑后同步 Redis TokenInfo</li>
|
||||||
|
* <li><b>绑定系统</b>:全量替换策略(先删旧关联,再插新关联),@Transactional 保证原子性</li>
|
||||||
|
* <li><b>分配角色</b>:先删旧角色,再插新角色,然后清除 Redis 会话强制重新登录</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @author mokee
|
||||||
|
* @see com.mokee.admin.service.impl.UserServiceImpl
|
||||||
|
*/
|
||||||
|
public interface UserService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询用户列表
|
||||||
|
*
|
||||||
|
* @param page 当前页码(从 1 开始)
|
||||||
|
* @param size 每页条数
|
||||||
|
* @param username 用户名(模糊查询,可选)
|
||||||
|
* @param realName 真实姓名(模糊查询,可选)
|
||||||
|
* @param email 邮箱(模糊查询,可选,用于按邮箱搜索用户)
|
||||||
|
* @return 分页结果,按创建时间倒序排列
|
||||||
|
*/
|
||||||
|
IPage<SysUser> page(Integer page, Integer size, String username, String realName, String email);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据主键ID查询用户详情
|
||||||
|
*
|
||||||
|
* @param id 用户ID
|
||||||
|
* @return 用户实体
|
||||||
|
*/
|
||||||
|
SysUser getById(String id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增用户
|
||||||
|
* <p>
|
||||||
|
* 密码在 Service 层使用 BCrypt 加密后存储。
|
||||||
|
* 默认状态为启用(status=1),登录失败次数为 0。
|
||||||
|
*
|
||||||
|
* @param sysUser 用户实体(password 为明文,由 Service 层加密)
|
||||||
|
* @return 新用户的 ID(UUID)
|
||||||
|
*/
|
||||||
|
String add(SysUser sysUser);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑用户信息
|
||||||
|
* <p>
|
||||||
|
* 不更新密码字段(密码通过独立的密码修改接口处理)。
|
||||||
|
* 编辑后同步刷新 Redis 中的 TokenInfo,确保前端实时获取最新用户信息。
|
||||||
|
*
|
||||||
|
* @param sysUser 用户实体(id 必填)
|
||||||
|
*/
|
||||||
|
void update(SysUser sysUser);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除用户(逻辑删除,is_deleted 标记为 1)
|
||||||
|
*
|
||||||
|
* @param id 用户ID
|
||||||
|
*/
|
||||||
|
void delete(String id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为用户绑定业务系统(全量替换)
|
||||||
|
* <p>
|
||||||
|
* 先删除用户所有旧系统绑定,再批量插入新绑定。
|
||||||
|
* 使用 @Transactional 保证原子性。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param systemIds 要绑定的系统ID列表
|
||||||
|
*/
|
||||||
|
void bindSystems(String userId, List<String> systemIds);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解除用户的所有业务系统绑定
|
||||||
|
* <p>
|
||||||
|
* 删除 sys_user_system 表中该用户的所有关联记录。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
*/
|
||||||
|
void unbindSystems(String userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为用户在指定系统中分配角色
|
||||||
|
* <p>
|
||||||
|
* 采用先删后增的全量替换策略:
|
||||||
|
* <ol>
|
||||||
|
* <li>删除该用户的所有旧角色关联(不限于指定系统)</li>
|
||||||
|
* <li>逐条插入新的角色关联</li>
|
||||||
|
* <li>清除用户 Redis 会话,强制重新登录以应用新权限</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param systemId 系统ID
|
||||||
|
* @param roleIds 角色ID列表
|
||||||
|
*/
|
||||||
|
void assignRoles(String userId, String systemId, List<String> roleIds);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户绑定的系统ID列表
|
||||||
|
* <p>
|
||||||
|
* 从 sys_user_system 表中查询该用户的所有关联记录。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @return 系统ID列表
|
||||||
|
*/
|
||||||
|
List<String> getUserSystemIds(String userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户在指定系统下的角色ID列表
|
||||||
|
* <p>
|
||||||
|
* 从 sys_user_role 表中查询该用户的所有角色关联。
|
||||||
|
* 注意:当前实现未按 systemId 过滤(简化处理),返回用户所有角色ID。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param systemId 系统ID(当前未使用,保留参数兼容)
|
||||||
|
* @return 角色ID列表
|
||||||
|
*/
|
||||||
|
List<String> getUserRoleIds(String userId, String systemId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员重置用户密码
|
||||||
|
* <p>
|
||||||
|
* 仅 super_admin 角色可调用。密码使用 BCrypt 加密后存储。
|
||||||
|
*
|
||||||
|
* @param userId 目标用户ID
|
||||||
|
* @param newPassword 新密码(明文,由 Service 层 BCrypt 加密)
|
||||||
|
*/
|
||||||
|
void resetPassword(String userId, String newPassword);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.mokee.admin.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.mokee.admin.mapper.SysConfigMapper;
|
||||||
|
import com.mokee.admin.service.ConfigService;
|
||||||
|
import com.mokee.common.entity.SysConfig;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统配置服务实现
|
||||||
|
* <p>
|
||||||
|
* 使用 ConcurrentHashMap 内存缓存全部配置项,启动时加载。
|
||||||
|
* getValue() 为热点方法,直接从内存读取(O(1),无 DB 查询)。
|
||||||
|
* save/delete 操作后自动刷新缓存。
|
||||||
|
*
|
||||||
|
* @author mokee
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ConfigServiceImpl implements ConfigService {
|
||||||
|
|
||||||
|
private final SysConfigMapper configMapper;
|
||||||
|
|
||||||
|
/** 内存缓存:configKey → configValue */
|
||||||
|
private final Map<String, String> cache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
refreshCache();
|
||||||
|
log.info("系统配置缓存已加载 — {} 项", cache.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getValue(String key, String defaultValue) {
|
||||||
|
return cache.getOrDefault(key, defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SysConfig getByKey(String key) {
|
||||||
|
return configMapper.selectOne(
|
||||||
|
new LambdaQueryWrapper<SysConfig>().eq(SysConfig::getConfigKey, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<SysConfig> listAll() {
|
||||||
|
return configMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<SysConfig>().orderByAsc(SysConfig::getConfigKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void save(SysConfig config) {
|
||||||
|
// 按 configKey 查重,存在则更新
|
||||||
|
SysConfig existing = configMapper.selectOne(
|
||||||
|
new LambdaQueryWrapper<SysConfig>().eq(SysConfig::getConfigKey, config.getConfigKey()));
|
||||||
|
if (existing != null) {
|
||||||
|
config.setId(existing.getId());
|
||||||
|
config.setUpdatedAt(LocalDateTime.now());
|
||||||
|
configMapper.updateById(config);
|
||||||
|
} else {
|
||||||
|
config.setUpdatedAt(LocalDateTime.now());
|
||||||
|
configMapper.insert(config);
|
||||||
|
}
|
||||||
|
refreshCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void delete(String id) {
|
||||||
|
configMapper.deleteById(id);
|
||||||
|
refreshCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void refreshCache() {
|
||||||
|
cache.clear();
|
||||||
|
List<SysConfig> all = configMapper.selectList(null);
|
||||||
|
for (SysConfig c : all) {
|
||||||
|
if (c.getConfigValue() != null) {
|
||||||
|
cache.put(c.getConfigKey(), c.getConfigValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package com.mokee.admin.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.mokee.admin.mapper.SysDictMapper;
|
||||||
|
import com.mokee.admin.service.DictService;
|
||||||
|
import com.mokee.common.entity.SysDict;
|
||||||
|
import com.mokee.common.exception.BusinessException;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字典服务实现
|
||||||
|
* <p>
|
||||||
|
* 基于 MyBatis-Plus 实现数据字典的 CRUD 和按类型查询。
|
||||||
|
* <p>
|
||||||
|
* 实现细节:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>listTypes()</b>:先查询全表,再在内存中做流式去重和排序。
|
||||||
|
* 全表数据量通常很小(几百条),内存处理性能足够。</li>
|
||||||
|
* <li><b>listByType()</b>:使用 LambdaQueryWrapper 精确过滤 dictType 和 status,
|
||||||
|
* 按 sortOrder 升序排列。前端下拉框按照此顺序展示。</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @author mokee
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DictServiceImpl implements DictService {
|
||||||
|
|
||||||
|
private final SysDictMapper sysDictMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有字典类型列表(去重后按字母排序)
|
||||||
|
* <p>
|
||||||
|
* 实现方式:查询全表 → 提取 dictType → 去重 → 排序。
|
||||||
|
* 假设字典表数据量不大(通常 < 500 条),全表扫描性能可接受。
|
||||||
|
*
|
||||||
|
* @return 去重并排序后的字典类型列表
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<String> listTypes() {
|
||||||
|
return sysDictMapper.selectList(null).stream()
|
||||||
|
.map(SysDict::getDictType) // 提取字典类型字段
|
||||||
|
.distinct() // 去重
|
||||||
|
.sorted() // 按字母排序
|
||||||
|
.collect(java.util.stream.Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按字典类型查询启用的字典项,按排序号升序排列
|
||||||
|
* <p>
|
||||||
|
* 过滤条件:
|
||||||
|
* <ul>
|
||||||
|
* <li>dictType 精确匹配</li>
|
||||||
|
* <li>status = 1(仅返回启用状态的字典项)</li>
|
||||||
|
* </ul>
|
||||||
|
* 排序:sortOrder 升序,数值小的排在前面。
|
||||||
|
*
|
||||||
|
* @param type 字典类型代码
|
||||||
|
* @return 该类型的启用字典项列表
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<SysDict> listByType(String type) {
|
||||||
|
LambdaQueryWrapper<SysDict> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.eq(SysDict::getDictType, type); // 按字典类型精确匹配
|
||||||
|
wrapper.eq(SysDict::getStatus, 1); // 只返回启用状态的项
|
||||||
|
wrapper.orderByAsc(SysDict::getSortOrder); // 按排序号升序
|
||||||
|
return sysDictMapper.selectList(wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据主键ID查询字典项
|
||||||
|
*
|
||||||
|
* @param id 字典项ID
|
||||||
|
* @return 字典项实体
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public SysDict getById(String id) {
|
||||||
|
return sysDictMapper.selectById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增字典项
|
||||||
|
*
|
||||||
|
* @param dict 字典项实体
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void add(SysDict dict) {
|
||||||
|
// 检查同一字典类型下值唯一性
|
||||||
|
if (sysDictMapper.selectCount(
|
||||||
|
new LambdaQueryWrapper<SysDict>()
|
||||||
|
.eq(SysDict::getDictType, dict.getDictType())
|
||||||
|
.eq(SysDict::getDictValue, dict.getDictValue())) > 0) {
|
||||||
|
throw new BusinessException("该字典类型下值已存在: " + dict.getDictType() + " / " + dict.getDictValue());
|
||||||
|
}
|
||||||
|
sysDictMapper.insert(dict);
|
||||||
|
log.info("新增字典项 - dictType: {}, dictLabel: {}", dict.getDictType(), dict.getDictLabel());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑字典项
|
||||||
|
* <p>
|
||||||
|
* 根据实体中的 id 更新对应记录。
|
||||||
|
*
|
||||||
|
* @param dict 字典项实体(id 必填)
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void update(SysDict dict) {
|
||||||
|
sysDictMapper.updateById(dict);
|
||||||
|
log.info("编辑字典项 - id: {}", dict.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除字典项(物理删除,直接从数据库中移除)
|
||||||
|
* <p>
|
||||||
|
* 注意:与系统的其他实体不同,字典表不使用逻辑删除。
|
||||||
|
* 删除后数据不可恢复。
|
||||||
|
*
|
||||||
|
* @param id 字典项ID
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void delete(String id) {
|
||||||
|
sysDictMapper.deleteById(id);
|
||||||
|
log.info("删除字典项 - id: {}", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package com.mokee.admin.service.impl;
|
||||||
|
|
||||||
|
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.config.UserContextHolder;
|
||||||
|
import com.mokee.admin.mapper.SysFileMapper;
|
||||||
|
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 java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件服务实现 — 仅提供元信息查询
|
||||||
|
* <p>
|
||||||
|
* 文件上传/下载/删除已迁移至外部文件对象存储服务(file.wg.zgitm.com),
|
||||||
|
* 外部服务直接写入 sys_file 表,本服务只做查询。
|
||||||
|
*
|
||||||
|
* @author mokee
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class FileServiceImpl implements FileService {
|
||||||
|
|
||||||
|
private final SysFileMapper sysFileMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IPage<SysFile> page(Integer page, Integer size, String fileType,
|
||||||
|
String fileName, String uploadUserName, String systemId) {
|
||||||
|
LambdaQueryWrapper<SysFile> 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);
|
||||||
|
// 数据权限:非 super_admin 只能看到绑定系统的文件
|
||||||
|
if (!UserContextHolder.isSuperAdmin()) {
|
||||||
|
List<String> scope = UserContextHolder.getSystemIds();
|
||||||
|
if (scope != null && !scope.isEmpty()) {
|
||||||
|
w.in(SysFile::getSystemId, scope);
|
||||||
|
} else {
|
||||||
|
w.eq(SysFile::getSystemId, "__NONE__");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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<SysFile> list(String fileType, String systemId) {
|
||||||
|
LambdaQueryWrapper<SysFile> 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<SysFile> w = new LambdaQueryWrapper<>();
|
||||||
|
w.eq(SysFile::getId, id);
|
||||||
|
w.select(SysFile.class, f -> !"file_data".equals(f.getColumn()));
|
||||||
|
return sysFileMapper.selectOne(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package com.mokee.admin.service.impl;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
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.config.UserContextHolder;
|
||||||
|
import com.mokee.admin.mapper.SysLoginLogMapper;
|
||||||
|
import com.mokee.admin.mapper.SysOperationLogMapper;
|
||||||
|
import com.mokee.admin.service.LogService;
|
||||||
|
import com.mokee.common.entity.SysLoginLog;
|
||||||
|
import com.mokee.common.entity.SysOperationLog;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志服务实现
|
||||||
|
* <p>
|
||||||
|
* 基于 MyBatis-Plus 实现登录日志和操作日志的分页查询。
|
||||||
|
* <p>
|
||||||
|
* 关键实现细节:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>参数提取</b>:使用私有辅助方法 intVal/strVal 安全地从 Map 中提取参数并处理 null 情况</li>
|
||||||
|
* <li><b>登录日志 systemName 填充</b>:查询分页结果后,遍历每条记录,通过 systemId 联表查询 sys_system 表获取系统名称,
|
||||||
|
* 填充到 SysLoginLog 的 transient 字段 systemName 中。每条记录单独查询,在数据量较小时可接受,
|
||||||
|
* 大数据量场景可优化为批量查询(一次性查出所有 systemId 对应的名称后填充)</li>
|
||||||
|
* <li><b>操作日志 module 模糊查询</b>:使用 LIKE 匹配,支持部分关键词搜索</li>
|
||||||
|
* <li><b>时间范围过滤</b>:使用 ge(>= startTime)和 le(<= endTime),支持只传开始或只传结束时间</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @author mokee
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class LogServiceImpl implements LogService {
|
||||||
|
|
||||||
|
private final SysLoginLogMapper sysLoginLogMapper;
|
||||||
|
private final SysOperationLogMapper sysOperationLogMapper;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从参数 Map 中安全获取整数值
|
||||||
|
* <p>
|
||||||
|
* 处理逻辑:key 不存在或值为 null → 返回默认值;值存在 → 转为 int。
|
||||||
|
*
|
||||||
|
* @param p 参数 Map
|
||||||
|
* @param key 参数键名
|
||||||
|
* @param fallback 默认值(当 key 不存在或值为 null 时返回)
|
||||||
|
* @return 整数值
|
||||||
|
*/
|
||||||
|
private int intVal(Map<String, Object> p, String key, int fallback) {
|
||||||
|
Object v = p.get(key);
|
||||||
|
return v != null ? ((Number) v).intValue() : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从参数 Map 中安全获取字符串值
|
||||||
|
*
|
||||||
|
* @param p 参数 Map
|
||||||
|
* @param key 参数键名
|
||||||
|
* @return 字符串值,key 不存在或值为 null 时返回 null
|
||||||
|
*/
|
||||||
|
private String strVal(Map<String, Object> p, String key) {
|
||||||
|
Object v = p.get(key);
|
||||||
|
return v != null ? v.toString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录日志分页查询
|
||||||
|
* <p>
|
||||||
|
* 查询流程:
|
||||||
|
* <ol>
|
||||||
|
* <li>从 params 中提取分页和过滤参数</li>
|
||||||
|
* <li>使用 LambdaQueryWrapper 构建动态查询条件(只添加非空的过滤条件)</li>
|
||||||
|
* <li>执行分页查询(按创建时间倒序)</li>
|
||||||
|
* <li>遍历结果集,通过 systemId 查询 sys_system 表填充 systemName 字段</li>
|
||||||
|
* </ol>
|
||||||
|
* <p>
|
||||||
|
* systemName 填充说明:
|
||||||
|
* SysLoginLog.systemName 是 @TableField(exist=false) 标记的 transient 字段,
|
||||||
|
* 不存储在数据库中。前端展示时需要显示系统名称而非系统ID,
|
||||||
|
* 因此在查询后通过 JdbcTemplate 逐条查询补充。
|
||||||
|
* 异常被忽略(catch ignored),即使某条记录的系统已删除也不会影响整体查询。
|
||||||
|
*
|
||||||
|
* @param params 查询参数 Map
|
||||||
|
* @return 分页结果,包含已填充 systemName 的日志列表
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public IPage<SysLoginLog> loginLogPage(Map<String, Object> params) {
|
||||||
|
int page = intVal(params, "page", 1);
|
||||||
|
int size = intVal(params, "pageSize", 10);
|
||||||
|
String username = strVal(params, "username");
|
||||||
|
String status = strVal(params, "status");
|
||||||
|
String startTime = strVal(params, "startTime");
|
||||||
|
String endTime = strVal(params, "endTime");
|
||||||
|
|
||||||
|
// 构建动态查询条件
|
||||||
|
LambdaQueryWrapper<SysLoginLog> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
if (StrUtil.isNotBlank(username)) {
|
||||||
|
// 用户名精确匹配
|
||||||
|
wrapper.eq(SysLoginLog::getUsername, username);
|
||||||
|
}
|
||||||
|
if (StrUtil.isNotBlank(status)) {
|
||||||
|
// 登录状态过滤:前端传递字符串 "0" 或 "1",转为整数
|
||||||
|
wrapper.eq(SysLoginLog::getStatus, Integer.parseInt(status));
|
||||||
|
}
|
||||||
|
if (StrUtil.isNotBlank(startTime)) {
|
||||||
|
// 起始时间 >= startTime
|
||||||
|
wrapper.ge(SysLoginLog::getCreatedAt, startTime);
|
||||||
|
}
|
||||||
|
if (StrUtil.isNotBlank(endTime)) {
|
||||||
|
// 结束时间 <= endTime
|
||||||
|
wrapper.le(SysLoginLog::getCreatedAt, endTime);
|
||||||
|
}
|
||||||
|
// 数据权限:非 super_admin 只能查看绑定系统的日志
|
||||||
|
if (!UserContextHolder.isSuperAdmin()) {
|
||||||
|
java.util.List<String> scope = UserContextHolder.getSystemIds();
|
||||||
|
if (scope != null && !scope.isEmpty()) {
|
||||||
|
wrapper.in(SysLoginLog::getSystemId, scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wrapper.orderByDesc(SysLoginLog::getCreatedAt);
|
||||||
|
IPage<SysLoginLog> result = sysLoginLogMapper.selectPage(new Page<>(page, size), wrapper);
|
||||||
|
|
||||||
|
// 遍历结果集,逐条填充系统名称(SysLoginLog.systemName 为 transient 字段)
|
||||||
|
for (SysLoginLog log : result.getRecords()) {
|
||||||
|
if (StrUtil.isNotBlank(log.getSystemId())) {
|
||||||
|
try {
|
||||||
|
// 通过 systemId 查询 sys_system 表获取系统名称
|
||||||
|
String name = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT system_name FROM sys_system WHERE id=? AND is_deleted=0",
|
||||||
|
String.class, log.getSystemId());
|
||||||
|
log.setSystemName(name);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// 忽略单条查询异常(如系统已被删除),不影响整体分页结果
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作日志分页查询
|
||||||
|
* <p>
|
||||||
|
* 支持按操作人用户名(精确)、操作模块(模糊)、操作类型(精确)、时间范围过滤。
|
||||||
|
* 操作日志不需要填充 systemName,因为操作日志表本身不关联系统。
|
||||||
|
*
|
||||||
|
* @param params 查询参数 Map
|
||||||
|
* @return 分页结果,按创建时间倒序
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public IPage<SysOperationLog> operationLogPage(Map<String, Object> params) {
|
||||||
|
int page = intVal(params, "page", 1);
|
||||||
|
int size = intVal(params, "pageSize", 10);
|
||||||
|
String username = strVal(params, "username");
|
||||||
|
String module = strVal(params, "module");
|
||||||
|
String action = strVal(params, "action");
|
||||||
|
String startTime = strVal(params, "startTime");
|
||||||
|
String endTime = strVal(params, "endTime");
|
||||||
|
|
||||||
|
// 构建动态查询条件
|
||||||
|
LambdaQueryWrapper<SysOperationLog> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
if (StrUtil.isNotBlank(username)) {
|
||||||
|
// 操作人用户名精确匹配
|
||||||
|
wrapper.eq(SysOperationLog::getUsername, username);
|
||||||
|
}
|
||||||
|
if (StrUtil.isNotBlank(module)) {
|
||||||
|
// 操作模块模糊查询:LIKE '%module%',支持部分关键词搜索
|
||||||
|
wrapper.like(SysOperationLog::getModule, module);
|
||||||
|
}
|
||||||
|
if (StrUtil.isNotBlank(action)) {
|
||||||
|
// 操作类型精确匹配(如 "新增"、"编辑"、"删除")
|
||||||
|
wrapper.eq(SysOperationLog::getAction, action);
|
||||||
|
}
|
||||||
|
if (StrUtil.isNotBlank(startTime)) {
|
||||||
|
wrapper.ge(SysOperationLog::getCreatedAt, startTime);
|
||||||
|
}
|
||||||
|
if (StrUtil.isNotBlank(endTime)) {
|
||||||
|
wrapper.le(SysOperationLog::getCreatedAt, endTime);
|
||||||
|
}
|
||||||
|
// 操作日志不做数据权限过滤(所有角色可见全部操作日志)
|
||||||
|
wrapper.orderByDesc(SysOperationLog::getCreatedAt);
|
||||||
|
return sysOperationLogMapper.selectPage(new Page<>(page, size), wrapper);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package com.mokee.admin.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.mokee.admin.config.UserContextHolder;
|
||||||
|
import com.mokee.admin.mapper.SysMenuMapper;
|
||||||
|
import com.mokee.admin.service.MenuService;
|
||||||
|
import com.mokee.common.dto.response.MenuMetaVO;
|
||||||
|
import com.mokee.common.dto.response.MenuRouterVO;
|
||||||
|
import com.mokee.common.entity.SysMenu;
|
||||||
|
import com.mokee.common.exception.BusinessException;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 菜单管理服务实现
|
||||||
|
* <p>
|
||||||
|
* 支持树形菜单查询和 CRUD 操作。
|
||||||
|
* <p>
|
||||||
|
* 关键实现细节:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>菜单树构建 buildMenuTree()</b>:递归算法,从根节点(parentId="0")开始,
|
||||||
|
* 遍历全部菜单列表,找出每个节点的子节点,递归构建子树。</li>
|
||||||
|
* <li><b>sortOrder 排序</b>:同级菜单按 sortOrder 升序排列,控制侧边栏菜单的显示顺序。
|
||||||
|
* 数据库查询时已排序,递归构建时保持该顺序。</li>
|
||||||
|
* <li><b>status 状态</b>:MenuRouterVO 会传递 status 字段给前端,前端根据 status 决定是否
|
||||||
|
* 渲染该菜单路由(0=隐藏,1=显示)。但当前实现未在查询时过滤 status,所有菜单都返回给前端。</li>
|
||||||
|
* <li><b>MenuMetaVO</b>:包含菜单名称和图标,用于前端路由的 meta 信息。</li>
|
||||||
|
* <li><b>children 为 null</b>:叶子节点(无子节点)时 children 设为 null 而非空列表,
|
||||||
|
* 前端据此判断是否渲染展开箭头。</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @author mokee
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MenuServiceImpl implements MenuService {
|
||||||
|
|
||||||
|
private final SysMenuMapper sysMenuMapper;
|
||||||
|
|
||||||
|
/** 校验 systemId 是否在用户的绑定系统范围内 */
|
||||||
|
private void checkSystemScope(String systemId) {
|
||||||
|
if (!UserContextHolder.isSuperAdmin()) {
|
||||||
|
List<String> scope = UserContextHolder.getSystemIds();
|
||||||
|
if (scope == null || scope.isEmpty() || !scope.contains(systemId)) {
|
||||||
|
throw new BusinessException(403, "无权访问该系统数据");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询指定系统的完整菜单树
|
||||||
|
* <p>
|
||||||
|
* 查询流程:
|
||||||
|
* <ol>
|
||||||
|
* <li>从数据库查询该系统下所有菜单(按 sortOrder 排序)</li>
|
||||||
|
* <li>调用 buildMenuTree 从根节点(parentId="0")递归构建树形结构</li>
|
||||||
|
* <li>返回根节点列表(每个根节点包含嵌套的子树)</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* @param systemId 业务系统ID
|
||||||
|
* @return 树形菜单列表(根节点集合),每个节点包含 children 子树
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<MenuRouterVO> getMenuTree(String systemId) {
|
||||||
|
checkSystemScope(systemId);
|
||||||
|
// 查询指定系统的所有菜单,按 sortOrder 排序
|
||||||
|
LambdaQueryWrapper<SysMenu> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.eq(SysMenu::getSystemId, systemId);
|
||||||
|
wrapper.orderByAsc(SysMenu::getSortOrder); // 按排序号升序
|
||||||
|
List<SysMenu> menus = sysMenuMapper.selectList(wrapper);
|
||||||
|
|
||||||
|
// 从根节点(parentId="0")开始递归构建树
|
||||||
|
return buildMenuTree(menus, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归构建菜单树
|
||||||
|
* <p>
|
||||||
|
* 算法说明:
|
||||||
|
* <ol>
|
||||||
|
* <li>遍历 allMenus 列表,筛选出 parentId 匹配的节点</li>
|
||||||
|
* <li>为每个节点创建 MenuRouterVO 并填充字段</li>
|
||||||
|
* <li>递归调用自身构建子节点树</li>
|
||||||
|
* <li>如果子节点列表为空,children 设为 null(前端据此判断是否显示展开箭头)</li>
|
||||||
|
* </ol>
|
||||||
|
* 注意:此算法时间复杂度为 O(n^2),在菜单数量较少(< 500)时性能可接受。
|
||||||
|
* 大规模场景可优化为:先用 Map<parentId, List<Menu>> 分组,再构建树(O(n))。
|
||||||
|
*
|
||||||
|
* @param allMenus 该系统的全部菜单列表
|
||||||
|
* @param parentId 当前层级的父节点ID
|
||||||
|
* @return 当前层级下的菜单节点列表(MenuRouterVO)
|
||||||
|
*/
|
||||||
|
private List<MenuRouterVO> buildMenuTree(List<SysMenu> allMenus, String parentId) {
|
||||||
|
List<MenuRouterVO> list = new ArrayList<>();
|
||||||
|
for (SysMenu menu : allMenus) {
|
||||||
|
// 找到当前父节点下的子节点
|
||||||
|
if (menu.getParentId().equals(parentId)) {
|
||||||
|
MenuRouterVO vo = new MenuRouterVO();
|
||||||
|
// 基础信息
|
||||||
|
vo.setId(menu.getId());
|
||||||
|
vo.setMenuName(menu.getMenuName());
|
||||||
|
vo.setMenuType(menu.getMenuType()); // 1=目录, 2=菜单, 3=按钮
|
||||||
|
vo.setIcon(menu.getIcon());
|
||||||
|
vo.setPermission(menu.getPermission()); // 权限标识(如 "system:user:list")
|
||||||
|
// 路由信息
|
||||||
|
vo.setPath(menu.getMenuPath()); // 前端路由路径
|
||||||
|
vo.setName(menu.getMenuName()); // 路由名称(兼容旧字段)
|
||||||
|
vo.setComponent(menu.getComponent()); // Vue 组件路径
|
||||||
|
// 排序和状态
|
||||||
|
vo.setSortOrder(menu.getSortOrder()); // 控制菜单排列顺序
|
||||||
|
vo.setStatus(menu.getStatus()); // 0=隐藏, 1=显示
|
||||||
|
// 元数据(前端路由 meta)
|
||||||
|
vo.setMeta(new MenuMetaVO(menu.getMenuName(), menu.getIcon()));
|
||||||
|
// 递归构建子节点
|
||||||
|
List<MenuRouterVO> children = buildMenuTree(allMenus, menu.getId());
|
||||||
|
// 叶子节点 children 设为 null(而非空列表),前端据此判断不显示展开箭头
|
||||||
|
vo.setChildren(children.isEmpty() ? null : children);
|
||||||
|
list.add(vo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID查询菜单详情
|
||||||
|
*
|
||||||
|
* @param id 菜单ID
|
||||||
|
* @return 菜单实体
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public SysMenu getById(String id) {
|
||||||
|
return sysMenuMapper.selectById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增菜单节点
|
||||||
|
* <p>
|
||||||
|
* 自动设置状态为启用(status=1),创建和更新时间自动填充。
|
||||||
|
*
|
||||||
|
* @param sysMenu 菜单实体
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void add(SysMenu sysMenu) {
|
||||||
|
checkSystemScope(sysMenu.getSystemId());
|
||||||
|
sysMenu.setStatus(1); // 默认启用
|
||||||
|
sysMenu.setCreatedAt(LocalDateTime.now());
|
||||||
|
sysMenu.setUpdatedAt(LocalDateTime.now());
|
||||||
|
sysMenuMapper.insert(sysMenu);
|
||||||
|
log.info("新增菜单 - menuName: {}", sysMenu.getMenuName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新菜单信息
|
||||||
|
* <p>
|
||||||
|
* 自动刷新更新时间戳。
|
||||||
|
*
|
||||||
|
* @param sysMenu 菜单实体(id 必填)
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void update(SysMenu sysMenu) {
|
||||||
|
SysMenu existing = sysMenuMapper.selectById(sysMenu.getId());
|
||||||
|
if (existing != null) checkSystemScope(existing.getSystemId());
|
||||||
|
sysMenu.setUpdatedAt(LocalDateTime.now());
|
||||||
|
sysMenuMapper.updateById(sysMenu);
|
||||||
|
log.info("更新菜单 - id: {}", sysMenu.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除菜单
|
||||||
|
* <p>
|
||||||
|
* 注意:当前实现不会级联删除子菜单,需在业务层面先删除子菜单再删除父菜单。
|
||||||
|
*
|
||||||
|
* @param id 菜单ID
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void delete(String id) {
|
||||||
|
SysMenu existing = sysMenuMapper.selectById(id);
|
||||||
|
if (existing != null) checkSystemScope(existing.getSystemId());
|
||||||
|
sysMenuMapper.deleteById(id);
|
||||||
|
log.info("删除菜单 - id: {}", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
package com.mokee.admin.service.impl;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
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.SysRoleMapper;
|
||||||
|
import com.mokee.admin.mapper.SysRoleMenuMapper;
|
||||||
|
import com.mokee.admin.mapper.SysUserRoleMapper;
|
||||||
|
import com.mokee.admin.service.RoleService;
|
||||||
|
import com.mokee.common.entity.SysRole;
|
||||||
|
import com.mokee.common.entity.SysRoleMenu;
|
||||||
|
import com.mokee.common.entity.SysUserRole;
|
||||||
|
import com.mokee.admin.config.UserContextHolder;
|
||||||
|
import com.mokee.common.exception.BusinessException;
|
||||||
|
import com.mokee.common.utils.RedisUtil;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色管理服务实现
|
||||||
|
* <p>
|
||||||
|
* 基于 MyBatis-Plus 实现角色的增删改查和菜单权限分配。
|
||||||
|
* <p>
|
||||||
|
* 关键设计:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>分页查询 systemName 填充</b>:与 LogServiceImpl 同理,角色的 systemName 也是 transient 字段,
|
||||||
|
* 查询后通过 systemId 逐条联表查询 sys_system 表获取系统名称并填充</li>
|
||||||
|
* <li><b>菜单权限分配 assignMenus()</b>:采用"先删后增"的全量替换策略 ——
|
||||||
|
* 先删除 sys_role_menu 表中该角色的所有记录,再批量插入新的菜单关联。
|
||||||
|
* 这比"差异计算"简单可靠,避免了逐个比对增减的复杂逻辑</li>
|
||||||
|
* <li><b>Redis 会话失效 invalidateSessionsForRole()</b>:分配菜单后,
|
||||||
|
* 需要让拥有该角色的所有用户的 Redis 会话失效,强制用户重新登录以获取最新菜单权限。
|
||||||
|
* 流程:查 sys_user_role 找到拥有该角色的用户 → 查 auth:user_session:{userId} 获取 tokenId →
|
||||||
|
* 删除 auth:token:{tokenId} 和 session key</li>
|
||||||
|
* <li><b>事务保证</b>:assignMenus 使用 @Transactional,确保删除旧关联和插入新关联的原子性</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @author mokee
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RoleServiceImpl implements RoleService {
|
||||||
|
|
||||||
|
private final SysRoleMapper sysRoleMapper;
|
||||||
|
private final SysRoleMenuMapper sysRoleMenuMapper;
|
||||||
|
private final SysUserRoleMapper sysUserRoleMapper;
|
||||||
|
private final RedisUtil redisUtil;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询角色列表
|
||||||
|
* <p>
|
||||||
|
* 支持按系统ID(精确匹配)和角色名称(模糊查询)过滤。
|
||||||
|
* 查询完成后遍历结果集,通过 systemId 查询 sys_system 表填充 systemName 字段。
|
||||||
|
*
|
||||||
|
* @param page 当前页码
|
||||||
|
* @param size 每页条数
|
||||||
|
* @param systemId 系统ID(可选,精确匹配)
|
||||||
|
* @param roleName 角色名称(可选,模糊查询)
|
||||||
|
* @return 分页结果,包含已填充 systemName 的角色列表,按创建时间倒序
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public IPage<SysRole> page(Integer page, Integer size, String systemId, String roleName) {
|
||||||
|
LambdaQueryWrapper<SysRole> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
// 按所属系统过滤
|
||||||
|
if (systemId != null) {
|
||||||
|
wrapper.eq(SysRole::getSystemId, systemId);
|
||||||
|
}
|
||||||
|
// 按角色名称模糊查询
|
||||||
|
if (StrUtil.isNotBlank(roleName)) {
|
||||||
|
wrapper.like(SysRole::getRoleName, roleName);
|
||||||
|
}
|
||||||
|
// 数据权限:非 super_admin 只能查看绑定系统的数据
|
||||||
|
if (!UserContextHolder.isSuperAdmin()) {
|
||||||
|
List<String> scope = UserContextHolder.getSystemIds();
|
||||||
|
if (scope != null && !scope.isEmpty()) {
|
||||||
|
wrapper.in(SysRole::getSystemId, scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wrapper.orderByDesc(SysRole::getCreatedAt);
|
||||||
|
IPage<SysRole> result = sysRoleMapper.selectPage(new Page<>(page, size), wrapper);
|
||||||
|
|
||||||
|
// 遍历结果集,逐条填充所属系统名称(SysRole.systemName 为 transient 字段)
|
||||||
|
for (SysRole role : result.getRecords()) {
|
||||||
|
if (StrUtil.isNotBlank(role.getSystemId())) {
|
||||||
|
try {
|
||||||
|
// 通过 systemId 查询系统名称
|
||||||
|
String name = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT system_name FROM sys_system WHERE id=? AND is_deleted=0",
|
||||||
|
String.class, role.getSystemId());
|
||||||
|
// 设为空字符串而非 null,避免前端显示问题
|
||||||
|
role.setSystemName(name != null ? name : "");
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// 忽略单条查询异常(如系统已被删除)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID查询角色详情
|
||||||
|
*
|
||||||
|
* @param id 角色ID
|
||||||
|
* @return 角色实体
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public SysRole getById(String id) {
|
||||||
|
return sysRoleMapper.selectById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增角色
|
||||||
|
* <p>
|
||||||
|
* 自动设置状态为启用(status=1),创建和更新时间自动填充。
|
||||||
|
*
|
||||||
|
* @param sysRole 角色实体
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void add(SysRole sysRole) {
|
||||||
|
// 检查角色编码在系统内唯一性
|
||||||
|
if (sysRoleMapper.selectCount(
|
||||||
|
new LambdaQueryWrapper<SysRole>()
|
||||||
|
.eq(SysRole::getSystemId, sysRole.getSystemId())
|
||||||
|
.eq(SysRole::getRoleCode, sysRole.getRoleCode())) > 0) {
|
||||||
|
throw new BusinessException("该系统中角色编码已存在: " + sysRole.getRoleCode());
|
||||||
|
}
|
||||||
|
// 默认启用状态
|
||||||
|
sysRole.setStatus(1);
|
||||||
|
sysRole.setCreatedAt(LocalDateTime.now());
|
||||||
|
sysRole.setUpdatedAt(LocalDateTime.now());
|
||||||
|
sysRoleMapper.insert(sysRole);
|
||||||
|
log.info("新增角色 - roleName: {}", sysRole.getRoleName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新角色信息
|
||||||
|
* <p>
|
||||||
|
* 自动刷新更新时间戳。
|
||||||
|
*
|
||||||
|
* @param sysRole 角色实体(id 必填)
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void update(SysRole sysRole) {
|
||||||
|
sysRole.setUpdatedAt(LocalDateTime.now());
|
||||||
|
sysRoleMapper.updateById(sysRole);
|
||||||
|
log.info("更新角色 - id: {}", sysRole.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除角色(MyBatis-Plus 逻辑删除)
|
||||||
|
*
|
||||||
|
* @param id 角色ID
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void delete(String id) {
|
||||||
|
sysRoleMapper.deleteById(id);
|
||||||
|
log.info("删除角色 - id: {}", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为角色分配菜单权限
|
||||||
|
* <p>
|
||||||
|
* 采用"先删后增"的全量替换策略:
|
||||||
|
* <ol>
|
||||||
|
* <li>删除 sys_role_menu 表中该角色的所有旧关联记录</li>
|
||||||
|
* <li>遍历 menuIds,逐条插入新的菜单关联</li>
|
||||||
|
* <li>清除所有拥有此角色用户的 Redis 会话,强制重新登录获取新权限</li>
|
||||||
|
* </ol>
|
||||||
|
* 使用 @Transactional 保证"删旧 + 插新"的原子性。
|
||||||
|
* <p>
|
||||||
|
* 为什么用全量替换而非增量更新?
|
||||||
|
* <ul>
|
||||||
|
* <li>前端通常提交完整的选中菜单列表,用全量替换最直接</li>
|
||||||
|
* <li>避免复杂的差异计算(哪些新增、哪些删除)</li>
|
||||||
|
* <li>在事务中性能可接受(角色菜单数量通常不多)</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @param roleId 角色ID
|
||||||
|
* @param menuIds 菜单ID列表(前端提交的完整选中集合)
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void assignMenus(String roleId, List<String> menuIds) {
|
||||||
|
// 第一步:清除角色的旧菜单关联
|
||||||
|
LambdaQueryWrapper<SysRoleMenu> deleteWrapper = new LambdaQueryWrapper<>();
|
||||||
|
deleteWrapper.eq(SysRoleMenu::getRoleId, roleId);
|
||||||
|
sysRoleMenuMapper.delete(deleteWrapper);
|
||||||
|
|
||||||
|
// 第二步:插入新的菜单关联(每条关联记录创建时间)
|
||||||
|
for (String menuId : menuIds) {
|
||||||
|
SysRoleMenu roleMenu = SysRoleMenu.builder()
|
||||||
|
.roleId(roleId)
|
||||||
|
.menuId(menuId)
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
sysRoleMenuMapper.insert(roleMenu);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第三步:清除所有拥有此角色的用户的 Redis 会话
|
||||||
|
// 因为菜单权限变更了,用户需要重新登录才能获取最新菜单
|
||||||
|
invalidateSessionsForRole(roleId);
|
||||||
|
|
||||||
|
log.info("角色分配菜单 - roleId: {}, menuIds: {}", roleId, menuIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除所有拥有指定角色的用户的 Redis 会话(跳过当前操作用户)
|
||||||
|
* <p>
|
||||||
|
* 流程:
|
||||||
|
* <ol>
|
||||||
|
* <li>从 sys_user_role 表查询拥有该角色的所有用户ID(去重)</li>
|
||||||
|
* <li>排除当前操作用户(避免把自己踢下线)</li>
|
||||||
|
* <li>对每个其他在线用户,删除 Redis session 和 token,强制重新登录以获取最新菜单权限</li>
|
||||||
|
* </ol>
|
||||||
|
* <p>
|
||||||
|
* 异常处理:整个方法被 try-catch 包裹,清除会话失败不影响主流程(角色菜单已成功分配)。
|
||||||
|
*
|
||||||
|
* @param roleId 角色ID
|
||||||
|
*/
|
||||||
|
private void invalidateSessionsForRole(String roleId) {
|
||||||
|
try {
|
||||||
|
// 查询拥有该角色的所有用户
|
||||||
|
List<SysUserRole> userRoles = sysUserRoleMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<SysUserRole>().eq(SysUserRole::getRoleId, roleId)
|
||||||
|
);
|
||||||
|
// 去重用户ID(一个用户可能有多条 user_role 记录,但只需清除一次会话)
|
||||||
|
String currentUserId = UserContextHolder.getUserId();
|
||||||
|
List<String> userIds = userRoles.stream()
|
||||||
|
.map(SysUserRole::getUserId)
|
||||||
|
.distinct()
|
||||||
|
.filter(uid -> !uid.equals(currentUserId)) // 跳过当前操作用户,避免把自己踢下线
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
// 逐个清除用户的 Redis 会话
|
||||||
|
for (String userId : userIds) {
|
||||||
|
// Redis key 格式:auth:user_session:{userId},值为 tokenId
|
||||||
|
String sessionKey = "auth:user_session:" + userId;
|
||||||
|
String tokenId = redisUtil.get(sessionKey);
|
||||||
|
if (tokenId != null) {
|
||||||
|
// 删除 TokenInfo 和 session 映射
|
||||||
|
redisUtil.delete(RedisUtil.key("auth:token:" + tokenId));
|
||||||
|
redisUtil.delete(sessionKey);
|
||||||
|
log.info("已清除用户 Redis 会话(角色菜单变更) - userId: {}, roleId: {}", userId, roleId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 清除会话失败不影响主流程(角色菜单已成功分配)
|
||||||
|
// 用户下次登录时自然会获取最新权限
|
||||||
|
log.warn("清除角色关联用户会话失败(不影响主流程)- roleId: {}, error: {}", roleId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询角色已分配的菜单ID列表
|
||||||
|
* <p>
|
||||||
|
* 用于前端角色编辑页面回显已选中的菜单树节点。
|
||||||
|
*
|
||||||
|
* @param roleId 角色ID
|
||||||
|
* @return 该角色拥有的菜单ID列表
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<String> getMenuIdsByRoleId(String roleId) {
|
||||||
|
LambdaQueryWrapper<SysRoleMenu> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.eq(SysRoleMenu::getRoleId, roleId);
|
||||||
|
return sysRoleMenuMapper.selectList(wrapper).stream()
|
||||||
|
.map(SysRoleMenu::getMenuId)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.mokee.admin.service.impl;
|
||||||
|
|
||||||
|
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.config.UserContextHolder;
|
||||||
|
import com.mokee.admin.mapper.SysApiMapper;
|
||||||
|
import com.mokee.admin.service.SystemApiService;
|
||||||
|
import com.mokee.common.entity.SysApi;
|
||||||
|
import com.mokee.common.exception.BusinessException;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统 API 服务实现
|
||||||
|
* <p>
|
||||||
|
* 基于 MyBatis-Plus 实现系统 API 的分页查询和 CRUD 操作
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SystemApiServiceImpl implements SystemApiService {
|
||||||
|
|
||||||
|
private final SysApiMapper sysApiMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询指定系统的 API 列表,按路径升序排列
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public IPage<SysApi> page(Integer page, Integer size, String systemId) {
|
||||||
|
LambdaQueryWrapper<SysApi> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
if (systemId != null) {
|
||||||
|
wrapper.eq(SysApi::getSystemId, systemId);
|
||||||
|
}
|
||||||
|
// 数据权限:非 super_admin 只能查看绑定系统的 API
|
||||||
|
if (!UserContextHolder.isSuperAdmin()) {
|
||||||
|
java.util.List<String> scope = UserContextHolder.getSystemIds();
|
||||||
|
if (scope != null && !scope.isEmpty()) {
|
||||||
|
wrapper.in(SysApi::getSystemId, scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wrapper.orderByAsc(SysApi::getApiPath);
|
||||||
|
return sysApiMapper.selectPage(new Page<>(page, size), wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SysApi getById(String id) {
|
||||||
|
return sysApiMapper.selectById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增 API 接口,自动设置创建/更新时间并启用
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void add(SysApi sysApi) {
|
||||||
|
// 检查同一系统下接口路径+请求方式唯一性
|
||||||
|
if (sysApiMapper.selectCount(
|
||||||
|
new LambdaQueryWrapper<SysApi>()
|
||||||
|
.eq(SysApi::getSystemId, sysApi.getSystemId())
|
||||||
|
.eq(SysApi::getApiPath, sysApi.getApiPath())
|
||||||
|
.eq(SysApi::getApiMethod, sysApi.getApiMethod())) > 0) {
|
||||||
|
throw new BusinessException("该系统中接口已存在: " + sysApi.getApiPath() + " [" + sysApi.getApiMethod() + "]");
|
||||||
|
}
|
||||||
|
sysApi.setCreatedAt(LocalDateTime.now());
|
||||||
|
sysApi.setUpdatedAt(LocalDateTime.now());
|
||||||
|
sysApi.setStatus(1);
|
||||||
|
sysApiMapper.insert(sysApi);
|
||||||
|
log.info("新增API - systemId: {}, path: {}, method: {}", sysApi.getSystemId(), sysApi.getApiPath(), sysApi.getApiMethod());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新 API 接口信息,自动刷新更新时间
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void update(SysApi sysApi) {
|
||||||
|
sysApi.setUpdatedAt(LocalDateTime.now());
|
||||||
|
sysApiMapper.updateById(sysApi);
|
||||||
|
log.info("更新API - id: {}", sysApi.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除 API 接口
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void delete(String id) {
|
||||||
|
sysApiMapper.deleteById(id);
|
||||||
|
log.info("删除API - id: {}", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
package com.mokee.admin.service.impl;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
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.SysSystemMapper;
|
||||||
|
import com.mokee.admin.service.SystemService;
|
||||||
|
import com.mokee.admin.config.UserContextHolder;
|
||||||
|
import com.mokee.common.entity.SysSystem;
|
||||||
|
import com.mokee.common.exception.BusinessException;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务系统服务实现
|
||||||
|
* <p>
|
||||||
|
* 基于 MyBatis-Plus 实现业务系统的增删改查及分页查询。
|
||||||
|
* <p>
|
||||||
|
* 核心实现细节:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>分页查询</b>:使用 LambdaQueryWrapper 构建动态条件,systemName 模糊查询,systemCode 精确查询</li>
|
||||||
|
* <li><b>查询全部系统 listAll()</b>:过滤 is_deleted=0,按 system_code 升序,仅查询未删除记录</li>
|
||||||
|
* <li><b>查询用户绑定系统 listByUserId()</b>:使用原生 SQL 的 INNER JOIN 查询 sys_user_system 中间表,
|
||||||
|
* 通过 JdbcTemplate 手动映射 ResultSet,因为 MyBatis-Plus 不擅长多表 JOIN</li>
|
||||||
|
* <li><b>新增系统</b>:自动生成 16 位文档令牌(UUID 去掉横线取前 16 位),用于公开文档访问</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @author mokee
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SystemServiceImpl implements SystemService {
|
||||||
|
|
||||||
|
private final SysSystemMapper sysSystemMapper;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询业务系统列表
|
||||||
|
* <p>
|
||||||
|
* systemName 使用 LIKE 模糊匹配,systemCode 使用 EQ 精确匹配。
|
||||||
|
* 结果按 sort_order 升序排列,确保前端列表顺序可控。
|
||||||
|
*
|
||||||
|
* @param page 当前页码
|
||||||
|
* @param size 每页条数
|
||||||
|
* @param systemName 系统名称模糊查询(可选)
|
||||||
|
* @param systemCode 系统编码精确查询(可选)
|
||||||
|
* @return 分页结果
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public IPage<SysSystem> page(Integer page, Integer size, String systemName, String systemCode) {
|
||||||
|
LambdaQueryWrapper<SysSystem> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
// 系统名称模糊查询(LIKE '%name%')
|
||||||
|
if (StrUtil.isNotBlank(systemName)) {
|
||||||
|
wrapper.like(SysSystem::getSystemName, systemName);
|
||||||
|
}
|
||||||
|
// 系统编码精确查询(= code)
|
||||||
|
if (StrUtil.isNotBlank(systemCode)) {
|
||||||
|
wrapper.eq(SysSystem::getSystemCode, systemCode);
|
||||||
|
}
|
||||||
|
// 数据权限:非 super_admin 只能查看绑定系统的数据,并排除网关系统本身
|
||||||
|
if (!UserContextHolder.isSuperAdmin()) {
|
||||||
|
java.util.List<String> scope = UserContextHolder.getSystemIds();
|
||||||
|
if (scope != null && !scope.isEmpty()) {
|
||||||
|
wrapper.in(SysSystem::getId, scope);
|
||||||
|
} else {
|
||||||
|
wrapper.eq(SysSystem::getId, "__NONE__");
|
||||||
|
}
|
||||||
|
// 网关系统是平台基础设施,业务管理员无需查看
|
||||||
|
wrapper.ne(SysSystem::getSystemCode, "gateway");
|
||||||
|
}
|
||||||
|
// 按排序号升序,数值小的排在前面
|
||||||
|
wrapper.orderByAsc(SysSystem::getSortOrder);
|
||||||
|
return sysSystemMapper.selectPage(new Page<>(page, size), wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID查询系统详情
|
||||||
|
*
|
||||||
|
* @param id 系统ID
|
||||||
|
* @return 系统实体
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public SysSystem getById(String id) {
|
||||||
|
return sysSystemMapper.selectById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增业务系统
|
||||||
|
* <p>
|
||||||
|
* 自动完成以下操作:
|
||||||
|
* <ol>
|
||||||
|
* <li>设置创建时间和更新时间为当前时间</li>
|
||||||
|
* <li>设置状态为启用(status=1)</li>
|
||||||
|
* <li>如未提供文档令牌,自动生成 16 位 UUID 子串作为 docToken</li>
|
||||||
|
* <li>记录操作日志</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* @param sysSystem 系统实体
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void add(SysSystem sysSystem) {
|
||||||
|
// 检查 system_code 唯一性(数据库有唯一约束,提前校验给出友好提示)
|
||||||
|
if (sysSystemMapper.selectCount(
|
||||||
|
new LambdaQueryWrapper<SysSystem>().eq(SysSystem::getSystemCode, sysSystem.getSystemCode())) > 0) {
|
||||||
|
throw new BusinessException("系统编码已存在: " + sysSystem.getSystemCode());
|
||||||
|
}
|
||||||
|
sysSystem.setCreatedAt(LocalDateTime.now());
|
||||||
|
sysSystem.setUpdatedAt(LocalDateTime.now());
|
||||||
|
sysSystem.setStatus(1);
|
||||||
|
// 自动生成文档访问令牌:UUID 去掉横线取前 16 位,保证唯一性和简洁性
|
||||||
|
if (sysSystem.getDocToken() == null || sysSystem.getDocToken().isEmpty()) {
|
||||||
|
sysSystem.setDocToken(java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 16));
|
||||||
|
}
|
||||||
|
// gatewayUrl 字段已废弃但 DB 有唯一约束,生成唯一值避免空串冲突
|
||||||
|
if (sysSystem.getGatewayUrl() == null || sysSystem.getGatewayUrl().isEmpty()) {
|
||||||
|
sysSystem.setGatewayUrl("gw-" + java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 12));
|
||||||
|
}
|
||||||
|
sysSystemMapper.insert(sysSystem);
|
||||||
|
log.info("新增业务系统 - systemCode: {}, docToken: {}", sysSystem.getSystemCode(), sysSystem.getDocToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新业务系统信息
|
||||||
|
* <p>
|
||||||
|
* 只更新传入的非 null 字段(MyBatis-Plus updateById 默认行为),
|
||||||
|
* 自动刷新 updatedAt 时间戳。
|
||||||
|
*
|
||||||
|
* @param sysSystem 系统实体(id 必填)
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void update(SysSystem sysSystem) {
|
||||||
|
sysSystem.setUpdatedAt(LocalDateTime.now());
|
||||||
|
sysSystemMapper.updateById(sysSystem);
|
||||||
|
log.info("更新业务系统 - id: {}", sysSystem.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过文档令牌查询系统信息
|
||||||
|
* <p>
|
||||||
|
* 公开接口,无需登录认证。同时校验令牌匹配和系统启用状态。
|
||||||
|
*
|
||||||
|
* @param token 文档访问令牌
|
||||||
|
* @return 系统实体,令牌不匹配或系统已停用返回 null
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public SysSystem getByDocToken(String token) {
|
||||||
|
return sysSystemMapper.selectOne(
|
||||||
|
new LambdaQueryWrapper<SysSystem>()
|
||||||
|
.eq(SysSystem::getDocToken, token) // 令牌精确匹配
|
||||||
|
.eq(SysSystem::getStatus, 1) // 只返回启用状态的系统
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询全部未删除的系统
|
||||||
|
* <p>
|
||||||
|
* 仅 super_admin 角色使用。返回所有 is_deleted=0 的记录。
|
||||||
|
* 使用 LambdaQueryWrapper 过滤逻辑删除标记。
|
||||||
|
*
|
||||||
|
* @return 全部未删除的系统列表,按 system_code 升序排列
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<SysSystem> listAll() {
|
||||||
|
return sysSystemMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<SysSystem>()
|
||||||
|
.eq(SysSystem::getIsDeleted, 0) // 过滤已逻辑删除的记录
|
||||||
|
.orderByAsc(SysSystem::getSystemCode) // 按系统编码升序
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户绑定的系统(通过 sys_user_system 关联表 INNER JOIN 查询)
|
||||||
|
* <p>
|
||||||
|
* 使用原生 SQL 而非 MyBatis-Plus 的关联查询,原因:
|
||||||
|
* <ul>
|
||||||
|
* <li>MyBatis-Plus 的 LambdaQueryWrapper 不直接支持多表 JOIN</li>
|
||||||
|
* <li>原生 SQL 更直观,能精确控制查询逻辑</li>
|
||||||
|
* <li>INNER JOIN 确保只返回用户确有绑定的系统</li>
|
||||||
|
* </ul>
|
||||||
|
* SQL: SELECT s.* FROM sys_system s INNER JOIN sys_user_system us ON s.id = us.system_id WHERE us.user_id = ? AND s.is_deleted = 0
|
||||||
|
* <p>
|
||||||
|
* 手动映射 ResultSet 到 SysSystem 实体,因为 JdbcTemplate 不能自动映射。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @return 用户绑定的系统列表(按 system_code 升序),用户无绑定时返回空列表
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<SysSystem> listByUserId(String userId) {
|
||||||
|
// 使用 Java 15+ 的文本块(Text Block)语法编写可读的多行 SQL
|
||||||
|
String sql = """
|
||||||
|
SELECT s.* FROM sys_system s
|
||||||
|
INNER JOIN sys_user_system us ON s.id = us.system_id
|
||||||
|
WHERE us.user_id = ? AND s.is_deleted = 0
|
||||||
|
ORDER BY s.system_code
|
||||||
|
""";
|
||||||
|
// 手动映射 ResultSet 到 SysSystem 实体
|
||||||
|
return jdbcTemplate.query(sql, (rs, rowNum) -> {
|
||||||
|
SysSystem s = new SysSystem();
|
||||||
|
s.setId(rs.getString("id"));
|
||||||
|
s.setSystemCode(rs.getString("system_code"));
|
||||||
|
s.setSystemName(rs.getString("system_name"));
|
||||||
|
s.setFrontUrl(rs.getString("front_url"));
|
||||||
|
s.setBackendUrl(rs.getString("backend_url"));
|
||||||
|
s.setIcon(rs.getString("icon"));
|
||||||
|
s.setDescription(rs.getString("description"));
|
||||||
|
s.setStatus(rs.getInt("status"));
|
||||||
|
s.setGatewayUrl(rs.getString("gateway_url"));
|
||||||
|
s.setDocToken(rs.getString("doc_token"));
|
||||||
|
s.setSortOrder(rs.getInt("sort_order"));
|
||||||
|
return s;
|
||||||
|
}, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除业务系统(MyBatis-Plus 逻辑删除)
|
||||||
|
* <p>
|
||||||
|
* 将 is_deleted 标记为 1,不会物理删除数据库记录。
|
||||||
|
* SQL 等价于: UPDATE sys_system SET is_deleted=1 WHERE id=?
|
||||||
|
*
|
||||||
|
* @param id 系统ID
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void delete(String id) {
|
||||||
|
sysSystemMapper.deleteById(id);
|
||||||
|
log.info("删除业务系统 - id: {}", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
package com.mokee.admin.service.impl;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import cn.hutool.crypto.digest.BCrypt;
|
||||||
|
import cn.hutool.json.JSONUtil;
|
||||||
|
import com.mokee.admin.config.UserContextHolder;
|
||||||
|
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.SysRoleMapper;
|
||||||
|
import com.mokee.admin.mapper.SysUserMapper;
|
||||||
|
import com.mokee.admin.mapper.SysUserRoleMapper;
|
||||||
|
import com.mokee.admin.mapper.SysUserSystemMapper;
|
||||||
|
import com.mokee.admin.service.UserService;
|
||||||
|
import com.mokee.common.dto.response.RoleInfo;
|
||||||
|
import com.mokee.common.dto.response.TokenInfo;
|
||||||
|
import com.mokee.common.exception.BusinessException;
|
||||||
|
import com.mokee.common.entity.SysRole;
|
||||||
|
import com.mokee.common.entity.SysUser;
|
||||||
|
import com.mokee.common.entity.SysUserRole;
|
||||||
|
import com.mokee.common.entity.SysUserSystem;
|
||||||
|
import com.mokee.common.utils.RedisUtil;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户管理服务实现
|
||||||
|
* <p>
|
||||||
|
* 新增用户时密码使用 BCrypt 加密,支持绑定/解绑系统、分配角色。
|
||||||
|
* <p>
|
||||||
|
* 关键实现细节:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>BCrypt 密码加密</b>:使用 Hutool 的 BCrypt.hashpw(),不存储明文密码</li>
|
||||||
|
* <li><b>编辑不更新密码</b>:update() 中将 password 置为 null,MyBatis-Plus updateById 会忽略 null 字段</li>
|
||||||
|
* <li><b>Redis TokenInfo 同步</b>:编辑用户后,如果用户当前在线,同步更新 Redis 中缓存的 TokenInfo,
|
||||||
|
* 确保前端 fetchUserInfo 拿到的实时数据。同步保持原有 TTL。</li>
|
||||||
|
* <li><b>绑定系统 - 全量替换</b>:先删旧关联再插新关联,@Transactional 保证原子性</li>
|
||||||
|
* <li><b>分配角色 - 全量替换 + 会话失效</b>:先删旧角色再插新角色,然后清除 Redis 会话强制重新登录</li>
|
||||||
|
* <li><b>邮箱过滤</b>:page() 方法支持 email 参数模糊查询,用于按邮箱搜索用户</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @author mokee
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserServiceImpl implements UserService {
|
||||||
|
|
||||||
|
private final SysUserMapper sysUserMapper;
|
||||||
|
private final SysUserSystemMapper sysUserSystemMapper;
|
||||||
|
private final SysUserRoleMapper sysUserRoleMapper;
|
||||||
|
private final SysRoleMapper sysRoleMapper;
|
||||||
|
private final RedisUtil redisUtil;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询用户列表
|
||||||
|
* <p>
|
||||||
|
* 支持按用户名、真实姓名、邮箱三个字段进行模糊查询(LIKE)。
|
||||||
|
* 使用 LambdaQueryWrapper 构建动态条件,只添加非空的过滤条件。
|
||||||
|
* 结果按创建时间倒序排列(最新用户在最前面)。
|
||||||
|
*
|
||||||
|
* @param page 当前页码
|
||||||
|
* @param size 每页条数
|
||||||
|
* @param username 用户名模糊查询(可选)
|
||||||
|
* @param realName 真实姓名模糊查询(可选)
|
||||||
|
* @param email 邮箱模糊查询(可选,用于按邮箱搜索用户)
|
||||||
|
* @return 分页结果
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public IPage<SysUser> page(Integer page, Integer size, String username, String realName, String email) {
|
||||||
|
LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
// 按用户名模糊查询
|
||||||
|
if (StrUtil.isNotBlank(username)) {
|
||||||
|
wrapper.like(SysUser::getUsername, username);
|
||||||
|
}
|
||||||
|
// 按真实姓名模糊查询
|
||||||
|
if (StrUtil.isNotBlank(realName)) {
|
||||||
|
wrapper.like(SysUser::getRealName, realName);
|
||||||
|
}
|
||||||
|
// 按邮箱模糊查询(扩展的查询条件)
|
||||||
|
if (StrUtil.isNotBlank(email)) {
|
||||||
|
wrapper.like(SysUser::getEmail, email);
|
||||||
|
}
|
||||||
|
// 数据权限:非 super_admin 只能查看绑定系统的用户(通过 sys_user_system 关联)
|
||||||
|
if (!UserContextHolder.isSuperAdmin()) {
|
||||||
|
// 排除拥有 super_admin 角色的用户
|
||||||
|
java.util.List<String> superAdminUserIds = sysUserRoleMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<SysUserRole>()
|
||||||
|
.inSql(SysUserRole::getRoleId,
|
||||||
|
"SELECT id FROM sys_role WHERE role_code = 'super_admin'")
|
||||||
|
).stream().map(SysUserRole::getUserId).distinct().toList();
|
||||||
|
if (!superAdminUserIds.isEmpty()) {
|
||||||
|
wrapper.notIn(SysUser::getId, superAdminUserIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
java.util.List<String> scope = UserContextHolder.getSystemIds();
|
||||||
|
if (scope != null && !scope.isEmpty()) {
|
||||||
|
java.util.List<String> scopeUserIds = sysUserSystemMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<SysUserSystem>().in(SysUserSystem::getSystemId, scope)
|
||||||
|
).stream().map(SysUserSystem::getUserId).distinct().toList();
|
||||||
|
if (scopeUserIds.isEmpty()) {
|
||||||
|
wrapper.eq(SysUser::getId, "__NONE__");
|
||||||
|
} else {
|
||||||
|
wrapper.in(SysUser::getId, scopeUserIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wrapper.orderByDesc(SysUser::getCreatedAt);
|
||||||
|
return sysUserMapper.selectPage(new Page<>(page, size), wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID查询用户详情
|
||||||
|
*
|
||||||
|
* @param id 用户ID
|
||||||
|
* @return 用户实体
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public SysUser getById(String id) {
|
||||||
|
return sysUserMapper.selectById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增用户
|
||||||
|
* <p>
|
||||||
|
* 处理流程:
|
||||||
|
* <ol>
|
||||||
|
* <li>使用 BCrypt 加密密码(Hash 后不可逆,验证时使用 BCrypt.checkpw)</li>
|
||||||
|
* <li>初始化登录失败次数为 0</li>
|
||||||
|
* <li>设置状态为启用(status=1)</li>
|
||||||
|
* <li>设置创建/更新时间</li>
|
||||||
|
* </ol>
|
||||||
|
* 注意:请求体中的 password 应为明文,由本方法加密后存储。
|
||||||
|
*
|
||||||
|
* @param sysUser 用户实体(password 为明文)
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String add(SysUser sysUser) {
|
||||||
|
// 检查用户名/邮箱是否已存在(数据库有唯一约束,提前校验给出友好提示)
|
||||||
|
if (sysUserMapper.selectCount(
|
||||||
|
new LambdaQueryWrapper<SysUser>().eq(SysUser::getUsername, sysUser.getUsername())) > 0) {
|
||||||
|
throw new BusinessException("用户名已存在: " + sysUser.getUsername());
|
||||||
|
}
|
||||||
|
// 空邮箱设为 null,避免多个空串违反唯一约束
|
||||||
|
if (sysUser.getEmail() != null && sysUser.getEmail().isEmpty()) {
|
||||||
|
sysUser.setEmail(null);
|
||||||
|
}
|
||||||
|
if (sysUser.getEmail() != null
|
||||||
|
&& sysUserMapper.selectCount(
|
||||||
|
new LambdaQueryWrapper<SysUser>().eq(SysUser::getEmail, sysUser.getEmail())) > 0) {
|
||||||
|
throw new BusinessException("邮箱已被使用: " + sysUser.getEmail());
|
||||||
|
}
|
||||||
|
// BCrypt 加密密码:Hash 后不可逆,验证时使用 BCrypt.checkpw(明文, Hash)
|
||||||
|
sysUser.setPassword(BCrypt.hashpw(sysUser.getPassword()));
|
||||||
|
sysUser.setLoginFailCount(0); // 初始登录失败次数为 0
|
||||||
|
sysUser.setStatus(1); // 默认启用
|
||||||
|
sysUser.setCreatedAt(LocalDateTime.now());
|
||||||
|
sysUser.setUpdatedAt(LocalDateTime.now());
|
||||||
|
sysUserMapper.insert(sysUser);
|
||||||
|
log.info("新增用户 - username: {}", sysUser.getUsername());
|
||||||
|
return sysUser.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑用户信息
|
||||||
|
* <p>
|
||||||
|
* 重要设计:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>不更新密码</b>:将 password 置为 null,MyBatis-Plus updateById 默认策略会忽略 null 字段,
|
||||||
|
* 因此不会覆盖数据库中的密码。密码通过独立的密码修改接口处理。</li>
|
||||||
|
* <li><b>Redis 同步</b>:编辑后同步更新 Redis 中的 TokenInfo。
|
||||||
|
* 因为网关从 Redis 读取用户信息注入请求头(X-User-Account 等),
|
||||||
|
* 如果只更新数据库不同步 Redis,在线的用户会看到旧数据,直到重新登录。</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @param sysUser 用户实体(id 必填,password 会被忽略)
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void update(SysUser sysUser) {
|
||||||
|
// 编辑时不更新密码(密码通过单独的密码修改接口处理)
|
||||||
|
// MyBatis-Plus updateById 默认忽略 null 字段
|
||||||
|
sysUser.setPassword(null);
|
||||||
|
sysUser.setUpdatedAt(LocalDateTime.now());
|
||||||
|
sysUserMapper.updateById(sysUser);
|
||||||
|
|
||||||
|
// 同步更新 Redis 中的 TokenInfo(如果用户当前在线)
|
||||||
|
// 确保前端 fetchUserInfo 拿到最新数据
|
||||||
|
syncRedisTokenInfo(sysUser);
|
||||||
|
|
||||||
|
log.info("更新用户 - id: {}", sysUser.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步更新 Redis TokenInfo 中的用户信息字段
|
||||||
|
* <p>
|
||||||
|
* 流程:
|
||||||
|
* <ol>
|
||||||
|
* <li>通过 auth:user_session:{userId} 查找 tokenId(用户是否在线)</li>
|
||||||
|
* <li>如果在线,读取 auth:token:{tokenId} 中的 TokenInfo JSON</li>
|
||||||
|
* <li>将用户更新的字段(username, realName, email, phone, avatar, post)同步到 TokenInfo</li>
|
||||||
|
* <li>保持原有的 TTL(过期时间),写回 Redis</li>
|
||||||
|
* </ol>
|
||||||
|
* 异常被忽略:同步失败不影响主流程(数据库已更新),用户重新登录后会获取最新数据。
|
||||||
|
*
|
||||||
|
* @param updatedUser 更新后的用户实体
|
||||||
|
*/
|
||||||
|
private void syncRedisTokenInfo(SysUser updatedUser) {
|
||||||
|
try {
|
||||||
|
// 查找用户当前是否在线(session key 是否存在)
|
||||||
|
String sessionKey = "auth:user_session:" + updatedUser.getId();
|
||||||
|
String tokenId = redisUtil.get(sessionKey);
|
||||||
|
if (tokenId == null) return; // 用户未在线,无需同步
|
||||||
|
|
||||||
|
// 读取 Redis 中缓存的 TokenInfo
|
||||||
|
String tokenKey = RedisUtil.key("auth:token:" + tokenId);
|
||||||
|
String jsonStr = redisUtil.get(tokenKey);
|
||||||
|
if (jsonStr == null) return;
|
||||||
|
|
||||||
|
// 反序列化 TokenInfo 并更新字段
|
||||||
|
TokenInfo tokenInfo = JSONUtil.toBean(jsonStr, TokenInfo.class);
|
||||||
|
// 只同步非 null 的字段(null 表示该字段没有被修改)
|
||||||
|
// username 也需同步,网关会在 X-User-Account 头中传递
|
||||||
|
if (updatedUser.getUsername() != null) tokenInfo.setUsername(updatedUser.getUsername());
|
||||||
|
if (updatedUser.getRealName() != null) tokenInfo.setRealName(updatedUser.getRealName());
|
||||||
|
if (updatedUser.getEmail() != null) tokenInfo.setEmail(updatedUser.getEmail());
|
||||||
|
if (updatedUser.getPhone() != null) tokenInfo.setPhone(updatedUser.getPhone());
|
||||||
|
if (updatedUser.getAvatar() != null) tokenInfo.setAvatar(updatedUser.getAvatar());
|
||||||
|
if (updatedUser.getPost() != null) tokenInfo.setPost(updatedUser.getPost());
|
||||||
|
|
||||||
|
// 保持原有 TTL,写回 Redis
|
||||||
|
Long ttl = redisUtil.getExpire(tokenKey);
|
||||||
|
if (ttl != null && ttl > 0) {
|
||||||
|
redisUtil.set(tokenKey, JSONUtil.toJsonStr(tokenInfo), ttl, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
log.debug("同步 Redis TokenInfo 成功 - userId: {}", updatedUser.getId());
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 同步失败不影响主流程(数据库已更新)
|
||||||
|
log.warn("同步 Redis TokenInfo 失败(不影响主流程)- userId: {}, error: {}",
|
||||||
|
updatedUser.getId(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除用户(MyBatis-Plus 逻辑删除)
|
||||||
|
* <p>
|
||||||
|
* 将 is_deleted 标记为 1,不会物理删除记录。
|
||||||
|
*
|
||||||
|
* @param id 用户ID
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void delete(String id) {
|
||||||
|
sysUserMapper.deleteById(id);
|
||||||
|
log.info("删除用户 - id: {}", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员重置用户密码
|
||||||
|
* <p>
|
||||||
|
* 仅 super_admin 可调用此方法(调用层需先校验角色)。
|
||||||
|
* 密码使用 BCrypt 加密后直接覆盖数据库中的旧密码,
|
||||||
|
* 同时清除该用户的 Redis 会话强制重新登录。
|
||||||
|
*
|
||||||
|
* @param userId 目标用户ID
|
||||||
|
* @param newPassword 新密码(明文)
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void resetPassword(String userId, String newPassword) {
|
||||||
|
SysUser user = sysUserMapper.selectById(userId);
|
||||||
|
if (user == null) {
|
||||||
|
throw new BusinessException(404, "用户不存在");
|
||||||
|
}
|
||||||
|
// BCrypt 加密新密码
|
||||||
|
user.setPassword(BCrypt.hashpw(newPassword));
|
||||||
|
user.setUpdatedAt(LocalDateTime.now());
|
||||||
|
sysUserMapper.updateById(user);
|
||||||
|
|
||||||
|
// 清除该用户的 Redis 会话,强制重新登录
|
||||||
|
try {
|
||||||
|
String sessionKey = "auth:user_session:" + userId;
|
||||||
|
String tokenId = redisUtil.get(sessionKey);
|
||||||
|
if (tokenId != null) {
|
||||||
|
redisUtil.delete(RedisUtil.key("auth:token:" + tokenId));
|
||||||
|
redisUtil.delete(sessionKey);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("清除用户 Redis 会话失败(不影响密码重置结果) - userId: {}", userId, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("管理员重置用户密码 - userId: {}, username: {}", userId, user.getUsername());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为用户绑定业务系统(全量替换:先删后增)
|
||||||
|
* <p>
|
||||||
|
* 使用 @Transactional 保证"删旧 + 插新"的原子性。
|
||||||
|
* 前端提交完整的系统ID列表,后端全量替换。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param systemIds 要绑定的系统ID列表
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void bindSystems(String userId, List<String> systemIds) {
|
||||||
|
// 第一步:清除旧绑定
|
||||||
|
LambdaQueryWrapper<SysUserSystem> deleteWrapper = new LambdaQueryWrapper<>();
|
||||||
|
deleteWrapper.eq(SysUserSystem::getUserId, userId);
|
||||||
|
sysUserSystemMapper.delete(deleteWrapper);
|
||||||
|
|
||||||
|
// 第二步:插入新绑定(每条关联记录创建时间)
|
||||||
|
for (String systemId : systemIds) {
|
||||||
|
SysUserSystem userSystem = SysUserSystem.builder()
|
||||||
|
.userId(userId)
|
||||||
|
.systemId(systemId)
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
sysUserSystemMapper.insert(userSystem);
|
||||||
|
}
|
||||||
|
log.info("用户绑定系统 - userId: {}, systemIds: {}", userId, systemIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解除用户所有业务系统绑定
|
||||||
|
* <p>
|
||||||
|
* 删除 sys_user_system 表中该用户的所有关联记录。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void unbindSystems(String userId) {
|
||||||
|
LambdaQueryWrapper<SysUserSystem> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.eq(SysUserSystem::getUserId, userId);
|
||||||
|
sysUserSystemMapper.delete(wrapper);
|
||||||
|
log.info("用户解绑所有系统 - userId: {}", userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为用户在指定系统中分配角色(全量替换策略)
|
||||||
|
* <p>
|
||||||
|
* 当前实现简化处理:删除该用户的所有角色关联(不限于指定系统),
|
||||||
|
* 再插入新的角色关联。如需严格按系统隔离,应只删除该系统下的角色。
|
||||||
|
* <p>
|
||||||
|
* 流程:
|
||||||
|
* <ol>
|
||||||
|
* <li>删除 sys_user_role 中该用户的所有旧角色关联</li>
|
||||||
|
* <li>逐条插入新的角色关联</li>
|
||||||
|
* <li>清除用户 Redis 会话,强制重新登录以应用新权限</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param systemId 系统ID(当前用于日志记录和后续扩展的隔离过滤)
|
||||||
|
* @param roleIds 角色ID列表
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void assignRoles(String userId, String systemId, List<String> roleIds) {
|
||||||
|
// 先清除用户的所有旧角色关联
|
||||||
|
// 注意:当前简化实现删除所有角色,未按 systemId 过滤
|
||||||
|
// 如需精确隔离:应通过 role 表的 system_id 字段过滤,只删除指定系统的角色
|
||||||
|
LambdaQueryWrapper<SysUserRole> deleteWrapper = new LambdaQueryWrapper<>();
|
||||||
|
deleteWrapper.eq(SysUserRole::getUserId, userId);
|
||||||
|
sysUserRoleMapper.delete(deleteWrapper);
|
||||||
|
|
||||||
|
// 插入新角色关联
|
||||||
|
for (String roleId : roleIds) {
|
||||||
|
SysUserRole userRole = SysUserRole.builder()
|
||||||
|
.userId(userId)
|
||||||
|
.roleId(roleId)
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
sysUserRoleMapper.insert(userRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步更新 Redis 会话中的角色信息(而不是清除会话强制重登)
|
||||||
|
// 修复:之前 invalidateUserSession 会把用户自己的 token 删掉,导致 401 跳登录
|
||||||
|
try {
|
||||||
|
String sessionKey = "auth:user_session:" + userId;
|
||||||
|
String tokenId = redisUtil.get(sessionKey);
|
||||||
|
if (tokenId != null) {
|
||||||
|
String tokenKey = RedisUtil.key("auth:token:" + tokenId);
|
||||||
|
String jsonStr = redisUtil.get(tokenKey);
|
||||||
|
if (jsonStr != null) {
|
||||||
|
TokenInfo tokenInfo = JSONUtil.toBean(jsonStr, TokenInfo.class);
|
||||||
|
// 从数据库重新查询用户全部角色,防止覆盖其他系统的角色
|
||||||
|
// 修复:之前 tokenInfo.setRoles(当前系统角色) 会用当前系统角色
|
||||||
|
// 覆盖掉其他系统的角色,导致 super_admin 等身份丢失,引发 403
|
||||||
|
if (tokenInfo.getRoles() != null) {
|
||||||
|
List<SysUserRole> allUserRoles = sysUserRoleMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<SysUserRole>().eq(SysUserRole::getUserId, userId));
|
||||||
|
List<RoleInfo> allRoles = new java.util.ArrayList<>();
|
||||||
|
for (SysUserRole ur : allUserRoles) {
|
||||||
|
SysRole role = sysRoleMapper.selectById(ur.getRoleId());
|
||||||
|
if (role != null) {
|
||||||
|
allRoles.add(new RoleInfo(role.getId(), role.getRoleName(), role.getRoleCode()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokenInfo.setRoles(allRoles);
|
||||||
|
}
|
||||||
|
Long ttl = redisUtil.getExpire(tokenKey);
|
||||||
|
if (ttl != null && ttl > 0) {
|
||||||
|
redisUtil.set(tokenKey, JSONUtil.toJsonStr(tokenInfo), ttl, java.util.concurrent.TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("同步角色到Redis会话失败 - userId: {}, error: {}", userId, e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("用户分配角色 - userId: {}, systemId: {}, roleIds: {}", userId, systemId, roleIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除用户的 Redis 会话(强制重新登录)
|
||||||
|
* <p>
|
||||||
|
* 通过删除 Redis 中的 TokenInfo 和 session 映射实现。
|
||||||
|
* 下次用户请求时网关发现 token 不存在,返回 401 强制前端跳转登录页。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
*/
|
||||||
|
private void invalidateUserSession(String userId) {
|
||||||
|
try {
|
||||||
|
// Redis key 格式:auth:user_session:{userId},值为 tokenId
|
||||||
|
String sessionKey = "auth:user_session:" + userId;
|
||||||
|
String tokenId = redisUtil.get(sessionKey);
|
||||||
|
if (tokenId != null) {
|
||||||
|
// 删除 TokenInfo 和 session 映射
|
||||||
|
redisUtil.delete(RedisUtil.key("auth:token:" + tokenId));
|
||||||
|
redisUtil.delete(sessionKey);
|
||||||
|
log.info("已清除用户 Redis 会话 - userId: {}", userId);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 清除会话失败不影响主流程(角色已成功分配)
|
||||||
|
log.warn("清除用户会话失败(不影响主流程)- userId: {}, error: {}", userId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户绑定的系统ID列表
|
||||||
|
* <p>
|
||||||
|
* 从 sys_user_system 中间表查询所有关联记录,提取 systemId。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @return 系统ID列表
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<String> getUserSystemIds(String userId) {
|
||||||
|
LambdaQueryWrapper<SysUserSystem> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.eq(SysUserSystem::getUserId, userId);
|
||||||
|
return sysUserSystemMapper.selectList(wrapper).stream()
|
||||||
|
.map(SysUserSystem::getSystemId)
|
||||||
|
.collect(java.util.stream.Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户在指定系统下的角色ID列表
|
||||||
|
* <p>
|
||||||
|
* 注意:当前实现未按 systemId 过滤(简化处理),返回用户所有角色ID。
|
||||||
|
* 如需严格隔离,应 JOIN sys_role 表过滤 system_id。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param systemId 系统ID(当前未使用,保留参数兼容)
|
||||||
|
* @return 角色ID列表
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<String> getUserRoleIds(String userId, String systemId) {
|
||||||
|
LambdaQueryWrapper<SysUserRole> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.eq(SysUserRole::getUserId, userId);
|
||||||
|
return sysUserRoleMapper.selectList(wrapper).stream()
|
||||||
|
.map(SysUserRole::getRoleId)
|
||||||
|
.collect(java.util.stream.Collectors.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.mokee.admin.task;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
|
import com.mokee.admin.mapper.SysLoginLogMapper;
|
||||||
|
import com.mokee.admin.mapper.SysOperationLogMapper;
|
||||||
|
import com.mokee.admin.mapper.SysUserMapper;
|
||||||
|
import com.mokee.common.entity.SysLoginLog;
|
||||||
|
import com.mokee.common.entity.SysOperationLog;
|
||||||
|
import com.mokee.common.entity.SysUser;
|
||||||
|
import com.mokee.common.enums.StatusEnum;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时任务
|
||||||
|
* <p>
|
||||||
|
* 凌晨执行用户不活跃锁定和过期日志清理
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ScheduledTask {
|
||||||
|
|
||||||
|
private final SysUserMapper sysUserMapper;
|
||||||
|
private final SysLoginLogMapper sysLoginLogMapper;
|
||||||
|
private final SysOperationLogMapper sysOperationLogMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 锁定不活跃用户
|
||||||
|
* <p>
|
||||||
|
* 每天凌晨 3:00 执行:将超过 365 天未登录的启用用户状态改为锁定
|
||||||
|
*/
|
||||||
|
@Scheduled(cron = "0 0 3 * * ?")
|
||||||
|
public void lockInactiveUsers() {
|
||||||
|
log.info("定时任务 - 开始锁定不活跃用户");
|
||||||
|
try {
|
||||||
|
LocalDateTime oneYearAgo = LocalDateTime.now().minusDays(365);
|
||||||
|
LambdaUpdateWrapper<SysUser> wrapper = new LambdaUpdateWrapper<>();
|
||||||
|
wrapper.set(SysUser::getStatus, StatusEnum.LOCKED.getCode())
|
||||||
|
.lt(SysUser::getLastLoginTime, oneYearAgo)
|
||||||
|
.eq(SysUser::getStatus, StatusEnum.ENABLED.getCode());
|
||||||
|
int updated = sysUserMapper.update(null, wrapper);
|
||||||
|
log.info("定时任务 - 锁定不活跃用户完成, 已更新 {} 条", updated);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("定时任务 - 锁定不活跃用户失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理过期日志
|
||||||
|
* <p>
|
||||||
|
* 每天凌晨 4:00 执行:删除 6 个月前的操作日志和 12 个月前的登录日志
|
||||||
|
*/
|
||||||
|
@Scheduled(cron = "0 0 4 * * ?")
|
||||||
|
public void cleanExpiredLogs() {
|
||||||
|
log.info("定时任务 - 开始清理过期日志");
|
||||||
|
try {
|
||||||
|
// 删除 6 个月前的操作日志
|
||||||
|
LocalDateTime sixMonthsAgo = LocalDateTime.now().minusMonths(6);
|
||||||
|
int deletedOps = sysOperationLogMapper.delete(
|
||||||
|
new LambdaUpdateWrapper<SysOperationLog>()
|
||||||
|
.lt(SysOperationLog::getCreatedAt, sixMonthsAgo)
|
||||||
|
);
|
||||||
|
log.info("定时任务 - 清理操作日志完成, 删除: {} 条", deletedOps);
|
||||||
|
|
||||||
|
// 删除 12 个月前的登录日志
|
||||||
|
LocalDateTime twelveMonthsAgo = LocalDateTime.now().minusMonths(12);
|
||||||
|
int deletedLogin = sysLoginLogMapper.delete(
|
||||||
|
new LambdaUpdateWrapper<SysLoginLog>()
|
||||||
|
.lt(SysLoginLog::getCreatedAt, twelveMonthsAgo)
|
||||||
|
);
|
||||||
|
log.info("定时任务 - 清理登录日志完成, 删除: {} 条", deletedLogin);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("定时任务 - 清理过期日志失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
mokee-gateway-admin/src/main/resources/application-prod.yml
Normal file
40
mokee-gateway-admin/src/main/resources/application-prod.yml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
app:
|
||||||
|
env-prefix: "[PROD]"
|
||||||
|
|
||||||
|
spring:
|
||||||
|
mail:
|
||||||
|
host: smtp.qiye.aliyun.com
|
||||||
|
port: 465
|
||||||
|
username: admin@zgitm.com
|
||||||
|
password: mokee2016.
|
||||||
|
properties:
|
||||||
|
mail.smtp.auth: true
|
||||||
|
mail.smtp.ssl.enable: true
|
||||||
|
mail.smtp.socketFactory.port: 465
|
||||||
|
mail.smtp.socketFactory.class: javax.net.ssl.SSLSocketFactory
|
||||||
|
datasource:
|
||||||
|
url: jdbc:mysql://db1.prod.zgitm.com:3306/mokeegateway?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
|
||||||
|
username: mokeegateway
|
||||||
|
password: mokee.
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
host: redis.db1.zgitm.com
|
||||||
|
port: 6379
|
||||||
|
password: mokee2016.
|
||||||
|
database: 1
|
||||||
|
timeout: 3000ms
|
||||||
|
lettuce:
|
||||||
|
pool:
|
||||||
|
max-active: 8
|
||||||
|
max-idle: 8
|
||||||
|
min-idle: 0
|
||||||
|
|
||||||
|
mybatis-plus:
|
||||||
|
configuration:
|
||||||
|
log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
com.mokee: INFO
|
||||||
|
org.springframework.web.servlet.DispatcherServlet: DEBUG
|
||||||
39
mokee-gateway-admin/src/main/resources/application-qa.yml
Normal file
39
mokee-gateway-admin/src/main/resources/application-qa.yml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
app:
|
||||||
|
env-prefix: "[QA]"
|
||||||
|
|
||||||
|
spring:
|
||||||
|
mail:
|
||||||
|
host: smtp.qiye.aliyun.com
|
||||||
|
port: 465
|
||||||
|
username: admin@zgitm.com
|
||||||
|
password: mokee2016.
|
||||||
|
properties:
|
||||||
|
mail.smtp.auth: true
|
||||||
|
mail.smtp.ssl.enable: true
|
||||||
|
mail.smtp.socketFactory.port: 465
|
||||||
|
mail.smtp.socketFactory.class: javax.net.ssl.SSLSocketFactory
|
||||||
|
datasource:
|
||||||
|
url: jdbc:mysql://db1.qa.zgitm.com:3306/mokeegatewayqa?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
|
||||||
|
username: mokeegatewayqa
|
||||||
|
password: mokee.
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
host: redis.db2.zgitm.com
|
||||||
|
port: 6379
|
||||||
|
password: mokee2016.
|
||||||
|
database: 2
|
||||||
|
timeout: 3000ms
|
||||||
|
lettuce:
|
||||||
|
pool:
|
||||||
|
max-active: 8
|
||||||
|
max-idle: 8
|
||||||
|
min-idle: 0
|
||||||
|
|
||||||
|
mybatis-plus:
|
||||||
|
configuration:
|
||||||
|
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
com.mokee: INFO
|
||||||
34
mokee-gateway-admin/src/main/resources/application.yml
Normal file
34
mokee-gateway-admin/src/main/resources/application.yml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
server:
|
||||||
|
port: 9002
|
||||||
|
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: mokee-gateway-admin
|
||||||
|
profiles:
|
||||||
|
active: @spring.profiles.active@
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
max-file-size: 2048MB
|
||||||
|
max-request-size: 2048MB
|
||||||
|
|
||||||
|
mybatis-plus:
|
||||||
|
mapper-locations: classpath*:/mapper/**/*.xml
|
||||||
|
type-aliases-package: com.mokee.common.entity
|
||||||
|
configuration:
|
||||||
|
map-underscore-to-camel-case: true
|
||||||
|
global-config:
|
||||||
|
db-config:
|
||||||
|
logic-delete-field: isDeleted
|
||||||
|
logic-delete-value: 1
|
||||||
|
logic-not-delete-value: 0
|
||||||
|
|
||||||
|
jwt:
|
||||||
|
secret: mokee-gateway-jwt-secret-key-2024-must-be-256-bits!!
|
||||||
|
expiration: 7200
|
||||||
|
|
||||||
|
knife4j:
|
||||||
|
enable: true
|
||||||
|
openapi:
|
||||||
|
title: 业务管理服务
|
||||||
|
description: 系统配置/日志/字典/定时任务
|
||||||
|
version: 1.0.0
|
||||||
32
mokee-gateway-auth/pom.xml
Normal file
32
mokee-gateway-auth/pom.xml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>com.mokee</groupId>
|
||||||
|
<artifactId>mokee-gateway</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>mokee-gateway-auth</artifactId>
|
||||||
|
<name>mokee-gateway-auth</name>
|
||||||
|
<description>认证用户服务 — 登录/登出/Token/用户/角色/菜单</description>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.mokee</groupId>
|
||||||
|
<artifactId>mokee-gateway-common</artifactId>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.mokee.auth;
|
||||||
|
|
||||||
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证用户服务 — 启动类
|
||||||
|
* <p>
|
||||||
|
* 负责用户登录/登出、Token 管理、用户信息查询、菜单路由、系统切换等
|
||||||
|
*/
|
||||||
|
@SpringBootApplication(scanBasePackages = {"com.mokee.auth", "com.mokee.common"})
|
||||||
|
@MapperScan("com.mokee.auth.mapper")
|
||||||
|
public class AuthApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(AuthApplication.class, args);
|
||||||
|
System.out.println("========================================");
|
||||||
|
System.out.println(" 认证用户服务启动成功! 端口: 9001");
|
||||||
|
System.out.println(" Knife4j 文档: http://localhost:9001/doc.html");
|
||||||
|
System.out.println("========================================");
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user