import { mkdirSync, writeFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' const string = { type: 'string' }, integer = { type: 'integer' }, number = { type: 'number' }, boolean = { type: 'boolean' } const date = { type: 'string', format: 'date-time' } const ref = name => ({ $ref: `#/components/schemas/${name}` }) const array = items => ({ type: 'array', items }) const object = (properties, required = Object.keys(properties)) => ({ type: 'object', properties, required, additionalProperties: false }) const schemas = { Error: object({ code: string, message: string, requestId: string }), Administrator: object({ id: string, username: string, displayName: string, role: { type: 'string', enum: ['super_admin', 'operator', 'viewer'] } }), LoginInput: object({ username: string, password: string }), LoginResult: object({ accessToken: string, expiresIn: integer, user: ref('Administrator') }), Desktop: object({ id: string, name: string, os: { type: 'string', enum: ['Windows', 'Ubuntu', '麒麟'] }, node: string, vmid: integer, pool: string, powerState: string, serviceState: string, provider: { type: 'string', enum: ['demo', 'proxmox'] }, endpoint: string, app: string, width: integer, height: integer, fps: integer, updatedAt: date }), Terminal: object({ id: string, sn: string, name: string, group: string, status: string, agentVersion: string, lastSeen: { ...date, nullable: true }, sessionDesktopId: { ...string, nullable: true }, configVersion: integer, fps: { ...number, nullable: true }, rtt: { ...number, nullable: true } }), User: object({ id: string, username: string, displayName: string, enabled: boolean, terminalId: { ...string, nullable: true }, desktopIds: array(string) }), Resource: object({ id: string, name: string, status: string, cpu: number, memory: number, storage: number, provider: string, updatedAt: date }), Task: object({ id: string, kind: string, action: string, targetId: string, targetName: string, actor: string, status: { type: 'string', enum: ['queued', 'running', 'success', 'failed', 'timeout'] }, message: string, providerRef: string, createdAt: date, updatedAt: date }), Audit: object({ id: string, actor: string, action: string, target: string, result: string, requestId: string, createdAt: date }), Overview: object({ terminalCount: integer, onlineTerminals: integer, desktopCount: integer, runningDesktops: integer, activeSessions: integer, failedTasks: integer, mode: string }), Settings: object({ mode: { type: 'string', enum: ['demo', 'production'] }, computeProvider: string, pveConfigured: boolean, terminalTransport: string, capabilities: array(string) }), ImportInput: object({ name: string, os: { type: 'string', enum: ['Windows', 'Ubuntu', '麒麟'] }, node: string, vmid: integer, endpoint: string, width: integer, height: integer, fps: integer }, ['name', 'os', 'node', 'vmid']), DiscoveredVM: object({ name: string, node: string, vmid: integer, status: string }), EnrollmentInput: object({ sn: string, name: string, group: string }), Enrollment: object({ id: string, activationSecret: string }), CreateUserInput: object({ username: string, displayName: string }), BindingInput: object({ terminalId: { ...string, nullable: true }, desktopIds: array(string), enabled: boolean }), DesktopAction: object({ action: { type: 'string', enum: ['start', 'shutdown', 'reboot'] } }), TerminalAction: object({ action: { type: 'string', enum: ['reboot', 'reset-session', 'collect-logs'] } }), RegisterInput: object({ sn: string, activationSecret: string, agentVersion: string }), RegisterResult: object({ terminalId: string, accessToken: string, heartbeatInterval: integer }), HeartbeatInput: object({ agentVersion: string, fps: { ...number, nullable: true }, rtt: { ...number, nullable: true } }, ['agentVersion']), Accepted: object({ accepted: boolean }), HeartbeatResult: object({ accepted: boolean, serverTime: date }), DeviceConfig: object({ configVersion: integer, identityMode: string, commandTransport: string, heartbeatInterval: integer }), TicketInput: object({ desktopId: string }), Ticket: object({ ticket: string, desktopId: string, expiresAt: date, credentialMode: string }), ConsumeInput: object({ ticket: string }), Connection: object({ desktopId: string, sessionId: string, protocol: string, endpoint: string, app: string, width: integer, height: integer, fps: integer, credentialMode: string }), DeviceDiagnostics: object({ agentVersion: string, bootId: string, uptimeSeconds: number, kioskState: string, logTail: string }), SessionInput: object({ sessionId: string, state: { type: 'string', enum: ['connected', 'ended'] } }), AckInput: object({ status: { type: 'string', enum: ['success', 'failed'] }, message: string, diagnostics: ref('DeviceDiagnostics') }, ['status']), } // Older frontends can omit this display field; the API always supplies it. schemas.Terminal.properties.sessionState = string for (const name of ['Desktop', 'Terminal', 'User', 'Resource', 'Task', 'Audit', 'DiscoveredVM']) { schemas[`${name}List`] = object({ items: array(ref(name)), page: integer, pageSize: integer, total: integer, requestId: string }) } const json = schema => ({ 'application/json': { schema } }) const operation = (id, output, { input, status = 200, list = false, param = false, command = false, security = 'adminBearer' } = {}) => ({ operationId: id, security: security ? [{ [security]: [] }] : [], ...(input ? { requestBody: { required: true, content: json(ref(input)) } } : {}), parameters: [ ...(param ? [{ name: 'id', in: 'path', required: true, schema: string }] : []), ...(list ? ['page', 'pageSize', 'keyword', 'status'].map(name => ({ name, in: 'query', schema: name.startsWith('page') ? integer : string })) : []), ...(command ? [{ name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', maxLength: 100 } }] : []), ], responses: { [status]: { description: 'Successful response', ...(output ? { content: json(ref(output)) } : {}) }, ...Object.fromEntries([400, 401, 403, 404, 409, 500, 502, 503].map(code => [code, { description: 'Request failed', content: json(ref('Error')) }])), }, }) const paths = { '/auth/login': { post: operation('login', 'LoginResult', { input: 'LoginInput', security: null }) }, '/auth/me': { get: operation('me', 'Administrator') }, '/auth/logout': { post: operation('logout', null, { status: 204 }) }, '/overview': { get: operation('overview', 'Overview') }, '/settings': { get: operation('settings', 'Settings') }, '/resources': { get: operation('resources', 'ResourceList', { list: true }) }, '/resources/vms': { get: operation('discoverVMs', 'DiscoveredVMList', { list: true }) }, '/resources/sync': { post: operation('syncResources', 'Task', { status: 202, command: true }) }, '/desktops': { get: operation('desktops', 'DesktopList', { list: true }) }, '/desktops/import': { post: operation('importDesktop', 'Desktop', { input: 'ImportInput' }) }, '/desktops/{id}': { get: operation('desktop', 'Desktop', { param: true }) }, '/desktops/{id}/actions': { post: operation('desktopAction', 'Task', { input: 'DesktopAction', param: true, status: 202, command: true }) }, '/terminals': { get: operation('terminals', 'TerminalList', { list: true }), post: operation('enrollTerminal', 'Enrollment', { input: 'EnrollmentInput', status: 201 }) }, '/terminals/{id}/commands': { post: operation('terminalCommand', 'Task', { input: 'TerminalAction', param: true, status: 202, command: true }) }, '/users': { get: operation('users', 'UserList', { list: true }), post: operation('createUser', 'User', { input: 'CreateUserInput', status: 201 }) }, '/users/{id}/bindings': { put: operation('updateBindings', 'User', { input: 'BindingInput', param: true }) }, '/tasks': { get: operation('tasks', 'TaskList', { list: true }) }, '/tasks/{id}': { get: operation('task', 'Task', { param: true }) }, '/tasks/{id}/diagnostics': { get: operation('taskDiagnostics', 'DeviceDiagnostics', { param: true }) }, '/audit-logs': { get: operation('audits', 'AuditList', { list: true }) }, '/terminal/register': { post: operation('registerDevice', 'RegisterResult', { input: 'RegisterInput', security: null }) }, '/terminal/heartbeat': { post: operation('heartbeat', 'HeartbeatResult', { input: 'HeartbeatInput', security: 'deviceBearer' }) }, '/terminal/config': { get: operation('deviceConfig', 'DeviceConfig', { security: 'deviceBearer' }) }, '/terminal/desktops': { get: operation('deviceDesktops', 'DesktopList', { security: 'deviceBearer', list: true }) }, '/terminal/commands': { get: operation('deviceCommands', 'TaskList', { security: 'deviceBearer', list: true }) }, '/terminal/commands/{id}/ack': { post: operation('deviceAck', 'Accepted', { input: 'AckInput', param: true, security: 'deviceBearer' }) }, '/terminal/connect-ticket': { post: operation('createTicket', 'Ticket', { input: 'TicketInput', status: 201, security: 'deviceBearer' }) }, '/terminal/connect-ticket/consume': { post: operation('consumeTicket', 'Connection', { input: 'ConsumeInput', security: 'deviceBearer' }) }, '/terminal/session': { post: operation('deviceSession', 'Accepted', { input: 'SessionInput', security: 'deviceBearer' }) }, } const output = { openapi: '3.0.3', info: { title: 'VDI 管理平台 API', version: '0.1.0', description: '第一阶段接口。平台票据不替代 Sunshine 配对;HTTP 202 只代表已提交。' }, servers: [{ url: '/api/v1' }], paths, components: { schemas, securitySchemes: { adminBearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, deviceBearer: { type: 'http', scheme: 'bearer', description: '设备激活后获得的独立令牌,不是管理员 JWT' } } } } const directory = fileURLToPath(new URL('../docs/openapi/', import.meta.url)) mkdirSync(directory, { recursive: true }) writeFileSync(`${directory}/platform.v1.json`, `${JSON.stringify(output, null, 2)}\n`) console.log('Updated docs/openapi/platform.v1.json')