feat: 租户管理与站点管理(创建向导/停用/域名/门面覆盖)

- API 层实装(api/tenants.ts):创建/详情/PATCH 租户、建站/改站、
  域名绑定解绑——400 撞车 detail(slug/域名归属/prefix 引导)经
  showErrorMessage:false 关全局 toast、由向导与绑域名弹窗内联常驻展示
- 租户列表:slug/名称/状态/域名数/版本/管理员概要 + 状态筛选 +
  行操作(详情/停用复启用/改名);停用确认框写明 503 服务暂停语义
- 创建向导(ElSteps 四步):名称+slug(^[a-z0-9-]+$ 与撞车内联拦截)
  → 域名多填 → 出厂档案(auto/jiancai 提示 prefix 全局唯一撞车引导
  改 blank;blank 提示墙与提示词取建材出厂档案)→ 管理员账号;
  201 后列表刷新并提示首登需改密
- 站点管理(详情抽屉):站点表/新建站(门面覆盖层表单——brand 组
  子集字段留空=继承租户默认)/停用复启用(404 语义提示)/域名绑定
  (撞车归属内联)与解绑(标签 × 确认)
- vitest +9(API 层 URL/方法/请求体契约);页面仅超管(菜单 roles 已挡)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-13 18:27:56 +08:00
parent ce668eaf2f
commit 6f22e51908
4 changed files with 1464 additions and 13 deletions
+218
View File
@@ -0,0 +1,218 @@
/**
* 租户/站点/域名 API 单元测试(Phase 3 Task 6)
*
* 钉住 API 层契约:端点 URL / 方法 / 请求体形状(事实源
* app/platform/routes/tenant_routes.py);HTTP 层行为已在 client.test.ts
* 覆盖,此处 mock http 只断言调用形态:
* - 列表/详情:GET /api/platform/tenants[/{slug}]
* - 创建向导:POST 全量六字段(domains 进 main 站点)
* - PATCH 租户:只发 name 或 status(slug 键绝不出现在 body——后端 400)
* - 站点:POST sites(brand_overlay 只含显式覆盖字段)/ PATCH sites/{site}
* - 域名:POST domains {domain} / DELETE domains/{domain}(路径段直拼)
* - RequestOptions 透传(向导/绑域名内联错误关全局 toast)
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/api/client', () => ({
http: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), del: vi.fn() }
}))
import { http } from '@/api/client'
import {
addSiteDomain,
createSite,
createTenant,
deleteSiteDomain,
fetchTenantDetail,
fetchTenantList,
patchSite,
patchTenant
} from '@/api/tenants'
describe('租户与站点管理 API(src/api/tenants.ts)', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fetchTenantList:GET /api/platform/tenants', async () => {
const body = {
items: [
{
slug: 'jiancai',
name: '建材租户',
status: 'active',
domain_count: 1,
current_version: 1,
admins: [{ username: 'jc-admin', status: 'active' }]
}
]
}
vi.mocked(http.get).mockResolvedValue(body)
const res = await fetchTenantList()
expect(res).toEqual(body)
expect(http.get).toHaveBeenCalledWith('/api/platform/tenants')
})
it('fetchTenantDetail:GET /api/platform/tenants/{slug}', async () => {
const body = {
slug: 'jiancai',
name: '建材租户',
status: 'active',
current_version: 1,
sites: [
{
slug: 'main',
name: 'main',
status: 'active',
domains: ['chat2.agent.hnxinyouhe.com'],
brand_overlay: {}
}
],
admins: [{ username: 'jc-admin', status: 'active' }]
}
vi.mocked(http.get).mockResolvedValue(body)
const res = await fetchTenantDetail('jiancai')
expect(res).toEqual(body)
expect(http.get).toHaveBeenCalledWith('/api/platform/tenants/jiancai', undefined)
})
it('createTenant:POST 全量六字段(向导提交形态)', async () => {
const body = {
name: '新租户',
slug: 'new-tenant',
profile: 'blank',
admin_username: 'nt-admin',
admin_password: 'initial-pass',
domains: ['a.example.com', 'b.example.com']
}
const created = {
slug: 'new-tenant',
name: '新租户',
status: 'active',
profile: 'blank',
version: 1,
site: { slug: 'main', domains: ['a.example.com', 'b.example.com'] },
admin_username: 'nt-admin'
}
vi.mocked(http.post).mockResolvedValue(created)
const res = await createTenant(body, { showErrorMessage: false })
expect(res).toEqual(created)
expect(http.post).toHaveBeenCalledWith('/api/platform/tenants', body, {
showErrorMessage: false
})
})
it('patchTenant 改名:PATCH {slug} 只发 name(slug 键不可出现)', async () => {
vi.mocked(http.patch).mockResolvedValue({
slug: 'jiancai',
name: '建材租户改',
status: 'active'
})
await patchTenant('jiancai', { name: '建材租户改' })
expect(http.patch).toHaveBeenCalledWith(
'/api/platform/tenants/jiancai',
{ name: '建材租户改' },
undefined
)
})
it('patchTenant 停用:PATCH {slug} 只发 status=disabled', async () => {
vi.mocked(http.patch).mockResolvedValue({
slug: 'jiancai',
name: '建材租户',
status: 'disabled'
})
await patchTenant('jiancai', { status: 'disabled' })
expect(http.patch).toHaveBeenCalledWith(
'/api/platform/tenants/jiancai',
{ status: 'disabled' },
undefined
)
})
it('createSite:POST sites 带 brand_overlay 覆盖子集(留空字段不进 overlay)', async () => {
const body = {
slug: 'shop1',
name: '西班牙站',
brand_overlay: { title: 'HESUPIPE ES', color: '#ff6600' }
}
const created = {
slug: 'shop1',
name: '西班牙站',
status: 'active',
domains: [],
brand_overlay: body.brand_overlay
}
vi.mocked(http.post).mockResolvedValue(created)
const res = await createSite('jiancai', body)
expect(res).toEqual(created)
expect(http.post).toHaveBeenCalledWith('/api/platform/tenants/jiancai/sites', body, undefined)
})
it('patchSite 停用:PATCH sites/{siteSlug} 只发 status', async () => {
vi.mocked(http.patch).mockResolvedValue({
slug: 'shop1',
name: '西班牙站',
status: 'disabled',
domains: [],
brand_overlay: {}
})
await patchSite('jiancai', 'shop1', { status: 'disabled' })
expect(http.patch).toHaveBeenCalledWith(
'/api/platform/tenants/jiancai/sites/shop1',
{ status: 'disabled' },
undefined
)
})
it('addSiteDomain:POST sites/{siteSlug}/domains {domain}', async () => {
vi.mocked(http.post).mockResolvedValue({
domain: 'es.hesupipe.com',
site: 'shop1'
})
const res = await addSiteDomain(
'jiancai',
'shop1',
{ domain: 'es.hesupipe.com' },
{ showErrorMessage: false }
)
expect(res).toEqual({ domain: 'es.hesupipe.com', site: 'shop1' })
expect(http.post).toHaveBeenCalledWith(
'/api/platform/tenants/jiancai/sites/shop1/domains',
{ domain: 'es.hesupipe.com' },
{ showErrorMessage: false }
)
})
it('deleteSiteDomain:DELETE sites/{siteSlug}/domains/{domain}(路径直拼)', async () => {
vi.mocked(http.del).mockResolvedValue({
ok: true,
domain: 'es.hesupipe.com',
site: 'shop1'
})
const res = await deleteSiteDomain('jiancai', 'shop1', 'es.hesupipe.com')
expect(res).toEqual({ ok: true, domain: 'es.hesupipe.com', site: 'shop1' })
expect(http.del).toHaveBeenCalledWith(
'/api/platform/tenants/jiancai/sites/shop1/domains/es.hesupipe.com',
undefined
)
})
})
+120 -6
View File
@@ -1,11 +1,17 @@
/**
* 租户与站点管理 API(Phase 3 Task 3 骨架;Task 4 增列表函数)
* 租户与站点管理 API(Phase 3 Task 3 骨架;Task 4 列表函数;Task 6 实装全部调用函数)
*
* 端点事实源:app/platform/routes/tenant_routes.py
* (/api/platform/tenants,require_super / require_tenant(slug) 鉴权)。
* 其余调用函数在 Task 6(租户管理与站点管理)实装。
*
* 错误语义(页面交互消费):
* - 400 预检撞车带中文 detail:slug 已存在 / 域名已绑定(含归属租户与站点)/
* 出厂 push.prefix 被占(引导改 blank 或先改占用方)——向导与绑域名
* 弹窗用 showErrorMessage:false 关全局 toast、改内联常驻展示;
* - 409 并发竞态(提交瞬间被抢先);
* - PATCH 带 slug 键一律 400(slug 不可变——界面不提供改 slug 入口)。
*/
import { http } from './client'
import { http, type RequestOptions } from './client'
/** 租户状态(Tenant.status) */
export type TenantStatus = 'active' | 'disabled'
@@ -75,11 +81,49 @@ export interface CreateSiteRequest {
domains?: string[]
}
/** 绑定域名请求(POST .../sites/{siteSlug}/domains,201;409=域名已被占用) */
/** 创建租户响应(create_tenant:201 返回概要;管理员凭据绝不回传) */
export interface TenantCreatedResponse {
slug: string
name: string
status: TenantStatus
profile: string
version: number
site: { slug: string; domains: string[] }
admin_username: string
}
/** 改名/改状态响应(patch_tenant / patch_site 返回形状) */
export interface TenantPatchResponse {
slug: string
name: string
status: TenantStatus
}
/** 改站点请求(PATCH .../sites/{siteSlug};slug 不可变——后端 400) */
export interface PatchSiteRequest {
name?: string
status?: TenantStatus
brand_overlay?: Record<string, unknown>
}
/** 绑定域名请求(POST .../sites/{siteSlug}/domains,201;400=撞车带归属) */
export interface AddDomainRequest {
domain: string
}
/** 绑定域名响应 */
export interface DomainBindResponse {
domain: string
site: string
}
/** 解绑域名响应 */
export interface DomainUnbindResponse {
ok: boolean
domain: string
site: string
}
/** 租户管理端点(鉴权与 409 并发冲突语义见 tenant_routes.py) */
export const TENANT_ENDPOINTS = {
list: '/api/platform/tenants',
@@ -87,8 +131,7 @@ export const TENANT_ENDPOINTS = {
detail: (slug: string) => `/api/platform/tenants/${slug}`,
patch: (slug: string) => `/api/platform/tenants/${slug}`,
createSite: (slug: string) => `/api/platform/tenants/${slug}/sites`,
patchSite: (slug: string, siteSlug: string) =>
`/api/platform/tenants/${slug}/sites/${siteSlug}`,
patchSite: (slug: string, siteSlug: string) => `/api/platform/tenants/${slug}/sites/${siteSlug}`,
addDomain: (slug: string, siteSlug: string) =>
`/api/platform/tenants/${slug}/sites/${siteSlug}/domains`,
deleteDomain: (slug: string, siteSlug: string, domain: string) =>
@@ -102,3 +145,74 @@ export const TENANT_ENDPOINTS = {
export function fetchTenantList(): Promise<TenantListResponse> {
return http.get<TenantListResponse>(TENANT_ENDPOINTS.list)
}
/** 租户详情(含 sites/domains/brand_overlay/admins;本租户管理员亦可读) */
export function fetchTenantDetail(
slug: string,
config?: RequestOptions
): Promise<TenantDetailResponse> {
return http.get<TenantDetailResponse>(TENANT_ENDPOINTS.detail(slug), config)
}
/**
* 创建租户(创建向导提交;201)。向导调用建议传
* `{ showErrorMessage: false }`——400 预检撞车 detail(slug 已存在 /
* 出厂 push.prefix 被占的改 blank 引导)在向导内联常驻展示。
*/
export function createTenant(
body: CreateTenantRequest,
config?: RequestOptions
): Promise<TenantCreatedResponse> {
return http.post<TenantCreatedResponse>(TENANT_ENDPOINTS.create, body, config)
}
/** 改名 / 停用 / 复启用(PATCH;slug 不可变,body 不含 slug 键) */
export function patchTenant(
slug: string,
body: PatchTenantRequest,
config?: RequestOptions
): Promise<TenantPatchResponse> {
return http.patch<TenantPatchResponse>(TENANT_ENDPOINTS.patch(slug), body, config)
}
/** 新建站点(brand_overlay 只放显式覆盖的字段——留空=继承租户默认) */
export function createSite(
slug: string,
body: CreateSiteRequest,
config?: RequestOptions
): Promise<SiteSummary> {
return http.post<SiteSummary>(TENANT_ENDPOINTS.createSite(slug), body, config)
}
/** 改站点(停用/复启用/改名/改覆盖层;整字典赋值——旧键不残留) */
export function patchSite(
slug: string,
siteSlug: string,
body: PatchSiteRequest,
config?: RequestOptions
): Promise<SiteSummary> {
return http.patch<SiteSummary>(TENANT_ENDPOINTS.patchSite(slug, siteSlug), body, config)
}
/** 绑定域名(全局唯一;400 detail 带现有归属"租户 x 的站点 y") */
export function addSiteDomain(
slug: string,
siteSlug: string,
body: AddDomainRequest,
config?: RequestOptions
): Promise<DomainBindResponse> {
return http.post<DomainBindResponse>(TENANT_ENDPOINTS.addDomain(slug, siteSlug), body, config)
}
/** 解绑域名(只解本站点的该域名;不存在 404) */
export function deleteSiteDomain(
slug: string,
siteSlug: string,
domain: string,
config?: RequestOptions
): Promise<DomainUnbindResponse> {
return http.del<DomainUnbindResponse>(
TENANT_ENDPOINTS.deleteDomain(slug, siteSlug, domain),
config
)
}