初始化

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

View File

@@ -0,0 +1,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;
}
}
}

View 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>

View 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;
}

View 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);
}
}

View 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&lt;Map&lt;String, String&gt;&gt; CTX = new ThreadLocal&lt;&gt;();
public static void set(Map&lt;String, String&gt; user) { CTX.set(user); }
public static Map&lt;String, String&gt; 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&lt;String, String&gt; user = new HashMap&lt;&gt;();
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&lt;Order&gt; 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浏览器 &lt;a&gt; 标签下载时无法自定义 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} &nbsp;|&nbsp; <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": "&lt;h1&gt;邮件内容&lt;/h1&gt;",
"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":"&lt;h1&gt;你好&lt;/h1&gt;","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 获取的一次性 Tokenform 字段传递)' },
{ 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>

View File

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

View 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>

View 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 Tokenquery 参数) |
**请求示例**
```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
View 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
View 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

File diff suppressed because it is too large Load Diff

268
docs/open-api-doc.md Normal file
View 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 | 服务内部错误 |

File diff suppressed because it is too large Load Diff

78
init_data.sql Normal file
View 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
View 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 '父菜单ID0表示顶级',
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
View 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
View 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
View 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
View 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
View 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
View 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 必须经用户明确授权

View 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()改为读取文件字节存入DBpage()/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中的TokenInfousername/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结构一致
- [ ] 部署前端到PRODNginx 10.10.1.160
- [ ] 部署后端到PROD10.10.1.170
- [ ] 验证头像上传端到端流程
- [ ] E-1 Cookie域localhost问题开发环境限制暂不处理
- [ ] F-3 bindSystems/unbindSystems Redis同步低优先级

View 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新增对外开放APIToken获取+邮件发送+一次消费新增邮件服务阿里企业邮箱新增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部署需要重新构建镜像

View 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.1170 上是 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` — 引入 loadEnvbuild 时 base 读取 VITE_STATIC_URLdev 回退 '/'
- `.env.production` — 新增 `VITE_STATIC_URL=https://web.gw.zgitm.com/`
- **效果**: login 域名首次下载资源后,跳转到 web 域名时浏览器缓存直接命中0 网络请求
## 待处理
- [ ] 重新 `npm run build` + 部署后验证效果
- [ ] 如果后续还慢,考虑 Vite 拆包 Element Plus 按需加载
- [ ] 考虑 160 Nginx 升级 gzip 压缩级别

View 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个bug42个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 18Vite 5.4兼容

View 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上传→系统名称
**开放 APIOpenApiController**:
- `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` | 重写(+downloadview改磁盘优先 |
| `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 导出到磁盘

View 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 + 邮件发送 + 下载方式

View 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 吞掉真实错误
- **修复**: 设置自定义 ResponseErrorHandlerhasError() 永远返回 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 设置自定义 ResponseErrorHandlerhasError() 永远返回 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.comNginx /widgets/ location
- [ ] 给 gateway-app-admin/gateway-app-user 角色分配菜单
- [ ] 生产环境验证

View 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跳过当前用户 |
## 待处理
- [ ] 重新构建部署前后端验证所有修复
- [ ] 问题1ElMessage 左下角)需进一步排查 — CSS 引入正确但可能浏览器缓存

View 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-wrapperpage-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 手动验证所有页面的分页和移动端效果
- [ ] 如有需要,部署到测试环境验证

View 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 账号登录,确认统计数据只显示绑定系统范围内

View 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-permissionsuper_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/` 路径也尝试解析 tokenCookie / 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/` 业务系统请求能正确转发并携带用户信息头

View 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浏览器无此全局变量
- 步骤 1index.html 引入 CSS不引入 JS、不放占位 div
- 步骤 2TopBar.vue 中放置占位 div必须在 Vue 模板中,不在 index.html
- 步骤 3main.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` 接口性能,优化响应时间
- [ ] 前端可考虑增加超时时间或减少并发请求数

View 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.vuefooter-icp、LoginView.vuelogin-footer 追加、SystemSelectView.vueselect-footer、IntegrationDoc.vuedoc-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-tscpre-existing TS 类型错误不影响部署
## 待处理
- [ ] super_admin 重新登录验证所有 26 个按钮权限可见
- [ ] 网关 `GlobalExceptionHandler` 优化ClientAbortException 降级为 WARN
- [ ] 排查 `devops.server.zgitm.com` 接口性能

View 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`
## 待处理
- [ ] 部署前端到服务器使线上文档页生效

View 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` 已配置

View 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]]

View 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 已验证过)

View 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 之前,先查 SkipAuthCacheServiceCaffeine 5min TTL匹配则放行
- 不改动 `/zgapi/v1/open/*` 路径
## 待处理
- [ ] 执行 API 白名单跳过鉴权方案实施(计划已就绪,下次会话继续)

View 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
View 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 的同样处理...
-- ============================================
-- 迁移完成
-- ============================================

View 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>

View File

@@ -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("========================================");
}
}

View File

@@ -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;
}
}
}

View File

@@ -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(); }
}

View File

@@ -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();
}
}

View File

@@ -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"); // 文件预览/下载不拦截
}
}

View File

@@ -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", "缓存已刷新"));
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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内容、contentTypehtml/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收件人/主题模糊查询), status0失败/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));
}
}

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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();
}
}

View File

@@ -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 获取一次性 TokenToken 消费后立即失效。文档: /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 = "调用此接口将消费 TokenToken 只能用一次。邮件发送成功后 Token 立即失效,发送失败不消费 Token。")
public Result<?> sendEmail(HttpServletRequest request) {
// 第1步验证 Token不消费只校验有效性
String tokenId = validateOpenToken(request);
if (tokenId == null) {
return Result.fail(401, "Token 无效或已消费");
}
// 第2步读取请求体兼容 UTF-8/GBK 编码
Map<String, String> body;
try {
body = readJsonBody(request);
} catch (Exception e) {
return Result.fail(400, "请求体 JSON 解析失败: " + e.getMessage());
}
String to = body.get("to");
String subject = body.get("subject");
String content = body.get("content");
String contentType = body.getOrDefault("contentType", "html");
if (to == null || subject == null || content == null) {
return Result.fail(400, "缺少必填参数: to, subject, content");
}
// 第3步先发邮件成功后再消费 Token
SysEmailLog log = SysEmailLog.builder()
.toAddress(to)
.subject(subject)
.content(content)
.contentType(contentType)
.createdAt(LocalDateTime.now())
.build();
try {
if ("html".equals(contentType)) {
emailService.sendHtml(to, subject, content);
} else {
emailService.sendText(to, subject, content);
}
log.setStatus(1);
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);
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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 系统实体(请求体 JSONid 必填)
* @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();
}
}

View File

@@ -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 用户实体(请求体 JSONpassword 为明文,由 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 用户实体(请求体 JSONid 必填)
* @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();
}
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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();
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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 系统IDUUID 字符串)
* @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);
}

View File

@@ -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 新用户的 IDUUID
*/
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);
}

View File

@@ -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());
}
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 置为 nullMyBatis-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 置为 nullMyBatis-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());
}
}

View File

@@ -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);
}
}
}

View 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

View 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

View 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

View 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>

View File

@@ -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