From 79650de8b0bfa349d7ca312313cdde2fcbad95df Mon Sep 17 00:00:00 2001 From: sunlei Date: Sun, 7 Jun 2026 20:09:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E4=B8=AA=E4=BA=BA?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=E7=9C=9F=E5=AE=9E=E8=B5=84=E6=96=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sql/admin-user-avatar.sql | 20 +++++ sql/vben-admin-init.sql | 25 +++++- src/admin/user/admin-user.controller.ts | 18 +++- src/admin/user/admin-user.entity.ts | 6 ++ src/admin/user/admin-user.service.ts | 36 ++++++++ src/common/swagger/swagger-response.ts | 1 + test/admin/admin-user-avatar-sql.spec.ts | 28 ++++++ test/admin/admin-user-profile.service.spec.ts | 86 +++++++++++++++++++ 8 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 sql/admin-user-avatar.sql create mode 100644 test/admin/admin-user-avatar-sql.spec.ts create mode 100644 test/admin/admin-user-profile.service.spec.ts diff --git a/sql/admin-user-avatar.sql b/sql/admin-user-avatar.sql new file mode 100644 index 0000000..07bb634 --- /dev/null +++ b/sql/admin-user-avatar.sql @@ -0,0 +1,20 @@ +-- 为现有 Admin 用户表补充头像字段。 +-- 只补列,不回写数据,避免覆盖用户已上传头像。 + +SET @admin_user_avatar_exists := ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'admin_user' + AND COLUMN_NAME = 'avatar' +); + +SET @admin_user_avatar_sql := IF( + @admin_user_avatar_exists = 0, + 'ALTER TABLE `admin_user` ADD COLUMN `avatar` varchar(1024) NOT NULL DEFAULT '''' AFTER `real_name`', + 'SELECT 1' +); + +PREPARE admin_user_avatar_stmt FROM @admin_user_avatar_sql; +EXECUTE admin_user_avatar_stmt; +DEALLOCATE PREPARE admin_user_avatar_stmt; diff --git a/sql/vben-admin-init.sql b/sql/vben-admin-init.sql index b039bf6..971f636 100644 --- a/sql/vben-admin-init.sql +++ b/sql/vben-admin-init.sql @@ -45,6 +45,7 @@ CREATE TABLE IF NOT EXISTS `admin_user` ( `username` varchar(255) NOT NULL, `password` varchar(255) NOT NULL, `real_name` varchar(255) NOT NULL, + `avatar` varchar(1024) NOT NULL DEFAULT '', `dept_id` bigint DEFAULT NULL, `home_path` varchar(255) NOT NULL DEFAULT '', `timezone` varchar(255) NOT NULL DEFAULT 'Asia/Shanghai', @@ -86,6 +87,22 @@ PREPARE admin_user_dept_id_stmt FROM @admin_user_dept_id_sql; EXECUTE admin_user_dept_id_stmt; DEALLOCATE PREPARE admin_user_dept_id_stmt; +SET @admin_user_avatar_exists := ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'admin_user' + AND COLUMN_NAME = 'avatar' +); +SET @admin_user_avatar_sql := IF( + @admin_user_avatar_exists = 0, + 'ALTER TABLE `admin_user` ADD COLUMN `avatar` varchar(1024) NOT NULL DEFAULT '''' AFTER `real_name`', + 'SELECT 1' +); +PREPARE admin_user_avatar_stmt FROM @admin_user_avatar_sql; +EXECUTE admin_user_avatar_stmt; +DEALLOCATE PREPARE admin_user_avatar_stmt; + SET @admin_user_dept_idx_exists := ( SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS @@ -229,11 +246,11 @@ ON DUPLICATE KEY UPDATE `status` = VALUES(`status`), `is_deleted` = 0; -INSERT INTO `admin_user` (`id`, `username`, `password`, `real_name`, `dept_id`, `home_path`, `timezone`, `status`) +INSERT INTO `admin_user` (`id`, `username`, `password`, `real_name`, `avatar`, `dept_id`, `home_path`, `timezone`, `status`) VALUES - (2041700000000000001, 'vben', '123456', 'Vben', 2041700000000200002, '/workspace', 'Asia/Shanghai', 1), - (2041700000000000002, 'admin', '123456', 'Admin', 2041700000000200001, '/workspace', 'Asia/Shanghai', 1), - (2041700000000000003, 'jack', '123456', 'Jack', 2041700000000200003, '/analytics', 'Asia/Shanghai', 1) + (2041700000000000001, 'vben', '123456', 'Vben', '', 2041700000000200002, '/workspace', 'Asia/Shanghai', 1), + (2041700000000000002, 'admin', '123456', 'Admin', '', 2041700000000200001, '/workspace', 'Asia/Shanghai', 1), + (2041700000000000003, 'jack', '123456', 'Jack', '', 2041700000000200003, '/analytics', 'Asia/Shanghai', 1) ON DUPLICATE KEY UPDATE `password` = VALUES(`password`), `real_name` = VALUES(`real_name`), diff --git a/src/admin/user/admin-user.controller.ts b/src/admin/user/admin-user.controller.ts index e365840..cb7ac13 100644 --- a/src/admin/user/admin-user.controller.ts +++ b/src/admin/user/admin-user.controller.ts @@ -1,4 +1,10 @@ -import { Controller, Get, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Put, + UseGuards, +} from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { CurrentAdminUser, vbenSuccess } from '@/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; @@ -16,4 +22,14 @@ export class AdminUserController { async info(@CurrentAdminUser() user: AdminUser) { return vbenSuccess(this.userService.serializeUser(user)); } + + @Put('profile') + @ApiOperation({ summary: '更新当前用户基础资料' }) + async updateProfile( + @CurrentAdminUser() user: AdminUser, + @Body() body: Record, + ) { + const updated = await this.userService.updateCurrentProfile(user.id, body); + return vbenSuccess(this.userService.serializeUser(updated)); + } } diff --git a/src/admin/user/admin-user.entity.ts b/src/admin/user/admin-user.entity.ts index fe6a672..31cd702 100644 --- a/src/admin/user/admin-user.entity.ts +++ b/src/admin/user/admin-user.entity.ts @@ -34,6 +34,12 @@ export class AdminUser { }) realName: string; + @Column({ + default: '', + length: 1024, + }) + avatar: string; + @Column({ name: 'dept_id', nullable: true, diff --git a/src/admin/user/admin-user.service.ts b/src/admin/user/admin-user.service.ts index b0d7c92..5e2e8af 100644 --- a/src/admin/user/admin-user.service.ts +++ b/src/admin/user/admin-user.service.ts @@ -142,14 +142,36 @@ export class AdminUserService { return null; } + async updateCurrentProfile(userId: string, data: AdminUserInput) { + const user = await this.findActiveUser(userId); + + if (data.realName !== undefined) { + const realName = String(data.realName || '').trim(); + if (!realName) throwVbenError('姓名不能为空', HttpStatus.BAD_REQUEST); + user.realName = realName; + } + if (data.homePath !== undefined) { + user.homePath = String(data.homePath || '').trim() || '/workspace'; + } + if (data.avatar !== undefined) { + user.avatar = String(data.avatar || '').trim(); + } + + await this.userRepository.save(user); + return this.findActiveUser(userId); + } + serializeUser(user: AdminUser) { return { + avatar: user.avatar || '', homePath: user.homePath, id: user.id, realName: user.realName, roles: (user.roles || []) .filter((role) => !role.isDeleted && role.status === 1) .map((role) => role.roleCode), + timezone: user.timezone, + userId: user.id, username: user.username, }; } @@ -168,6 +190,7 @@ export class AdminUserService { deptName: user.dept?.name || '', homePath: user.homePath, id: user.id, + avatar: user.avatar, realName: user.realName, roleIds: activeRoles.map((role) => role.id), roleNames: activeRoles.map((role) => role.name), @@ -219,6 +242,19 @@ export class AdminUserService { return Array.from(result); } + private async findActiveUser(id: string) { + const user = await this.userRepository.findOne({ + relations: ['roles', 'dept'], + where: { + id, + isDeleted: false, + }, + }); + + if (!user) throwVbenError('用户不存在', HttpStatus.BAD_REQUEST); + return user; + } + private async ensureUsernameAvailable(username: string, ignoreId?: string) { if (!username.trim()) { throwVbenError('用户名不能为空', HttpStatus.BAD_REQUEST); diff --git a/src/common/swagger/swagger-response.ts b/src/common/swagger/swagger-response.ts index a7e7ad4..4d418b9 100644 --- a/src/common/swagger/swagger-response.ts +++ b/src/common/swagger/swagger-response.ts @@ -768,6 +768,7 @@ function adminUserExample() { id: '1000000000000000001', username: 'admin', realName: '管理员', + avatar: '/api/minio/download?objectName=avatars/admin/avatar.jpg', status: 1, }; } diff --git a/test/admin/admin-user-avatar-sql.spec.ts b/test/admin/admin-user-avatar-sql.spec.ts new file mode 100644 index 0000000..887581b --- /dev/null +++ b/test/admin/admin-user-avatar-sql.spec.ts @@ -0,0 +1,28 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +describe('admin user avatar sql', () => { + const avatarSql = readFileSync( + join(process.cwd(), 'sql/admin-user-avatar.sql'), + 'utf8', + ); + const initSql = readFileSync( + join(process.cwd(), 'sql/vben-admin-init.sql'), + 'utf8', + ); + + it('adds avatar column for existing databases', () => { + expect(avatarSql).toContain("COLUMN_NAME = 'avatar'"); + expect(avatarSql).toContain('ALTER TABLE `admin_user` ADD COLUMN `avatar`'); + expect(avatarSql).toContain('varchar(1024)'); + }); + + it('keeps init sql aligned without resetting existing avatars', () => { + expect(initSql).toContain('`avatar` varchar(1024) NOT NULL DEFAULT'); + expect(initSql).toContain("COLUMN_NAME = 'avatar'"); + expect(initSql).toContain( + "INSERT INTO `admin_user` (`id`, `username`, `password`, `real_name`, `avatar`, `dept_id`, `home_path`, `timezone`, `status`)", + ); + expect(initSql).not.toContain('`avatar` = VALUES(`avatar`)'); + }); +}); diff --git a/test/admin/admin-user-profile.service.spec.ts b/test/admin/admin-user-profile.service.spec.ts new file mode 100644 index 0000000..48c5891 --- /dev/null +++ b/test/admin/admin-user-profile.service.spec.ts @@ -0,0 +1,86 @@ +import { AdminUserService } from '../../src/admin/user/admin-user.service'; + +describe('AdminUserService profile', () => { + const userRepository = { + findOne: jest.fn(), + save: jest.fn(), + }; + const roleRepository = { + find: jest.fn(), + }; + const deptRepository = { + find: jest.fn(), + }; + + const service = new AdminUserService( + userRepository as any, + roleRepository as any, + deptRepository as any, + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('updates current profile fields including uploaded avatar url', async () => { + const user = { + avatar: '', + homePath: '/workspace', + id: '2041700000000000001', + realName: '旧姓名', + roles: [], + timezone: 'Asia/Shanghai', + username: 'admin', + }; + userRepository.findOne.mockResolvedValue(user); + + const result = await service.updateCurrentProfile(user.id, { + avatar: + '/api/minio/download?objectName=avatars%2F2041700000000000001%2Favatar.jpg', + homePath: '/profile', + realName: ' 新姓名 ', + }); + + expect(userRepository.findOne).toHaveBeenCalledWith( + expect.objectContaining({ + relations: ['roles', 'dept'], + where: { + id: user.id, + isDeleted: false, + }, + }), + ); + expect(userRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + avatar: + '/api/minio/download?objectName=avatars%2F2041700000000000001%2Favatar.jpg', + homePath: '/profile', + realName: '新姓名', + }), + ); + expect(result).toEqual(user); + }); + + it('serializes avatar and userId for Admin frontend user store', () => { + expect( + service.serializeUser({ + avatar: '/api/minio/download?objectName=avatars%2Favatar.jpg', + homePath: '/workspace', + id: '2041700000000000001', + realName: '管理员', + roles: [], + timezone: 'Asia/Shanghai', + username: 'admin', + } as any), + ).toEqual({ + avatar: '/api/minio/download?objectName=avatars%2Favatar.jpg', + homePath: '/workspace', + id: '2041700000000000001', + realName: '管理员', + roles: [], + timezone: 'Asia/Shanghai', + userId: '2041700000000000001', + username: 'admin', + }); + }); +});