feat: 接入个人中心真实资料

This commit is contained in:
sunlei 2026-06-07 20:09:24 +08:00
parent 395712e6e5
commit 79650de8b0
8 changed files with 215 additions and 5 deletions

20
sql/admin-user-avatar.sql Normal file
View File

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

View File

@ -45,6 +45,7 @@ CREATE TABLE IF NOT EXISTS `admin_user` (
`username` varchar(255) NOT NULL, `username` varchar(255) NOT NULL,
`password` varchar(255) NOT NULL, `password` varchar(255) NOT NULL,
`real_name` varchar(255) NOT NULL, `real_name` varchar(255) NOT NULL,
`avatar` varchar(1024) NOT NULL DEFAULT '',
`dept_id` bigint DEFAULT NULL, `dept_id` bigint DEFAULT NULL,
`home_path` varchar(255) NOT NULL DEFAULT '', `home_path` varchar(255) NOT NULL DEFAULT '',
`timezone` varchar(255) NOT NULL DEFAULT 'Asia/Shanghai', `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; EXECUTE admin_user_dept_id_stmt;
DEALLOCATE PREPARE 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 := ( SET @admin_user_dept_idx_exists := (
SELECT COUNT(*) SELECT COUNT(*)
FROM INFORMATION_SCHEMA.STATISTICS FROM INFORMATION_SCHEMA.STATISTICS
@ -229,11 +246,11 @@ ON DUPLICATE KEY UPDATE
`status` = VALUES(`status`), `status` = VALUES(`status`),
`is_deleted` = 0; `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 VALUES
(2041700000000000001, 'vben', '123456', 'Vben', 2041700000000200002, '/workspace', 'Asia/Shanghai', 1), (2041700000000000001, 'vben', '123456', 'Vben', '', 2041700000000200002, '/workspace', 'Asia/Shanghai', 1),
(2041700000000000002, 'admin', '123456', 'Admin', 2041700000000200001, '/workspace', 'Asia/Shanghai', 1), (2041700000000000002, 'admin', '123456', 'Admin', '', 2041700000000200001, '/workspace', 'Asia/Shanghai', 1),
(2041700000000000003, 'jack', '123456', 'Jack', 2041700000000200003, '/analytics', 'Asia/Shanghai', 1) (2041700000000000003, 'jack', '123456', 'Jack', '', 2041700000000200003, '/analytics', 'Asia/Shanghai', 1)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
`password` = VALUES(`password`), `password` = VALUES(`password`),
`real_name` = VALUES(`real_name`), `real_name` = VALUES(`real_name`),

View File

@ -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 { ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentAdminUser, vbenSuccess } from '@/common'; import { CurrentAdminUser, vbenSuccess } from '@/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@ -16,4 +22,14 @@ export class AdminUserController {
async info(@CurrentAdminUser() user: AdminUser) { async info(@CurrentAdminUser() user: AdminUser) {
return vbenSuccess(this.userService.serializeUser(user)); return vbenSuccess(this.userService.serializeUser(user));
} }
@Put('profile')
@ApiOperation({ summary: '更新当前用户基础资料' })
async updateProfile(
@CurrentAdminUser() user: AdminUser,
@Body() body: Record<string, any>,
) {
const updated = await this.userService.updateCurrentProfile(user.id, body);
return vbenSuccess(this.userService.serializeUser(updated));
}
} }

View File

@ -34,6 +34,12 @@ export class AdminUser {
}) })
realName: string; realName: string;
@Column({
default: '',
length: 1024,
})
avatar: string;
@Column({ @Column({
name: 'dept_id', name: 'dept_id',
nullable: true, nullable: true,

View File

@ -142,14 +142,36 @@ export class AdminUserService {
return null; 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) { serializeUser(user: AdminUser) {
return { return {
avatar: user.avatar || '',
homePath: user.homePath, homePath: user.homePath,
id: user.id, id: user.id,
realName: user.realName, realName: user.realName,
roles: (user.roles || []) roles: (user.roles || [])
.filter((role) => !role.isDeleted && role.status === 1) .filter((role) => !role.isDeleted && role.status === 1)
.map((role) => role.roleCode), .map((role) => role.roleCode),
timezone: user.timezone,
userId: user.id,
username: user.username, username: user.username,
}; };
} }
@ -168,6 +190,7 @@ export class AdminUserService {
deptName: user.dept?.name || '', deptName: user.dept?.name || '',
homePath: user.homePath, homePath: user.homePath,
id: user.id, id: user.id,
avatar: user.avatar,
realName: user.realName, realName: user.realName,
roleIds: activeRoles.map((role) => role.id), roleIds: activeRoles.map((role) => role.id),
roleNames: activeRoles.map((role) => role.name), roleNames: activeRoles.map((role) => role.name),
@ -219,6 +242,19 @@ export class AdminUserService {
return Array.from(result); 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) { private async ensureUsernameAvailable(username: string, ignoreId?: string) {
if (!username.trim()) { if (!username.trim()) {
throwVbenError('用户名不能为空', HttpStatus.BAD_REQUEST); throwVbenError('用户名不能为空', HttpStatus.BAD_REQUEST);

View File

@ -768,6 +768,7 @@ function adminUserExample() {
id: '1000000000000000001', id: '1000000000000000001',
username: 'admin', username: 'admin',
realName: '管理员', realName: '管理员',
avatar: '/api/minio/download?objectName=avatars/admin/avatar.jpg',
status: 1, status: 1,
}; };
} }

View File

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

View File

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