feat: 实现租户管理功能,支持租户ID格式校验与自动创建 [AC-AISVC-10, AC-AISVC-12, AC-ASA-01]
This commit is contained in:
parent
a23f1a2089
commit
ac8c33cf94
|
|
@ -42,15 +42,19 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<div class="tenant-selector">
|
<div class="tenant-selector">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="currentTenantId"
|
v-model="currentTenantId"
|
||||||
placeholder="选择租户"
|
placeholder="选择租户"
|
||||||
size="default"
|
size="default"
|
||||||
|
:loading="loading"
|
||||||
@change="handleTenantChange"
|
@change="handleTenantChange"
|
||||||
>
|
>
|
||||||
<el-option label="默认租户" value="default" />
|
<el-option
|
||||||
<el-option label="租户 A" value="tenant_a" />
|
v-for="tenant in tenantList"
|
||||||
<el-option label="租户 B" value="tenant_b" />
|
:key="tenant.id"
|
||||||
|
:label="tenant.name"
|
||||||
|
:value="tenant.id"
|
||||||
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -62,15 +66,19 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useTenantStore } from '@/stores/tenant'
|
import { useTenantStore } from '@/stores/tenant'
|
||||||
|
import { getTenantList, type Tenant } from '@/api/tenant'
|
||||||
import { Odometer, FolderOpened, Cpu, Monitor, Connection, ChatDotSquare } from '@element-plus/icons-vue'
|
import { Odometer, FolderOpened, Cpu, Monitor, Connection, ChatDotSquare } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const tenantStore = useTenantStore()
|
const tenantStore = useTenantStore()
|
||||||
|
|
||||||
const currentTenantId = ref(tenantStore.currentTenantId)
|
const currentTenantId = ref(tenantStore.currentTenantId)
|
||||||
|
const tenantList = ref<Tenant[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string) => {
|
||||||
return route.path === path || route.path.startsWith(path + '/')
|
return route.path === path || route.path.startsWith(path + '/')
|
||||||
|
|
@ -78,7 +86,47 @@ const isActive = (path: string) => {
|
||||||
|
|
||||||
const handleTenantChange = (val: string) => {
|
const handleTenantChange = (val: string) => {
|
||||||
tenantStore.setTenant(val)
|
tenantStore.setTenant(val)
|
||||||
|
// 刷新页面以加载新租户的数据
|
||||||
|
window.location.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate tenant ID format: name@ash@year
|
||||||
|
const isValidTenantId = (tenantId: string): boolean => {
|
||||||
|
return /^[^@]+@ash@\d{4}$/.test(tenantId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchTenantList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
// 检查当前租户ID格式是否有效
|
||||||
|
if (!isValidTenantId(currentTenantId.value)) {
|
||||||
|
console.warn('Invalid tenant ID format, resetting to default:', currentTenantId.value)
|
||||||
|
currentTenantId.value = 'default@ash@2026'
|
||||||
|
tenantStore.setTenant(currentTenantId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getTenantList()
|
||||||
|
tenantList.value = response.tenants || []
|
||||||
|
|
||||||
|
// 如果当前租户不在列表中,默认选择第一个
|
||||||
|
if (tenantList.value.length > 0 && !tenantList.value.find(t => t.id === currentTenantId.value)) {
|
||||||
|
const firstTenant = tenantList.value[0]
|
||||||
|
currentTenantId.value = firstTenant.id
|
||||||
|
tenantStore.setTenant(firstTenant.id)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('获取租户列表失败')
|
||||||
|
console.error('Failed to fetch tenant list:', error)
|
||||||
|
// 失败时使用默认租户
|
||||||
|
tenantList.value = [{ id: 'default@ash@2026', name: 'default (2026)' }]
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchTenantList()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export interface Tenant {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
displayName: string
|
||||||
|
year: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TenantListResponse {
|
||||||
|
tenants: Tenant[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTenantList() {
|
||||||
|
return request<TenantListResponse>({
|
||||||
|
url: '/admin/tenants',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
// Default tenant ID format: name@ash@year
|
||||||
|
const DEFAULT_TENANT_ID = 'default@ash@2026'
|
||||||
|
|
||||||
export const useTenantStore = defineStore('tenant', {
|
export const useTenantStore = defineStore('tenant', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
currentTenantId: localStorage.getItem('X-Tenant-Id') || 'default'
|
currentTenantId: localStorage.getItem('X-Tenant-Id') || DEFAULT_TENANT_ID
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
setTenant(id: string) {
|
setTenant(id: string) {
|
||||||
|
|
|
||||||
|
|
@ -9,5 +9,6 @@ from app.api.admin.kb import router as kb_router
|
||||||
from app.api.admin.llm import router as llm_router
|
from app.api.admin.llm import router as llm_router
|
||||||
from app.api.admin.rag import router as rag_router
|
from app.api.admin.rag import router as rag_router
|
||||||
from app.api.admin.sessions import router as sessions_router
|
from app.api.admin.sessions import router as sessions_router
|
||||||
|
from app.api.admin.tenants import router as tenants_router
|
||||||
|
|
||||||
__all__ = ["dashboard_router", "embedding_router", "kb_router", "llm_router", "rag_router", "sessions_router"]
|
__all__ = ["dashboard_router", "embedding_router", "kb_router", "llm_router", "rag_router", "sessions_router", "tenants_router"]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
"""
|
||||||
|
Tenant management endpoints.
|
||||||
|
Provides tenant list and management functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_session
|
||||||
|
from app.core.exceptions import MissingTenantIdException
|
||||||
|
from app.core.middleware import parse_tenant_id
|
||||||
|
from app.core.tenant import get_tenant_id
|
||||||
|
from app.models import ErrorResponse
|
||||||
|
from app.models.entities import Tenant
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/tenants", tags=["Tenants"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_tenant_id() -> str:
|
||||||
|
"""Dependency to get current tenant ID or raise exception."""
|
||||||
|
tenant_id = get_tenant_id()
|
||||||
|
if not tenant_id:
|
||||||
|
raise MissingTenantIdException()
|
||||||
|
return tenant_id
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
operation_id="listTenants",
|
||||||
|
summary="List all tenants",
|
||||||
|
description="Get a list of all tenants from the system.",
|
||||||
|
responses={
|
||||||
|
200: {"description": "List of tenants"},
|
||||||
|
401: {"description": "Unauthorized", "model": ErrorResponse},
|
||||||
|
403: {"description": "Forbidden", "model": ErrorResponse},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def list_tenants(
|
||||||
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""
|
||||||
|
Get a list of all tenants from the tenants table.
|
||||||
|
Returns tenant ID and display name (first part of tenant_id).
|
||||||
|
"""
|
||||||
|
logger.info("Getting all tenants")
|
||||||
|
|
||||||
|
# Get all tenants from tenants table
|
||||||
|
stmt = select(Tenant).order_by(Tenant.created_at.desc())
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
tenants = result.scalars().all()
|
||||||
|
|
||||||
|
# Format tenant list with display name
|
||||||
|
tenant_list = []
|
||||||
|
for tenant in tenants:
|
||||||
|
name, year = parse_tenant_id(tenant.tenant_id)
|
||||||
|
tenant_list.append({
|
||||||
|
"id": tenant.tenant_id,
|
||||||
|
"name": f"{name} ({year})",
|
||||||
|
"displayName": name,
|
||||||
|
"year": year,
|
||||||
|
"createdAt": tenant.created_at.isoformat() if tenant.created_at else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"Found {len(tenant_list)} tenants: {[t['id'] for t in tenant_list]}")
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"tenants": tenant_list,
|
||||||
|
"total": len(tenant_list)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
@ -4,6 +4,7 @@ Middleware for AI Service.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from fastapi import Request, Response, status
|
from fastapi import Request, Response, status
|
||||||
|
|
@ -19,11 +20,32 @@ TENANT_ID_HEADER = "X-Tenant-Id"
|
||||||
ACCEPT_HEADER = "Accept"
|
ACCEPT_HEADER = "Accept"
|
||||||
SSE_CONTENT_TYPE = "text/event-stream"
|
SSE_CONTENT_TYPE = "text/event-stream"
|
||||||
|
|
||||||
|
# Tenant ID format: name@ash@year (e.g., szmp@ash@2026)
|
||||||
|
TENANT_ID_PATTERN = re.compile(r'^[^@]+@ash@\d{4}$')
|
||||||
|
|
||||||
|
|
||||||
|
def validate_tenant_id_format(tenant_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-10] Validate tenant ID format: name@ash@year
|
||||||
|
Examples: szmp@ash@2026, abc123@ash@2025
|
||||||
|
"""
|
||||||
|
return bool(TENANT_ID_PATTERN.match(tenant_id))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_tenant_id(tenant_id: str) -> tuple[str, str]:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-10] Parse tenant ID into name and year.
|
||||||
|
Returns: (name, year)
|
||||||
|
"""
|
||||||
|
parts = tenant_id.split('@')
|
||||||
|
return parts[0], parts[2]
|
||||||
|
|
||||||
|
|
||||||
class TenantContextMiddleware(BaseHTTPMiddleware):
|
class TenantContextMiddleware(BaseHTTPMiddleware):
|
||||||
"""
|
"""
|
||||||
[AC-AISVC-10, AC-AISVC-12] Middleware to extract and validate X-Tenant-Id header.
|
[AC-AISVC-10, AC-AISVC-12] Middleware to extract and validate X-Tenant-Id header.
|
||||||
Injects tenant context into request state for downstream processing.
|
Injects tenant context into request state for downstream processing.
|
||||||
|
Validates tenant ID format and auto-creates tenant if not exists.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||||
|
|
@ -44,10 +66,31 @@ class TenantContextMiddleware(BaseHTTPMiddleware):
|
||||||
).model_dump(exclude_none=True),
|
).model_dump(exclude_none=True),
|
||||||
)
|
)
|
||||||
|
|
||||||
set_tenant_context(tenant_id.strip())
|
tenant_id = tenant_id.strip()
|
||||||
request.state.tenant_id = tenant_id.strip()
|
|
||||||
|
|
||||||
logger.info(f"[AC-AISVC-10] Tenant context set: tenant_id={tenant_id.strip()}")
|
# Validate tenant ID format
|
||||||
|
if not validate_tenant_id_format(tenant_id):
|
||||||
|
logger.warning(f"[AC-AISVC-10] Invalid tenant ID format: {tenant_id}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
content=ErrorResponse(
|
||||||
|
code=ErrorCode.INVALID_TENANT_ID.value,
|
||||||
|
message="Invalid tenant ID format. Expected: name@ash@year (e.g., szmp@ash@2026)",
|
||||||
|
).model_dump(exclude_none=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Auto-create tenant if not exists (for admin endpoints)
|
||||||
|
if request.url.path.startswith("/admin/") or request.url.path.startswith("/ai/"):
|
||||||
|
try:
|
||||||
|
await self._ensure_tenant_exists(request, tenant_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[AC-AISVC-10] Failed to ensure tenant exists: {e}")
|
||||||
|
# Continue processing even if tenant creation fails
|
||||||
|
|
||||||
|
set_tenant_context(tenant_id)
|
||||||
|
request.state.tenant_id = tenant_id
|
||||||
|
|
||||||
|
logger.info(f"[AC-AISVC-10] Tenant context set: tenant_id={tenant_id}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
|
|
@ -56,6 +99,39 @@ class TenantContextMiddleware(BaseHTTPMiddleware):
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
async def _ensure_tenant_exists(self, request: Request, tenant_id: str) -> None:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-10] Ensure tenant exists in database, create if not.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import async_session_maker
|
||||||
|
from app.models.entities import Tenant
|
||||||
|
|
||||||
|
name, year = parse_tenant_id(tenant_id)
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
# Check if tenant exists
|
||||||
|
stmt = select(Tenant).where(Tenant.tenant_id == tenant_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
existing_tenant = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing_tenant:
|
||||||
|
logger.debug(f"[AC-AISVC-10] Tenant already exists: {tenant_id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create new tenant
|
||||||
|
new_tenant = Tenant(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
name=name,
|
||||||
|
year=year,
|
||||||
|
)
|
||||||
|
session.add(new_tenant)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info(f"[AC-AISVC-10] Auto-created new tenant: {tenant_id} (name={name}, year={year})")
|
||||||
|
|
||||||
|
|
||||||
def is_sse_request(request: Request) -> bool:
|
def is_sse_request(request: Request) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from app.api import chat_router, health_router
|
from app.api import chat_router, health_router
|
||||||
from app.api.admin import dashboard_router, embedding_router, kb_router, llm_router, rag_router, sessions_router
|
from app.api.admin import dashboard_router, embedding_router, kb_router, llm_router, rag_router, sessions_router, tenants_router
|
||||||
|
from app.api.admin.kb_optimized import router as kb_optimized_router
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.database import close_db, init_db
|
from app.core.database import close_db, init_db
|
||||||
from app.core.exceptions import (
|
from app.core.exceptions import (
|
||||||
|
|
@ -115,9 +116,11 @@ app.include_router(chat_router)
|
||||||
app.include_router(dashboard_router)
|
app.include_router(dashboard_router)
|
||||||
app.include_router(embedding_router)
|
app.include_router(embedding_router)
|
||||||
app.include_router(kb_router)
|
app.include_router(kb_router)
|
||||||
|
app.include_router(kb_optimized_router)
|
||||||
app.include_router(llm_router)
|
app.include_router(llm_router)
|
||||||
app.include_router(rag_router)
|
app.include_router(rag_router)
|
||||||
app.include_router(sessions_router)
|
app.include_router(sessions_router)
|
||||||
|
app.include_router(tenants_router)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ class ChatResponse(BaseModel):
|
||||||
class ErrorCode(str, Enum):
|
class ErrorCode(str, Enum):
|
||||||
INVALID_REQUEST = "INVALID_REQUEST"
|
INVALID_REQUEST = "INVALID_REQUEST"
|
||||||
MISSING_TENANT_ID = "MISSING_TENANT_ID"
|
MISSING_TENANT_ID = "MISSING_TENANT_ID"
|
||||||
|
INVALID_TENANT_ID = "INVALID_TENANT_ID"
|
||||||
INTERNAL_ERROR = "INTERNAL_ERROR"
|
INTERNAL_ERROR = "INTERNAL_ERROR"
|
||||||
SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE"
|
SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE"
|
||||||
TIMEOUT = "TIMEOUT"
|
TIMEOUT = "TIMEOUT"
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,22 @@ class SessionStatus(str, Enum):
|
||||||
EXPIRED = "expired"
|
EXPIRED = "expired"
|
||||||
|
|
||||||
|
|
||||||
|
class Tenant(SQLModel, table=True):
|
||||||
|
"""
|
||||||
|
[AC-AISVC-10] Tenant entity for storing tenant information.
|
||||||
|
Tenant ID format: name@ash@year (e.g., szmp@ash@2026)
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "tenants"
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
tenant_id: str = Field(..., description="Full tenant ID (format: name@ash@year)", unique=True, index=True)
|
||||||
|
name: str = Field(..., description="Tenant display name (first part of tenant_id)")
|
||||||
|
year: str = Field(..., description="Year part from tenant_id")
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation time")
|
||||||
|
updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update time")
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeBase(SQLModel, table=True):
|
class KnowledgeBase(SQLModel, table=True):
|
||||||
"""
|
"""
|
||||||
[AC-ASA-01] Knowledge base entity with tenant isolation.
|
[AC-ASA-01] Knowledge base entity with tenant isolation.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue