fix: sandbox node binding API proxy + auto apply

This commit is contained in:
Haitao Pan 2026-02-06 14:12:44 +08:00
parent 3ebd966ec6
commit c504a8da30
3 changed files with 125 additions and 8 deletions

View File

@ -0,0 +1,59 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
type ErrorPayload = {
error: string
}
function isAllowedRootEmail(email?: string): boolean {
return email?.trim().toLowerCase() === 'admin@svc.plus'
}
export async function POST(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
if (!isAllowedRootEmail(user.email)) {
return NextResponse.json<ErrorPayload>({ error: 'root_only' }, { status: 403 })
}
const headers = new Headers({
Authorization: `Bearer ${session.token}`,
Accept: 'application/json',
})
const body = await request.text()
const contentType = request.headers.get('content-type') ?? 'application/json'
headers.set('Content-Type', contentType)
const response = await fetch(`${ACCOUNT_API_BASE}/admin/sandbox/bind`, {
method: 'POST',
headers,
body,
cache: 'no-store',
})
const payload = await response.json().catch(() => null)
if (payload === null) {
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
}
return NextResponse.json(payload, { status: response.status })
}

View File

@ -0,0 +1,52 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
type ErrorPayload = {
error: string
}
function isAllowedRootEmail(email?: string): boolean {
return email?.trim().toLowerCase() === 'admin@svc.plus'
}
export async function GET(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
if (!isAllowedRootEmail(user.email)) {
return NextResponse.json<ErrorPayload>({ error: 'root_only' }, { status: 403 })
}
const response = await fetch(`${ACCOUNT_API_BASE}/admin/sandbox/binding`, {
method: 'GET',
headers: {
Authorization: `Bearer ${session.token}`,
Accept: 'application/json',
},
cache: 'no-store',
})
const payload = await response.json().catch(() => null)
if (payload === null) {
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
}
return NextResponse.json(payload, { status: response.status })
}

View File

@ -46,6 +46,7 @@ export default function SandboxNodeBindingPanel() {
const currentBinding = useMemo(() => getSandboxNodeBinding(), [])
const [draftAddress, setDraftAddress] = useState<string>(currentBinding?.address ?? '')
const [isSaving, setIsSaving] = useState(false)
useEffect(() => {
// Initial load from server to stay in sync
@ -72,9 +73,11 @@ export default function SandboxNodeBindingPanel() {
return (current?.address ?? '') !== draftAddress
}, [draftAddress])
const handleApply = async () => {
const address = draftAddress.trim()
const handleApply = async (rawAddress: string) => {
const address = rawAddress.trim()
try {
setIsSaving(true)
setMessage(null)
const response = await fetch('/api/admin/sandbox/bind', {
method: 'POST',
headers: {
@ -104,6 +107,8 @@ export default function SandboxNodeBindingPanel() {
// Refresh local state if needed (though we already updated it)
} catch (err: any) {
setMessage(`错误:${err.message}`)
} finally {
setIsSaving(false)
}
}
@ -124,8 +129,10 @@ export default function SandboxNodeBindingPanel() {
value={draftAddress}
disabled={isLoading || !nodes}
onChange={(e) => {
setDraftAddress(e.target.value)
setMessage(null)
const next = e.target.value
setDraftAddress(next)
// 自动确认:选择后立即提交到服务器
void handleApply(next)
}}
className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800 focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-200"
>
@ -139,14 +146,14 @@ export default function SandboxNodeBindingPanel() {
</label>
<button
onClick={handleApply}
disabled={!isChanged}
onClick={() => void handleApply(draftAddress)}
disabled={!isChanged || isSaving}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${isChanged
? 'bg-purple-600 text-white hover:bg-purple-700 shadow-sm'
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
>
{isSaving ? '保存中…' : '确认应用'}
</button>
</div>
@ -179,4 +186,3 @@ export default function SandboxNodeBindingPanel() {
</Card>
)
}