链盾shieldchiain | 团队功能、邀请成员、权限修改、移除成员、SpringSecurity、RBAC权限控制

数据库最终版本

sys_role

sys_team

sys_user_team

sys_invitation

建表

复制代码
-- 用户-团队关联表(用户与团队的多对多关系,存储用户在团队中的角色)
DROP TABLE IF EXISTS `sys_user_team`;
CREATE TABLE `sys_user_team` (
  `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `user_id` INT NOT NULL COMMENT '用户ID(关联user表id)',
  `team_id` INT NOT NULL COMMENT '团队ID(关联sys_team表team_id)',
  `role_id` TINYINT NOT NULL COMMENT '角色ID(关联sys_role表role_id)',
  `join_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '加入团队时间',
  `user_name` VARCHAR(50) DEFAULT '' COMMENT '关联用户姓名(冗余字段,优化查询)',
  `team_name` VARCHAR(100) DEFAULT '' COMMENT '关联团队名称(冗余字段,优化查询)',
  `role_name` VARCHAR(50) DEFAULT '' COMMENT '关联角色名称(冗余字段,优化查询)',
  PRIMARY KEY (`id`),
  -- 联合唯一索引:避免用户在同一团队重复关联
  UNIQUE KEY `uk_SYS_user_team` (`user_id`, `team_id`),
  -- 外键约束(关联用户表)
  CONSTRAINT `fk_SYS_user_team_user` FOREIGN KEY (`user_id`) 
    REFERENCES `user` (`id`) 
    ON DELETE CASCADE  -- 当用户被删除时,自动删除该用户的所有团队关联记录
    ON UPDATE CASCADE, -- 当用户ID更新时,自动同步更新
  -- 外键约束(关联团队表)
  CONSTRAINT `fk_SYS_user_team_team` FOREIGN KEY (`team_id`) 
    REFERENCES `sys_team` (`team_id`) 
    ON DELETE CASCADE  -- 当团队被删除时,自动删除该团队的所有用户关联记录
    ON UPDATE CASCADE,
  -- 外键约束(关联角色表)
  CONSTRAINT `fk_SYS_user_team_role` FOREIGN KEY (`role_id`) 
    REFERENCES `sys_role` (`role_id`) 
    ON DELETE RESTRICT  -- 禁止删除已被用户-团队关联使用的角色
    ON UPDATE CASCADE,
  -- 普通索引:优化按团队、角色查询的场景
  KEY `idx_team_id` (`team_id`),
  KEY `idx_role_id` (`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户-团队关联表';

修改user表新增email字段,用于邀请成员逻辑

前端

1. api配置

javascript 复制代码
// api/team.js
import request from '@/utils/request'

// 获取团队成员分页列表
export function getTeamMembers(params) {
    return request({
        url: '/team/members',
        method: 'get',
        params: {
            page: params.page,
            size: params.size
        }
    })
}

// 邀请成员
export function inviteMember(data) {
    return request({
        url: '/team/invite',
        method: 'post',
        data
    })
}

// 修改成员角色
export function updateMemberRole(data) {
    return request({
        url: '/team/member/role',
        method: 'put',
        data
    })
}

// 移除成员
export function removeMember(userTeamId) {
    return request({
        url: `/team/member/${userTeamId}`,
        method: 'delete'
    })
}

2. 前端逻辑

javascript 复制代码
<script setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { 
    UserFilled, Plus, Edit, Delete, Message, Link
} from '@element-plus/icons-vue'

import { 
    getTeamMembers, inviteMember, updateMemberRole, removeMember 
} from '@/api/team.js'

const route = useRoute()

// ========== 页面状态 ==========
const loading = ref(false)

const inviteDialogVisible = ref(false)
const inviteMethod = ref('email')
const selectedRole = ref('member')
const emailAddress = ref('')
const inviteLink = ref('')

// 删除成员的对话框状态
const removeDialogVisible = ref(false)
const removeTarget = ref(null)
// 打开删除对话框
const handleRemoveDialog = (member) => {
    removeTarget.value = member
    removeDialogVisible.value = true
}
//执行删除
const confirmRemoveMember = async () => {
    try {
        const response = await removeMember(removeTarget.value.id)

        if (response.code === 200) {
            ElMessage.success('成员移除成功')
            removeDialogVisible.value = false
            loadTeamMembers()
        } else {
            ElMessage.error(response.msg || '移除失败')
        }
    } catch (error) {
        ElMessage.error('移除失败,请稍后重试')
    }
}



// 分页
const pagination = reactive({
    currentPage: 1,
    pageSize: 10,
    total: 0
})

// 角色选项
const roleOptions = [
    { label: '管理员', value: 'admin' },
    { label: '只读角色', value: 'readonly' },
    { label: '普通成员', value: 'member' }
]


// 成员列表
const teamMembers = ref([])


const roleDialogVisible = ref(false)
const roleForm = reactive({
    userId: null,
    role: ''
})

// 打开编辑角色对话框
const openRoleDialog = (member) => {
    roleForm.userId = member.id
    roleForm.role = member.role
    roleDialogVisible.value = true
}

// 提交修改
const submitRoleUpdate = async () => {
    const roleMap = {
        admin: 3,
        member: 2,
        readonly: 1
    }

    const response = await updateMemberRole({
        userId: roleForm.userId,
        roleId: roleMap[roleForm.role]
    })

    if (response.code === 1) {
        ElMessage.success('角色修改成功')
        roleDialogVisible.value = false
        loadTeamMembers()
    } else {
        ElMessage.error(response.msg || '修改失败')
    }
}


// ========== 加载团队成员 ==========
const loadTeamMembers = async () => {
    try {
        loading.value = true
        const params = {
            page: pagination.currentPage,
            size: pagination.pageSize
        }

        const response = await getTeamMembers(params)
        if (response.code === 1) {
            teamMembers.value = response.data.records || []
            pagination.total = response.data.total || 0
        } else {
            ElMessage.error(response.msg || '获取团队成员失败')
        }
    } catch (error) {
        ElMessage.error('网络错误,请稍后重试')
        console.error('加载团队成员失败:', error)
    } finally {
        loading.value = false
    }
}

// 页面初始化加载
onMounted(() => loadTeamMembers())

// 路由变化自动刷新
watch(() => route.fullPath, () => loadTeamMembers())

// ========== 邀请成员 ==========
const handleInvite = async () => {
    try {
        if (inviteMethod.value === 'email' && !emailAddress.value) {
            ElMessage.warning('请输入邮箱地址')
            return
        }

        const inviteData = {
            inviteMethod: inviteMethod.value,
            email: emailAddress.value,
            role: selectedRole.value
        }

        const response = await inviteMember(inviteData)

        if (response.code === 1) {
            ElMessage.success(inviteMethod.value === 'email' ? '邀请发送成功' : '邀请链接生成成功')

            if (inviteMethod.value === 'link') {
                inviteLink.value = response.data
                // 链接邀请:不关闭窗口,仅生成链接(用户手动点击取消关闭)
            } else {
                // 邮件邀请:成功后关闭窗口,清空邮箱
                inviteDialogVisible.value = false
                emailAddress.value = ''
                loadTeamMembers() // 邮件邀请成功后刷新成员列表(链接邀请无需刷新)
            }
        } else {
            ElMessage.error(response.msg || '邀请失败')
        }
    } catch (error) {
        ElMessage.error('邀请失败,请检查网络连接')
        console.error('邀请成员失败:', error)
    }
}



// ========== 修改权限(最终生效版,解决弹窗无响应) ==========
const handleEditRole = async (member) => {
    try {
        // 1. 确认修改弹窗(保持不变)
        await ElMessageBox.confirm(
            `确定要修改 ${member.username} 的角色吗?`,
            '修改角色',
            { 
                type: 'warning',
                confirmButtonText: '确定',
                cancelButtonText: '取消',
                // 确保按钮可点击(避免样式阻塞)
                customClass: 'no-block-btn'
            }
        )

        // 2. 创建响应式的角色值
        const selectedRole = ref(member.role)
        
        // 3. 使用 h() 函数创建包含下拉框的 MessageBox
        const { value: newRole } = await ElMessageBox({
            title: '选择新角色',
            message: h('div', [
                h('div', { style: 'margin-bottom: 15px; color: #606266;' }, '请选择新角色:'),
                h('el-select', {
                    modelValue: selectedRole.value,
                    'onUpdate:modelValue': (val) => { selectedRole.value = val },
                    style: 'width: 100%',
                    placeholder: '请选择角色'
                }, [
                    h('el-option', { label: '管理员', value: 'admin' }),
                    h('el-option', { label: '只读角色', value: 'readonly' }),
                    h('el-option', { label: '普通成员', value: 'member' })
                ])
            ]),
            beforeClose: (action, instance, done) => {
                if (action === 'confirm' && !selectedRole.value) {
                    ElMessage.warning('请选择角色')
                    return false
                }
                done()
            }
        })


        // 3. 角色字符串 → 后端数字ID(不变)
        const roleMap = {
            admin: 3,
            member: 2,
            readonly: 1
        };
        const roleId = roleMap[newRole];

        // 4. 调用后端接口(不变)
        const response = await updateMemberRole({
            userId: member.id,
            roleId: roleId
        });

        // 5. 结果处理(不变)
        if (response.code === 1) {
            ElMessage.success('角色修改成功');
            loadTeamMembers();
        } else {
            ElMessage.error(response.msg || '修改失败');
        }

    } catch (error) {
        // 捕获取消操作(兼容 Element Plus 不同版本的错误格式)
        if (error === 'cancel' || error?.name === 'CanceledError' || error?.code === 'CANCEL') {
            return;
        }
        ElMessage.error('修改角色失败');
        console.error('修改角色异常:', error);
    }
}

// ========== 移除成员 ==========
const handleRemoveMember = async (member) => {
    try {
        await ElMessageBox.confirm(
            `确定要移除成员 ${member.username} 吗?`,
            '确认操作',
            { type: 'warning' }
        )

        const response = await removeMember(member.id)
        if (response.code === 1) {
            ElMessage.success('成员移除成功')
            loadTeamMembers()
        } else {
            ElMessage.error(response.msg || '移除失败')
        }
    } catch (error) {
        if (error !== 'cancel') {
            ElMessage.error('移除成员失败')
        }
    }
}

// ========== 工具方法 ==========
const getRoleTagType = (role) => ({
    admin: 'danger',
    readonly: 'warning',
    member: 'success'
}[role] || 'info')

const getRoleText = (role) => ({
    admin: '管理员',
    readonly: '只读角色',
    member: '普通成员'
}[role] || role)

const handlePageChange = () => loadTeamMembers()

// ========== 复制邀请链接(最终修复版) ==========
const copyInviteLink = () => {
    // 1. 先验证链接是否存在
    if (!inviteLink.value) {
        ElMessage.warning('邀请链接尚未生成,请先点击"确认"生成链接')
        return
    }

    try {
        // 方案1:优先使用剪贴板 API(现代浏览器 HTTPS 环境)
        if (navigator.clipboard && window.isSecureContext) {
            navigator.clipboard.writeText(inviteLink.value).then(() => {
                ElMessage.success('链接已复制到剪贴板')
            }).catch(() => {
                // API 失败,自动降级到方案2
                fallbackCopy(inviteLink.value)
            })
        } else {
            // 非 HTTPS/本地环境,直接用方案2
            fallbackCopy(inviteLink.value)
        }
    } catch (error) {
        // 极端情况,直接提示手动复制
        ElMessage.error('复制失败,请手动选中链接复制')
        console.error('复制失败:', error)
    }
}

// 降级方案:创建临时输入框复制(兼容 HTTP/本地开发/旧浏览器)
const fallbackCopy = (text) => {
    // 创建临时 input 框(确保能被选中)
    const tempInput = document.createElement('input')
    // 关键:设置 input 为可见(部分浏览器对隐藏元素的 select 有限制)
    tempInput.style.position = 'absolute'
    tempInput.style.left = '-9999px'
    tempInput.style.top = '0'
    // 绑定值(直接用传入的 text,避免 v-model 绑定延迟)
    tempInput.value = text
    // 必须添加到 DOM 中才能选中
    document.body.appendChild(tempInput)
    // 选中所有文本(focus + select 双重保障)
    tempInput.focus()
    tempInput.select()
    // 执行复制命令(兼容性覆盖所有浏览器)
    const success = document.execCommand('copy')
    // 复制后立即移除临时元素
    document.body.removeChild(tempInput)
    // 提示结果
    if (success) {
        ElMessage.success('链接已复制到剪贴板')
    } else {
        ElMessage.error('复制失败,请手动选中链接复制')
    }
}

3. 前端页面

html 复制代码
<template>
    <div class="team-page">
        <!-- 页面标题区域 -->
        <div class="page-header">
            <div class="header-content">
                <div class="title-wrapper">
                    <h1>团队管理</h1>
                </div>
                <p class="subtitle">管理团队成员和权限分配</p>
            </div>
        </div>

        <!-- 内容区域 -->
        <div class="content-wrapper">
            <!-- 操作栏 -->
            <div class="operate-bar">
                <el-button type="primary" @click="inviteDialogVisible = true">
                    <el-icon><Plus /></el-icon>邀请成员
                </el-button>
            </div>

            <!-- 成员列表 -->
            <el-card class="member-list" shadow="never">
                <template #header>
                    <div class="card-header">
                        <div class="header-left">
                            <el-icon :size="20"><UserFilled /></el-icon>
                            <span class="header-title">成员列表</span>
                        </div>
                    </div>
                </template>

                <el-table 
                    :data="teamMembers" 
                    style="width: 100%"
                    :header-cell-style="{ 
                        backgroundColor: '#f5f7fa',
                        fontSize: '15px',
                        fontWeight: 600
                    }"
                >
                    <el-table-column 
                        prop="username" 
                        label="用户名" 
                        min-width="200"
                    />
                    <el-table-column 
                        prop="role" 
                        label="角色" 
                        width="500"
                    >
                        <template #default="{ row }">
                            <el-tag 
                                :type="getRoleTagType(row.role)"
                                effect="light"
                            >
                                {{ getRoleText(row.role) }}
                            </el-tag>
                        </template>
                    </el-table-column>
                    <el-table-column 
                        prop="joinTime" 
                        label="加入时间" 
                        width="500" 
                    />
                    <el-table-column 
                        label="操作" 
                        width="200" 
                        fixed="right"
                    >
                        <template #default="{ row }">
                            <div class="operation-buttons">
                                <el-tooltip content="修改权限" placement="top">
                                    <el-button type="primary" @click="openRoleDialog(row)" link>
                                        <el-icon :size="20"><Edit /></el-icon>
                                    </el-button>

                                </el-tooltip>
                                <el-tooltip content="移除成员" placement="top">
                                    <el-button 
                                        type="danger" 
                                        @click="handleRemoveDialog(row)"
                                        link
                                    >
                                        <el-icon :size="20"><Delete /></el-icon>
                                    </el-button>
                                </el-tooltip>
                            </div>
                        </template>
                    </el-table-column>
                </el-table>

                <!-- 分页 -->
                <div class="pagination-container">
                    <el-pagination
                        v-model:current-page="pagination.currentPage"
                        v-model:page-size="pagination.pageSize"
                        :page-sizes="[10, 20, 50, 100]"
                        :total="pagination.total"
                        layout="total, sizes, prev, pager, next, jumper"
                        @size-change="handlePageChange"
                        @current-change="handlePageChange"
                        background
                    />
                </div>
            </el-card>
        </div>

        <!-- 邀请对话框 -->
        <el-dialog
            v-model="inviteDialogVisible"
            title="邀请成员"
            width="500px"
            align-center
            class="export-dialog"
        >
            <div class="invite-form">
                <div class="section-title">选择邀请方式:</div>
                <el-radio-group v-model="inviteMethod" class="invite-method">
                    <el-radio label="email" border class="method-radio">
                        <div class="method-content">
                            <el-icon><Message /></el-icon>
                            <span>邮件邀请</span>
                        </div>
                    </el-radio>
                    <el-radio label="link" border class="method-radio">
                        <div class="method-content">
                            <el-icon><Link /></el-icon>
                            <span>链接邀请</span>
                        </div>
                    </el-radio>
                </el-radio-group>

                <el-divider content-position="center">角色设置</el-divider>

                <div class="role-select">
                    <div class="section-title">选择角色权限:</div>
                    <el-select v-model="selectedRole" placeholder="请选择角色" class="role-selector">
                        <el-option
                            v-for="option in roleOptions"
                            :key="option.value"
                            :label="option.label"
                            :value="option.value"
                        />
                    </el-select>
                </div>

                <template v-if="inviteMethod === 'email'">
                    <div class="email-input">
                        <div class="section-title">邮箱地址:</div>
                        <el-input 
                            v-model="emailAddress"
                            placeholder="请输入邮箱地址"
                            size="large"
                        />
                    </div>
                </template>

                <template v-else>
                    <div class="link-display">
                        <div class="section-title">邀请链接:</div>
                        <el-alert
                            title="请妥善保管邀请链接,链接有效期为24小时"
                            type="info"
                            :closable="false"
                            show-icon
                            class="link-alert"
                        />
                        <div class="link-box">
                            <el-input 
                                v-model:value="inviteLink"
                                readonly
                                size="large"
                                :value="inviteLink" 
                            >
                                <template #append>
                                    <el-button type="button" @click="copyInviteLink">复制</el-button>
                                </template>
                            </el-input>
                        </div>
                    </div>
                </template>
            </div>

            <template #footer>
                <span class="dialog-footer">
                    <el-button plain @click="inviteDialogVisible = false">取消</el-button>
                    <el-button type="primary" @click="handleInvite">
                        确认
                    </el-button>
                </span>
            </template>
        </el-dialog>

        <el-dialog
            v-model="roleDialogVisible"
            title="修改角色"
            width="400px" >
            <el-form label-width="100px">
                <el-form-item label="角色">
                    <el-select v-model="roleForm.role" placeholder="请选择角色">
                        <el-option label="管理员" value="admin" />
                        <el-option label="只读角色" value="readonly" />
                        <el-option label="普通成员" value="member" />
                    </el-select>
                </el-form-item>
            </el-form>

            <template #footer>
                <el-button @click="roleDialogVisible = false">取消</el-button>
                <el-button type="primary" @click="submitRoleUpdate">
                    确认
                </el-button>
            </template>
        </el-dialog>

        <el-dialog v-model="removeDialogVisible" title="确认移除" width="400px">
            <span>确定要移除成员 {{ removeTarget?.username }} 吗?此操作不可恢复。</span>

            <template #footer>
                <el-button @click="removeDialogVisible = false">取消</el-button>
                <el-button type="danger" @click="confirmRemoveMember">移除</el-button>
            </template>
        </el-dialog>

    </div>
</template>

后端

邮箱邀请时先校验用户是否存在,存在则直接加入团队,不存在则返回错误

  1. 邮箱邀请逻辑分支 :单独处理 email 邀请方式,先查询 user 表是否存在该邮箱;
  2. 用户存在校验 :存在则直接创建 sys_user_team 关联记录(加入团队),不存在则返回 code=0 错误;
  3. 保留原有邀请记录:即使直接加入团队,仍创建邀请记录(状态设为 "已确认"),便于追溯;
  4. 新增 Mapper 方法:查询邮箱对应的用户 ID、校验用户是否已在团队。

0. 实体类、DTO、VO

复制代码
SysInvitation
java 复制代码
package com.sky.entity;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.time.LocalDateTime;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SysInvitation implements Serializable {
    private static final long serialVersionUID = 1L;

    // 邀请唯一标识
    private Integer inviteId;

    // 邀请者ID(必须是团队管理员)
    private Integer createUser;

    // 被邀请人邮箱(用户可能未注册,所以不用user_id)
    private String inviteEmail;

    // 邀请加入的团队ID
    private Integer teamId;

    // 角色(1-只读,2-普通,3-管理员)
    private Integer roleId;

    // 邀请状态:0-待确认,1-已接受,2-已拒绝
    private Integer status;

    // 邀请链接(用于未注册用户点击注册并加入)
    private String inviteLink;

    // 邀请发起时间
    private LocalDateTime createTime;

    // 被邀请人确认时间(接受/拒绝时更新)
    private LocalDateTime updateTime;

    // 更新操作人(可选)
    private Integer updateUser;
}
复制代码
SysUserTeam
java 复制代码
package com.sky.entity;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.time.LocalDateTime;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SysUserTeam implements Serializable {
    private static final long serialVersionUID = 1L;

    // 自增主键
    private Integer id;

    // 用户id
    private Integer userId;

    // 团队id
    private Integer teamId;

    // 角色id
    private Integer roleId;

    // 用户加入团队时间
    private LocalDateTime joinTime;

    private String userName;

    private String teamName;

    private String roleName;

    // 状态(0-不可用,1-可用)
    private Integer status;

    private LocalDateTime updateTime;

    private Integer updateUser;
}
复制代码
Team
java 复制代码
package com.sky.entity;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.time.LocalDateTime;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Team implements Serializable {
    private static final long serialVersionUID = 1L;

    private Integer teamId;
    private String teamName;
    private String teamDesc;
    private Integer createUser;
    private Integer updateUser;
    private LocalDateTime createTime;
    private LocalDateTime updateTime;
    // 团队状态:0-无效状态,1-有效状态
    private Integer status;

}
复制代码
InviteRequestDTO
java 复制代码
package com.sky.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.io.Serializable;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class InviteRequestDTO implements Serializable {
    private static final long serialVersionUID = 1L;

    @NotBlank(message = "邀请方式不能为空")
    private String inviteMethod; // email/link

    @Email(message = "邮箱格式不正确")
    private String email;

    @NotNull(message = "角色不能为空")
    // 后端传过来的数据:admin、readonly、member
    private String role;

    private Integer teamId; // 从当前用户信息获取
}
复制代码
UpdateRoleRequestDTO
java 复制代码
package com.sky.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.validation.constraints.NotNull;
import java.io.Serializable;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UpdateRoleRequestDTO implements Serializable {
    private static final long serialVersionUID = 1L;

    @NotNull(message = "用户ID不能为空")
    private Integer userId; // 前端传:成员ID

    @NotNull(message = "角色不能为空")
    private Integer roleId;
}
复制代码
TeamMemberVO
java 复制代码
package com.sky.vo;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import com.fasterxml.jackson.annotation.JsonProperty;

import java.io.Serializable;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TeamMemberVO implements Serializable {
    private static final long serialVersionUID = 1L;

    @JsonProperty("id")
    private Integer userId;          // 映射前端的id字段

    private String username;

    @JsonProperty("role")
    private String role;             // 角色代码:admin/readonly/member

    private String joinTime;


    // 获取角色显示名称
    public String getRoleName() {
        switch (role) {
            case "admin": return "管理员";
            case "readonly": return "只读用户";
            case "member": return "普通成员";
            default: return "未知角色";
        }
    }
}

1. Controller

复制代码
SysTeamController
java 复制代码
package com.sky.controller;

import com.sky.dto.InviteRequestDTO;
import com.sky.entity.SysInvitation;
import com.sky.dto.UpdateRoleRequestDTO;
import com.sky.result.PageResult;
import com.sky.result.Result;
import com.sky.service.SysTeamService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;

/**
 * 团队管理
 */
@RestController
@RequestMapping("/team")
@Slf4j
@Api(tags = "团队相关接口")
public class SysTeamController {
    @Autowired
    private SysTeamService sysTeamService;

    /**
     * 获取当前用户所在团队成员列表
     * @param page
     * @param size
     * @return
     */
    @GetMapping("/members")
    @ApiOperation("获取当前用户所在团队成员列表")
    public Result<PageResult> getPageTeamMembers(
            @RequestParam(defaultValue = "1") Integer page,
            @RequestParam(defaultValue = "10") Integer size) {
        log.info("正在分页查询获取当前用户所在的团队成员列表...");
        PageResult pageResult = sysTeamService.getPageTeamMembers(page, size);
        return Result.success(pageResult);
    }

    /**
     * 邀请成员
     * @param request
     * @return
     */
    @PostMapping("/invite")
    @ApiOperation("邀请成员")
    public Result<String> inviteMember(@RequestBody @Valid InviteRequestDTO request) {
        log.info("用户正在邀请成员,参数为:{}" , request);
        return sysTeamService.inviteMember(request);// 直接返回result
    }

    /**
     * 修改成员角色
     * @param request
     * @return
     */
    @PutMapping("/member/role")
    @ApiOperation("修改成员角色")
    public Result<String> updateMemberRole(@RequestBody @Valid UpdateRoleRequestDTO request) {
        log.info("用户修改成员角色,参数为:{}" , request);
        sysTeamService.updateMemberRole(request);
        return Result.success("角色修改成功");
    }

    /**
     * 移除成员
     * @param userTeamId
     * @return
     */
    @DeleteMapping("/member/{userTeamId}")
    @ApiOperation("移除成员")
    public Result<String> removeMember(@PathVariable Integer userTeamId) {
        log.info("用户移除成员,userTeamId:{}" , userTeamId);
        sysTeamService.removeMember(userTeamId);
        return Result.success("成员移除成功");
    }

    /**
     * 获取邀请信息
     * @param inviteId
     * @return
     */
    @GetMapping("/invitation/{inviteId}")
    @ApiOperation("获取邀请信息")
    public Result<SysInvitation> getInvitation(@PathVariable Integer inviteId) {
        log.info("正在获取邀请信息,inviteId:{}" , inviteId);
        sysTeamService.getInvitationById(inviteId);
        return Result.success();
    }

    /**
     * 接受邀请
     * @param inviteId
     * @return
     */
    @PostMapping("/invitation/{inviteId}/accept")
    @ApiOperation("接受邀请")
    public Result<String> acceptInvitation(@PathVariable Integer inviteId) {
        log.info("接受邀请信息,inviteId:{}" , inviteId);
        sysTeamService.acceptInvitation(inviteId);
        return Result.success("加入团队成功");
    }
}

2. Service

复制代码
SysTeamService
java 复制代码
package com.sky.service;

import com.sky.dto.InviteRequestDTO;
import com.sky.dto.UpdateRoleRequestDTO;
import com.sky.result.PageResult;
import com.sky.result.Result;

public interface SysTeamService {

    /**
     * 分页查询当前团队成员
     * @param page
     * @param size
     * @return
     */
    PageResult getPageTeamMembers(Integer page, Integer size);

    /**
     * 邀请成员
     * @param request
     * @return
     */
    Result inviteMember(InviteRequestDTO request);

    /**
     * 修改成员角色
     * @param request
     * @return
     */
    Result updateMemberRole(UpdateRoleRequestDTO request);

    /**
     * 移除成员
     * @param userTeamId
     * @return
     */
    Result removeMember(Integer userTeamId);

    /**
     * 获取邀请信息
     * @param inviteId
     * @return
     */
    void getInvitationById(Integer inviteId);

    /**
     * 接受邀请
     * @param inviteId
     * @return
     */
    void acceptInvitation(Integer inviteId);
}
复制代码
SysTeamServiceImpl
java 复制代码
package com.sky.service.impl;

import com.github.pagehelper.Page;
import com.github.pagehelper.PageHelper;
import com.sky.context.BaseContext;
import com.sky.dto.InviteRequestDTO;
import com.sky.dto.UpdateRoleRequestDTO;
import com.sky.entity.*;
import com.sky.exception.TeamException;
import com.sky.mapper.SysTeamMapper;
import com.sky.result.PageResult;
import com.sky.result.Result;
import com.sky.service.SysTeamService;
import com.sky.vo.TeamMemberVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;

@Service
@Slf4j
public class SysTeamServiceImpl implements SysTeamService {

    @Autowired
    private SysTeamMapper sysTeamMapper;


    /**
     * 分页查询当期团队成员信息
     * @param page
     * @param size
     * @return
     */
    public PageResult getPageTeamMembers(Integer page, Integer size) {
        // 获取当前用户ID和团队ID
        Integer currentUserId = BaseContext.getCurrentId();
        // 修改或创建团队ID
        Integer currentTeamId = getOrCreateUserTeam(currentUserId);

        // 设置分页参数
        PageHelper.startPage(page, size);

        // 查询团队成员列表
        Page<TeamMemberVO> memberPage = sysTeamMapper.selectTeamMembersByTeamId(currentTeamId);

        return new PageResult(memberPage.getTotal(), memberPage.getResult());
    }

    /**
     * 邀请新成员--邮件邀请、链接邀请
     * @param request
     * @return
     */
    @Transactional
    public Result inviteMember(InviteRequestDTO request) {
        Integer currentUserId = BaseContext.getCurrentId();
        Integer currentTeamId = getOrCreateUserTeam(currentUserId);

        // 验证当前用户是否有邀请权限
        if (!hasInvitePermission(currentUserId, currentTeamId)) {
            throw new TeamException("无权限邀请成员");
        }

        // 2. 新增:角色字符串 → 数字ID 映射转换(核心修复)
        Integer roleId = mapRoleToId(request.getRole());
        if (roleId == null) {
            return Result.error("无效的角色类型");
        }

        // 2. 根据邀请方式处理
        if ("email".equals(request.getInviteMethod())) {
            // 2.1 获取前端传入的邮箱
            String inviteEmail = request.getEmail();
            if (inviteEmail == null || inviteEmail.trim().isEmpty()) {
                return Result.error("邮箱不能为空");
            }

            // 2.2 查找该邮箱对应的用户ID
            Integer invitedUserId = sysTeamMapper.getUserIdByEmail(inviteEmail);
            if (invitedUserId == null) {
                // 邮箱不存在,返回 code=0,前端提示"该用户不存在"
                return Result.error("该用户不存在");
            }

            // 2.3 校验该用户是否已在团队中
            Integer isInTeam = sysTeamMapper.countUserInTeam(invitedUserId, currentTeamId);
            if (isInTeam != null && isInTeam > 0) {
                return Result.error("该用户已在团队中");
            }

            // 2.4 构建邀请记录(状态设为"已确认",因为直接加入)
            SysInvitation invitation = buildInvitation(request, currentUserId, currentTeamId);
            invitation.setStatus(1); // 0=待确认,1=已确认(直接加入)
            sysTeamMapper.insertInvitation(invitation);

            // 2.5 直接将用户加入团队(创建 sys_user_team 关联记录)
            SysUserTeam userTeam = SysUserTeam.builder()
                    .userId(invitedUserId)
                    .teamId(currentTeamId)
                    .roleId(roleId) // 前端选择的角色ID
                    .joinTime(LocalDateTime.now())
                    .status(1) // 正常状态
                    .build();
            sysTeamMapper.insertUserTeam(userTeam);

            // 2.6 可选:发送"已加入团队"通知邮件(而非邀请链接)
            //sendJoinTeamNoticeEmail(inviteEmail, currentTeamId);

            log.info("通过邮箱邀请用户成功:invitedUserId={}, teamId={}, roleId={}",
                    invitedUserId, currentTeamId, roleId);
            return Result.success("邀请成功,用户已加入团队");
        } else if ("link".equals(request.getInviteMethod())) {
            // 链接邀请:保留原有逻辑(生成邀请链接,创建待确认记录)
            SysInvitation invitation = buildInvitation(request, currentUserId, currentTeamId);
            sysTeamMapper.insertInvitation(invitation);
            log.info("生成的邀请链接: {}", invitation.getInviteLink());
            return Result.success(invitation.getInviteLink());
        }

        return Result.error("无效的邀请方式");
    }

    /**
     * 更新成员角色
     * @param request
     * @return
     */
    @Transactional
    public Result updateMemberRole(UpdateRoleRequestDTO request) {
        // 1. 获取当前登录用户的团队ID(当前管理员所在团队)
        Integer currentUserId = BaseContext.getCurrentId();
        Integer currentTeamId = sysTeamMapper.getTeamIdByUserId(currentUserId);
        if (currentTeamId == null) {
            return Result.error("当前用户无所属团队");
        }

        // 2. 自动查询 userTeamId(核心:通过 团队ID + 目标用户ID 定位关联记录)
        Integer userTeamId = sysTeamMapper.getUserTeamIdByTeamIdAndUserId(currentTeamId, request.getUserId());
        if (userTeamId == null) {
            return Result.error("该用户不在当前团队中");
        }

        // 3. 校验当前用户是否是团队管理员(只有管理员能修改角色)
        Integer currentUserRole = sysTeamMapper.selectUserRoleInTeam(currentUserId, currentTeamId);
        if (currentUserRole == null || currentUserRole != 3) { // 3=管理员ID
            return Result.error("无权限修改角色");
        }

        // 4. 校验角色ID合法性
        List<Integer> validRoleIds = Arrays.asList(1, 2, 3); // 允许的角色ID
        if (!validRoleIds.contains(request.getRoleId())) {
            return Result.error("无效的角色ID");
        }

        // 5. 构造 SysUserTeam 实体类(关键修改)
        SysUserTeam sysUserTeam = new SysUserTeam();
        sysUserTeam.setId(userTeamId); // 设置用户-团队关系ID(主键)
        sysUserTeam.setRoleId(request.getRoleId()); // 设置新角色ID
        //sysUserTeam.setUserId(BaseContext.getCurrentId());

        // 5. 执行修改(更新 sys_user_team 表的 role_id)
        int rows = sysTeamMapper.updateUserTeamRole(sysUserTeam);
        return rows > 0 ? Result.success("角色修改成功") : Result.error("修改失败");
    }

    /**
     * 从团队中移除成员
     * @param userId
     * @return
     */
    @Transactional
    public Result removeMember(Integer userId) {
        // 注意:前端传的是 userId,后端自动查 userTeamId
        // 1. 获取当前登录用户(管理员)信息
        Integer currentUserId = BaseContext.getCurrentId();
        Integer currentTeamId = sysTeamMapper.getTeamIdByUserId(currentUserId);
        if (currentTeamId == null) {
            return Result.error("当前用户无所属团队");
        }

        // 2. 校验当前用户是否是团队管理员(只有管理员能移除成员)
        Integer currentUserRole = sysTeamMapper.selectUserRoleInTeam(currentUserId, currentTeamId);
        if (currentUserRole == null || currentUserRole != 3) { // 3=管理员ID
            return Result.error("无权限移除成员");
        }

        // 3. 自动查询 userTeamId(通过 团队ID + 目标用户ID)
        Integer userTeamId = sysTeamMapper.getUserTeamIdByTeamIdAndUserId(currentTeamId, userId);
        if (userTeamId == null) {
            return Result.error("该用户不在当前团队中");
        }

        // 4. 校验:不能移除自己(管理员不能删除自己)
        if (currentUserId.equals(userId)) {
            return Result.error("不能移除自己");
        }

        // 5. 构造 SysUserTeam 实体类(关键修改)
        SysUserTeam sysUserTeam = new SysUserTeam();
        sysUserTeam.setId(userTeamId);

        // 5. 执行删除(移除用户-团队关联记录)
        int rows = sysTeamMapper.removeMember(sysUserTeam);
        return rows > 0 ? Result.success("成员移除成功") : Result.error("移除失败");
    }

    @Override
    public void getInvitationById(Integer inviteId) {

    }

    @Override
    public void acceptInvitation(Integer inviteId) {

    }


    // ============ 私有方法 ============

    /**
     * 验证是否有邀请权限
     */
    private boolean hasInvitePermission(Integer userId, Integer teamId) {
        // 查询用户在团队中的角色
        Integer roleId = sysTeamMapper.selectUserRoleInTeam(userId, teamId);
        // 管理员有邀请权限(role_id = 3)
        return roleId != null && roleId == 3;
    }

    /**
     * 前端角色字符串 → 后端数字ID 映射(关键方法)
     */
    private Integer mapRoleToId(String role) {
        if (role == null) {
            return 2; // 默认普通成员
        }
        switch (role.toLowerCase()) {
            case "admin":
                return 3; // 管理员
            case "readonly":
                return 1; // 只读角色(根据后端实际ID调整)
            case "member":
                return 2; // 普通成员(根据后端实际ID调整)
            default:
                return null; // 无效角色
        }
    }

    /**
     * 构建邀请记录
     */
    private SysInvitation buildInvitation(InviteRequestDTO request, Integer createUser, Integer teamId) {
        return SysInvitation.builder()
                .inviteEmail(request.getEmail())
                .teamId(teamId)
                .roleId(mapRoleToId(request.getRole())) // 使用role_id替代isInviteAdmin
                .status(0) // 待确认
                .inviteLink(generateInviteLink())
                .build();
    }

    /**
     * 生成邀请链接
     */
    private String generateInviteLink() {
        String token = UUID.randomUUID().toString();
        return "https://shield-chain.com/invite/" + token;
    }

    /**
     * 发送邀请邮件
     */
    private void sendInviteEmail(String email, String inviteLink) {
        // 实际项目中应集成邮件服务
        log.info("发送邀请邮件到: {},链接: {}", email, inviteLink);
        // 这里可以调用邮件服务发送实际邮件
    }

    /**
     * 验证是否有管理权限
     */
    private boolean hasManagePermission(Integer userId) {
        // 简化实现,实际应根据具体权限判断
        Integer roleId = sysTeamMapper.selectUserRoleByUserId(userId);
        return roleId != null && roleId == 3; // 管理员权限
    }

    /**
     * 获取或创建当前用户的团队ID
     */
    private Integer getOrCreateUserTeam(Integer userId) {
        // 首先尝试获取用户现有的团队ID
        Integer teamId = sysTeamMapper.getTeamIdByUserId(userId);

        if (teamId == null) {
            // 用户没有团队,自动创建新团队
            teamId = createNewTeamForUser(userId);
            log.info("为用户 {} 自动创建新团队 {}", userId, teamId);
        }

        return teamId;
    }

    /**
     * 为用户创建新团队
     */
    @Transactional
    public Integer createNewTeamForUser(Integer userId) {
        // 1. 创建团队基本信息
        Team newTeam = Team.builder()
                .teamName(generateDefaultTeamName(userId))
                .createUser(userId)
                .createTime(LocalDateTime.now())
                .status(1) // 激活状态
                .build();

        sysTeamMapper.insertTeam(newTeam);
        Integer newTeamId = newTeam.getTeamId();

        // 调试日志:打印回写后的 teamId(修复后应显示具体数值,如 1、2)
        System.out.println("创建团队后回写的 teamId:" + newTeam.getTeamId());

        // 强制校验:若仍为 null,直接抛异常,避免后续错误
        if (newTeam.getTeamId() == null) {
            throw new RuntimeException("团队创建失败!自增 teamId 未回写,请检查 XML 配置和表结构");
        }

        // 2. 将用户设置为团队管理员
        SysUserTeam userTeam = SysUserTeam.builder()
                .userId(userId)
                .teamId(newTeamId)
                .roleId(3) // 管理员角色
                .joinTime(LocalDateTime.now())
                .status(1)
                .build();

        sysTeamMapper.insertUserTeam(userTeam);
        System.out.println("用户-团队关联插入成功:userId=" + userId + ", teamId=" + newTeam.getTeamId());

        return newTeam.getTeamId();
    }

    /**
     * 生成默认团队名称
     */
    private String generateDefaultTeamName(Integer userId) {
        return "团队_" + userId + "_" + System.currentTimeMillis();
    }
}

3. Mapper

复制代码
SysTeamMapper
java 复制代码
package com.sky.mapper;

import com.github.pagehelper.Page;
import com.sky.annotation.AutoFill;
import com.sky.entity.SysInvitation;
import com.sky.entity.SysUserTeam;
import com.sky.entity.Team;
import com.sky.enumeration.OperationType;
import com.sky.vo.TeamMemberVO;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;

@Mapper
public interface SysTeamMapper {

    /**
     * 根据用户ID查询团队ID
     * @param currentUserId
     * @return
     */
    @Select("select team_id from sys_user_team where user_id = #{currentUserId} ")
    Integer getTeamIdByUserId(Integer currentUserId);


    /**
     * 团队成员分页查询
     * @param currentTeamId
     * @return
     */
    Page<TeamMemberVO> selectTeamMembersByTeamId(Integer currentTeamId);

    /**
     * 查询用户在团队中的角色
     * @param userId
     * @param teamId
     * @return
     */
    @Select("SELECT role_id FROM sys_user_team WHERE user_id = #{userId} AND team_id = #{teamId} AND status = 1")
    Integer selectUserRoleInTeam(Integer userId, Integer teamId);

    /**
     * 插入邀请记录
     * @param invitation
     */
    @AutoFill(OperationType.INSERT)
    void insertInvitation(SysInvitation invitation);

    /**
     * 根据用户ID查询角色
     * @param userId
     * @return
     */
    @Select("SELECT role_id FROM sys_user_team WHERE user_id = #{userId} AND status = 1")
    Integer selectUserRoleByUserId(Integer userId);

    //Integer countRoleById(Integer roleId);

    /**
     * 插入新团队
     * @param newTeam
     */
    @AutoFill(OperationType.INSERT)
    void insertTeam(Team newTeam);

    /**
     * 插入用户-团队对应关系
     * @param userTeam
     */
    void insertUserTeam(SysUserTeam userTeam);

    /**
     * 根据邮箱查找用户id
     * @param inviteEmail
     * @return
     */
    @Select("select id from user where email = #{inviteEmail}")
    Integer getUserIdByEmail(String inviteEmail);

    /**
     * 校验用户是否已经在团队中
     * @param invitedUserId
     * @param currentTeamId
     * @return
     */
    @Select("select count(1) from sys_user_team where user_id = #{invitedUserId} and team_id = #{currentTeamId} ")
    Integer countUserInTeam(Integer invitedUserId, Integer currentTeamId);

    /**
     * 通过 团队ID + 用户ID 查询 userTeamId(自动关联,不用前端传)
     * @param currentTeamId
     * @param userId
     * @return
     */
    @Select("select id from sys_user_team where team_id = #{currentTeamId} and user_id = #{userId}")
    Integer getUserTeamIdByTeamIdAndUserId(Integer currentTeamId, Integer userId);

    /**
     * 修改成员角色
     * @param sysUserTeam
     * @return
     */
    @AutoFill(OperationType.UPDATE)
    @Update("update sys_user_team set role_id = #{roleId}, update_time = #{updateTime}, update_user = #{updateUser}  " +
            "where id = #{id}")
    int updateUserTeamRole(SysUserTeam sysUserTeam);

    /**
     * 从当前团队移除用户(status = 0)
     * @param sysUserTeam
     * @return
     */
    @AutoFill(OperationType.UPDATE)
    @Update("update sys_user_team set status = 0, update_time = #{updateTime}, update_user = #{updateUser}  where id = #{id}")
    int removeMember(SysUserTeam sysUserTeam);
}
java 复制代码
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.SysTeamMapper">

    <select id="selectTeamMembersByTeamId" resultType="com.sky.vo.TeamMemberVO">
        SELECT
            u.id as userId,
            u.username as username,
            CASE
                WHEN ut.role_id = 1 THEN 'readonly'
                WHEN ut.role_id = 2 THEN 'member'
                WHEN ut.role_id = 3 THEN 'admin'
                ELSE 'unknown'
                END as role,
            DATE_FORMAT(ut.join_time, '%Y-%m-%d %H:%i:%s') as joinTime
        FROM sys_user_team ut
                 INNER JOIN user u ON ut.user_id = u.id
        WHERE ut.team_id = #{currentTeamId}
          AND ut.status = 1
        ORDER BY ut.join_time DESC
    </select>

    <!-- 动态插入邀请记录:只插入非空字段 -->
    <insert id="insertInvitation" parameterType="com.sky.entity.SysInvitation"
            useGeneratedKeys="true" keyProperty="inviteId" keyColumn="invite_id">
        INSERT INTO sys_invitation
        <trim prefix="(" suffix=")" suffixOverrides=",">
            <!-- 主键自增,无需手动插入(useGeneratedKeys 自动返回主键) -->
            <if test="createUser != null">create_user,</if>
            <if test="inviteEmail != null and inviteEmail != ''">invite_email,</if>
            <if test="teamId != null">team_id,</if>
            <if test="roleId != null">role_id,</if>
            <if test="status != null">status,</if>
            <if test="inviteLink != null and inviteLink != ''">invite_link,</if>
            <if test="createTime != null">create_time,</if>
        </trim>
        <trim prefix="VALUES (" suffix=")" suffixOverrides=",">
            <if test="createUser != null">#{createUser},</if>
            <if test="inviteEmail != null and inviteEmail != ''">#{inviteEmail},</if>
            <if test="teamId != null">#{teamId},</if>
            <if test="roleId != null">#{roleId},</if>
            <if test="status != null">#{status},</if>
            <if test="inviteLink != null and inviteLink != ''">#{inviteLink},</if>
            <if test="createTime != null">#{createTime},</if>
        </trim>
    </insert>

    <insert id="insertUserTeam" parameterType="com.sky.entity.SysUserTeam"
            useGeneratedKeys="true" keyProperty="id" keyColumn="id">
        INSERT INTO sys_user_team
        (
            user_id,
            team_id,
            role_id,
            join_time,
            status,
            user_name,
            team_name,
            role_name
        )
        VALUES (
                   #{userId},
                   #{teamId},
                   #{roleId},
                   #{joinTime},
                   #{status},
                   -- 1. 子查询获取用户名(给 user 表加反引号,避免关键字冲突)
                   (SELECT COALESCE(username, '') FROM `user` WHERE id = #{userId}),
                   -- 2. 子查询获取团队名(处理 teamId 为 null 的情况,返回空字符串)
                   (SELECT COALESCE(team_name, '') FROM sys_team WHERE team_id = #{teamId} LIMIT 1),
                   -- 3. 子查询获取角色名
                   (SELECT COALESCE(role_name, '') FROM sys_role WHERE role_id = #{roleId} LIMIT 1)
               )
    </insert>

    <!-- 修复 insertTeam:显式指定字段,确保自增 teamId 回写 -->
    <insert id="insertTeam" parameterType="com.sky.entity.Team"
            useGeneratedKeys="true" keyProperty="teamId" keyColumn="team_id">
        INSERT INTO sys_team (team_name, create_user, create_time, status)
        VALUES (#{teamName}, #{createUser}, #{createTime}, #{status})
    </insert>
</mapper>

测试效果

邀请成员:

第一次邀请时自动为该用户创建一个团队

邮箱邀请-- 只要输入正确的邮箱就可以直接将该成员加入团队

链接邀请:点击确定,后端返回一个测试邀请链接

实际权限控制

团队功能的权限控制核心是 「基于角色的访问控制(RBAC)」 ------ 已经在功能中设计了 admin(管理员)、member(普通成员)、readonly(只读成员)三种角色,现在需要通过「后端接口校验」+「前端 UI 适配」实现全链路权限控制,确保不同角色只能操作对应权限的功能。

权限控制包括资源权限和数据权限

资源权限:一般用RBAC

第一步:修改后端核心代码(UserServiceImpl + 新增依赖组件)

  1. 新建实体sysrole
java 复制代码
package com.sky.entity;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SysRole implements Serializable {
    private static final long serialVersionUID = 1L;

    private Integer roleId;
    private String roleName;
    private String roleDesc;
    private String permCodes; // 数据库中存储的JSON字符串,如["SOFTWARE_VIEW",...]
}
  1. 修改UserLoginVO
java 复制代码
// com.sky.vo.UserLoginVO.java
import lombok.Data;
import java.util.List;

@Data
public class UserLoginVO {
    private Integer id; // 用户ID
    private String username; // 用户名
    private String did; // 用户DID
    private Integer status; // 账号状态
    private List<String> permCodes; // 权限码集合(核心:前端用于控制UI)
}
  1. 修改UserService
java 复制代码
// com.sky.service.UserService.java
import com.sky.dto.UserLoginDTO;
import com.sky.dto.UserRegisterDTO;
import com.sky.vo.UserLoginVO;

public interface UserService {
    // 登录方法返回类型改为 UserLoginVO
    UserLoginVO login(UserLoginDTO userLoginDTO);
    boolean register(UserRegisterDTO userRegisterDTO);
}
  1. serviceImpl
java 复制代码
import com.alibaba.fastjson.JSON;
import com.sky.entity.SysRole;
import com.sky.mapper.SysRoleMapper;
import com.sky.mapper.SysTeamMapper;
import com.sky.vo.UserLoginVO;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

@Service
@Slf4j
public class UserServiceImpl implements UserService {

    // ... 原有常量和注入保持不变 ...

    // 新增注入:团队Mapper和角色Mapper
    @Autowired
    private SysTeamMapper sysTeamMapper;
    @Autowired
    private SysRoleMapper sysRoleMapper;

    /**
     * 用户登录(核心修改:返回UserLoginVO + 权限码)
     */
    public UserLoginVO login(UserLoginDTO userLoginDTO) {
        String username = userLoginDTO.getUsername();
        String password = userLoginDTO.getPassword();

        // 1. 根据用户名查询数据库(原有逻辑不变)
        User user = userMapper.getByUsername(username);
        if (user == null) {
            throw new AccountNotFoundException(MessageConstant.ACCOUNT_NOT_FOUND);
        }
        if (!password.equals(user.getPassword())) { // 后期需MD5加密
            throw new PasswordErrorException(MessageConstant.PASSWORD_ERROR);
        }
        if (user.getStatus() == StatusConstant.DISABLE) {
            throw new AccountLockedException(MessageConstant.ACCOUNT_LOCKED);
        }

        // 2. 查询用户的权限码集合(新增核心逻辑)
        List<String> permCodes = getPermCodesByUserId(user.getId());

        // 3. 封装UserLoginVO(屏蔽敏感字段,只返回前端需要的信息)
        UserLoginVO loginVO = new UserLoginVO();
        BeanUtils.copyProperties(user, loginVO); // 拷贝id、username、did、status等字段
        loginVO.setPermCodes(permCodes); // 设置权限码集合

        // 4. 返回VO对象
        return loginVO;
    }

    /**
     * 新增:根据用户ID查询权限码集合(用户→团队→角色→权限)
     */
    private List<String> getPermCodesByUserId(Integer userId) {
        try {
            // 步骤1:查询用户所属团队ID(无团队则返回空权限)
            Integer teamId = sysTeamMapper.getTeamIdByUserId(userId);
            if (teamId == null) {
                log.warn("用户{}暂无所属团队,仅返回基础查看权限", userId);
                return List.of("SOFTWARE_VIEW", "DID_VIEW", "SBOM_VIEW", "VULN_VIEW"); // 默认只读权限
            }

            // 步骤2:查询用户在该团队的角色ID
            Integer roleId = sysTeamMapper.getRoleIdByUserIdAndTeamId(userId, teamId);
            if (roleId == null) {
                log.warn("用户{}在团队{}无角色,仅返回基础查看权限", userId, teamId);
                return List.of("SOFTWARE_VIEW", "DID_VIEW", "SBOM_VIEW", "VULN_VIEW");
            }

            // 步骤3:查询角色对应的perm_codes(JSON字符串转List)
            SysRole role = sysRoleMapper.selectById(roleId);
            if (role == null || role.getPermCodes() == null) {
                log.warn("角色{}未配置权限码", roleId);
                return new ArrayList<>();
            }

            // 步骤4:JSON字符串解析为List<String>(匹配数据库存储格式)
            return JSON.parseArray(role.getPermCodes(), String.class);
        } catch (Exception e) {
            log.error("查询用户{}权限码失败", userId, e);
            return new ArrayList<>(); // 异常时返回空权限,避免登录失败
        }
    }

    // ... 注册方法(register)保持不变 ...
}

第二步:后端权限拦截(接口级校验)

通过「自定义注解 + AOP」拦截接口,确保无权限用户无法调用敏感接口(如邀请成员、上传软件)。

  1. 自定义新注解
java 复制代码
// com.sky.annotation.RequiresPerm.java
import java.lang.annotation.*;

@Target({ElementType.METHOD}) // 仅用于接口方法
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RequiresPerm {
    String value(); // 传入需要的权限码(如"TEAM_INVITE")
}
  1. 新增权限拦截切面
java 复制代码
// com.sky.aspect.PermAspect.java
import com.sky.exception.NoPermissionException;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.core.annotation.Order;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.util.Arrays;
import java.util.List;

@Aspect
@Component
@Order(1) // 确保权限校验优先执行
public class PermAspect {

    // 切入点:拦截所有带@RequiresPerm注解的方法
    @Pointcut("@annotation(com.sky.annotation.RequiresPerm)")
    public void permPointcut() {}

    @Around("permPointcut() && @annotation(requiresPerm)")
    public Object checkPerm(ProceedingJoinPoint joinPoint, com.sky.annotation.RequiresPerm requiresPerm) throws Throwable {
        // 1. 获取当前登录用户的权限码(从SecurityContext,需配合Spring Security)
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null) {
            throw new NoPermissionException("用户未登录");
        }

        // 2. 从用户信息中获取权限码集合(这里假设登录时已将permCodes存入Authentication)
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        List<String> userPerms = Arrays.asList(userDetails.getAuthorities().stream()
                .map(grantedAuthority -> grantedAuthority.getAuthority())
                .toArray(String[]::new));

        // 3. 校验是否拥有注解指定的权限码
        String requiredPerm = requiresPerm.value();
        if (!userPerms.contains(requiredPerm)) {
            throw new NoPermissionException("无权限执行该操作");
        }

        // 4. 有权限,执行原接口方法
        return joinPoint.proceed();
    }
}
  1. 接口添加权限注释

给需要权限控制的接口添加 @RequiresPerm,绑定对应的权限码:

java 复制代码
// 团队管理接口(示例)
@RestController
@RequestMapping("/team")
public class TeamController {

    // 邀请成员:仅管理员有TEAM_INVITE权限
    @PostMapping("/invite")
    @RequiresPerm("TEAM_INVITE")
    public Result inviteMember(@RequestBody InviteDTO inviteDTO) {
        return teamService.inviteMember(inviteDTO);
    }

    // 移除成员:仅管理员有TEAM_REMOVE权限
    @DeleteMapping("/member/{userId}")
    @RequiresPerm("TEAM_REMOVE")
    public Result removeMember(@PathVariable Integer userId) {
        return teamService.removeMember(userId);
    }

    // 上传软件包:普通用户/管理员有SOFTWARE_UPLOAD权限
    @PostMapping("/software/upload")
    @RequiresPerm("SOFTWARE_UPLOAD")
    public Result uploadSoftware(@RequestParam MultipartFile file) {
        return softwareService.upload(file);
    }

    // 查看软件列表:所有角色有SOFTWARE_VIEW权限
    @GetMapping("/software/list")
    @RequiresPerm("SOFTWARE_VIEW")
    public Result<PageResult> listSoftware() {
        return softwareService.getPageList();
    }
}
  1. 新增无权限异常类(NoPermissionException)
java 复制代码
// com.sky.exception.NoPermissionException.java
import com.sky.constant.MessageConstant;

public class NoPermissionException extends RuntimeException {
    public NoPermissionException(String message) {
        super(message);
    }
}
  1. 全局异常处理
java 复制代码
// com.sky.exception.GlobalExceptionHandler.java
import com.sky.result.Result;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    // 无权限异常处理
    @ExceptionHandler(NoPermissionException.class)
    public Result handleNoPermission(NoPermissionException e) {
        return Result.error(e.getMessage()); // 返回code=0 + 错误信息
    }

    // ... 其他异常处理(如账号不存在、密码错误)保持不变 ...
}

第三步:前端权限控制(UI 适配)

前端核心是「登录时存储权限码 + 根据权限码显示 / 隐藏功能按钮 + 路由拦截」。

1.登录时存储权限码

javascript 复制代码
// src/api/user.js(登录接口)
export const login = (userLoginDTO) => {
  return request({
    url: '/user/login',
    method: 'POST',
    data: userLoginDTO
  });
};

// 登录页面逻辑(src/views/login/Login.vue)
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { login } from '@/api/user';
import { ElMessage } from 'element-plus';

const router = useRouter();
const form = ref({ username: '', password: '' });

const handleLogin = async () => {
  try {
    const res = await login(form.value);
    if (res.code === 1) {
      const { id, username, permCodes } = res.data;
      // 存储用户信息和权限码到本地存储(或Pinia)
      localStorage.setItem('userId', id);
      localStorage.setItem('username', username);
      localStorage.setItem('permCodes', JSON.stringify(permCodes)); // 关键:存储权限码
      ElMessage.success('登录成功');
      router.push('/home');
    }
  } catch (err) {
    ElMessage.error(err.message || '登录失败');
  }
};
  1. 封装权限工具函数
java 复制代码
// src/utils/permission.js
/**
 * 判断用户是否拥有指定权限码
 * @param {string} perm - 权限码(如"TEAM_INVITE")
 * @returns {boolean}
 */
export const hasPerm = (perm) => {
  const permCodes = JSON.parse(localStorage.getItem('permCodes') || '[]');
  return permCodes.includes(perm);
};

/**
 * 判断用户是否拥有任意一个权限码(适用于多权限兼容场景)
 * @param {string[]} perms - 权限码数组(如["SOFTWARE_VIEW", "SOFTWARE_FULL_VIEW"])
 * @returns {boolean}
 */
export const hasAnyPerm = (perms) => {
  const permCodes = JSON.parse(localStorage.getItem('permCodes') || '[]');
  return perms.some(perm => permCodes.includes(perm));
};
  1. 组件中控制 UI 显示 / 隐藏

根据权限码显示按钮,完全匹配你的角色权限设计:

java 复制代码
<!-- 团队管理页面示例 -->
<template>
  <!-- 邀请成员按钮:仅管理员有TEAM_INVITE权限 -->
  <el-button 
    type="primary" 
    @click="showInviteDialog = true"
    v-if="hasPerm('TEAM_INVITE')"
    style="margin-bottom: 20px;"
  >
    邀请成员
  </el-button>

  <!-- 软件上传按钮:普通用户/管理员有SOFTWARE_UPLOAD权限 -->
  <el-button 
    type="success" 
    @click="handleUpload"
    v-if="hasPerm('SOFTWARE_UPLOAD')"
  >
    上传软件包
  </el-button>

  <!-- 成员操作列:修改权限/移除成员(仅管理员可见) -->
  <el-table-column label="操作" width="180">
    <template #default="{ row }">
      <el-button 
        type="text" 
        @click="handleEditRole(row)"
        v-if="hasPerm('TEAM_REMOVE')"
      >
        修改权限
      </el-button>
      <el-button 
        type="text" 
        color="red" 
        @click="handleRemoveMember(row)"
        v-if="hasPerm('TEAM_REMOVE')"
      >
        移除成员
      </el-button>
    </template>
  </el-table-column>
</template>

<script setup>
import { hasPerm } from '@/utils/permission';
// ... 其他逻辑(如showInviteDialog、handleUpload等)
</script>
  1. 路由权限拦截

避免用户直接通过 URL 访问无权限页面:

java 复制代码
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import { hasPerm } from '@/utils/permission';
import { ElMessage } from 'element-plus';

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/team/invite',
      component: () => import('@/views/team/InviteMember.vue'),
      meta: { requiredPerm: 'TEAM_INVITE' } // 需要的权限码
    },
    {
      path: '/software/upload',
      component: () => import('@/views/software/Upload.vue'),
      meta: { requiredPerm: 'SOFTWARE_UPLOAD' }
    }
  ]
});

// 路由守卫:进入页面前置校验
router.beforeEach((to, from, next) => {
  const requiredPerm = to.meta.requiredPerm;
  if (requiredPerm) { // 该页面需要权限
    if (hasPerm(requiredPerm)) {
      next(); // 有权限,放行
    } else {
      ElMessage.warning('无权限访问该页面');
      next(from.path); // 无权限,跳回原页面
    }
  } else {
    next(); // 无需权限,放行
  }
});

export default router;
相关推荐
Seven9727 分钟前
剑指offer-41、和为S的连续正数序列
java
凯子坚持 c28 分钟前
不用复杂配置!本地 Chat2DB 秒变远程可用,跨网操作数据库就这么简单
数据库
q***656929 分钟前
Windows环境下安装Redis并设置Redis开机自启
数据库·windows·redis
q***965835 分钟前
Windows版Redis本地后台启动
数据库·windows·redis
q***816439 分钟前
【Redis】centos7 systemctl 启动 Redis 失败
数据库·redis·缓存
q***098042 分钟前
MySQL 常用 SQL 语句大全
数据库·sql·mysql
q***649742 分钟前
VS与SQL Sever(C语言操作数据库)
c语言·数据库·sql
程序员小假1 小时前
有了解过 SpringBoot 的参数配置吗?
java·后端
f***24111 小时前
java学习进阶之路,如果从一个菜鸟进阶成大神
java·开发语言·学习