diff --git a/backend/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysUserVo.java b/backend/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysUserVo.java index 97f81ef1..2d2f2dc5 100644 --- a/backend/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysUserVo.java +++ b/backend/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysUserVo.java @@ -75,11 +75,16 @@ public class SysUserVo implements Serializable { private String sex; /** - * 头像地址 + * 头像 OSS ID */ - @Translation(type = TransConstant.OSS_ID_TO_URL) private Long avatar; + /** + * 头像访问地址 + */ + @Translation(type = TransConstant.OSS_ID_TO_URL, mapper = "avatar") + private String avatarUrl; + /** * 密码 */ diff --git a/docs/开发文档/API/用户认证登录API.md b/docs/开发文档/API/用户认证登录API.md index 9147c439..ac6f51fb 100644 --- a/docs/开发文档/API/用户认证登录API.md +++ b/docs/开发文档/API/用户认证登录API.md @@ -395,7 +395,8 @@ loginWithEncryption() "email": "admin@example.com", "phonenumber": "13800138000", "sex": "1", - "avatar": "/profile/avatar/default.jpg", + "avatar": "1938120323613728770", + "avatarUrl": "http://example.com/ruoyi/2025/08/31/xxxx.jpeg?X-Amz-Algorithm=AWS4-HMAC-SHA256&...", "status": "0", "loginIp": "127.0.0.1", "loginDate": "2025-01-26 10:30:00", @@ -409,6 +410,13 @@ loginWithEncryption() } ``` +**字段说明补充**: + +- `avatar`:始终返回 `sys_user.avatar` 中存储的 OSS ID(字符串),用于鉴权下载接口 `/resource/oss/download/{ossId}`。 +- `avatarUrl`:系统通过 `TransConstant.OSS_ID_TO_URL` 规则实时翻译得到的可直接访问地址(MinIO 预签名 URL),供无需链路改造的场景快速显示;有效期受 OSS 策略控制。 + +前端如需在带 Token 的受保护组件(如 `ImagePreview`)中展示,请优先使用 `avatar` 并通过统一的认证下载逻辑获取图片,避免直接暴露临时 URL。 + **超级管理员权限说明**: admin 用户具有系统最高权限`*:*:*`,可以访问所有功能模块和执行所有操作。 @@ -663,7 +671,9 @@ methods: { --- **📝 维护**: 用户认证登录 API 文档 -**🔄 更新**: 2025-01-03 +**🔄 更新记录**: +- 2025-11-28:`/system/user/getInfo` 增加 `avatarUrl` 字段,并明确 `avatar` 返回 OSS ID +- 2025-01-03:文档初始化 **📁 文件**: 用户认证登录 API.md ## 获取具有指定权限的用户列表 @@ -693,7 +703,8 @@ methods: { deptId: string; // 部门ID deptName: string; // 部门名称 }; - avatar: string; // 头像URL + avatar: string; // 头像OSS ID + avatarUrl?: string; // 头像访问URL(可选,预签名) }[]; total: number; // 总记录数 } @@ -713,7 +724,8 @@ methods: { "deptId": "1", "deptName": "后勤部" }, - "avatar": "http://example.com/avatar.jpg" + "avatar": "1938120323613728770", + "avatarUrl": "http://example.com/avatar.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&..." } ], "total": 1 diff --git a/docs/开发文档/ImagePreview组件使用说明.md b/docs/开发文档/ImagePreview组件使用说明.md index dc31f3a5..70439eca 100644 --- a/docs/开发文档/ImagePreview组件使用说明.md +++ b/docs/开发文档/ImagePreview组件使用说明.md @@ -65,6 +65,36 @@ ImagePreview 是一个图片预览组件,支持两种模式: ``` +### 4. 顶部个人头像(自动识别OSS ID) + +```vue + + + +``` + +> ✅ 用于首页 `Navbar` 头像区域时,`avatarIsOssId` 会自动识别当前值是否为 OSS ID,从而在带 Token 的 `/resource/oss/download/{ossId}` 接口与传统 URL 之间无缝切换,保证头像始终通过认证通道加载。 + ## 技术实现 ### 核心功能 @@ -126,6 +156,7 @@ const urlCache = ref>(new Map()); ## 更新记录 +- **2025-11-28**:补充顶部栏个人头像场景示例,强调 OSS ID 自动识别策略,确保 `Navbar` 等公共区域也复用统一的图片安全加载方案。 - **2025-08-02**:修复组件,支持OSS ID模式,添加缓存机制和加载状态 - 修复了导入错误和computed未定义的问题 - 添加了完整的错误处理和日志记录 \ No newline at end of file diff --git a/docs/开发文档/移动端开发规范总结.md b/docs/开发文档/移动端开发规范总结.md index 797a5987..0b5d92fd 100644 --- a/docs/开发文档/移动端开发规范总结.md +++ b/docs/开发文档/移动端开发规范总结.md @@ -737,6 +737,13 @@ export default { ``` +#### 头像鉴权加载 + +- 统一从 `/system/user/getInfo` 获取 `avatar`(OSS ID)与 `avatarUrl`(预签名地址)。 +- 页面中优先识别 `avatar` 是否为 OSS ID,使用 `uni.request`(`responseType: 'arraybuffer'`)访问 `/resource/oss/download/{ossId}` 并转换为 `Blob URL/Base64`,确保 Token 认证生效。 +- 若历史账号仍存储直接 URL,则回退到 `avatar` 或 `avatarUrl` 原始值,最后兜底 `/static/111.png`。 +- 首页、详情页等通用区域必需复用该策略,避免直接暴露预签名链接导致过期或越权。 + #### 列表优化 ```vue diff --git a/mobile/src/pages/index/index.vue b/mobile/src/pages/index/index.vue index a8cee91e..cbf959f0 100644 --- a/mobile/src/pages/index/index.vue +++ b/mobile/src/pages/index/index.vue @@ -681,7 +681,8 @@ import Card from '@/components/Card.vue'; import ServiceItem from '@/components/ServiceItem.vue'; import GridLayout from '@/components/GridLayout.vue'; import { userManager, userState } from '@/utils/user'; -import { api } from '@/common/request'; +import { api, request } from '@/common/request'; +import { REQUEST_CONFIG } from '@/common/config'; import { hasPermi, permissionMixin } from '@/utils/permission'; import RouterManager from '@/utils/router'; @@ -698,8 +699,10 @@ export default { userInfo: { name: '用户', avatar: '', + avatarOssId: '', deptName: '', }, + avatarBlobUrl: '', // 轮播图片数据 bannerList: [ { @@ -817,6 +820,12 @@ export default { this.checkLoginStatus(); this.loadUserInfo(); }, + onUnload() { + this.cleanupAvatarBlob(); + }, + beforeDestroy() { + this.cleanupAvatarBlob(); + }, methods: { // 全局权限校验方法 hasPermi(permissions) { @@ -831,15 +840,220 @@ export default { }, // 加载用户信息 - loadUserInfo() { - const displayUser = userManager.formatUserForDisplay(); - if (displayUser) { + async loadUserInfo() { + try { + const displayUser = userManager.formatUserForDisplay(); + if (!displayUser) { + return; + } + this.userInfo.name = displayUser.name; - this.userInfo.avatar = displayUser.avatar; this.userInfo.deptName = displayUser.deptName; + this.userInfo.avatar = ''; + this.userInfo.avatarOssId = ''; + + if (!displayUser.avatar) { + return; + } + + const resolvedAvatar = await this.resolveAvatarValue(displayUser.avatar); + if (resolvedAvatar) { + const displayableUrl = await this.getDisplayableAvatarUrl( + resolvedAvatar.url, + resolvedAvatar.ossId, + ); + this.userInfo.avatar = displayableUrl || resolvedAvatar.url; + this.userInfo.avatarOssId = resolvedAvatar.ossId || ''; + } + } catch (error) { + console.error('加载用户信息失败:', error); } }, + // 统一处理头像字段,兼容URL与OSS ID + async resolveAvatarValue(rawAvatar) { + const avatarValue = (rawAvatar || '').toString().trim(); + if (!avatarValue) { + return null; + } + + // 直接可用的URL + if (this.isDirectUrl(avatarValue)) { + return { url: avatarValue, ossId: '' }; + } + + // 后端仅返回了相对路径(例如/resource/oss/preview/xxx 或 /api/resource/...) + if (avatarValue.startsWith('/resource/') || avatarValue.startsWith('/api/resource/')) { + return { + url: this.normalizeResourceUrl(avatarValue), + ossId: '', + }; + } + + // 其余场景按OSS ID处理 + const ossId = avatarValue.split(',')[0].trim(); + if (!ossId) { + return null; + } + + const urlFromOss = await this.fetchAvatarFromOss(ossId); + return urlFromOss + ? { + url: urlFromOss, + ossId, + } + : null; + }, + + // 判断是否为绝对可用的URL + isDirectUrl(value) { + const lower = value.toLowerCase(); + return ( + this.isAbsoluteExternalUrl(lower) || + lower.startsWith('data:') || + lower.startsWith('blob:') || + value.startsWith('/static/') + ); + }, + + isAbsoluteExternalUrl(value) { + const lower = value.toLowerCase(); + return lower.startsWith('http://') || lower.startsWith('https://'); + }, + + // 将后端返回的/resource路径转换成可直接展示的URL + normalizeResourceUrl(url) { + if (!url) return ''; + if (url.startsWith('/api/')) { + return url; + } + return `${REQUEST_CONFIG.serverUrl}${url}`; + }, + + // 确保头像URL可以直接展示(必要时转换为blob URL) + async getDisplayableAvatarUrl(url, ossId) { + if (!url) { + return ''; + } + + // 静态资源或可匿名访问的URL直接返回 + if (this.isAbsoluteExternalUrl(url) || url.startsWith('/static/')) { + return url; + } + + // 优先尝试把现有URL转换为blob,确保请求可以带上Token + const blobUrl = await this.fetchImageBlobUrl(url); + if (blobUrl) { + this.updateAvatarBlobUrl(blobUrl); + return blobUrl; + } + + // 兜底:通过下载接口重新获取 + if (ossId) { + const downloadPath = `/resource/oss/download/${encodeURIComponent(ossId)}`; + const downloadBlobUrl = await this.fetchImageBlobUrl(downloadPath); + if (downloadBlobUrl) { + this.updateAvatarBlobUrl(downloadBlobUrl); + return downloadBlobUrl; + } + } + + return url; + }, + + updateAvatarBlobUrl(blobUrl) { + if (!blobUrl) { + return; + } + this.cleanupAvatarBlob(); + this.avatarBlobUrl = blobUrl; + }, + + cleanupAvatarBlob() { + if (this.avatarBlobUrl && this.avatarBlobUrl.startsWith('blob:')) { + try { + URL.revokeObjectURL(this.avatarBlobUrl); + } catch (error) { + console.warn('释放头像blob URL失败:', error); + } + } + this.avatarBlobUrl = ''; + }, + + async fetchImageBlobUrl(imageUrl) { + try { + if (!imageUrl) { + return ''; + } + + if (this.isAbsoluteExternalUrl(imageUrl) || imageUrl.startsWith('/static/')) { + return imageUrl; + } + + let requestUrl = imageUrl; + + const serverPrefix = REQUEST_CONFIG.serverUrl || ''; + if ( + serverPrefix && + requestUrl.startsWith(serverPrefix) && + serverPrefix.startsWith('/') + ) { + requestUrl = requestUrl.slice(serverPrefix.length); + if (!requestUrl.startsWith('/')) { + requestUrl = `/${requestUrl}`; + } + } + + if (requestUrl.startsWith('/api/')) { + requestUrl = requestUrl.replace('/api', ''); + } + + if (!requestUrl.startsWith('/')) { + requestUrl = `/${requestUrl}`; + } + + const arrayBuffer = await request({ + url: requestUrl, + method: 'GET', + responseType: 'arraybuffer', + }); + + if (!arrayBuffer) { + return ''; + } + + const blob = new Blob([arrayBuffer], { type: 'image/*' }); + return URL.createObjectURL(blob); + } catch (error) { + console.error('获取头像blob失败:', error); + return ''; + } + }, + + // 通过OSS ID获取文件访问URL + async fetchAvatarFromOss(ossId) { + try { + const response = await request({ + url: `/resource/oss/listByIds/${encodeURIComponent(ossId)}`, + method: 'GET', + }); + + if ( + response.code === 200 && + Array.isArray(response.data) && + response.data.length > 0 + ) { + const fileInfo = response.data[0]; + if (fileInfo?.url) { + return this.normalizeResourceUrl(fileInfo.url); + } + } + } catch (error) { + console.error('根据OSS ID获取头像失败:', error); + } + return ''; + }, + // 点击用户头像 handleUserClick() { // const info = userManager.getUserInfoString(); diff --git a/web/src/api/system/user/types.ts b/web/src/api/system/user/types.ts index d3b8ba8c..7d887ce5 100644 --- a/web/src/api/system/user/types.ts +++ b/web/src/api/system/user/types.ts @@ -37,6 +37,7 @@ export interface UserVO extends BaseEntity { phonenumber: string; sex: string; avatar: string; + avatarUrl?: string; status: string; delFlag: string; loginIp: string; diff --git a/web/src/layout/components/Navbar.vue b/web/src/layout/components/Navbar.vue index 3b59e608..4269e57d 100644 --- a/web/src/layout/components/Navbar.vue +++ b/web/src/layout/components/Navbar.vue @@ -75,7 +75,8 @@
- + +
@@ -179,4 +168,12 @@ const closeDialog = () => { line-height: 110px; border-radius: 50%; } + +.avatar-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + gap: 12px; +}