diff --git a/memory/session-20260716-1.md b/memory/session-20260716-1.md index 8482b70..c16bb8a 100644 --- a/memory/session-20260716-1.md +++ b/memory/session-20260716-1.md @@ -12,27 +12,32 @@ metadata: ## 会话摘要 -用户反馈前端图标选择器(IconPicker)缺少直接上传功能——需要先到文件管理页面上传图片,再回到表单选择。本次在 IconPicker 组件的"已上传"标签页中增加了上传按钮,支持直接在弹窗内上传图标图片,上传完成后自动选中。 +1. IconPicker 增加上传按钮:前端图标选择器"已上传"标签页增加直接上传功能 +2. 修复 /view 端点 404:后端新增文件预览代理端点,每次请求实时向文件服务器获取 Token 并代理返回图片 ## 关键操作 -### IconPicker 增加上传按钮 +### 一、IconPicker 增加上传按钮 - **做了什么**: 在 IconPicker.vue 的"已上传"标签页头部增加了「上传」按钮,点击后选择图片即自动上传到文件服务(fileType=icon),上传完成自动刷新列表并选中新图标 - **涉及文件**: `mokee-gateway-web/src/components/IconPicker.vue` -- **具体改动**: - 1. 模板:在 popup-header 中添加 `` 上传按钮(仅在 tab==='upload' 时显示)+ 隐藏 `` - 2. 模板:添加 `.upload-status-bar` 上传进度条(el-progress + Loading 动画) - 3. 脚本:导入 `Upload`, `Loading` 图标和 `ElMessage` - 4. 脚本:添加 `FILE_SERVICE`, `uploading`, `uploadPercent`, `fileInputRef` 状态 - 5. 脚本:实现 `getFileServiceToken()` — 直调文件服务获取 Token(带缓存) - 6. 脚本:实现 `triggerUploadIcon()` / `onIconFileSelected()` — 选择文件后自动上传 - 7. 样式:添加 `@keyframes spin` 和 `.upload-status-bar` 样式 - -- **上传流程**: 选择图片 → 获取文件服务 Token → FormData 上传(fileType=icon)→ file.wg.zgitm.com/api/upload → 刷新列表 → 自动选中新图标 - - **结果**: TypeScript 编译通过,IconPicker.vue 无类型错误 +### 二、新增文件预览代理端点 GET /{id}/view + +- **做了什么**: 用户反馈上传图标后 `` 加载 `/zgapi/v1/admin/file/{id}/view` 返回 404,原因是文件迁至外部存储后该端点被移除。本次在 admin 后端重新实现该端点,每次请求自动向文件服务器获取 Token → 下载文件 → 返回给浏览器 +- **涉及文件**: + 1. `mokee-gateway-admin/.../config/WebMvcConfig.java` — 新增 `fileServiceRestTemplate` Bean + 2. `mokee-gateway-admin/.../service/FileService.java` — 新增 `view()` 方法签名 + 3. `mokee-gateway-admin/.../service/impl/FileServiceImpl.java` — 实现核心逻辑:查元信息 → 获取 Token → RestTemplate 代理下载 → 写回 HttpServletResponse + 4. `mokee-gateway-admin/.../controller/FileController.java` — 新增 `GET /{id}/view` 端点 +- **结果**: Maven 编译通过,零错误 +- **设计要点**: + - 文件服务 URL 通过 `@Value("${file.service.url:https://file.wg.zgitm.com}")` 注入,可配置 + - Token 不使用缓存,每次请求实时获取(避免一次消费问题) + - 端点不受 `UserContextInterceptor` 拦截(WebMvcConfig 中已排除 `/view` 路径) + - 错误时返回纯文本状态(404/502/500),不依赖 JSON 响应结构 + ## 重要信息 - 文件服务 URL: `VITE_FILE_SERVICE_URL` 环境变量,默认 `https://file.wg.zgitm.com` diff --git a/mokee-gateway-admin/src/main/java/com/mokee/admin/config/WebMvcConfig.java b/mokee-gateway-admin/src/main/java/com/mokee/admin/config/WebMvcConfig.java index 4ab06b6..a0a74c5 100644 --- a/mokee-gateway-admin/src/main/java/com/mokee/admin/config/WebMvcConfig.java +++ b/mokee-gateway-admin/src/main/java/com/mokee/admin/config/WebMvcConfig.java @@ -1,12 +1,18 @@ package com.mokee.admin.config; import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; /** - * Web MVC 配置 — 注册用户上下文拦截器 + * Web MVC 配置 + *

+ * - 注册用户上下文拦截器(文件预览/下载端点不拦截) + * - 注册 RestTemplate Bean(用于代理请求文件服务器) */ @Configuration @RequiredArgsConstructor @@ -20,4 +26,13 @@ public class WebMvcConfig implements WebMvcConfigurer { .addPathPatterns("/zgapi/v1/admin/**") // 仅管理后台接口 .excludePathPatterns("/zgapi/v1/admin/file/**/view", "/zgapi/v1/admin/file/**/download"); // 文件预览/下载不拦截 } + + /** 文件服务器代理专用 RestTemplate — 较短超时,避免阻塞页面渲染 */ + @Bean + public RestTemplate fileServiceRestTemplate() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(5000); + factory.setReadTimeout(30000); + return new RestTemplate(factory); + } } diff --git a/mokee-gateway-admin/src/main/java/com/mokee/admin/controller/FileController.java b/mokee-gateway-admin/src/main/java/com/mokee/admin/controller/FileController.java index 537f680..b550af4 100644 --- a/mokee-gateway-admin/src/main/java/com/mokee/admin/controller/FileController.java +++ b/mokee-gateway-admin/src/main/java/com/mokee/admin/controller/FileController.java @@ -53,4 +53,15 @@ public class FileController { public Result getById(@PathVariable String id) { return Result.ok(fileService.getMetaById(id)); } + + /** + * 文件预览 — 代理到文件服务器获取图片并返回 + *

+ * 用于 <img> 标签直接加载。每次请求从文件服务器获取新 Token 后下载文件, + * 对前端透明。不走用户上下文拦截器(WebMvcConfig 中已排除)。 + */ + @GetMapping("/{id}/view") + public void view(@PathVariable String id, jakarta.servlet.http.HttpServletResponse response) { + fileService.view(id, response); + } } diff --git a/mokee-gateway-admin/src/main/java/com/mokee/admin/service/FileService.java b/mokee-gateway-admin/src/main/java/com/mokee/admin/service/FileService.java index ac6c03e..874b93f 100644 --- a/mokee-gateway-admin/src/main/java/com/mokee/admin/service/FileService.java +++ b/mokee-gateway-admin/src/main/java/com/mokee/admin/service/FileService.java @@ -25,4 +25,15 @@ public interface FileService { /** 查询元信息(不含 file_data) */ SysFile getMetaById(String id); + + /** + * 代理文件预览 — 从文件服务器获取 Token、下载文件、流式返回给客户端 + *

+ * 用于 <img> 标签加载场景。每次请求都从文件服务器获取新 Token, + * 避免 Token 缓存带来的消费时序问题。 + * + * @param id 文件 ID + * @param response HTTP 响应对象(用于设置 Content-Type 和输出流) + */ + void view(String id, jakarta.servlet.http.HttpServletResponse response); } diff --git a/mokee-gateway-admin/src/main/java/com/mokee/admin/service/impl/FileServiceImpl.java b/mokee-gateway-admin/src/main/java/com/mokee/admin/service/impl/FileServiceImpl.java index c1edd05..f7e6594 100644 --- a/mokee-gateway-admin/src/main/java/com/mokee/admin/service/impl/FileServiceImpl.java +++ b/mokee-gateway-admin/src/main/java/com/mokee/admin/service/impl/FileServiceImpl.java @@ -7,17 +7,26 @@ 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 jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import java.io.IOException; import java.util.List; +import java.util.Map; /** - * 文件服务实现 — 仅提供元信息查询 + * 文件服务实现 *

* 文件上传/下载/删除已迁移至外部文件对象存储服务(file.wg.zgitm.com), - * 外部服务直接写入 sys_file 表,本服务只做查询。 + * 外部服务直接写入 sys_file 表,本服务做元信息查询 + 预览代理。 * * @author mokee */ @@ -27,6 +36,11 @@ import java.util.List; public class FileServiceImpl implements FileService { private final SysFileMapper sysFileMapper; + private final RestTemplate fileServiceRestTemplate; + + /** 文件服务器地址 */ + @Value("${file.service.url:https://file.wg.zgitm.com}") + private String fileServiceUrl; @Override public IPage page(Integer page, Integer size, String fileType, @@ -72,4 +86,82 @@ public class FileServiceImpl implements FileService { w.select(SysFile.class, f -> !"file_data".equals(f.getColumn())); return sysFileMapper.selectOne(w); } + + @Override + public void view(String id, HttpServletResponse response) { + // 1. 查文件元信息 + SysFile file = getMetaById(id); + if (file == null) { + respondError(response, 404, "文件不存在"); + return; + } + + // 2. 从文件服务器获取下载 Token + String token = fetchDownloadToken(); + if (token == null) { + log.warn("获取文件服务器 Token 失败 — fileId={}", id); + respondError(response, 502, "获取下载凭证失败"); + return; + } + + // 3. 从文件服务器下载文件二进制数据 + try { + String downloadUrl = fileServiceUrl + "/api/download/" + id + "?token=" + token; + ResponseEntity resp = fileServiceRestTemplate.exchange( + downloadUrl, HttpMethod.GET, null, byte[].class); + + byte[] body = resp.getBody(); + if (body == null || (resp.getStatusCode().isError())) { + log.warn("文件服务器返回空或错误 — fileId={}, status={}", id, resp.getStatusCode()); + respondError(response, 502, "文件获取失败"); + return; + } + + // 4. 流式返回给客户端 + response.setContentType(file.getMimeType() != null ? file.getMimeType() : "application/octet-stream"); + response.setContentLength(body.length); + response.getOutputStream().write(body); + response.getOutputStream().flush(); + } catch (IOException e) { + log.warn("文件预览流式输出失败 — fileId={}", id, e); + respondError(response, 500, "文件输出失败"); + } catch (Exception e) { + log.warn("文件服务器请求失败 — fileId={}", id, e); + respondError(response, 502, "文件服务器连接失败"); + } + } + + /** 向文件服务器获取下载 Token */ + @SuppressWarnings("unchecked") + private String fetchDownloadToken() { + try { + HttpHeaders headers = new HttpHeaders(); + headers.set("Content-Type", "application/json"); + HttpEntity> request = new HttpEntity<>( + Map.of("systemCode", "gateway"), headers); + ResponseEntity resp = fileServiceRestTemplate.postForEntity( + fileServiceUrl + "/api/token", request, Map.class); + if (resp.getBody() != null && resp.getBody().get("data") instanceof Map) { + Map data = (Map) resp.getBody().get("data"); + Object token = data.get("token"); + return token != null ? token.toString() : null; + } + return null; + } catch (Exception e) { + log.warn("获取文件服务 Token 异常", e); + return null; + } + } + + /** 向客户端返回错误 */ + private void respondError(HttpServletResponse response, int status, String msg) { + try { + response.setStatus(status); + response.setContentType("text/plain;charset=UTF-8"); + response.getWriter().write(msg); + response.getWriter().flush(); + } catch (IOException ignored) { + // 连接可能已断开,忽略 + } + } }