feat: API层与认证流(client拦截/守卫/登录改密实装/域API骨架/vitest)
- 自建轻量 axios client(src/api/client.ts):Bearer 注入、401 清会话跳登录
(login 请求除外)、409 精确匹配"必须先修改初始密码"跳强制改密(区别于并发
冲突 409)、429/网络错误统一 ElMessage、ApiError 标准化、showErrorMessage
可opt-out——模板 utils/http 的 {code,msg,data} 信封与后端裸 HTTP 语义不符故弃用
- Pinia auth store(src/store/modules/auth.ts):token/role/tenant_slug/
must_change 落地并持久化(all-kefu-auth key),login/logout/changePassword/
会话过期/强制改密跳转(动态 import 断循环)
- 路由守卫:token 事实源判定、must_change 全站门(除改密页)、UserInfo 由登录
快照派生(后端无用户信息端点);user store 残留自愈
- 登录页实装(去除角色调试开关)、强制改密页实装;用户菜单/锁屏对接真实 logout
- 域 API 骨架:tenants/config/conversations/stats/settings(类型+端点常量)
- mask 哨兵助手:__SET__ ↔ 已设置 表单语义
- vitest 25 例(client 11/store 8/mask 6);登录页拖拽验证 reset 空引用修复
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* auth store 单元测试(Phase 3 Task 3)
|
||||
*
|
||||
* 覆盖:
|
||||
* - login 成功存 token/role/tenant_slug/must_change_password/username(含模板侧同步)
|
||||
* - logout:调 API + 清空状态 + 模板侧 logOut(API 失败也照常本地清)
|
||||
* - localStorage 持久化(all-kefu 前缀键;新建 pinia 实例模拟刷新恢复)
|
||||
* - handleSessionExpired / handleMustChangePassword / changePassword
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createApp, defineComponent } from 'vue'
|
||||
import { createPersistedState } from 'pinia-plugin-persistedstate'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetchLogin: vi.fn(),
|
||||
fetchLogout: vi.fn(),
|
||||
fetchChangePassword: vi.fn(),
|
||||
userStore: {
|
||||
logOut: vi.fn(),
|
||||
setLoginStatus: vi.fn(),
|
||||
setToken: vi.fn(),
|
||||
setUserInfo: vi.fn(),
|
||||
checkAndClearWorktabs: vi.fn(),
|
||||
info: {}
|
||||
},
|
||||
routerPush: vi.fn(),
|
||||
routerReplace: vi.fn(),
|
||||
currentRoute: { value: { path: '/' } }
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
fetchLogin: mocks.fetchLogin,
|
||||
fetchLogout: mocks.fetchLogout,
|
||||
fetchChangePassword: mocks.fetchChangePassword
|
||||
}))
|
||||
|
||||
vi.mock('@/store/modules/user', () => ({
|
||||
useUserStore: () => mocks.userStore
|
||||
}))
|
||||
|
||||
vi.mock('@/router', () => ({
|
||||
router: { push: mocks.routerPush, replace: mocks.routerReplace, currentRoute: mocks.currentRoute }
|
||||
}))
|
||||
|
||||
import { useAuthStore } from '@/store/modules/auth'
|
||||
|
||||
const LOGIN_RESPONSE = {
|
||||
token: 'tok-abc',
|
||||
role: 'super',
|
||||
tenant_slug: null,
|
||||
must_change_password: true
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 等价应用的持久化 pinia:
|
||||
* pinia.use() 在未安装 app 时(_a 为空)插件只进 toBeInstalled 队列,
|
||||
* 必须 app.use(pinia) 才真正生效——真实应用经 initStore(main.ts)如此;
|
||||
* 测试里用一次性 app 复刻同一装配,否则持久化插件静默不运行。
|
||||
*/
|
||||
function freshPersistedPinia() {
|
||||
const pinia = createPinia()
|
||||
pinia.use(createPersistedState())
|
||||
createApp(defineComponent(() => () => null)).use(pinia)
|
||||
setActivePinia(pinia)
|
||||
return pinia
|
||||
}
|
||||
|
||||
describe('auth store(src/store/modules/auth.ts)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
mocks.currentRoute.value.path = '/'
|
||||
})
|
||||
|
||||
it('login 成功:存 token/role/tenantSlug/mustChangePassword/username 并同步模板侧', async () => {
|
||||
freshPersistedPinia()
|
||||
mocks.fetchLogin.mockResolvedValue({ ...LOGIN_RESPONSE })
|
||||
|
||||
const store = useAuthStore()
|
||||
await store.login({ username: 'admin', password: 'admin123' })
|
||||
|
||||
expect(store.token).toBe('tok-abc')
|
||||
expect(store.role).toBe('super')
|
||||
expect(store.tenantSlug).toBeNull()
|
||||
expect(store.mustChangePassword).toBe(true)
|
||||
expect(store.username).toBe('admin')
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
|
||||
// 模板侧(user store)同步:token / 登录标记 / 用户信息(菜单过滤用 roles)
|
||||
expect(mocks.userStore.setToken).toHaveBeenCalledWith('tok-abc', '')
|
||||
expect(mocks.userStore.setLoginStatus).toHaveBeenCalledWith(true)
|
||||
expect(mocks.userStore.setUserInfo).toHaveBeenCalled()
|
||||
const info = mocks.userStore.setUserInfo.mock.calls[0][0]
|
||||
expect(info.roles).toEqual(['super'])
|
||||
expect(info.userName).toBe('admin')
|
||||
})
|
||||
|
||||
it('login 失败:状态保持空、错误向上抛', async () => {
|
||||
freshPersistedPinia()
|
||||
mocks.fetchLogin.mockRejectedValue(new Error('用户名或密码错误'))
|
||||
|
||||
const store = useAuthStore()
|
||||
await expect(store.login({ username: 'a', password: 'b' })).rejects.toThrow('用户名或密码错误')
|
||||
|
||||
expect(store.token).toBe('')
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(mocks.userStore.setLoginStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logout:调登出 API + 清空状态 + 模板侧 logOut', async () => {
|
||||
freshPersistedPinia()
|
||||
mocks.fetchLogin.mockResolvedValue({ ...LOGIN_RESPONSE })
|
||||
mocks.fetchLogout.mockResolvedValue({ ok: true })
|
||||
|
||||
const store = useAuthStore()
|
||||
await store.login({ username: 'admin', password: 'x' })
|
||||
|
||||
await store.logout()
|
||||
|
||||
expect(mocks.fetchLogout).toHaveBeenCalledTimes(1)
|
||||
expect(store.token).toBe('')
|
||||
expect(store.role).toBe('')
|
||||
expect(store.tenantSlug).toBeNull()
|
||||
expect(store.username).toBe('')
|
||||
expect(store.mustChangePassword).toBe(false)
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(mocks.userStore.logOut).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('logout 时后端已不可达:仍完成本地清理(best effort)', async () => {
|
||||
freshPersistedPinia()
|
||||
mocks.fetchLogin.mockResolvedValue({ ...LOGIN_RESPONSE })
|
||||
mocks.fetchLogout.mockRejectedValue(new Error('network down'))
|
||||
|
||||
const store = useAuthStore()
|
||||
await store.login({ username: 'admin', password: 'x' })
|
||||
|
||||
await expect(store.logout()).resolves.toBeUndefined()
|
||||
|
||||
expect(store.token).toBe('')
|
||||
expect(mocks.userStore.logOut).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('localStorage 持久化:all-kefu 前缀键;新 pinia 实例(模拟刷新)恢复会话', async () => {
|
||||
freshPersistedPinia()
|
||||
mocks.fetchLogin.mockResolvedValue({ ...LOGIN_RESPONSE, role: 'tenant', tenant_slug: 'jiancai', must_change_password: false })
|
||||
|
||||
await useAuthStore().login({ username: 'jcadmin', password: 'x' })
|
||||
|
||||
// 持久化键带 all-kefu 前缀
|
||||
expect(localStorage.getItem('all-kefu-auth')).toBeTruthy()
|
||||
|
||||
// 模拟页面刷新:全新 pinia + 插件,从 localStorage 恢复
|
||||
freshPersistedPinia()
|
||||
const restored = useAuthStore()
|
||||
expect(restored.token).toBe('tok-abc')
|
||||
expect(restored.role).toBe('tenant')
|
||||
expect(restored.tenantSlug).toBe('jiancai')
|
||||
expect(restored.username).toBe('jcadmin')
|
||||
expect(restored.mustChangePassword).toBe(false)
|
||||
expect(restored.isAuthenticated).toBe(true)
|
||||
})
|
||||
|
||||
it('handleSessionExpired:清空会话并委托模板侧 logOut(401 全局处理路径)', () => {
|
||||
freshPersistedPinia()
|
||||
const store = useAuthStore()
|
||||
store.token = 'tok'
|
||||
store.role = 'super'
|
||||
store.username = 'admin'
|
||||
|
||||
store.handleSessionExpired()
|
||||
|
||||
expect(store.token).toBe('')
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(mocks.userStore.logOut).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('handleMustChangePassword:置标志并跳强制改密页;已在改密页不重复跳', async () => {
|
||||
freshPersistedPinia()
|
||||
const store = useAuthStore()
|
||||
|
||||
await store.handleMustChangePassword()
|
||||
|
||||
expect(store.mustChangePassword).toBe(true)
|
||||
await vi.waitFor(() => {
|
||||
expect(mocks.routerReplace).toHaveBeenCalledWith('/auth/force-change-password')
|
||||
})
|
||||
|
||||
// 已在改密页:不再跳
|
||||
mocks.routerReplace.mockClear()
|
||||
mocks.currentRoute.value.path = '/auth/force-change-password'
|
||||
await store.handleMustChangePassword()
|
||||
expect(mocks.routerReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('changePassword 成功:清 must_change 标志', async () => {
|
||||
freshPersistedPinia()
|
||||
mocks.fetchChangePassword.mockResolvedValue({ ok: true, must_change_password: false })
|
||||
const store = useAuthStore()
|
||||
store.mustChangePassword = true
|
||||
|
||||
await store.changePassword({ old_password: 'old', new_password: 'new-pass-123' })
|
||||
|
||||
expect(mocks.fetchChangePassword).toHaveBeenCalledWith({
|
||||
old_password: 'old',
|
||||
new_password: 'new-pass-123'
|
||||
})
|
||||
expect(store.mustChangePassword).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* HTTP client 单元测试(Phase 3 Task 3)
|
||||
*
|
||||
* 契约对齐后端 app/platform/auth.py 与 auth_routes.py:
|
||||
* - Authorization: Bearer <token> 注入
|
||||
* - 401(非登录请求)→ 清会话 + 跳登录(登录请求自身的 401 = 凭据错误,只提示不跳转)
|
||||
* - 409 detail === "必须先修改初始密码" → 跳强制改密页;
|
||||
* 其余 409(并发冲突等)为普通业务错误,不跳转
|
||||
* - 其余错误 / 网络错误 → ElMessage 出 detail / 统一文案
|
||||
* - 成功透传 response.data
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AxiosError, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
userStore: {
|
||||
logOut: vi.fn(),
|
||||
setLoginStatus: vi.fn(),
|
||||
setToken: vi.fn(),
|
||||
setUserInfo: vi.fn(),
|
||||
checkAndClearWorktabs: vi.fn(),
|
||||
info: {}
|
||||
},
|
||||
routerPush: vi.fn(),
|
||||
routerReplace: vi.fn(),
|
||||
currentRoute: { value: { path: '/' } },
|
||||
elMessageError: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('element-plus', () => ({
|
||||
ElMessage: { error: mocks.elMessageError, success: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('@/store/modules/user', () => ({
|
||||
useUserStore: () => mocks.userStore
|
||||
}))
|
||||
|
||||
vi.mock('@/router', () => ({
|
||||
router: { push: mocks.routerPush, replace: mocks.routerReplace, currentRoute: mocks.currentRoute }
|
||||
}))
|
||||
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { http } from '@/api/client'
|
||||
import { useAuthStore } from '@/store/modules/auth'
|
||||
|
||||
/** 自定义 axios adapter:按计划响应回放,并捕获请求头 */
|
||||
function makeAdapter(plan: { status: number; body: unknown } | { networkError: true }) {
|
||||
let captured: InternalAxiosRequestConfig | null = null
|
||||
const adapter = (config: InternalAxiosRequestConfig): Promise<AxiosResponse> => {
|
||||
captured = config
|
||||
if ('networkError' in plan) {
|
||||
// 模拟断网:无 response 的 AxiosError
|
||||
return Promise.reject(
|
||||
new AxiosError('Network Error', 'ECONNABORTED', config, undefined, undefined)
|
||||
)
|
||||
}
|
||||
const response: AxiosResponse = {
|
||||
status: plan.status,
|
||||
statusText: '',
|
||||
headers: {},
|
||||
config,
|
||||
data: plan.body
|
||||
}
|
||||
if (plan.status >= 200 && plan.status < 300) return Promise.resolve(response)
|
||||
return Promise.reject(
|
||||
new AxiosError(
|
||||
`Request failed with status code ${plan.status}`,
|
||||
String(plan.status),
|
||||
config,
|
||||
undefined,
|
||||
response
|
||||
)
|
||||
)
|
||||
}
|
||||
return {
|
||||
adapter,
|
||||
capturedHeaders: () => (captured as InternalAxiosRequestConfig | null)?.headers
|
||||
}
|
||||
}
|
||||
|
||||
describe('HTTP client(src/api/client.ts)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
mocks.currentRoute.value.path = '/'
|
||||
})
|
||||
|
||||
describe('token 注入', () => {
|
||||
it('有 token 时自动注入 Authorization: Bearer <token>', async () => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.token = 'tok-123'
|
||||
|
||||
const { adapter, capturedHeaders } = makeAdapter({ status: 200, body: { ok: true } })
|
||||
await http.post<{ ok: boolean }>('/api/platform/auth/logout', null, { adapter })
|
||||
|
||||
expect(capturedHeaders()?.get('Authorization')).toBe('Bearer tok-123')
|
||||
})
|
||||
|
||||
it('无 token 时不注入 Authorization 头', async () => {
|
||||
const { adapter, capturedHeaders } = makeAdapter({ status: 200, body: { ok: true } })
|
||||
await http.post('/api/platform/auth/logout', null, { adapter })
|
||||
|
||||
expect(capturedHeaders()?.get('Authorization')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('401 处理', () => {
|
||||
it('非登录请求 401:清空会话并触发登出清理(跳登录)', async () => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.token = 'expired-token'
|
||||
|
||||
const { adapter } = makeAdapter({
|
||||
status: 401,
|
||||
body: { detail: '未认证或凭证已失效' }
|
||||
})
|
||||
await expect(http.get('/api/platform/tenants', { adapter })).rejects.toThrow(
|
||||
'未认证或凭证已失效'
|
||||
)
|
||||
|
||||
expect(authStore.token).toBe('')
|
||||
expect(authStore.isAuthenticated).toBe(false)
|
||||
// 模板侧全套清理(含跳登录页)由 userStore.logOut 承担
|
||||
expect(mocks.userStore.logOut).toHaveBeenCalled()
|
||||
expect(ElMessage.error).toHaveBeenCalledWith('未认证或凭证已失效')
|
||||
})
|
||||
|
||||
it('登录请求自身 401(凭据错误):只提示 detail,不触发登出/跳转', async () => {
|
||||
const { adapter } = makeAdapter({
|
||||
status: 401,
|
||||
body: { detail: '用户名或密码错误' }
|
||||
})
|
||||
await expect(
|
||||
http.post('/api/platform/auth/login', { username: 'a', password: 'b' }, { adapter })
|
||||
).rejects.toThrow('用户名或密码错误')
|
||||
|
||||
expect(mocks.userStore.logOut).not.toHaveBeenCalled()
|
||||
expect(mocks.routerReplace).not.toHaveBeenCalledWith('/auth/force-change-password')
|
||||
expect(ElMessage.error).toHaveBeenCalledWith('用户名或密码错误')
|
||||
})
|
||||
})
|
||||
|
||||
describe('409 处理', () => {
|
||||
it('409 detail 为必须先修改初始密码:置 must_change 并跳强制改密页', async () => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.token = 'tok'
|
||||
authStore.mustChangePassword = false
|
||||
|
||||
const { adapter } = makeAdapter({
|
||||
status: 409,
|
||||
body: { detail: '必须先修改初始密码' }
|
||||
})
|
||||
await expect(http.get('/api/platform/stats', { adapter })).rejects.toThrow(
|
||||
'必须先修改初始密码'
|
||||
)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mocks.routerReplace).toHaveBeenCalledWith('/auth/force-change-password')
|
||||
})
|
||||
expect(authStore.mustChangePassword).toBe(true)
|
||||
})
|
||||
|
||||
it('已在强制改密页时收到 409:不再重复跳转', async () => {
|
||||
mocks.currentRoute.value.path = '/auth/force-change-password'
|
||||
const { adapter } = makeAdapter({
|
||||
status: 409,
|
||||
body: { detail: '必须先修改初始密码' }
|
||||
})
|
||||
await expect(http.get('/api/platform/stats', { adapter })).rejects.toThrow()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(ElMessage.error).toHaveBeenCalled()
|
||||
})
|
||||
expect(mocks.routerReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('其他 409(并发冲突):普通业务错误,不跳改密页', async () => {
|
||||
const { adapter } = makeAdapter({
|
||||
status: 409,
|
||||
body: { detail: '并发冲突: 域名已被占用' }
|
||||
})
|
||||
await expect(http.post('/api/platform/tenants', {}, { adapter })).rejects.toThrow(
|
||||
'并发冲突: 域名已被占用'
|
||||
)
|
||||
|
||||
expect(mocks.routerReplace).not.toHaveBeenCalled()
|
||||
expect(ElMessage.error).toHaveBeenCalledWith('并发冲突: 域名已被占用')
|
||||
})
|
||||
})
|
||||
|
||||
describe('其他错误与成功', () => {
|
||||
it('登录 429 限流:弹出后端 detail 提示,不触发跳转', async () => {
|
||||
const { adapter } = makeAdapter({
|
||||
status: 429,
|
||||
body: { detail: '登录尝试过于频繁,请15分钟后再试' }
|
||||
})
|
||||
await expect(
|
||||
http.post('/api/platform/auth/login', { username: 'a', password: 'b' }, { adapter })
|
||||
).rejects.toThrow('登录尝试过于频繁,请15分钟后再试')
|
||||
|
||||
expect(ElMessage.error).toHaveBeenCalledWith('登录尝试过于频繁,请15分钟后再试')
|
||||
expect(mocks.userStore.logOut).not.toHaveBeenCalled()
|
||||
expect(mocks.routerReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('网络错误(无响应):统一文案提示', async () => {
|
||||
const { adapter } = makeAdapter({ networkError: true })
|
||||
await expect(http.get('/api/platform/stats', { adapter })).rejects.toThrow()
|
||||
|
||||
expect(ElMessage.error).toHaveBeenCalledTimes(1)
|
||||
const message = vi.mocked(ElMessage.error).mock.calls[0][0] as string
|
||||
expect(message).toContain('网络')
|
||||
})
|
||||
|
||||
it('showErrorMessage: false 时不弹全局错误提示', async () => {
|
||||
const { adapter } = makeAdapter({ status: 400, body: { detail: '原密码不正确' } })
|
||||
await expect(
|
||||
http.post('/api/platform/auth/change-password', {}, { adapter, showErrorMessage: false })
|
||||
).rejects.toThrow('原密码不正确')
|
||||
|
||||
expect(ElMessage.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('成功:直接透传 response.data(非 axios 包装)', async () => {
|
||||
const body = {
|
||||
token: 't',
|
||||
role: 'super',
|
||||
tenant_slug: null,
|
||||
must_change_password: true
|
||||
}
|
||||
const { adapter } = makeAdapter({ status: 200, body })
|
||||
const res = await http.post<typeof body>('/api/platform/auth/login', {}, { adapter })
|
||||
|
||||
expect(res).toEqual(body)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 掩码哨兵辅助纯函数测试(Phase 3 Task 3)
|
||||
*
|
||||
* 契约对齐后端 app/platform/routes/config_routes.py:
|
||||
* 密钥字段 GET 响应非空时回 `__SET__`(MASKED 哨兵),PUT 提交 `__SET__`
|
||||
* 表示"保持库值"。本模块负责 `__SET__` ↔ 表单"已设置"语义的相互转换,
|
||||
* 配置中心 / 平台设置页(Task 7/9)消费。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
MASKED_DISPLAY,
|
||||
MASKED_SENTINEL,
|
||||
isMaskedSentinel,
|
||||
secretPlaceholder,
|
||||
secretToSubmit
|
||||
} from '@/api/mask'
|
||||
|
||||
describe('掩码哨兵辅助(src/api/mask.ts)', () => {
|
||||
it('哨兵识别:__SET__ 为哨兵,其他值不是', () => {
|
||||
expect(isMaskedSentinel('__SET__')).toBe(true)
|
||||
expect(isMaskedSentinel('sk-real-key')).toBe(false)
|
||||
expect(isMaskedSentinel('')).toBe(false)
|
||||
expect(isMaskedSentinel(null)).toBe(false)
|
||||
expect(isMaskedSentinel(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('GET 掩码值 → 表单占位语义:已设置提示可覆盖,未设置提示待输入', () => {
|
||||
expect(MASKED_SENTINEL).toBe('__SET__')
|
||||
expect(MASKED_DISPLAY).toBe('已设置')
|
||||
// 后端下发的密钥字段只可能是哨兵(已设置)或空串/缺省(未设置)
|
||||
expect(secretPlaceholder('__SET__')).toBe('已设置,输入新值覆盖')
|
||||
expect(secretPlaceholder('')).toBe('未设置,输入新值')
|
||||
expect(secretPlaceholder(null)).toBe('未设置,输入新值')
|
||||
expect(secretPlaceholder(undefined)).toBe('未设置,输入新值')
|
||||
})
|
||||
|
||||
it('表单值 → 提交值:未改动(空输入 + 原本已设置)回传哨兵保持库值', () => {
|
||||
// 用户没有输入新值,且库里有值 → 回 __SET__,后端保持原值不落哨兵字面量
|
||||
expect(secretToSubmit('', '__SET__')).toBe('__SET__')
|
||||
})
|
||||
|
||||
it('表单值 → 提交值:输入了新值则提交新值(覆盖)', () => {
|
||||
expect(secretToSubmit('sk-new-key', '__SET__')).toBe('sk-new-key')
|
||||
expect(secretToSubmit('sk-first-key', '')).toBe('sk-first-key')
|
||||
expect(secretToSubmit('sk-first-key', null)).toBe('sk-first-key')
|
||||
})
|
||||
|
||||
it('表单值 → 提交值:未设置且留空 → 提交空串(保持未设置)', () => {
|
||||
expect(secretToSubmit('', '')).toBe('')
|
||||
expect(secretToSubmit('', null)).toBe('')
|
||||
})
|
||||
|
||||
it('往返一致性:掩码字段未改动时,提交值仍是哨兵(后端可识别为"保持")', () => {
|
||||
const fromServer = '__SET__'
|
||||
const displayed = secretPlaceholder(fromServer)
|
||||
expect(displayed).toContain('已设置')
|
||||
|
||||
const submitted = secretToSubmit('', fromServer)
|
||||
expect(isMaskedSentinel(submitted)).toBe(true)
|
||||
})
|
||||
})
|
||||
+28
-50
@@ -1,60 +1,38 @@
|
||||
/**
|
||||
* 认证 API——脚手架阶段(Phase 3 Task 2)的开发桩。
|
||||
* 认证 API(Phase 3 Task 3 实装,替换 Task 2 开发桩)
|
||||
*
|
||||
* 后端真实端点为 /api/platform/auth/*(登录 / 用户信息 / 改密),
|
||||
* Task 3 对接真实登录时将本文件替换为真实请求。
|
||||
* 对接后端 app/platform/routes/auth_routes.py:
|
||||
* - POST /api/platform/auth/login {username, password}
|
||||
* → {token, role: "super"|"tenant", tenant_slug, must_change_password}
|
||||
* - POST /api/platform/auth/logout(Bearer;撤销当前 token)
|
||||
* - POST /api/platform/auth/change-password {old_password, new_password}
|
||||
* → {ok, must_change_password}
|
||||
*
|
||||
* 桩行为:
|
||||
* - fetchLogin:开发态直接发放本地会话令牌,不发起网络请求;
|
||||
* - fetchGetUserInfo:按登录页的角色调试开关(sessionStorage
|
||||
* `all-kefu-dev-role`:super | tenant)返回用户信息,用于演示
|
||||
* 纯前端菜单权限控制——super 全量菜单,tenant 隐藏
|
||||
* 租户管理 / 平台设置(见 router/modules/*.ts 的 meta.roles);
|
||||
* - 生产构建(非 DEV)下调用将抛出显式错误,避免带桩上线。
|
||||
* 错误语义(HTTP client 统一处理,见 src/api/client.ts):
|
||||
* - 401 凭据错误/会话过期(登录请求自身 401 只提示不跳转)
|
||||
* - 429 登录限流(5 次/15 分钟)
|
||||
* - 400 原密码不正确
|
||||
*/
|
||||
const DEV_ROLE_KEY = 'all-kefu-dev-role'
|
||||
export type DevStubRole = 'super' | 'tenant'
|
||||
import { http } from './client'
|
||||
import type { LoginParams, LoginResponse, ChangePasswordParams } from '@/store/modules/auth'
|
||||
|
||||
export function setDevStubRole(role: DevStubRole) {
|
||||
sessionStorage.setItem(DEV_ROLE_KEY, role)
|
||||
/** 登录 */
|
||||
export function fetchLogin(params: LoginParams): Promise<LoginResponse> {
|
||||
return http.post<LoginResponse>('/api/platform/auth/login', params)
|
||||
}
|
||||
|
||||
export function getDevStubRole(): DevStubRole {
|
||||
return sessionStorage.getItem(DEV_ROLE_KEY) === 'tenant' ? 'tenant' : 'super'
|
||||
/** 登出(撤销当前 token;需 Bearer) */
|
||||
export function fetchLogout(): Promise<{ ok: boolean }> {
|
||||
return http.post<{ ok: boolean }>('/api/platform/auth/logout')
|
||||
}
|
||||
|
||||
function assertStubAllowed(): void {
|
||||
if (!import.meta.env.DEV) {
|
||||
throw new Error('认证 API 尚未对接真实后端(Task 3 实现),生产构建不可用')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录(开发桩:任意账号密码直接获得开发会话)
|
||||
* @param params 登录参数
|
||||
* @returns 登录响应
|
||||
*/
|
||||
export function fetchLogin(params: Api.Auth.LoginParams) {
|
||||
assertStubAllowed()
|
||||
void params
|
||||
return Promise.resolve<Api.Auth.LoginResponse>({
|
||||
token: 'dev-stub-token',
|
||||
refreshToken: ''
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户信息(开发桩:角色取自登录页调试开关)
|
||||
* @returns 用户信息
|
||||
*/
|
||||
export function fetchGetUserInfo() {
|
||||
assertStubAllowed()
|
||||
const role = getDevStubRole()
|
||||
return Promise.resolve<Api.Auth.UserInfo>({
|
||||
buttons: [],
|
||||
roles: [role],
|
||||
userId: 1,
|
||||
userName: role === 'super' ? '平台超管(调试)' : '租户管理员(调试)',
|
||||
email: 'dev@all-kefu.local'
|
||||
})
|
||||
/** 修改密码(验旧密 → 写新 hash;改完当前会话保持有效) */
|
||||
export function fetchChangePassword(params: ChangePasswordParams): Promise<{
|
||||
ok: boolean
|
||||
must_change_password: boolean
|
||||
}> {
|
||||
return http.post<{ ok: boolean; must_change_password: boolean }>(
|
||||
'/api/platform/auth/change-password',
|
||||
params
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 平台 API HTTP client(Phase 3 Task 3,自建薄封装)
|
||||
*
|
||||
* ## 为什么不用模板的 src/utils/http
|
||||
*
|
||||
* 模板封装与后端约定对不上,改造成本高于自建:
|
||||
* - 响应:模板要求 `{code, msg, data}` 业务信封;后端(FastAPI)业务语义
|
||||
* 直用 HTTP 状态码,成功响应就是数据本身
|
||||
* - 错误:模板读 `data.msg`;FastAPI 错误体是 `{detail: 中文}`
|
||||
* - 凭据:模板裸塞 `Authorization: <token>`;后端要求 `Bearer <token>`
|
||||
* - 模板无 409 强制改密语义
|
||||
*
|
||||
* ## 本 client 约定
|
||||
*
|
||||
* - 请求拦截:auth store 有 token 时自动注入 `Authorization: Bearer <token>`
|
||||
* - 401(非登录请求):清会话 + 跳登录(登录请求自身的 401 = 凭据错误,
|
||||
* 只弹 detail,不触发登出/跳转——否则登录页死循环)
|
||||
* - 409 且 detail 为强制改密文案:跳强制改密页;其余 409(并发冲突等)
|
||||
* 是普通业务错误,只弹 detail(后端 409 双语义:enforce_password_changed
|
||||
* 与租户/域名并发冲突,见 app/platform/routes/)
|
||||
* - 其余错误/网络错误:ElMessage 出后端 detail / 统一网络文案
|
||||
* - 成功:直接透传 response.data
|
||||
* - 请求配置 showErrorMessage: false 可关闭全局错误提示(调用方自行处理)
|
||||
*/
|
||||
import axios, {
|
||||
AxiosError,
|
||||
type AxiosRequestConfig,
|
||||
type AxiosResponse,
|
||||
type InternalAxiosRequestConfig
|
||||
} from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/store/modules/auth'
|
||||
|
||||
/**
|
||||
* 强制改密 409 的 detail 契约(app/platform/auth.py enforce_password_changed
|
||||
* 的静态文案)。只有命中该文案的 409 才触发改密跳转。
|
||||
*/
|
||||
export const MUST_CHANGE_DETAIL = '必须先修改初始密码'
|
||||
|
||||
/** 网络错误统一文案(无响应时后端不可能给 detail) */
|
||||
export const NETWORK_ERROR_MESSAGE = '网络连接异常,请检查网络后重试'
|
||||
|
||||
/** 登录端点路径(其 401/429 是凭据/限流业务错误,不走会话过期处理) */
|
||||
const LOGIN_URL = '/api/platform/auth/login'
|
||||
|
||||
/** 统一 API 错误(供调用方按需识别状态码) */
|
||||
export class ApiError extends Error {
|
||||
readonly status?: number
|
||||
readonly url?: string
|
||||
|
||||
constructor(message: string, status?: number, url?: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.url = url
|
||||
}
|
||||
}
|
||||
|
||||
/** 后端错误体形态(FastAPI HTTPException:{detail: string}) */
|
||||
interface ApiErrorBody {
|
||||
detail?: string
|
||||
}
|
||||
|
||||
/** 扩展请求配置:允许关闭全局错误提示 */
|
||||
export interface RequestOptions extends AxiosRequestConfig {
|
||||
showErrorMessage?: boolean
|
||||
}
|
||||
|
||||
const instance = axios.create({
|
||||
timeout: 15000,
|
||||
// 开发走 vite 代理(/),生产同源反代;两环境 VITE_API_URL 均为 '/'
|
||||
baseURL: import.meta.env.VITE_API_URL || '/',
|
||||
// 后端业务语义直用 HTTP 状态码,非 2xx 一律进错误拦截器
|
||||
validateStatus: (status) => status >= 200 && status < 300
|
||||
})
|
||||
|
||||
/** 请求拦截:Bearer token 注入 */
|
||||
instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const { token } = useAuthStore()
|
||||
if (token) {
|
||||
config.headers.set('Authorization', `Bearer ${token}`)
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
/** 提取错误提示文案:优先后端 detail,其次统一网络文案 */
|
||||
function extractDetail(error: AxiosError<ApiErrorBody>): string {
|
||||
if (error.response?.data?.detail) {
|
||||
return error.response.data.detail
|
||||
}
|
||||
return error.response ? `请求失败(HTTP ${error.response.status})` : NETWORK_ERROR_MESSAGE
|
||||
}
|
||||
|
||||
/** 响应拦截:401/409 全局处理 + 统一错误提示 */
|
||||
instance.interceptors.response.use(
|
||||
(response: AxiosResponse) => response,
|
||||
async (error: AxiosError<ApiErrorBody>) => {
|
||||
const status = error.response?.status
|
||||
const detail = extractDetail(error)
|
||||
const url = error.config?.url || ''
|
||||
const config = error.config as (RequestOptions & InternalAxiosRequestConfig) | undefined
|
||||
const isLoginRequest = url.includes(LOGIN_URL)
|
||||
|
||||
if (status === 401 && !isLoginRequest) {
|
||||
// 会话过期:清空状态 + 模板侧全套清理(含跳登录,登录页不重复跳)
|
||||
await useAuthStore().handleSessionExpired()
|
||||
} else if (status === 409 && detail === MUST_CHANGE_DETAIL) {
|
||||
// 强制改密:置标志 + 跳改密页(已在改密页不重复跳)
|
||||
await useAuthStore().handleMustChangePassword()
|
||||
}
|
||||
|
||||
if (config?.showErrorMessage !== false) {
|
||||
ElMessage.error(detail)
|
||||
}
|
||||
|
||||
throw new ApiError(detail, status, url)
|
||||
}
|
||||
)
|
||||
|
||||
/** 发起请求,成功透传 response.data */
|
||||
async function request<T>(config: RequestOptions): Promise<T> {
|
||||
const response = await instance.request<T>(config)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** API 方法集合 */
|
||||
export const http = {
|
||||
get<T = unknown>(url: string, config?: RequestOptions): Promise<T> {
|
||||
return request<T>({ ...config, url, method: 'GET' })
|
||||
},
|
||||
post<T = unknown>(url: string, data?: unknown, config?: RequestOptions): Promise<T> {
|
||||
return request<T>({ ...config, url, method: 'POST', data })
|
||||
},
|
||||
put<T = unknown>(url: string, data?: unknown, config?: RequestOptions): Promise<T> {
|
||||
return request<T>({ ...config, url, method: 'PUT', data })
|
||||
},
|
||||
patch<T = unknown>(url: string, data?: unknown, config?: RequestOptions): Promise<T> {
|
||||
return request<T>({ ...config, url, method: 'PATCH', data })
|
||||
},
|
||||
del<T = unknown>(url: string, config?: RequestOptions): Promise<T> {
|
||||
return request<T>({ ...config, url, method: 'DELETE' })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 租户配置中心 API 骨架(Phase 3 Task 3)
|
||||
*
|
||||
* 端点事实源:app/platform/routes/config_routes.py
|
||||
* (配置快照 / 分组编辑 / 版本历史 / 回滚 / 重置 / 试金石)。
|
||||
* 本文件只建类型与端点常量——调用函数在 Task 7(配置中心四件)实装。
|
||||
*
|
||||
* 密钥掩码哨兵:GET 的密钥字段非空时为 '__SET__'(见 src/api/mask.ts
|
||||
* 的表单语义转换);PUT 提交 '__SET__' = 保持库值。
|
||||
*/
|
||||
|
||||
/** 配置组名(快照顶层键) */
|
||||
export type ConfigGroupKey = 'brand' | 'prompts' | 'wall' | 'push' | 'email' | 'ai'
|
||||
|
||||
/** 当前生效配置响应(GET /api/platform/tenants/{slug}/config) */
|
||||
export interface TenantConfigResponse {
|
||||
slug: string
|
||||
version: number
|
||||
/** 掩码视图的整份快照(密钥字段可能为 '__SET__') */
|
||||
config: Record<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
/** 配置版本行(GET .../config/versions) */
|
||||
export interface ConfigVersionItem {
|
||||
version: number
|
||||
created_at: string
|
||||
created_by: string
|
||||
note: string
|
||||
}
|
||||
|
||||
/** 版本列表响应 */
|
||||
export interface ConfigVersionListResponse {
|
||||
slug: string
|
||||
items: ConfigVersionItem[]
|
||||
}
|
||||
|
||||
/** 版本详情响应(含整份快照) */
|
||||
export interface ConfigVersionDetailResponse extends ConfigVersionItem {
|
||||
slug: string
|
||||
config: Record<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
/** 回滚请求(POST .../config/rollback,产生新版本) */
|
||||
export interface ConfigRollbackRequest {
|
||||
version: number
|
||||
note: string
|
||||
}
|
||||
|
||||
/** 分组保存请求(PUT .../config/{group}) */
|
||||
export interface PutConfigGroupRequest {
|
||||
note: string
|
||||
value: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** 词表试金石请求(POST .../wall/test) */
|
||||
export interface WallTestRequest {
|
||||
sentences: string[]
|
||||
has_contact?: boolean
|
||||
}
|
||||
|
||||
/** 词表试金石响应(逐句判定) */
|
||||
export interface WallTestResponse {
|
||||
results: Array<{ sentence: string; blocked: boolean; rule?: string }>
|
||||
}
|
||||
|
||||
/** 配置中心端点 */
|
||||
export const CONFIG_ENDPOINTS = {
|
||||
current: (slug: string) => `/api/platform/tenants/${slug}/config`,
|
||||
putGroup: (slug: string, group: ConfigGroupKey | string) =>
|
||||
`/api/platform/tenants/${slug}/config/${group}`,
|
||||
resetGroup: (slug: string, group: ConfigGroupKey | string) =>
|
||||
`/api/platform/tenants/${slug}/config/${group}/reset`,
|
||||
versions: (slug: string) => `/api/platform/tenants/${slug}/config/versions`,
|
||||
versionDetail: (slug: string, version: number) =>
|
||||
`/api/platform/tenants/${slug}/config/versions/${version}`,
|
||||
rollback: (slug: string) => `/api/platform/tenants/${slug}/config/rollback`,
|
||||
wallTest: (slug: string) => `/api/platform/tenants/${slug}/wall/test`
|
||||
} as const
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 会话与线索 API 骨架(Phase 3 Task 3)
|
||||
*
|
||||
* 端点事实源:app/platform/routes/data_routes.py
|
||||
* (会话列表/详情/删除、推送日志)。本文件只建类型与端点常量——
|
||||
* 调用函数在 Task 5(会话与线索)实装。
|
||||
*/
|
||||
|
||||
/** 意向层级(Evaluation.intent_level) */
|
||||
export type IntentLevel = 'high' | 'medium' | 'low' | 'none' | null
|
||||
|
||||
/** 联系方式(评估产出) */
|
||||
export interface ContactInfo {
|
||||
email?: string | null
|
||||
whatsapp?: string | null
|
||||
phone?: string | null
|
||||
}
|
||||
|
||||
/** 会话列表行(GET /api/platform/tenants/{slug}/conversations) */
|
||||
export interface ConversationListItem {
|
||||
id: string
|
||||
session_id: string
|
||||
status: string
|
||||
source: string
|
||||
site: string | null
|
||||
ip: string
|
||||
device: string
|
||||
country: string | null
|
||||
intent_level: IntentLevel
|
||||
reasoning: string | null
|
||||
contact_info: ContactInfo | null
|
||||
email_sent: boolean
|
||||
message_count: number
|
||||
preview: string
|
||||
created_at: string | null
|
||||
}
|
||||
|
||||
/** 会话列表查询参数(intent 白名单校验在后端;from/to 为闭区间日期) */
|
||||
export interface ConversationListParams {
|
||||
intent?: string
|
||||
site?: string
|
||||
from?: string
|
||||
to?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
/** 会话列表响应 */
|
||||
export interface ConversationListResponse {
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
items: ConversationListItem[]
|
||||
}
|
||||
|
||||
/** 会话详情消息行(时间升序) */
|
||||
export interface ConversationMessage {
|
||||
role: 'user' | 'assistant' | 'system' | string
|
||||
content: string
|
||||
created_at: string | null
|
||||
}
|
||||
|
||||
/** 会话详情响应(GET .../conversations/{convId}) */
|
||||
export interface ConversationDetailResponse extends ConversationListItem {
|
||||
client_info: Record<string, unknown>
|
||||
messages: ConversationMessage[]
|
||||
profile: {
|
||||
chips: Record<string, unknown>
|
||||
summary: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
/** 推送日志行(GET .../push-logs) */
|
||||
export interface PushLogItem {
|
||||
id: number
|
||||
conversation_id: string
|
||||
status: string
|
||||
error: string | null
|
||||
created_at: string | null
|
||||
}
|
||||
|
||||
/** 推送日志响应 */
|
||||
export interface PushLogResponse {
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
items: PushLogItem[]
|
||||
}
|
||||
|
||||
/** 会话与线索端点 */
|
||||
export const CONVERSATION_ENDPOINTS = {
|
||||
list: (slug: string) => `/api/platform/tenants/${slug}/conversations`,
|
||||
detail: (slug: string, convId: string) =>
|
||||
`/api/platform/tenants/${slug}/conversations/${convId}`,
|
||||
remove: (slug: string, convId: string) =>
|
||||
`/api/platform/tenants/${slug}/conversations/${convId}`,
|
||||
pushLogs: (slug: string) => `/api/platform/tenants/${slug}/push-logs`
|
||||
} as const
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 掩码哨兵辅助(Phase 3 Task 3)
|
||||
*
|
||||
* 契约对齐后端 app/platform/routes/config_routes.py:
|
||||
* - 密钥字段 GET 响应:库中非空 → `__SET__`(MASKED 哨兵);空/缺省 → 原样
|
||||
* - PUT 提交:密钥字段传 `__SET__` = 保持库值(后端还原,哨兵字面量绝不落库)
|
||||
*
|
||||
* 本模块提供哨兵 ↔ 表单语义的纯函数转换,配置中心(Task 7)与
|
||||
* 平台设置(Task 9)的凭据表单消费:
|
||||
* - secretPlaceholder:GET 掩码值 → 输入框占位文案("已设置,输入新值覆盖")
|
||||
* - secretToSubmit:用户输入 + 原掩码状态 → 提交值(未改动回哨兵保持库值)
|
||||
*/
|
||||
|
||||
/** 后端密钥掩码哨兵(config_routes.MASKED) */
|
||||
export const MASKED_SENTINEL = '__SET__'
|
||||
|
||||
/** 表单侧"已设置"展示语义 */
|
||||
export const MASKED_DISPLAY = '已设置'
|
||||
|
||||
/** 值是否为掩码哨兵 */
|
||||
export function isMaskedSentinel(value: unknown): value is typeof MASKED_SENTINEL {
|
||||
return value === MASKED_SENTINEL
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 掩码值 → 表单占位文案
|
||||
*
|
||||
* @param maskedValue 后端下发的密钥字段值(哨兵=已设置;空串/null/undefined=未设置)
|
||||
*/
|
||||
export function secretPlaceholder(maskedValue: string | null | undefined): string {
|
||||
return isMaskedSentinel(maskedValue) ? '已设置,输入新值覆盖' : '未设置,输入新值'
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单输入 → 提交值
|
||||
*
|
||||
* @param input 用户输入(空串=未改动/清空)
|
||||
* @param originalMasked GET 时的掩码值(哨兵=库中已有值;否则=未设置)
|
||||
* @returns 提交值:输入非空 → 新值;输入空且已设置 → 哨兵(保持库值);输入空且未设置 → 空串
|
||||
*/
|
||||
export function secretToSubmit(input: string, originalMasked: string | null | undefined): string {
|
||||
if (input !== '') return input
|
||||
return isMaskedSentinel(originalMasked) ? MASKED_SENTINEL : ''
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 平台设置 API 骨架(Phase 3 Task 3)
|
||||
*
|
||||
* 端点事实源:app/platform/routes/config_routes.py 的 settings_router
|
||||
* (/api/platform/settings,仅超管:集团 AI 凭据 / 默认 SMTP 等)。
|
||||
* 本文件只建类型与端点常量——调用函数在 Task 9(平台设置)实装。
|
||||
*
|
||||
* 密钥掩码:key 名含 key/password/secret 的非空字符串值在列表响应中
|
||||
* 掩码为 '__SET__'(嵌套 dict 递归两层);PUT 提交 '__SET__' = 保持库值。
|
||||
* 表单语义转换见 src/api/mask.ts。
|
||||
*/
|
||||
|
||||
/** 平台设置项(列表响应行;value 可能是掩码哨兵或任意 JSON) */
|
||||
export interface PlatformSettingItem {
|
||||
key: string
|
||||
value: unknown
|
||||
}
|
||||
|
||||
/** 平台设置列表响应 */
|
||||
export interface PlatformSettingsResponse {
|
||||
items: PlatformSettingItem[]
|
||||
}
|
||||
|
||||
/** upsert 单个设置项请求(同 key 再写 = 整值替换) */
|
||||
export interface PutPlatformSettingRequest {
|
||||
key: string
|
||||
value: unknown
|
||||
}
|
||||
|
||||
/** 平台设置端点 */
|
||||
export const SETTINGS_ENDPOINTS = {
|
||||
list: '/api/platform/settings',
|
||||
put: '/api/platform/settings'
|
||||
} as const
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 统计分析 API 骨架(Phase 3 Task 3)
|
||||
*
|
||||
* 端点事实源:app/platform/routes/stats_routes.py + app/platform/stats.py
|
||||
* (平台合并统计 / 单租户统计,?from/&to= 闭区间)。本文件只建类型与
|
||||
* 端点常量——调用函数在 Task 4(仪表盘)与 Task 8(统计分析)实装。
|
||||
*/
|
||||
|
||||
/** 指标卡(None 项为 null——如无推送时成功率无定义) */
|
||||
export interface StatsCards {
|
||||
conversations: number
|
||||
leads: number
|
||||
high_intent: number
|
||||
push_success_rate: number | null
|
||||
}
|
||||
|
||||
/** 漏斗计数 */
|
||||
export interface StatsFunnel {
|
||||
conversations: number
|
||||
leads: number
|
||||
evaluated: number
|
||||
push_succeeded: number
|
||||
}
|
||||
|
||||
/** 按日趋势行 */
|
||||
export interface StatsTrendPoint {
|
||||
date: string
|
||||
conversations: number
|
||||
leads: number
|
||||
}
|
||||
|
||||
/** 分布视图(intent 按枚举键、source/site 计数表、country Top 数组) */
|
||||
export interface StatsDistributions {
|
||||
intent: Record<string, number>
|
||||
source: Record<string, number>
|
||||
site: Record<string, number>
|
||||
country: Array<{ country: string; count: number }>
|
||||
}
|
||||
|
||||
/** 推送健康计数 */
|
||||
export interface PushHealth {
|
||||
pending: number
|
||||
success: number
|
||||
failed: number
|
||||
skipped: number
|
||||
duplicated: number
|
||||
}
|
||||
|
||||
/** 统计响应(平台合并与单租户同构) */
|
||||
export interface StatsResponse {
|
||||
cards: StatsCards
|
||||
funnel: StatsFunnel
|
||||
trend: StatsTrendPoint[]
|
||||
distributions: StatsDistributions
|
||||
push_health: PushHealth
|
||||
}
|
||||
|
||||
/** 统计查询参数(from/to 闭区间,ISO 日期) */
|
||||
export interface StatsParams {
|
||||
from?: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
/** 统计端点(platform 合并版仅超管;单租户版超管或本租户管理员) */
|
||||
export const STATS_ENDPOINTS = {
|
||||
platform: '/api/platform/stats',
|
||||
tenant: (slug: string) => `/api/platform/tenants/${slug}/stats`
|
||||
} as const
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 租户与站点管理 API 骨架(Phase 3 Task 3)
|
||||
*
|
||||
* 端点事实源:app/platform/routes/tenant_routes.py
|
||||
* (/api/platform/tenants,require_super / require_tenant(slug) 鉴权)。
|
||||
* 本文件只建类型与端点常量——调用函数在 Task 6(租户管理与站点管理)实装。
|
||||
*/
|
||||
|
||||
/** 租户状态(Tenant.status) */
|
||||
export type TenantStatus = 'active' | 'disabled'
|
||||
|
||||
/** 站点概要(_site_summary:slug/name/status/domains/brand_overlay) */
|
||||
export interface SiteSummary {
|
||||
slug: string
|
||||
name: string
|
||||
status: string
|
||||
domains: string[]
|
||||
brand_overlay: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** 管理员概要(_admin_summary:不含任何凭据字段) */
|
||||
export interface TenantAdminSummary {
|
||||
username: string
|
||||
status: string
|
||||
}
|
||||
|
||||
/** 租户列表行(GET /api/platform/tenants) */
|
||||
export interface TenantListItem {
|
||||
slug: string
|
||||
name: string
|
||||
status: TenantStatus
|
||||
domain_count: number
|
||||
current_version: number | null
|
||||
admins: TenantAdminSummary[]
|
||||
}
|
||||
|
||||
/** 租户列表响应 */
|
||||
export interface TenantListResponse {
|
||||
items: TenantListItem[]
|
||||
}
|
||||
|
||||
/** 租户详情响应(GET /api/platform/tenants/{slug}) */
|
||||
export interface TenantDetailResponse {
|
||||
slug: string
|
||||
name: string
|
||||
status: TenantStatus
|
||||
sites: SiteSummary[]
|
||||
admins: TenantAdminSummary[]
|
||||
current_version: number | null
|
||||
}
|
||||
|
||||
/** 创建租户请求(POST /api/platform/tenants,201) */
|
||||
export interface CreateTenantRequest {
|
||||
name: string
|
||||
slug: string
|
||||
/** 出厂档案:auto | jiancai | blank */
|
||||
profile: string
|
||||
admin_username: string
|
||||
admin_password: string
|
||||
domains: string[]
|
||||
}
|
||||
|
||||
/** 改名/改状态请求(PATCH /api/platform/tenants/{slug}) */
|
||||
export interface PatchTenantRequest {
|
||||
name?: string
|
||||
status?: TenantStatus
|
||||
}
|
||||
|
||||
/** 创建站点请求(POST /api/platform/tenants/{slug}/sites,201) */
|
||||
export interface CreateSiteRequest {
|
||||
slug: string
|
||||
name?: string
|
||||
brand_overlay?: Record<string, unknown>
|
||||
domains?: string[]
|
||||
}
|
||||
|
||||
/** 绑定域名请求(POST .../sites/{siteSlug}/domains,201;409=域名已被占用) */
|
||||
export interface AddDomainRequest {
|
||||
domain: string
|
||||
}
|
||||
|
||||
/** 租户管理端点(鉴权与 409 并发冲突语义见 tenant_routes.py) */
|
||||
export const TENANT_ENDPOINTS = {
|
||||
list: '/api/platform/tenants',
|
||||
create: '/api/platform/tenants',
|
||||
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}`,
|
||||
addDomain: (slug: string, siteSlug: string) =>
|
||||
`/api/platform/tenants/${slug}/sites/${siteSlug}/domains`,
|
||||
deleteDomain: (slug: string, siteSlug: string, domain: string) =>
|
||||
`/api/platform/tenants/${slug}/sites/${siteSlug}/domains/${domain}`
|
||||
} as const
|
||||
Reference in New Issue
Block a user