cc-haha/src/server/api/providers.ts
程序员阿江(Relakkes) 8c99b0f892 feat(providers): support drag-and-drop reordering of the providers list
为服务商列表添加拖拽排序(#753)。复用项目已有的原生 HTML5 拖拽模式(对齐
Sidebar),零新增依赖;通过重排 providers.json 的数组顺序持久化,不引入 order
字段;仅行首手柄可拖,避免与行内操作按钮冲突;采用乐观更新 + 失败回滚。

后端: reorderProviders service、PUT /api/providers/reorder 路由、
ReorderProvidersSchema(校验 orderedIds 为现有 id 的排列)。
前端: providersApi.reorder、store action、Settings 拖拽手柄 UI、中/英/日/韩/繁文案。
测试: 后端 9 用例 + 前端 3 用例。
2026-06-12 16:21:58 +08:00

229 lines
7.7 KiB
TypeScript

/**
* Providers REST API
*
* GET /api/providers — list all saved providers + activeId
* GET /api/providers/presets — list available presets
* GET /api/providers/auth-status — check whether any usable auth exists
* GET /api/providers/settings — read cc-haha managed settings.json
* POST /api/providers — add a provider
* PUT /api/providers/settings — update cc-haha managed settings.json
* PUT /api/providers/:id — update a provider
* DELETE /api/providers/:id — delete a provider
* POST /api/providers/:id/activate — activate a saved provider
* POST /api/providers/official — activate official (clear env)
* POST /api/providers/:id/test — test a saved provider
* POST /api/providers/test — test unsaved config
*/
import { z } from 'zod'
import { ProviderService } from '../services/providerService.js'
import { PROVIDER_PRESETS } from '../config/providerPresets.js'
import {
CreateProviderSchema,
UpdateProviderSchema,
TestProviderSchema,
ReorderProvidersSchema,
} from '../types/provider.js'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
import { diagnosticsService } from '../services/diagnosticsService.js'
const providerService = new ProviderService()
export async function handleProvidersApi(
req: Request,
_url: URL,
segments: string[],
): Promise<Response> {
try {
const id = segments[2]
const action = segments[3]
// POST /api/providers/test
if (id === 'test' && req.method === 'POST') {
return await handleTestUnsaved(req)
}
// GET /api/providers/presets
if (id === 'presets' && req.method === 'GET') {
return Response.json({ presets: PROVIDER_PRESETS })
}
// GET /api/providers/auth-status
if (id === 'auth-status' && req.method === 'GET') {
const status = await providerService.checkAuthStatus()
return Response.json(status)
}
// /api/providers/settings
if (id === 'settings') {
if (req.method === 'GET') {
return Response.json(await providerService.getManagedSettings())
}
if (req.method === 'PUT') {
const body = await parseJsonBody(req)
await providerService.updateManagedSettings(body)
return Response.json({ ok: true })
}
throw methodNotAllowed(req.method)
}
// POST /api/providers/official
if (id === 'official' && req.method === 'POST') {
await providerService.activateOfficial()
return Response.json({ ok: true })
}
// PUT /api/providers/reorder
if (id === 'reorder') {
if (req.method !== 'PUT') throw methodNotAllowed(req.method)
return await handleReorder(req)
}
// /api/providers (no ID)
if (!id) {
if (req.method === 'GET') {
const { providers, activeId } = await providerService.listProviders()
return Response.json({ providers, activeId })
}
if (req.method === 'POST') {
return await handleCreate(req)
}
throw methodNotAllowed(req.method)
}
// /api/providers/:id/activate
if (action === 'activate') {
if (req.method !== 'POST') throw methodNotAllowed(req.method)
await providerService.activateProvider(id)
return Response.json({ ok: true })
}
// /api/providers/:id/test
if (action === 'test') {
if (req.method !== 'POST') throw methodNotAllowed(req.method)
let overrides: { baseUrl?: string; modelId?: string; apiFormat?: string; authStrategy?: string } | undefined
try {
const body = await req.json()
if (body && typeof body === 'object') overrides = body as typeof overrides
} catch { /* no body is fine — uses saved values */ }
const result = await providerService.testProvider(id, overrides)
if (!result.connectivity.success || result.proxy?.success === false) {
void diagnosticsService.recordEvent({
type: 'provider_test_failed',
severity: 'warn',
summary: result.connectivity.error || result.proxy?.error || 'Provider test failed',
details: {
providerId: id,
httpStatus: result.connectivity.httpStatus ?? result.proxy?.httpStatus,
apiFormat: overrides?.apiFormat,
modelId: overrides?.modelId,
connectivity: result.connectivity,
proxy: result.proxy,
},
})
}
return Response.json({ result })
}
// /api/providers/:id
if (req.method === 'GET') {
const provider = await providerService.getProvider(id)
return Response.json({ provider })
}
if (req.method === 'PUT') {
return await handleUpdate(req, id)
}
if (req.method === 'DELETE') {
await providerService.deleteProvider(id)
return Response.json({ ok: true })
}
throw methodNotAllowed(req.method)
} catch (error) {
return errorResponse(error)
}
}
async function handleCreate(req: Request): Promise<Response> {
const body = await parseJsonBody(req)
try {
const input = CreateProviderSchema.parse(body)
const provider = await providerService.addProvider(input)
return Response.json({ provider }, { status: 201 })
} catch (err) {
if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; '))
throw err
}
}
async function handleUpdate(req: Request, id: string): Promise<Response> {
const body = await parseJsonBody(req)
try {
const input = UpdateProviderSchema.parse(body)
const provider = await providerService.updateProvider(id, input)
return Response.json({ provider })
} catch (err) {
if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; '))
throw err
}
}
async function handleReorder(req: Request): Promise<Response> {
const body = await parseJsonBody(req)
try {
const input = ReorderProvidersSchema.parse(body)
const providers = await providerService.reorderProviders(input.orderedIds)
return Response.json({ providers })
} catch (err) {
if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; '))
throw err
}
}
async function handleTestUnsaved(req: Request): Promise<Response> {
const body = await parseJsonBody(req)
try {
const input = TestProviderSchema.parse(body)
const result = await providerService.testProviderConfig(input)
if (!result.connectivity.success || result.proxy?.success === false) {
void diagnosticsService.recordEvent({
type: 'provider_test_failed',
severity: 'warn',
summary: result.connectivity.error || result.proxy?.error || 'Provider test failed',
details: {
providerId: null,
baseUrl: input.baseUrl,
apiFormat: input.apiFormat,
modelId: input.modelId,
connectivity: result.connectivity,
proxy: result.proxy,
},
})
}
return Response.json({ result })
} catch (err) {
if (!(err instanceof z.ZodError)) {
void diagnosticsService.recordEvent({
type: 'provider_test_failed',
severity: 'warn',
summary: err instanceof Error ? err.message : String(err),
details: { providerId: null, error: err },
})
}
if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; '))
throw err
}
}
async function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
try {
return (await req.json()) as Record<string, unknown>
} catch {
throw ApiError.badRequest('Invalid JSON body')
}
}
function methodNotAllowed(method: string): ApiError {
return new ApiError(405, `Method ${method} not allowed`, 'METHOD_NOT_ALLOWED')
}