mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-06-04 13:30:32 +08:00
feat: Implement cross-tenant access functionality
- Added configuration for enabling cross-tenant access in the application. - Introduced new API endpoints for listing and searching tenants, accessible only to users with the appropriate permissions. - Updated user and tenant models to include the `can_access_all_tenants` field. - Enhanced the authentication middleware to handle cross-tenant requests and validate user permissions. - Created a new TenantSelector component for selecting tenants in the frontend, improving user experience. - Updated internationalization files to support new tenant-related strings and messages.
This commit is contained in:
@@ -613,3 +613,8 @@ web_search:
|
||||
|
||||
# 全局超时设置
|
||||
timeout: 10
|
||||
|
||||
# 租户配置
|
||||
tenant:
|
||||
# 是否启用跨租户访问功能(内网环境可开启)
|
||||
enable_cross_tenant_access: true
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface LoginResponse {
|
||||
email: string
|
||||
avatar?: string
|
||||
tenant_id: number
|
||||
can_access_all_tenants?: boolean
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -66,6 +67,7 @@ export interface UserInfo {
|
||||
email: string
|
||||
avatar?: string
|
||||
tenant_id: string
|
||||
can_access_all_tenants?: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
83
frontend/src/api/tenant/index.ts
Normal file
83
frontend/src/api/tenant/index.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { get } from '@/utils/request'
|
||||
|
||||
// 租户信息接口
|
||||
export interface TenantInfo {
|
||||
id: number
|
||||
name: string
|
||||
description?: string
|
||||
api_key?: string
|
||||
status?: string
|
||||
business?: string
|
||||
storage_quota?: number
|
||||
storage_used?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// 搜索租户参数
|
||||
export interface SearchTenantsParams {
|
||||
keyword?: string
|
||||
tenant_id?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
// 搜索租户响应
|
||||
export interface SearchTenantsResponse {
|
||||
success: boolean
|
||||
data?: {
|
||||
items: TenantInfo[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有租户列表(需要跨租户访问权限)
|
||||
* @deprecated 建议使用 searchTenants 代替,支持分页和搜索
|
||||
*/
|
||||
export async function listAllTenants(): Promise<{ success: boolean; data?: { items: TenantInfo[] }; message?: string }> {
|
||||
try {
|
||||
const response = await get('/api/v1/tenants/all')
|
||||
return response as unknown as { success: boolean; data?: { items: TenantInfo[] }; message?: string }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || '获取租户列表失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索租户(支持分页、关键词搜索和租户ID过滤)
|
||||
*/
|
||||
export async function searchTenants(params: SearchTenantsParams = {}): Promise<SearchTenantsResponse> {
|
||||
try {
|
||||
const queryParams = new URLSearchParams()
|
||||
if (params.keyword) {
|
||||
queryParams.append('keyword', params.keyword)
|
||||
}
|
||||
if (params.tenant_id) {
|
||||
queryParams.append('tenant_id', String(params.tenant_id))
|
||||
}
|
||||
if (params.page) {
|
||||
queryParams.append('page', String(params.page))
|
||||
}
|
||||
if (params.page_size) {
|
||||
queryParams.append('page_size', String(params.page_size))
|
||||
}
|
||||
|
||||
const queryString = queryParams.toString()
|
||||
const url = `/api/v1/tenants/search${queryString ? '?' + queryString : ''}`
|
||||
const response = await get(url)
|
||||
return response as unknown as SearchTenantsResponse
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || '搜索租户失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
534
frontend/src/components/TenantSelector.vue
Normal file
534
frontend/src/components/TenantSelector.vue
Normal file
@@ -0,0 +1,534 @@
|
||||
<template>
|
||||
<div class="tenant-selector">
|
||||
<div v-if="canAccessAllTenants" class="tenant-selector-wrapper">
|
||||
<div class="tenant-menu-item" @click="toggleDropdown" ref="triggerRef">
|
||||
<div class="tenant-item-box">
|
||||
<div class="tenant-icon">
|
||||
<t-icon name="usergroup" size="16px" />
|
||||
</div>
|
||||
<span class="tenant-title">{{ currentTenantName }}</span>
|
||||
</div>
|
||||
<t-icon name="chevron-up" class="tenant-arrow" :class="{ rotated: showDropdown }" />
|
||||
</div>
|
||||
<div v-if="showDropdown" class="tenant-overlay" @click="close">
|
||||
<div class="tenant-dropdown" @click.stop :style="dropdownStyle">
|
||||
<div class="tenant-list" ref="tenantList">
|
||||
<div
|
||||
v-for="tenant in tenants"
|
||||
:key="tenant.id"
|
||||
:class="['tenant-item', { selected: isSelected(tenant.id) }]"
|
||||
@click="selectTenant(tenant.id)"
|
||||
>
|
||||
<div class="tenant-item-info">
|
||||
<span class="tenant-item-name">{{ tenant.name }}</span>
|
||||
<span v-if="tenant.description" class="tenant-item-desc">{{ tenant.description }}</span>
|
||||
<span class="tenant-item-id">ID: {{ tenant.id }}</span>
|
||||
</div>
|
||||
<t-icon v-if="isSelected(tenant.id)" name="check" size="16px" class="tenant-check-icon" />
|
||||
</div>
|
||||
<div v-if="tenants.length === 0 && !loading" class="tenant-empty">
|
||||
{{ $t('tenant.noMatch') }}
|
||||
</div>
|
||||
<div v-if="loading" class="tenant-loading">
|
||||
{{ $t('tenant.loading') }}
|
||||
</div>
|
||||
<div v-if="hasMore && !loading" class="tenant-load-more" @click="loadMore">
|
||||
{{ $t('tenant.loadMore') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tenant-search">
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="$t('tenant.searchPlaceholder')"
|
||||
class="tenant-search-input"
|
||||
@keydown.esc="closeDropdown"
|
||||
@input="handleSearchInput"
|
||||
/>
|
||||
<div class="tenant-search-hint">
|
||||
{{ $t('tenant.searchHint') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="tenant-menu-item readonly">
|
||||
<div class="tenant-item-box">
|
||||
<div class="tenant-icon">
|
||||
<t-icon name="usergroup" size="16px" />
|
||||
</div>
|
||||
<span class="tenant-title">{{ currentTenantName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch, onUnmounted, nextTick } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { searchTenants, type TenantInfo } from '@/api/tenant'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { MessagePlugin } from 'tdesign-vue-next'
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const showDropdown = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const tenants = ref<TenantInfo[]>([])
|
||||
const triggerRef = ref<HTMLElement | null>(null)
|
||||
const tenantList = ref<HTMLElement | null>(null)
|
||||
const searchInput = ref<HTMLInputElement | null>(null)
|
||||
const dropdownStyle = ref<Record<string, string>>({})
|
||||
|
||||
// 分页相关
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const searchTimer = ref<number | null>(null)
|
||||
|
||||
const canAccessAllTenants = computed(() => authStore.canAccessAllTenants)
|
||||
const selectedTenantId = computed(() => authStore.selectedTenantId)
|
||||
const defaultTenantId = computed(() => authStore.tenant?.id ? Number(authStore.tenant.id) : null)
|
||||
|
||||
const currentTenantId = computed(() => {
|
||||
return selectedTenantId.value || defaultTenantId.value
|
||||
})
|
||||
|
||||
const currentTenantName = computed(() => {
|
||||
if (!currentTenantId.value) return t('tenant.unknown')
|
||||
const tenant = tenants.value.find(t => t.id === currentTenantId.value)
|
||||
if (tenant) return tenant.name
|
||||
return authStore.tenant?.name || t('tenant.unknown')
|
||||
})
|
||||
|
||||
const hasMore = computed(() => {
|
||||
return tenants.value.length < total.value
|
||||
})
|
||||
|
||||
const isSelected = (tenantId: number) => {
|
||||
return currentTenantId.value === tenantId
|
||||
}
|
||||
|
||||
const updateDropdownPosition = () => {
|
||||
if (!triggerRef.value) return
|
||||
|
||||
const rect = triggerRef.value.getBoundingClientRect()
|
||||
const dropdownMaxHeight = 400 // max-height
|
||||
const spaceAbove = rect.top
|
||||
const padding = 16
|
||||
|
||||
// 计算可用高度,确保有足够空间显示搜索框
|
||||
const availableHeight = Math.min(dropdownMaxHeight, spaceAbove - padding)
|
||||
|
||||
// 确保最小高度(至少能显示搜索框和一些列表项)
|
||||
const minHeight = 200
|
||||
const finalHeight = Math.max(minHeight, availableHeight)
|
||||
|
||||
// 向上弹出,确保不遮挡用户头像
|
||||
dropdownStyle.value = {
|
||||
bottom: `${window.innerHeight - rect.top + 8}px`,
|
||||
left: `${rect.left}px`,
|
||||
width: '280px',
|
||||
height: `${finalHeight}px`,
|
||||
maxHeight: `${finalHeight}px`
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (!canAccessAllTenants.value) return
|
||||
showDropdown.value = !showDropdown.value
|
||||
if (showDropdown.value) {
|
||||
if (tenants.value.length === 0) {
|
||||
loadTenants()
|
||||
}
|
||||
nextTick(() => {
|
||||
updateDropdownPosition()
|
||||
searchInput.value?.focus()
|
||||
})
|
||||
} else {
|
||||
// 关闭时重置搜索
|
||||
searchQuery.value = ''
|
||||
currentPage.value = 1
|
||||
tenants.value = []
|
||||
total.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
const closeDropdown = () => {
|
||||
showDropdown.value = false
|
||||
searchQuery.value = ''
|
||||
currentPage.value = 1
|
||||
tenants.value = []
|
||||
total.value = 0
|
||||
if (searchTimer.value) {
|
||||
clearTimeout(searchTimer.value)
|
||||
searchTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const close = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest('.tenant-dropdown') && !target.closest('.tenant-menu-item')) {
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
const selectTenant = (tenantId: number) => {
|
||||
// 如果选择的是默认租户,清除选择
|
||||
if (tenantId === defaultTenantId.value) {
|
||||
authStore.setSelectedTenant(null)
|
||||
} else {
|
||||
authStore.setSelectedTenant(tenantId)
|
||||
}
|
||||
closeDropdown()
|
||||
// 触发页面刷新以加载新租户的数据
|
||||
MessagePlugin.success(t('tenant.switchSuccess'))
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const loadTenants = async (append = false) => {
|
||||
if (loading.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
// 解析搜索关键词,判断是否是租户ID
|
||||
let keyword = searchQuery.value.trim()
|
||||
let tenantID: number | undefined = undefined
|
||||
|
||||
// 如果搜索关键词是纯数字,尝试作为租户ID查询
|
||||
if (keyword && /^\d+$/.test(keyword)) {
|
||||
tenantID = Number(keyword)
|
||||
keyword = '' // 清空关键词,使用租户ID查询
|
||||
}
|
||||
|
||||
const response = await searchTenants({
|
||||
keyword: keyword || undefined,
|
||||
tenant_id: tenantID,
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value
|
||||
})
|
||||
|
||||
if (response.success && response.data) {
|
||||
if (append) {
|
||||
tenants.value = [...tenants.value, ...response.data.items]
|
||||
} else {
|
||||
tenants.value = response.data.items
|
||||
}
|
||||
total.value = response.data.total
|
||||
authStore.setAllTenants(tenants.value)
|
||||
} else {
|
||||
MessagePlugin.error(response.message || t('tenant.loadTenantsFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load tenants:', error)
|
||||
MessagePlugin.error(t('tenant.loadTenantsFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearchInput = () => {
|
||||
// 防抖处理,延迟500ms后搜索
|
||||
if (searchTimer.value) {
|
||||
clearTimeout(searchTimer.value)
|
||||
}
|
||||
|
||||
searchTimer.value = window.setTimeout(() => {
|
||||
currentPage.value = 1
|
||||
tenants.value = []
|
||||
total.value = 0
|
||||
loadTenants()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (hasMore.value && !loading.value) {
|
||||
currentPage.value++
|
||||
loadTenants(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest('.tenant-selector-wrapper')) {
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
if (showDropdown.value) {
|
||||
updateDropdownPosition()
|
||||
}
|
||||
}
|
||||
|
||||
watch(showDropdown, (newVal) => {
|
||||
if (newVal) {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
window.addEventListener('resize', handleResize)
|
||||
window.addEventListener('scroll', handleResize, true)
|
||||
updateDropdownPosition()
|
||||
} else {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.removeEventListener('scroll', handleResize, true)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 不再自动加载,等用户打开下拉框时再加载
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.removeEventListener('scroll', handleResize, true)
|
||||
if (searchTimer.value) {
|
||||
clearTimeout(searchTimer.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.tenant-selector {
|
||||
width: 100%;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tenant-selector-wrapper {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tenant-menu-item {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 48px;
|
||||
padding: 13px 8px 13px 16px;
|
||||
box-sizing: border-box;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-radius: 4px;
|
||||
background: #30323605;
|
||||
color: #00000099;
|
||||
|
||||
.tenant-icon,
|
||||
.tenant-title {
|
||||
color: #00000099;
|
||||
}
|
||||
}
|
||||
|
||||
&.readonly {
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tenant-item-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tenant-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
color: #00000099;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tenant-title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #000000e6;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tenant-arrow {
|
||||
font-size: 16px;
|
||||
color: #00000066;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.tenant-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.tenant-dropdown {
|
||||
position: fixed;
|
||||
background: #fff;
|
||||
border: 1px solid #e7e9eb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transform-origin: bottom left;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tenant-search {
|
||||
padding: 8px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tenant-search-input {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #e7e9eb;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
border-color: #07c05f;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.tenant-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 4px 0;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tenant-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: #07c05f1a;
|
||||
|
||||
.tenant-item-name {
|
||||
color: #07c05f;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tenant-item-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tenant-item-name {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tenant-item-desc {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tenant-item-id {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.tenant-loading {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tenant-load-more {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: #07c05f;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
}
|
||||
|
||||
.tenant-search-hint {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.tenant-check-icon {
|
||||
color: #07c05f;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.tenant-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -205,10 +205,33 @@ const loadUserInfo = async () => {
|
||||
try {
|
||||
const response = await getCurrentUser()
|
||||
if (response.success && response.data && response.data.user) {
|
||||
const user = response.data.user
|
||||
userInfo.value = {
|
||||
username: response.data.user.username || t('common.info'),
|
||||
email: response.data.user.email || 'user@example.com',
|
||||
avatar: response.data.user.avatar || ''
|
||||
username: user.username || t('common.info'),
|
||||
email: user.email || 'user@example.com',
|
||||
avatar: user.avatar || ''
|
||||
}
|
||||
// 同时更新 authStore 中的用户信息,确保包含 can_access_all_tenants 字段
|
||||
authStore.setUser({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
avatar: user.avatar,
|
||||
tenant_id: user.tenant_id,
|
||||
can_access_all_tenants: user.can_access_all_tenants || false,
|
||||
created_at: user.created_at,
|
||||
updated_at: user.updated_at
|
||||
})
|
||||
// 如果返回了租户信息,也更新租户信息
|
||||
if (response.data.tenant) {
|
||||
authStore.setTenant({
|
||||
id: String(response.data.tenant.id),
|
||||
name: response.data.tenant.name,
|
||||
api_key: response.data.tenant.api_key || '',
|
||||
owner_id: user.id,
|
||||
created_at: response.data.tenant.created_at,
|
||||
updated_at: response.data.tenant.updated_at
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -161,6 +161,7 @@
|
||||
|
||||
<!-- 下半部分:用户菜单 -->
|
||||
<div class="menu_bottom">
|
||||
<TenantSelector />
|
||||
<UserMenu />
|
||||
</div>
|
||||
|
||||
@@ -194,6 +195,7 @@ import { useAuthStore } from '@/stores/auth';
|
||||
import { useUIStore } from '@/stores/ui';
|
||||
import { MessagePlugin } from "tdesign-vue-next";
|
||||
import UserMenu from '@/components/UserMenu.vue';
|
||||
import TenantSelector from '@/components/TenantSelector.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { kbFileTypeVerification } from '@/utils';
|
||||
|
||||
|
||||
@@ -1149,6 +1149,7 @@ export default {
|
||||
},
|
||||
tenant: {
|
||||
title: 'Tenant Information',
|
||||
currentTenant: 'Current Tenant',
|
||||
sectionDescription: 'View detailed configuration for the tenant',
|
||||
apiDocument: 'API Document',
|
||||
name: 'Tenant Name',
|
||||
@@ -1195,6 +1196,13 @@ export default {
|
||||
apiKeyCopied: 'API Key copied to clipboard',
|
||||
unknown: 'Unknown',
|
||||
formatError: 'Format error',
|
||||
searchPlaceholder: 'Search by name or enter tenant ID...',
|
||||
searchHint: 'Search by name or enter tenant ID directly',
|
||||
noMatch: 'No matching tenants found',
|
||||
switchSuccess: 'Tenant switched successfully',
|
||||
loadTenantsFailed: 'Failed to load tenant list',
|
||||
loading: 'Loading...',
|
||||
loadMore: 'Load more',
|
||||
details: {
|
||||
idLabel: 'Tenant ID',
|
||||
idDescription: 'Unique identifier of your tenant',
|
||||
|
||||
@@ -640,6 +640,7 @@ export default {
|
||||
},
|
||||
tenant: {
|
||||
title: 'Информация об арендаторе',
|
||||
currentTenant: 'Текущий арендатор',
|
||||
sectionDescription: 'Просмотр детальной конфигурации арендатора',
|
||||
apiDocument: 'Документация API',
|
||||
name: 'Имя арендатора',
|
||||
@@ -686,6 +687,13 @@ export default {
|
||||
apiKeyCopied: 'API Key скопирован в буфер обмена',
|
||||
unknown: 'Неизвестно',
|
||||
formatError: 'Ошибка формата',
|
||||
searchPlaceholder: 'Поиск по имени или введите ID арендатора...',
|
||||
searchHint: 'Поиск по имени или введите ID арендатора напрямую',
|
||||
noMatch: 'Не найдено подходящих арендаторов',
|
||||
switchSuccess: 'Арендатор успешно переключен',
|
||||
loadTenantsFailed: 'Не удалось загрузить список арендаторов',
|
||||
loading: 'Загрузка...',
|
||||
loadMore: 'Загрузить еще',
|
||||
details: {
|
||||
idLabel: 'ID арендатора',
|
||||
idDescription: 'Уникальный идентификатор вашего арендатора',
|
||||
|
||||
@@ -732,6 +732,7 @@ export default {
|
||||
},
|
||||
tenant: {
|
||||
title: "租户信息",
|
||||
currentTenant: "当前租户",
|
||||
sectionDescription: "查看租户的详细配置信息",
|
||||
apiDocument: "API文档",
|
||||
name: "租户名称",
|
||||
@@ -777,6 +778,13 @@ export default {
|
||||
apiKeyCopied: "API密钥已复制到剪贴板",
|
||||
unknown: "未知",
|
||||
formatError: "格式错误",
|
||||
searchPlaceholder: "搜索租户名称或输入租户ID...",
|
||||
searchHint: "支持按名称搜索或直接输入租户ID",
|
||||
noMatch: "未找到匹配的租户",
|
||||
switchSuccess: "租户切换成功",
|
||||
loadTenantsFailed: "加载租户列表失败",
|
||||
loading: "加载中...",
|
||||
loadMore: "加载更多",
|
||||
details: {
|
||||
idLabel: "租户 ID",
|
||||
idDescription: "您所属租户的唯一标识",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { UserInfo, TenantInfo, KnowledgeBaseInfo } from '@/api/auth'
|
||||
import type { TenantInfo as TenantInfoFromAPI } from '@/api/tenant'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
@@ -11,6 +12,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const refreshToken = ref<string>('')
|
||||
const knowledgeBases = ref<KnowledgeBaseInfo[]>([])
|
||||
const currentKnowledgeBase = ref<KnowledgeBaseInfo | null>(null)
|
||||
const selectedTenantId = ref<number | null>(null)
|
||||
const allTenants = ref<TenantInfoFromAPI[]>([])
|
||||
|
||||
// 计算属性
|
||||
const isLoggedIn = computed(() => {
|
||||
@@ -29,6 +32,15 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
return user.value?.id || ''
|
||||
})
|
||||
|
||||
const canAccessAllTenants = computed(() => {
|
||||
return user.value?.can_access_all_tenants || false
|
||||
})
|
||||
|
||||
const effectiveTenantId = computed(() => {
|
||||
// 如果选择了其他租户,使用选择的租户ID,否则使用用户默认租户ID
|
||||
return selectedTenantId.value || (tenant.value?.id ? Number(tenant.value.id) : null)
|
||||
})
|
||||
|
||||
// 操作方法
|
||||
const setUser = (userData: UserInfo) => {
|
||||
user.value = userData
|
||||
@@ -67,6 +79,23 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const setSelectedTenant = (tenantId: number | null) => {
|
||||
selectedTenantId.value = tenantId
|
||||
if (tenantId !== null) {
|
||||
localStorage.setItem('weknora_selected_tenant_id', String(tenantId))
|
||||
} else {
|
||||
localStorage.removeItem('weknora_selected_tenant_id')
|
||||
}
|
||||
}
|
||||
|
||||
const setAllTenants = (tenants: TenantInfoFromAPI[]) => {
|
||||
allTenants.value = tenants
|
||||
}
|
||||
|
||||
const getSelectedTenant = () => {
|
||||
return selectedTenantId.value
|
||||
}
|
||||
|
||||
|
||||
const logout = () => {
|
||||
// 清空状态
|
||||
@@ -76,6 +105,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
refreshToken.value = ''
|
||||
knowledgeBases.value = []
|
||||
currentKnowledgeBase.value = null
|
||||
selectedTenantId.value = null
|
||||
allTenants.value = []
|
||||
|
||||
// 清空localStorage
|
||||
localStorage.removeItem('weknora_user')
|
||||
@@ -95,6 +126,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const storedRefreshToken = localStorage.getItem('weknora_refresh_token')
|
||||
const storedKnowledgeBases = localStorage.getItem('weknora_knowledge_bases')
|
||||
const storedCurrentKb = localStorage.getItem('weknora_current_kb')
|
||||
const storedSelectedTenantId = localStorage.getItem('weknora_selected_tenant_id')
|
||||
|
||||
if (storedUser) {
|
||||
try {
|
||||
@@ -137,6 +169,15 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
console.error(i18n.global.t('authStore.errors.parseCurrentKnowledgeBaseFailed'), e)
|
||||
}
|
||||
}
|
||||
|
||||
if (storedSelectedTenantId) {
|
||||
try {
|
||||
selectedTenantId.value = Number(storedSelectedTenantId)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse selected tenant ID', e)
|
||||
selectedTenantId.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化时从localStorage恢复状态
|
||||
@@ -150,12 +191,16 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
refreshToken,
|
||||
knowledgeBases,
|
||||
currentKnowledgeBase,
|
||||
selectedTenantId,
|
||||
allTenants,
|
||||
|
||||
// 计算属性
|
||||
isLoggedIn,
|
||||
hasValidTenant,
|
||||
currentTenantId,
|
||||
currentUserId,
|
||||
canAccessAllTenants,
|
||||
effectiveTenantId,
|
||||
|
||||
// 方法
|
||||
setUser,
|
||||
@@ -164,6 +209,9 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
setRefreshToken,
|
||||
setKnowledgeBases,
|
||||
setCurrentKnowledgeBase,
|
||||
setSelectedTenant,
|
||||
setAllTenants,
|
||||
getSelectedTenant,
|
||||
logout,
|
||||
initFromStorage
|
||||
}
|
||||
|
||||
@@ -25,6 +25,22 @@ instance.interceptors.request.use(
|
||||
config.headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// 添加跨租户访问请求头(如果选择了其他租户)
|
||||
const selectedTenantId = localStorage.getItem('weknora_selected_tenant_id');
|
||||
const defaultTenantId = localStorage.getItem('weknora_tenant');
|
||||
if (selectedTenantId) {
|
||||
try {
|
||||
const defaultTenant = defaultTenantId ? JSON.parse(defaultTenantId) : null;
|
||||
const defaultId = defaultTenant?.id ? String(defaultTenant.id) : null;
|
||||
// 如果选择的租户ID与默认租户ID不同,添加请求头
|
||||
if (selectedTenantId !== defaultId) {
|
||||
config.headers["X-Tenant-ID"] = selectedTenantId;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse tenant info', e);
|
||||
}
|
||||
}
|
||||
|
||||
config.headers["X-Request-ID"] = `${generateRandomString(12)}`;
|
||||
return config;
|
||||
},
|
||||
|
||||
@@ -625,6 +625,7 @@ const handleLogin = async () => {
|
||||
email: response.user.email || '',
|
||||
avatar: response.user.avatar,
|
||||
tenant_id: String(response.tenant.id) || '',
|
||||
can_access_all_tenants: response.user.can_access_all_tenants || false,
|
||||
created_at: response.user.created_at || new Date().toISOString(),
|
||||
updated_at: response.user.updated_at || new Date().toISOString()
|
||||
})
|
||||
|
||||
@@ -52,6 +52,45 @@ func (r *tenantRepository) ListTenants(ctx context.Context) ([]*types.Tenant, er
|
||||
return tenants, nil
|
||||
}
|
||||
|
||||
// SearchTenants searches tenants with pagination and filters
|
||||
func (r *tenantRepository) SearchTenants(ctx context.Context, keyword string, tenantID uint64, page, pageSize int) ([]*types.Tenant, int64, error) {
|
||||
var tenants []*types.Tenant
|
||||
var total int64
|
||||
|
||||
query := r.db.WithContext(ctx).Model(&types.Tenant{})
|
||||
|
||||
// Filter by tenant ID if provided
|
||||
if tenantID > 0 {
|
||||
query = query.Where("id = ?", tenantID)
|
||||
}
|
||||
|
||||
// Filter by keyword if provided (search in name and description)
|
||||
if keyword != "" {
|
||||
query = query.Where("name LIKE ? OR description LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
|
||||
}
|
||||
|
||||
// Count total
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
if page > 0 && pageSize > 0 {
|
||||
offset := (page - 1) * pageSize
|
||||
query = query.Offset(offset).Limit(pageSize)
|
||||
}
|
||||
|
||||
// Order by created_at DESC
|
||||
query = query.Order("created_at DESC")
|
||||
|
||||
// Execute query
|
||||
if err := query.Find(&tenants).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return tenants, total, nil
|
||||
}
|
||||
|
||||
// UpdateTenant updates tenant
|
||||
func (r *tenantRepository) UpdateTenant(ctx context.Context, tenant *types.Tenant) error {
|
||||
return r.db.WithContext(ctx).Model(&types.Tenant{}).Where("id = ?", tenant.ID).Updates(tenant).Error
|
||||
|
||||
@@ -280,3 +280,49 @@ func (r *tenantService) ExtractTenantIDFromAPIKey(apiKey string) (uint64, error)
|
||||
|
||||
return tenantID, nil
|
||||
}
|
||||
|
||||
// ListAllTenants lists all tenants (for users with cross-tenant access permission)
|
||||
// This method returns all tenants without filtering, intended for admin users
|
||||
func (s *tenantService) ListAllTenants(ctx context.Context) ([]*types.Tenant, error) {
|
||||
tenants, err := s.repo.ListTenants(ctx)
|
||||
if err != nil {
|
||||
logger.ErrorWithFields(ctx, err, nil)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "All tenants list retrieved successfully, total: %d", len(tenants))
|
||||
return tenants, nil
|
||||
}
|
||||
|
||||
// SearchTenants searches tenants with pagination and filters
|
||||
func (s *tenantService) SearchTenants(ctx context.Context, keyword string, tenantID uint64, page, pageSize int) ([]*types.Tenant, int64, error) {
|
||||
tenants, total, err := s.repo.SearchTenants(ctx, keyword, tenantID, page, pageSize)
|
||||
if err != nil {
|
||||
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
||||
"keyword": keyword,
|
||||
"tenantID": tenantID,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
})
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "Tenants search completed, keyword: %s, tenantID: %d, page: %d, pageSize: %d, total: %d, found: %d",
|
||||
keyword, tenantID, page, pageSize, total, len(tenants))
|
||||
return tenants, total, nil
|
||||
}
|
||||
|
||||
// GetTenantByIDForUser gets a tenant by ID with permission check
|
||||
// This method verifies that the user has permission to access the tenant
|
||||
func (s *tenantService) GetTenantByIDForUser(ctx context.Context, tenantID uint64, userID string) (*types.Tenant, error) {
|
||||
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
|
||||
if err != nil {
|
||||
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
||||
"tenant_id": tenantID,
|
||||
"user_id": userID,
|
||||
})
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tenant, nil
|
||||
}
|
||||
|
||||
@@ -102,6 +102,8 @@ type TenantConfig struct {
|
||||
DefaultSessionName string `yaml:"default_session_name" json:"default_session_name"`
|
||||
DefaultSessionTitle string `yaml:"default_session_title" json:"default_session_title"`
|
||||
DefaultSessionDescription string `yaml:"default_session_description" json:"default_session_description"`
|
||||
// EnableCrossTenantAccess enables cross-tenant access for users with permission
|
||||
EnableCrossTenantAccess bool `yaml:"enable_cross_tenant_access" json:"enable_cross_tenant_access"`
|
||||
}
|
||||
|
||||
// ModelConfig 模型配置
|
||||
|
||||
@@ -20,20 +20,23 @@ import (
|
||||
// Provides functionality for creating, retrieving, updating, and deleting tenants
|
||||
// through the REST API endpoints
|
||||
type TenantHandler struct {
|
||||
service interfaces.TenantService
|
||||
config *config.Config
|
||||
service interfaces.TenantService
|
||||
userService interfaces.UserService
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
// NewTenantHandler creates a new tenant handler instance with the provided service
|
||||
// Parameters:
|
||||
// - service: An implementation of the TenantService interface for business logic
|
||||
// - userService: An implementation of the UserService interface for user operations
|
||||
// - config: Application configuration
|
||||
//
|
||||
// Returns a pointer to the newly created TenantHandler
|
||||
func NewTenantHandler(service interfaces.TenantService, config *config.Config) *TenantHandler {
|
||||
func NewTenantHandler(service interfaces.TenantService, userService interfaces.UserService, config *config.Config) *TenantHandler {
|
||||
return &TenantHandler{
|
||||
service: service,
|
||||
config: config,
|
||||
service: service,
|
||||
userService: userService,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +237,139 @@ func (h *TenantHandler) ListTenants(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ListAllTenants handles the HTTP request for retrieving a list of all tenants
|
||||
// This endpoint requires cross-tenant access permission
|
||||
// Parameters:
|
||||
// - c: Gin context for the HTTP request
|
||||
func (h *TenantHandler) ListAllTenants(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Get current user from context
|
||||
user, err := h.userService.GetCurrentUser(ctx)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "Failed to get current user: %v", err)
|
||||
c.Error(errors.NewUnauthorizedError("Failed to get user information").WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if cross-tenant access is enabled
|
||||
if h.config == nil || h.config.Tenant == nil || !h.config.Tenant.EnableCrossTenantAccess {
|
||||
logger.Warnf(ctx, "Cross-tenant access is disabled, user: %s", user.ID)
|
||||
c.Error(errors.NewForbiddenError("Cross-tenant access is disabled"))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user has permission
|
||||
if !user.CanAccessAllTenants {
|
||||
logger.Warnf(ctx, "User %s attempted to list all tenants without permission", user.ID)
|
||||
c.Error(errors.NewForbiddenError("Insufficient permissions to access all tenants"))
|
||||
return
|
||||
}
|
||||
|
||||
tenants, err := h.service.ListAllTenants(ctx)
|
||||
if err != nil {
|
||||
// Check if this is an application-specific error
|
||||
if appErr, ok := errors.IsAppError(err); ok {
|
||||
logger.Error(ctx, "Failed to retrieve all tenants list: application error", appErr)
|
||||
c.Error(appErr)
|
||||
} else {
|
||||
logger.ErrorWithFields(ctx, err, nil)
|
||||
c.Error(errors.NewInternalServerError("Failed to retrieve all tenants list").WithDetails(err.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"items": tenants,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// SearchTenants handles the HTTP request for searching tenants with pagination
|
||||
// This endpoint requires cross-tenant access permission
|
||||
// Query parameters:
|
||||
// - keyword: search keyword (optional)
|
||||
// - tenant_id: filter by tenant ID (optional)
|
||||
// - page: page number (default: 1)
|
||||
// - page_size: page size (default: 20)
|
||||
func (h *TenantHandler) SearchTenants(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Get current user from context
|
||||
user, err := h.userService.GetCurrentUser(ctx)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "Failed to get current user: %v", err)
|
||||
c.Error(errors.NewUnauthorizedError("Failed to get user information").WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if cross-tenant access is enabled
|
||||
if h.config == nil || h.config.Tenant == nil || !h.config.Tenant.EnableCrossTenantAccess {
|
||||
logger.Warnf(ctx, "Cross-tenant access is disabled, user: %s", user.ID)
|
||||
c.Error(errors.NewForbiddenError("Cross-tenant access is disabled"))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user has permission
|
||||
if !user.CanAccessAllTenants {
|
||||
logger.Warnf(ctx, "User %s attempted to search tenants without permission", user.ID)
|
||||
c.Error(errors.NewForbiddenError("Insufficient permissions to access all tenants"))
|
||||
return
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
keyword := c.Query("keyword")
|
||||
tenantIDStr := c.Query("tenant_id")
|
||||
pageStr := c.DefaultQuery("page", "1")
|
||||
pageSizeStr := c.DefaultQuery("page_size", "20")
|
||||
|
||||
var tenantID uint64
|
||||
if tenantIDStr != "" {
|
||||
parsedID, err := strconv.ParseUint(tenantIDStr, 10, 64)
|
||||
if err == nil {
|
||||
tenantID = parsedID
|
||||
}
|
||||
}
|
||||
|
||||
page, err := strconv.Atoi(pageStr)
|
||||
if err != nil || page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
pageSize, err := strconv.Atoi(pageSizeStr)
|
||||
if err != nil || pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100 // Limit max page size
|
||||
}
|
||||
|
||||
tenants, total, err := h.service.SearchTenants(ctx, keyword, tenantID, page, pageSize)
|
||||
if err != nil {
|
||||
// Check if this is an application-specific error
|
||||
if appErr, ok := errors.IsAppError(err); ok {
|
||||
logger.Error(ctx, "Failed to search tenants: application error", appErr)
|
||||
c.Error(appErr)
|
||||
} else {
|
||||
logger.ErrorWithFields(ctx, err, nil)
|
||||
c.Error(errors.NewInternalServerError("Failed to search tenants").WithDetails(err.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"items": tenants,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// AgentConfigRequest represents the request body for updating agent configuration
|
||||
type AgentConfigRequest struct {
|
||||
MaxIterations int `json:"max_iterations"`
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/config"
|
||||
@@ -37,6 +38,24 @@ func isNoAuthAPI(path string, method string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// canAccessTenant checks if a user can access a target tenant
|
||||
func canAccessTenant(user *types.User, targetTenantID uint64, cfg *config.Config) bool {
|
||||
// 1. 检查功能是否启用
|
||||
if cfg == nil || cfg.Tenant == nil || !cfg.Tenant.EnableCrossTenantAccess {
|
||||
return false
|
||||
}
|
||||
// 2. 检查用户权限
|
||||
if !user.CanAccessAllTenants {
|
||||
return false
|
||||
}
|
||||
// 3. 如果目标租户是用户自己的租户,允许访问
|
||||
if user.TenantID == targetTenantID {
|
||||
return true
|
||||
}
|
||||
// 4. 用户有跨租户权限,允许访问(具体验证在中间件中完成)
|
||||
return true
|
||||
}
|
||||
|
||||
// Auth 认证中间件
|
||||
func Auth(
|
||||
tenantService interfaces.TenantService,
|
||||
@@ -63,10 +82,44 @@ func Auth(
|
||||
user, err := userService.ValidateToken(c.Request.Context(), token)
|
||||
if err == nil && user != nil {
|
||||
// JWT Token认证成功
|
||||
// 获取租户信息
|
||||
tenant, err := tenantService.GetTenantByID(c.Request.Context(), user.TenantID)
|
||||
// 检查是否有跨租户访问请求
|
||||
targetTenantID := user.TenantID
|
||||
tenantHeader := c.GetHeader("X-Tenant-ID")
|
||||
if tenantHeader != "" {
|
||||
// 解析目标租户ID
|
||||
parsedTenantID, err := strconv.ParseUint(tenantHeader, 10, 64)
|
||||
if err == nil {
|
||||
// 检查用户是否有跨租户访问权限
|
||||
if canAccessTenant(user, parsedTenantID, cfg) {
|
||||
// 验证目标租户是否存在
|
||||
targetTenant, err := tenantService.GetTenantByID(c.Request.Context(), parsedTenantID)
|
||||
if err == nil && targetTenant != nil {
|
||||
targetTenantID = parsedTenantID
|
||||
log.Printf("User %s switching to tenant %d", user.ID, targetTenantID)
|
||||
} else {
|
||||
log.Printf("Error getting target tenant by ID: %v, tenantID: %d", err, parsedTenantID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Invalid target tenant ID",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 用户没有权限访问目标租户
|
||||
log.Printf("User %s attempted to access tenant %d without permission", user.ID, parsedTenantID)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Forbidden: insufficient permissions to access target tenant",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取租户信息(使用目标租户ID)
|
||||
tenant, err := tenantService.GetTenantByID(c.Request.Context(), targetTenantID)
|
||||
if err != nil {
|
||||
log.Printf("Error getting tenant by ID: %v, tenantID: %d, userID: %s", err, user.TenantID, user.ID)
|
||||
log.Printf("Error getting tenant by ID: %v, tenantID: %d, userID: %s", err, targetTenantID, user.ID)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Unauthorized: invalid tenant",
|
||||
})
|
||||
@@ -75,13 +128,13 @@ func Auth(
|
||||
}
|
||||
|
||||
// 存储用户和租户信息到上下文
|
||||
c.Set(types.TenantIDContextKey.String(), user.TenantID)
|
||||
c.Set(types.TenantIDContextKey.String(), targetTenantID)
|
||||
c.Set(types.TenantInfoContextKey.String(), tenant)
|
||||
c.Set("user", user)
|
||||
c.Request = c.Request.WithContext(
|
||||
context.WithValue(
|
||||
context.WithValue(
|
||||
context.WithValue(c.Request.Context(), types.TenantIDContextKey, user.TenantID),
|
||||
context.WithValue(c.Request.Context(), types.TenantIDContextKey, targetTenantID),
|
||||
types.TenantInfoContextKey, tenant,
|
||||
),
|
||||
"user", user,
|
||||
|
||||
@@ -256,6 +256,10 @@ func RegisterChatRoutes(r *gin.RouterGroup, handler *session.Handler) {
|
||||
|
||||
// RegisterTenantRoutes 注册租户相关的路由
|
||||
func RegisterTenantRoutes(r *gin.RouterGroup, handler *handler.TenantHandler) {
|
||||
// 添加获取所有租户的路由(需要跨租户权限)
|
||||
r.GET("/tenants/all", handler.ListAllTenants)
|
||||
// 添加搜索租户的路由(需要跨租户权限,支持分页和搜索)
|
||||
r.GET("/tenants/search", handler.SearchTenants)
|
||||
// 租户路由组
|
||||
tenantRoutes := r.Group("/tenants")
|
||||
{
|
||||
|
||||
@@ -22,6 +22,12 @@ type TenantService interface {
|
||||
UpdateAPIKey(ctx context.Context, id uint64) (string, error)
|
||||
// ExtractTenantIDFromAPIKey extracts the tenant ID from the API key
|
||||
ExtractTenantIDFromAPIKey(apiKey string) (uint64, error)
|
||||
// ListAllTenants lists all tenants (for users with cross-tenant access permission)
|
||||
ListAllTenants(ctx context.Context) ([]*types.Tenant, error)
|
||||
// SearchTenants searches tenants with pagination and filters
|
||||
SearchTenants(ctx context.Context, keyword string, tenantID uint64, page, pageSize int) ([]*types.Tenant, int64, error)
|
||||
// GetTenantByIDForUser gets a tenant by ID with permission check
|
||||
GetTenantByIDForUser(ctx context.Context, tenantID uint64, userID string) (*types.Tenant, error)
|
||||
}
|
||||
|
||||
// TenantRepository defines the tenant repository interface
|
||||
@@ -32,6 +38,8 @@ type TenantRepository interface {
|
||||
GetTenantByID(ctx context.Context, id uint64) (*types.Tenant, error)
|
||||
// ListTenants lists all tenants
|
||||
ListTenants(ctx context.Context) ([]*types.Tenant, error)
|
||||
// SearchTenants searches tenants with pagination and filters
|
||||
SearchTenants(ctx context.Context, keyword string, tenantID uint64, page, pageSize int) ([]*types.Tenant, int64, error)
|
||||
// UpdateTenant updates a tenant
|
||||
UpdateTenant(ctx context.Context, tenant *types.Tenant) error
|
||||
// DeleteTenant deletes a tenant
|
||||
|
||||
@@ -22,6 +22,8 @@ type User struct {
|
||||
TenantID uint64 `json:"tenant_id" gorm:"index"`
|
||||
// Whether the user is active
|
||||
IsActive bool `json:"is_active" gorm:"default:true"`
|
||||
// Whether the user can access all tenants (cross-tenant access)
|
||||
CanAccessAllTenants bool `json:"can_access_all_tenants" gorm:"default:false"`
|
||||
// Creation time of the user
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// Last updated time of the user
|
||||
@@ -89,26 +91,28 @@ type RegisterResponse struct {
|
||||
|
||||
// UserInfo represents user information for API responses
|
||||
type UserInfo struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Avatar string `json:"avatar"`
|
||||
TenantID uint64 `json:"tenant_id"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Avatar string `json:"avatar"`
|
||||
TenantID uint64 `json:"tenant_id"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CanAccessAllTenants bool `json:"can_access_all_tenants"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ToUserInfo converts User to UserInfo (without sensitive data)
|
||||
func (u *User) ToUserInfo() *UserInfo {
|
||||
return &UserInfo{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Email: u.Email,
|
||||
Avatar: u.Avatar,
|
||||
TenantID: u.TenantID,
|
||||
IsActive: u.IsActive,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Email: u.Email,
|
||||
Avatar: u.Avatar,
|
||||
TenantID: u.TenantID,
|
||||
IsActive: u.IsActive,
|
||||
CanAccessAllTenants: u.CanAccessAllTenants,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 000016_add_can_access_all_tenants.down.sql
|
||||
-- Remove can_access_all_tenants column from users table
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE users
|
||||
DROP COLUMN IF EXISTS can_access_all_tenants;
|
||||
|
||||
COMMIT;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 000016_add_can_access_all_tenants.up.sql
|
||||
-- Add can_access_all_tenants column to users table
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS can_access_all_tenants BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
COMMIT;
|
||||
|
||||
Reference in New Issue
Block a user