zlwu пре 11 месеци
родитељ
комит
ba7b7b8497

+ 7 - 2
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;
+
     /**
      * 密码
      */

+ 16 - 4
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

+ 31 - 0
docs/开发文档/ImagePreview组件使用说明.md

@@ -65,6 +65,36 @@ ImagePreview 是一个图片预览组件,支持两种模式:
 </template>
 ```
 
+### 4. 顶部个人头像(自动识别OSS ID)
+
+```vue
+<script setup lang="ts">
+import { computed } from 'vue';
+import ImagePreview from '@/components/ImagePreview/index.vue';
+import { useUserStore } from '@/store/modules/user';
+
+const userStore = useUserStore();
+const avatarIsOssId = computed(() => {
+  const avatar = userStore.avatar;
+  if (!avatar) return false;
+  return !/^https?:\/\//i.test(avatar) && !avatar.startsWith('/');
+});
+</script>
+
+<template>
+  <ImagePreview
+    v-if="userStore.avatar"
+    class="user-avatar"
+    :src="userStore.avatar"
+    :width="24"
+    :height="24"
+    :is-oss-id="avatarIsOssId"
+  />
+</template>
+```
+
+> ✅ 用于首页 `Navbar` 头像区域时,`avatarIsOssId` 会自动识别当前值是否为 OSS ID,从而在带 Token 的 `/resource/oss/download/{ossId}` 接口与传统 URL 之间无缝切换,保证头像始终通过认证通道加载。
+
 ## 技术实现
 
 ### 核心功能
@@ -126,6 +156,7 @@ const urlCache = ref<Map<string, string>>(new Map());
 
 ## 更新记录
 
+- **2025-11-28**:补充顶部栏个人头像场景示例,强调 OSS ID 自动识别策略,确保 `Navbar` 等公共区域也复用统一的图片安全加载方案。
 - **2025-08-02**:修复组件,支持OSS ID模式,添加缓存机制和加载状态
 - 修复了导入错误和computed未定义的问题
 - 添加了完整的错误处理和日志记录 

+ 7 - 0
docs/开发文档/移动端开发规范总结.md

@@ -737,6 +737,13 @@ export default {
 </script>
 ```
 
+#### 头像鉴权加载
+
+- 统一从 `/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

+ 219 - 5
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,13 +840,218 @@ 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 '';
         },
 
         // 点击用户头像

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

+ 14 - 1
web/src/layout/components/Navbar.vue

@@ -75,7 +75,8 @@
       <div class="avatar-container">
         <el-dropdown class="right-menu-item hover-effect" trigger="click" @command="handleCommand">
           <div class="avatar-wrapper">
-            <img :src="userStore.avatar" class="user-avatar" />
+            <ImagePreview v-if="userStore.avatar" class="user-avatar" :src="userStore.avatar" :width="24" :height="24" :is-oss-id="avatarIsOssId" />
+            <img v-else :src="userStore.avatar" class="user-avatar" />
             <el-icon><caret-bottom /></el-icon>
           </div>
           <template #dropdown>
@@ -98,9 +99,12 @@
 </template>
 
 <script setup lang="ts">
+import { ComponentInternalInstance, computed, getCurrentInstance, ref, watch } from 'vue';
+import { storeToRefs } from 'pinia';
 import Hamburger from '@/components/Hamburger/index.vue';
 import TopNav from '@/components/TopNav/index.vue';
 import SearchMenu from './TopBar/search.vue';
+import ImagePreview from '@/components/ImagePreview/index.vue';
 import { useAppStore } from '@/store/modules/app';
 import { useUserStore } from '@/store/modules/user';
 import { useSettingsStore } from '@/store/modules/settings';
@@ -129,6 +133,15 @@ const dynamic = ref(false);
 const tenantEnabled = ref(true);
 // 搜索菜单
 const searchMenuRef = ref<InstanceType<typeof SearchMenu>>();
+const avatarIsOssId = computed(() => {
+  const avatar = userStore.avatar;
+  if (!avatar) {
+    return false;
+  }
+  const isHttp = /^https?:\/\//i.test(avatar);
+  const isAbsolutePath = avatar.startsWith('/');
+  return !isHttp && !isAbsolutePath;
+});
 
 const openSearchMenu = () => {
   searchMenuRef.value?.openSearch();

+ 7 - 1
web/src/store/modules/user.ts

@@ -13,6 +13,7 @@ export const useUserStore = defineStore('user', () => {
   const userId = ref<string | number>('');
   const tenantId = ref<string>('');
   const avatar = ref('');
+  const avatarUrl = ref('');
   const deptName = ref('');
   const phonenumber = ref('');
   const roles = ref<Array<string>>([]); // 用户角色编码集合 → 判断路由权限
@@ -40,7 +41,9 @@ export const useUserStore = defineStore('user', () => {
     if (res) {
       const data = res.data;
       const user = data.user;
-      const profile = user.avatar == '' || user.avatar == null ? defAva : user.avatar;
+      const avatarValue = user.avatar === '' || user.avatar === null || user.avatar === undefined ? '' : String(user.avatar);
+      const previewUrl = user.avatarUrl || '';
+      const profile = avatarValue || defAva;
 
       if (data.roles && data.roles.length > 0) {
         // 验证返回的roles是否是一个非空数组
@@ -52,6 +55,7 @@ export const useUserStore = defineStore('user', () => {
       name.value = user.userName;
       nickname.value = user.nickName;
       avatar.value = profile;
+      avatarUrl.value = previewUrl || (avatarValue ? '' : defAva);
       userId.value = user.userId;
       tenantId.value = user.tenantId;
       deptName.value = user.deptName;
@@ -72,6 +76,7 @@ export const useUserStore = defineStore('user', () => {
 
   const setAvatar = (value: string) => {
     avatar.value = value;
+    avatarUrl.value = value;
   };
 
   return {
@@ -81,6 +86,7 @@ export const useUserStore = defineStore('user', () => {
     name,
     nickname,
     avatar,
+    avatarUrl,
     deptName,
     phonenumber,
     roles,

+ 41 - 44
web/src/views/system/user/profile/userAvatar.vue

@@ -2,7 +2,7 @@
   <div class="user-info-head" @click="editCropper()">
     <img :src="options.img" title="点击上传头像" class="img-circle img-lg" />
     <el-dialog v-model="open" :title="title" width="800px" append-to-body @opened="modalOpened" @close="closeDialog">
-      <el-row>
+      <el-row justify="center">
         <el-col :xs="24" :md="12" :style="{ height: '350px' }">
           <vue-cropper
             v-if="visible"
@@ -17,40 +17,23 @@
             @real-time="realTime"
           />
         </el-col>
-        <el-col :xs="24" :md="12" :style="{ height: '350px' }">
-          <div class="avatar-upload-preview">
-            <img :src="options.previews.url" :style="options.previews.img" />
-          </div>
-        </el-col>
       </el-row>
       <br />
-      <el-row>
-        <el-col :lg="2" :md="2">
-          <el-upload action="#" :http-request="requestUpload" :show-file-list="false" :before-upload="beforeUpload">
-            <el-button>
-              选择
-              <el-icon class="el-icon--right">
-                <Upload />
-              </el-icon>
-            </el-button>
-          </el-upload>
-        </el-col>
-        <el-col :lg="{ span: 1, offset: 2 }" :md="2">
-          <el-button icon="Plus" @click="changeScale(1)"></el-button>
-        </el-col>
-        <el-col :lg="{ span: 1, offset: 1 }" :md="2">
-          <el-button icon="Minus" @click="changeScale(-1)"></el-button>
-        </el-col>
-        <el-col :lg="{ span: 1, offset: 1 }" :md="2">
-          <el-button icon="RefreshLeft" @click="rotateLeft()"></el-button>
-        </el-col>
-        <el-col :lg="{ span: 1, offset: 1 }" :md="2">
-          <el-button icon="RefreshRight" @click="rotateRight()"></el-button>
-        </el-col>
-        <el-col :lg="{ span: 2, offset: 6 }" :md="2">
-          <el-button type="primary" @click="uploadImg()">提 交</el-button>
-        </el-col>
-      </el-row>
+      <div class="avatar-actions">
+        <el-upload action="#" :http-request="requestUpload" :show-file-list="false" :before-upload="beforeUpload">
+          <el-button>
+            选择
+            <el-icon class="el-icon--right">
+              <Upload />
+            </el-icon>
+          </el-button>
+        </el-upload>
+        <el-button icon="Plus" @click="changeScale(1)"></el-button>
+        <el-button icon="Minus" @click="changeScale(-1)"></el-button>
+        <el-button icon="RefreshLeft" @click="rotateLeft()"></el-button>
+        <el-button icon="RefreshRight" @click="rotateRight()"></el-button>
+        <el-button type="primary" @click="uploadImg()">提 交</el-button>
+      </div>
     </el-dialog>
   </div>
 </template>
@@ -84,13 +67,13 @@ const title = ref('修改头像');
 const cropper = ref<any>({});
 //图片裁剪数据
 const options = reactive<Options>({
-  img: userStore.avatar,
+  img: userStore.avatarUrl || userStore.avatar,
   autoCrop: true,
   autoCropWidth: 200,
   autoCropHeight: 200,
   fixedBox: true,
   outputType: 'png',
-  fileName: '',
+  fileName: 'avatar.png',
   previews: {},
   visible: false
 });
@@ -134,14 +117,20 @@ const beforeUpload = (file: UploadRawFile): any => {
 /** 上传图片 */
 const uploadImg = async () => {
   cropper.value.getCropBlob(async (data: any) => {
-    const formData = new FormData();
-    formData.append('avatarfile', data, options.fileName);
-    const res = await uploadAvatar(formData);
-    open.value = false;
-    options.img = res.data.imgUrl;
-    userStore.setAvatar(options.img);
-    proxy?.$modal.msgSuccess('修改成功');
-    visible.value = false;
+    try {
+      const formData = new FormData();
+      const fileName = options.fileName || `avatar.${options.outputType || 'png'}`;
+      formData.append('avatarfile', data, fileName);
+      const res = await uploadAvatar(formData);
+      await userStore.getInfo(); // 刷新用户信息以获取最新的oss_id
+      options.img = res?.data?.imgUrl || userStore.avatarUrl || userStore.avatar;
+      open.value = false;
+      proxy?.$modal.msgSuccess('修改成功');
+    } catch (error) {
+      proxy?.$modal.msgError('上传失败,请重试');
+    } finally {
+      visible.value = false;
+    }
   });
 };
 /** 实时预览 */
@@ -150,7 +139,7 @@ const realTime = (data: any) => {
 };
 /** 关闭窗口 */
 const closeDialog = () => {
-  options.img = userStore.avatar;
+  options.img = userStore.avatarUrl || userStore.avatar;
   options.visible = false;
 };
 </script>
@@ -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;
+}
 </style>