feat: 实现租户管理功能,支持租户ID格式校验与自动创建 [AC-AISVC-10, AC-AISVC-12, AC-ASA-01]
This commit is contained in:
parent
a23f1a2089
commit
ac8c33cf94
|
|
@ -46,11 +46,15 @@
|
|||
v-model="currentTenantId"
|
||||
placeholder="选择租户"
|
||||
size="default"
|
||||
:loading="loading"
|
||||
@change="handleTenantChange"
|
||||
>
|
||||
<el-option label="默认租户" value="default" />
|
||||
<el-option label="租户 A" value="tenant_a" />
|
||||
<el-option label="租户 B" value="tenant_b" />
|
||||
<el-option
|
||||
v-for="tenant in tenantList"
|
||||
:key="tenant.id"
|
||||
:label="tenant.name"
|
||||
:value="tenant.id"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -62,15 +66,19 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
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 { ElMessage } from 'element-plus'
|
||||
|
||||
const route = useRoute()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
const currentTenantId = ref(tenantStore.currentTenantId)
|
||||
const tenantList = ref<Tenant[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return route.path === path || route.path.startsWith(path + '/')
|
||||
|
|
@ -78,7 +86,47 @@ const isActive = (path: string) => {
|
|||
|
||||
const handleTenantChange = (val: string) => {
|
||||
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>
|
||||
|
||||
<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'
|
||||
|
||||
// Default tenant ID format: name@ash@year
|
||||
const DEFAULT_TENANT_ID = 'default@ash@2026'
|
||||
|
||||
export const useTenantStore = defineStore('tenant', {
|
||||
state: () => ({
|
||||
currentTenantId: localStorage.getItem('X-Tenant-Id') || 'default'
|
||||
currentTenantId: localStorage.getItem('X-Tenant-Id') || DEFAULT_TENANT_ID
|
||||
}),
|
||||
actions: {
|
||||
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.rag import router as rag_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 re
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import Request, Response, status
|
||||
|
|
@ -19,11 +20,32 @@ TENANT_ID_HEADER = "X-Tenant-Id"
|
|||
ACCEPT_HEADER = "Accept"
|
||||
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):
|
||||
"""
|
||||
[AC-AISVC-10, AC-AISVC-12] Middleware to extract and validate X-Tenant-Id header.
|
||||
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:
|
||||
|
|
@ -44,10 +66,31 @@ class TenantContextMiddleware(BaseHTTPMiddleware):
|
|||
).model_dump(exclude_none=True),
|
||||
)
|
||||
|
||||
set_tenant_context(tenant_id.strip())
|
||||
request.state.tenant_id = tenant_id.strip()
|
||||
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:
|
||||
response = await call_next(request)
|
||||
|
|
@ -56,6 +99,39 @@ class TenantContextMiddleware(BaseHTTPMiddleware):
|
|||
|
||||
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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||
from fastapi.responses import JSONResponse
|
||||
|
||||
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.database import close_db, init_db
|
||||
from app.core.exceptions import (
|
||||
|
|
@ -115,9 +116,11 @@ app.include_router(chat_router)
|
|||
app.include_router(dashboard_router)
|
||||
app.include_router(embedding_router)
|
||||
app.include_router(kb_router)
|
||||
app.include_router(kb_optimized_router)
|
||||
app.include_router(llm_router)
|
||||
app.include_router(rag_router)
|
||||
app.include_router(sessions_router)
|
||||
app.include_router(tenants_router)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ class ChatResponse(BaseModel):
|
|||
class ErrorCode(str, Enum):
|
||||
INVALID_REQUEST = "INVALID_REQUEST"
|
||||
MISSING_TENANT_ID = "MISSING_TENANT_ID"
|
||||
INVALID_TENANT_ID = "INVALID_TENANT_ID"
|
||||
INTERNAL_ERROR = "INTERNAL_ERROR"
|
||||
SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE"
|
||||
TIMEOUT = "TIMEOUT"
|
||||
|
|
|
|||
|
|
@ -102,6 +102,22 @@ class SessionStatus(str, Enum):
|
|||
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):
|
||||
"""
|
||||
[AC-ASA-01] Knowledge base entity with tenant isolation.
|
||||
|
|
|
|||
Loading…
Reference in New Issue