feat: 实现租户管理功能,支持租户ID格式校验与自动创建 [AC-AISVC-10, AC-AISVC-12, AC-ASA-01]

This commit is contained in:
MerCry 2026-02-25 23:08:36 +08:00
parent a23f1a2089
commit ac8c33cf94
9 changed files with 260 additions and 13 deletions

View File

@ -42,15 +42,19 @@
</div>
<div class="header-right">
<div class="tenant-selector">
<el-select
v-model="currentTenantId"
placeholder="选择租户"
<el-select
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>

View File

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

View File

@ -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) {

View File

@ -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"]

View File

@ -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)
}
)

View File

@ -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:
"""

View File

@ -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__":

View File

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

View File

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