@@ -0,0 +1,70 @@
import fs from 'node:fs' ;
import path from 'node:path' ;
import { createRequire } from 'node:module' ;
const require = createRequire ( new URL ( '../web/package.json' , import . meta . url ) ) ;
const yaml = require ( 'js-yaml' ) ;
const root = path . resolve ( path . dirname ( new URL ( import . meta . url ) . pathname . replace ( /^\/([A-Za-z]:)/ , '$1' ) ) , '..' ) ;
const source = fs . readdirSync ( path . join ( root , 'internal/onboarding' ) ) . filter ( x => x . endsWith ( '.go' ) && ! x . endsWith ( '_test.go' ) ) . map ( x => fs . readFileSync ( path . join ( root , 'internal/onboarding' , x ) , 'utf8' ) ) . join ( '\n' ) ;
const descriptions = { device _id : '生产编号,KP01-加12位大写MAC;不是平台UUID' , platform _device _id : '平台公开UUID,沿用现有设备管理接口' , factory _mac : '12位大写十六进制MAC;相同MAC不能创建第二台物理设备' , hardware _model : '生产登记型号,当前为KP01' , hardware _revision : '硬件修订号,注册必须与库存一致' , batch _no : '生产批次,方便追溯' , key _version : '设备认证密钥版本' , expected _key _version : '交付放行目标版本;轮换时为当前生效版本' , enrollment _job _id : '生产登记或密钥轮换任务UUID' , receiver _public _key _pem : '工装RSA公钥,PKIX PEM, 2048至4096位,指数65537' , evidence _ref : '烧录读回或人工对账的证据引用,不放密钥' , boot _id : '每次启动生成并保持的UUID v4' , client _nonce : '设备生成的16字节随机数,Base64URL无填充' , challenge _id : 'C01签名校时返回的一次性挑战' , firmware _version : '固件版本,用于兼容诊断' , sdk _version : 'SDK版本,用于兼容诊断' , setup _id : '云端15分钟临时设置会话UUID' , local _setup _id : '设备本次设置入口UUID v4;重试保持不变' , transport _profile : '固定LOCAL_WEB,表示设备提供本地网页入口' , code _challenge : 'SHA256(ASCII verifier)的Base64URL无填充值' , token : '90秒内只能兑换一次的handoff令牌' , verifier : '设备交给手机的PKCE校验值;32字节随机数的Base64URL无填充字符串' , known _revision : '设备已知状态版本;允许0' , claim _id : '待物理确认的绑定申请UUID' , challenge : 'C05给设备的短时按键挑战,手机不能获取' , binding _digest : '云端生成的绑定摘要,按键回报原值' , expected _binding _generation : '提交时预期绑定代数,防止跨解绑或重新绑定重放' , press _event _id : '该次真实物理按键事件UUID v4,重试保持不变' , binding _generation : '绑定代数;解绑、重新绑定后旧代数无效' , device _revision : '云端设备状态版本号' , local _generation : '本次启动的本地持久化序号,不能倒退' , state _hash : 'STATE1规范字符串的SHA256小写十六进制摘要' , reason : '关闭、轮换或人工处理原因,见对应枚举' , final _revision : '设备关闭设置入口前看到的最新状态版本' , elder _id : '使用者公开UUID,复用subject_profile' , household _id : '家庭公开UUID;省略时须提供新家庭名称' , household _name : '新建家庭的名称' , preferred _name : '使用者称呼,用于设备人设和家庭展示' , phone _e164 : '含国家区号的手机号,例如+8613800000000' , purpose : '固定LOGIN' , sms _challenge _id : '发送短信得到的挑战UUID' , code : '6位短信验证码;5分钟有效,最多验证5次' , binding _id : '当前已生效的绑定公开UUID' , received _revision : '网页已收到的设备状态版本' , activation _id : '该物理设备唯一的激活任务UUID' , config _version : '配置版本,必须与当前云端版本一致' , config _digest : 'config_b64解码后的原始字节SHA256;不能重序列化再计算' , attempt _id : '设备生成的UUID v4,表示一次引擎初始化或首通执行' , stage : 'SDK_REGISTER或CONVERSATION_TEST;与C08平台当前阶段一致' , user _initiated : 'CONVERSATION_TEST必须为true,代表用户主动发起' , sdk _result : 'SDK执行返回码;SUCCESS_OBSERVED时必须为0' , outcome : 'SUCCESS_OBSERVED、DEFINITE_FAILURE或UNKNOWN;未知结果不得盲目重试' , evidence : '首通验收使用的真实会话和事件引用' , conversation _id : '测试会话公开UUID' , started _event _id : '已入库session_started事件ID' , first _audio _event _id : '已入库first_audio_received事件ID' , ended _event _id : '已入库session_ended事件ID' , expected _version : '运维列表中的激活任务版本,防止覆盖并发处理' , conclusion : 'OBSERVED_REGISTERED、CONFIRMED_NOT_REGISTERED或DEVICE_ATTEMPT_STOPPED' , device _secret : '已核实的供应商单设备密钥,仅人工对账写入,加密保存' , rtc _app _id : '供应商RTC应用ID;不填则使用已保存值' } ;
const schemas = { } ;
for ( const [ , name , body ] of source . matchAll ( /type (\w+) struct \{([\s\S]*?)\n\}/g ) ) {
if ( ! name . endsWith ( 'Input' ) && name !== 'ActivationEvidence' ) continue ;
const props = { } , required = [ ] ;
for ( const [ , type , field , omit ] of body . matchAll ( /\w+\s+(\w+)\s+`json:"([^",]+)(,omitempty)?"`/g ) ) {
props [ field ] = type === 'ActivationEvidence' ? { $ref : '#/components/schemas/ActivationEvidence' } : { type : type === 'bool' ? 'boolean' : /^int/ . test ( type ) ? 'integer' : 'string' } ;
if ( /^int/ . test ( type ) ) props [ field ] . format = 'int64' ;
props [ field ] . description = descriptions [ field ] ? ? field ;
if ( ! omit ) required . push ( field ) ;
}
schemas [ name ] = { type : 'object' , additionalProperties : false , properties : props , ... ( required . length ? { required } : { } ) } ;
}
const set = ( n , f , v ) => Object . assign ( schemas [ n ] . properties [ f ] , v ) ;
set ( 'TimeChallengeInput' , 'device_id' , { pattern : '^KP01-[0-9A-F]{12}$' } ) ; set ( 'InventoryInput' , 'factory_mac' , { pattern : '^[0-9A-F]{12}$' } ) ;
set ( 'SetupInput' , 'transport_profile' , { enum : [ 'LOCAL_WEB' ] } ) ; set ( 'CloseInput' , 'reason' , { enum : [ 'SUCCESS' , 'TIMEOUT' , 'CANCELLED' , 'NETWORK_SWITCH' ] } ) ; set ( 'SMSSendInput' , 'purpose' , { enum : [ 'LOGIN' ] } ) ; set ( 'SMSSendInput' , 'phone_e164' , { pattern : '^\\+[1-9][0-9]{7,14}$' } ) ; set ( 'SMSVerifyInput' , 'code' , { pattern : '^[0-9]{6}$' } ) ;
set ( 'ResultInput' , 'stage' , { enum : [ 'SDK_REGISTER' , 'CONVERSATION_TEST' ] } ) ; set ( 'ResultInput' , 'outcome' , { enum : [ 'SUCCESS_OBSERVED' , 'DEFINITE_FAILURE' , 'UNKNOWN' ] } ) ; set ( 'ReconcileInput' , 'conclusion' , { enum : [ 'OBSERVED_REGISTERED' , 'CONFIRMED_NOT_REGISTERED' , 'DEVICE_ATTEMPT_STOPPED' ] } ) ;
schemas . PollInput . required = [ 'setup_id' , 'boot_id' , 'known_revision' ] ; // Closed sessions remain usable for activation polling until boot/generation changes.
schemas . Empty = { type : 'object' , additionalProperties : false } ;
const str = ( description ) => ( { type : 'string' , description } ) , int = ( description ) => ( { type : 'integer' , format : 'int64' , description } ) , bool = { type : 'boolean' } , ref = n => ( { $ref : '#/components/schemas/' + n } ) ;
const object = properties => ( { type : 'object' , properties } ) ;
schemas . State = object ( { device _id : str ( descriptions . device _id ) , device _revision : int ( descriptions . device _revision ) , binding _generation : int ( descriptions . binding _generation ) , binding _state : { type : 'string' , enum : [ 'UNBOUND' , 'CLAIM_PENDING' , 'BOUND' , 'TRANSFER_PENDING' ] } , activation _state : { type : 'string' , enum : [ 'NOT_STARTED' , 'RESERVED' , 'CONTACT_POSSIBLE' , 'REGISTERED_OBSERVED' , 'READY' , 'RECONCILE_REQUIRED' , 'BLOCKED' ] } , state _hash : str ( descriptions . state _hash ) } ) ;
schemas . Command = object ( { command _id : str ( '命令标识' ) , type : { type : 'string' , enum : [ 'CONFIRM_CLAIM' , 'APPLY_ACTIVATION' ] } , expires _at _ms : int ( '命令到期UTC毫秒' ) , payload : { oneOf : [ object ( { claim _id : str ( '申请编号' ) , challenge : str ( descriptions . challenge ) , binding _digest : str ( descriptions . binding _digest ) , expected _binding _generation : int ( descriptions . expected _binding _generation ) , masked _account : str ( '脱敏的申请账号' ) } ) , object ( { config _b64 : str ( '原始配置JSON的Base64URL无填充编码' ) , config _mac : str ( 'HMAC-SHA256(raw设备密钥, CFG1换行config_b64)' ) } ) ] } } ) ;
schemas . ActivationConfig = object ( { contract _version : str ( 'ONBOARDING-1.1' ) , device _id : str ( descriptions . device _id ) , activation _id : str ( '任务编号' ) , config _version : int ( '版本' ) , binding _generation : int ( '当前绑定代数' ) , expires _at _ms : int ( '24小时配置有效期' ) , iot : object ( { instance _id : str ( '供应商实例' ) , product _key : str ( '产品编号' ) , device _name : str ( '冻结的物理设备名' ) , product _secret : str ( '供应商产品注册密钥,只在设备认证后的HTTPS激活配置中下发' ) } ) , bot _id : str ( '智能体ID' ) } ) ;
schemas . WebStatus = object ( { setup _id : str ( '临时会话UUID' ) , state : ref ( 'State' ) , binding _id : str ( descriptions . binding _id ) , claim _id : str ( descriptions . claim _id ) , device _saved : bool , authenticated : bool , closed : bool , sms _enabled : bool , expires _at _ms : int ( '设置入口到期时间' ) } ) ;
schemas . Browser = object ( { csrf _token : str ( '保存在当前标签页sessionStorage;写请求放X-CSRF-Token' ) , data : ref ( 'WebStatus' ) } ) ;
schemas . Failure = object ( { ok : { type : 'boolean' , const : false } , code : str ( '稳定业务错误码' ) , message : str ( '用户可读说明' ) , request _id : str ( '请求追踪UUID' ) } ) ;
const data = { TimeChallenge : object ( { payload _b64 : str ( 'TIME1校时JSON的Base64URL无填充' ) , mac _hex : str ( 'HMAC-SHA256(raw32设备密钥, TIME1换行payload_b64)' ) } ) , Register : object ( { state : ref ( 'State' ) , contract _version : str ( 'ONBOARDING-1.1' ) } ) , Setup : object ( { setup _id : str ( '设置UUID' ) , expires _at _ms : int ( '15分钟到期' ) , poll _after _ms : int ( '首次2000' ) , state : ref ( 'State' ) } ) , Handoff : object ( { handoff _token : str ( '90秒一次性令牌' ) , expires _at _ms : int ( '到期毫秒' ) , entry _url : str ( '固定HTTPS入口;设备将token和verifier放fragment' ) } ) , Poll : object ( { state : ref ( 'State' ) , command : { oneOf : [ ref ( 'Command' ) , { type : 'null' } ] } , poll _after _ms : int ( '下一次轮询间隔' ) } ) , Confirmation : object ( { claim _id : str ( '申请UUID' ) , state : ref ( 'State' ) , already _applied : bool } ) , StateAck : object ( { accepted _revision : int ( '已接受版本' ) , setup _can _close : bool } ) , Attempt : object ( { allowed : bool , attempt _id : str ( '执行UUID' ) , permit _expires _at _ms : int ( '30秒有效期' ) , state : ref ( 'State' ) } ) , Result : object ( { activation _id : str ( '任务UUID' ) , state : ref ( 'State' ) , reconcile _required : bool } ) , ActivationAck : object ( { accepted : bool , state : ref ( 'State' ) } ) , Close : object ( { close _allowed : bool , state : ref ( 'State' ) } ) , Claim : object ( { claim _id : str ( '申请UUID' ) , expires _at _ms : int ( '120秒有效期' ) , state : ref ( 'State' ) } ) , StateOnly : object ( { state : ref ( 'State' ) } ) , Ack : object ( { accepted : bool } ) , SMS : object ( { sms _challenge _id : str ( '验证码挑战UUID' ) , expires _at _ms : int ( '5分钟' ) , resend _after _ms : int ( '60000' ) } ) , Profile : object ( { elder _id : str ( '使用者公开UUID' ) , household _id : str ( '家庭公开UUID' ) } ) , Profiles : object ( { items : { type : 'array' , items : object ( { household _id : str ( '家庭公开UUID' ) , household _name : str ( '家庭名' ) , elder _id : str ( '使用者UUID,空值表示家庭无使用者' ) , preferred _name : str ( '称呼' ) } ) } } ) , Devices : object ( { items : { type : 'array' , items : { type : 'object' , additionalProperties : true } } , limit : int ( '运维列表最多200条;按设备创建时间倒序' ) } ) , Inventory : object ( { device _id : str ( descriptions . device _id ) , platform _device _id : str ( descriptions . platform _device _id ) , enrollment _job _id : str ( '生产或轮换任务UUID' ) , key _version : int ( '目标凭据版本' ) } ) , Delivery : object ( { algorithm : str ( 'RSA-OAEP-SHA256+A256GCM' ) , enrollment _job _id : str ( '任务UUID' ) , wrapped _key _b64 : str ( 'RSA包装的AES数据密钥' ) , ciphertext _b64 : str ( '前12字节为nonce,后接AES-GCM密文和16字节认证标签;AAD为任务UUID' ) , expires _at _ms : int ( '交付包到期时间' ) } ) , Release : object ( { released : bool , device _id : str ( descriptions . device _id ) , key _version : int ( '生效版本' ) } ) , Logout : object ( { signed _out : bool } ) , Account : object ( { csrf _token : str ( '账号会话写请求CSRF' ) , authenticated : bool } ) } ;
data . Browser = ref ( 'Browser' ) ; data . WebStatus = ref ( 'WebStatus' ) ;
data . Inventory . properties . contract _version = str ( 'ONBOARDING-1.1' ) ; data . Inventory . properties . released = bool ; data . Release = data . Inventory ;
schemas . DevicesItem = object ( { platform _device _id : str ( '平台设备UUID' ) , device _id : str ( '物理设备编号' ) , model : str ( '型号' ) , binding _generation : int ( '当前绑定代数' ) , device _revision : int ( '状态版本' ) , released : bool , activation _id : str ( '激活任务UUID' ) , activation _state : str ( '激活状态' ) , activation _version : int ( '对账使用的预期任务版本' ) , key _version : int ( '当前生效设备认证密钥版本' ) , pending _key _version : int ( '待交付或放行的版本,没有则0' ) , current _attempt _id : str ( '当前供应商注册尝试UUID' ) , enrollment _job _id : str ( '最新生产或轮换任务UUID' ) , last _error _code : str ( '故障原因码' ) , provider _device _name : str ( '冻结的供应商物理名' ) , household _name : str ( '家庭名,仅我的设备' ) , preferred _name : str ( '使用者称呼,仅我的设备' ) } ) ; data . Devices . properties . items . items = ref ( 'DevicesItem' ) ;
for ( const [ name , schema ] of Object . entries ( data ) ) schemas [ name + 'Response' ] = object ( { ok : { type : 'boolean' , const : true } , code : { type : 'string' , const : 'OK' } , request _id : { type : 'string' , format : 'uuid' } , server _time _ms : int ( 'UTC毫秒' ) , data : schema } ) ;
schemas . TimeChallengeResponse = data . TimeChallenge ;
const endpoints = [
[ 'POST' , '/api/v1/device/time-challenges' , 'C01 校时挑战' , 'TimeChallengeInput' , 'TimeChallenge' , 'public' ] , [ 'POST' , '/api/v1/device/register' , 'C02 首次认证和启动注册' , 'RegisterInput' , 'Register' , 'device' ] , [ 'POST' , '/api/v1/device/setup-sessions' , 'C03 创建临时设置会话' , 'SetupInput' , 'Setup' , 'device' ] , [ 'POST' , '/api/v1/device/handoffs' , 'C04 获取一次性手机入口' , 'HandoffInput' , 'Handoff' , 'device' ] , [ 'POST' , '/api/v1/device/poll' , 'C05 查询状态和命令' , 'PollInput' , 'Poll' , 'device' ] , [ 'POST' , '/api/v1/device/claim-confirmations' , 'C06 物理按键确认绑定' , 'ConfirmationInput' , 'Confirmation' , 'device' ] , [ 'POST' , '/api/v1/device/state-acks' , 'C07 绑定状态持久化回执' , 'StateAckInput' , 'StateAck' , 'device' ] , [ 'POST' , '/api/v1/device/activation-attempts' , 'C08 申请设备执行许可' , 'AttemptInput' , 'Attempt' , 'device' ] , [ 'POST' , '/api/v1/device/activation-results' , 'C09 回报设备执行结果' , 'ResultInput' , 'Result' , 'device' ] , [ 'POST' , '/api/v1/device/activation-acks' , 'C10 激活结果保存回执' , 'ActivationAckInput' , 'ActivationAck' , 'device' ] , [ 'POST' , '/api/v1/device/setup-close' , 'C11 关闭临时设置入口' , 'CloseInput' , 'Close' , 'device' ] ,
[ 'POST' , '/api/v1/web/handoffs/exchange' , '手机兑换一次性入口' , 'ExchangeInput' , 'Browser' , 'exchange' ] , [ 'GET' , '/api/v1/web/setup/session' , '恢复手机设置会话' , null , 'Browser' , 'web' ] , [ 'POST' , '/api/v1/web/setup/auth-link' , '关联OIDC登录' , 'Empty' , 'Browser' , 'oidc' ] , [ 'POST' , '/api/v1/web/auth/sms/send' , '发送登录短信' , 'SMSSendInput' , 'SMS' , 'web' ] , [ 'POST' , '/api/v1/web/auth/sms/verify' , '验证短信并登录' , 'SMSVerifyInput' , 'Browser' , 'web' ] , [ 'POST' , '/api/v1/web/profiles' , '查询可选择的家庭使用者' , 'Empty' , 'Profiles' , 'web' ] , [ 'POST' , '/api/v1/web/profiles/create' , '创建家庭或使用者' , 'ProfileInput' , 'Profile' , 'web' ] , [ 'POST' , '/api/v1/web/claims' , '提交绑定申请' , 'ClaimInput' , 'Claim' , 'web' ] , [ 'POST' , '/api/v1/web/claims/cancel' , '取消自己的绑定申请' , 'CancelInput' , 'StateOnly' , 'web' ] , [ 'POST' , '/api/v1/web/setup/status' , '查询手机绑定进度' , 'Empty' , 'WebStatus' , 'web' ] , [ 'POST' , '/api/v1/web/setup/ack' , '网页确认收到绑定结果' , 'WebAckInput' , 'Ack' , 'web' ] , [ 'GET' , '/api/v1/web/onboarding/devices' , '设置结束后查询我的设备' , null , 'Devices' , 'account' ] , [ 'GET' , '/api/v1/web/account/session' , '恢复账号会话及CSRF' , null , 'Account' , 'account' ] , [ 'POST' , '/api/v1/web/auth/logout' , '退出本浏览器登录' , 'Empty' , 'Logout' , 'account' ] ,
[ 'GET' , '/api/v1/ops/device-inventory' , '查询生产设备和激活状态' , null , 'Devices' , 'ops' ] , [ 'POST' , '/api/v1/ops/device-inventory' , '登记生产设备' , 'InventoryInput' , 'Inventory' , 'ops' ] , [ 'POST' , '/api/v1/ops/device-inventory/{id}/credential-deliveries' , '向工装交付加密密钥' , 'DeliveryInput' , 'Delivery' , 'ops' ] , [ 'POST' , '/api/v1/ops/device-inventory/{id}/release' , '确认烧录读回并放行' , 'ReleaseInput' , 'Release' , 'ops' ] , [ 'POST' , '/api/v1/ops/device-inventory/{id}/credential-rotations' , '准备轮换凭据' , 'RotationInput' , 'Inventory' , 'ops' ] , [ 'POST' , '/api/v1/ops/onboarding-activations/{id}/reconcile' , '人工记录激活对账结论' , 'ReconcileInput' , 'StateOnly' , 'ops' ] ] ;
const spec = { openapi : '3.1.0' , info : { title : 'SaaS 首次认证、绑定和激活接口' , version : '0.78.0' , description : 'ONBOARDING-1.1。仅用于生产登记为physical_confirm的设备;旧设备维持原协议。详细规则见docs/onboarding-implementation.md。' } , paths : { } , components : { securitySchemes : { DeviceRequest : { type : 'apiKey' , in : 'header' , name : 'Authorization' , description : 'Device-HMAC-SHA256小写十六进制签名;DEVREQ1九行规范;其余必填签名头见操作参数' } , Bearer : { type : 'http' , scheme : 'bearer' } , SetupCookie : { type : 'apiKey' , in : 'cookie' , name : '__Host-onboarding' } , AccountCookie : { type : 'apiKey' , in : 'cookie' , name : '__Host-platform-onboarding' } } , schemas } } ;
function header ( name , required = true , description = name ) { return { in : 'header' , name , required , schema : { type : 'string' } , description } }
for ( const [ method , url , title , input , output , auth ] of endpoints ) {
const params = [ header ( 'X-Request-ID' , method === 'POST' && auth !== 'public' , 'UUID v4;业务重试保持相同值和原始正文' ) ] ;
if ( url . includes ( '{id}' ) ) params . push ( { in : 'path' , name : 'id' , required : true , schema : { type : 'string' , format : 'uuid' } , description : url . includes ( 'onboarding-activations' ) ? '激活任务UUID' : '平台设备公开UUID' } ) ;
if ( auth === 'device' ) for ( const name of [ 'X-Device-ID' , 'X-Key-Version' , 'X-Timestamp-Ms' , 'X-Nonce' ] ) params . push ( header ( name ) ) ;
if ( [ 'web' , 'oidc' , 'account' ] . includes ( auth ) && method === 'POST' ) params . push ( header ( 'X-CSRF-Token' ) , header ( 'Origin' , true , '必须与ONBOARDING_ENTRY_URL同源' ) ) ;
if ( auth === 'exchange' ) params . push ( header ( 'Origin' , true , '必须与ONBOARDING_ENTRY_URL同源' ) ) ;
if ( auth === 'ops' ) { params . push ( header ( 'X-Tenant-ID' , true , '租户公开UUID;服务端核验运维角色' ) ) ; if ( input === 'InventoryInput' || input === 'RotationInput' ) params . push ( header ( 'Idempotency-Key' , true , '16至160字符,重试保持相同值' ) ) ; }
const security = auth === 'device' ? [ { DeviceRequest : [ ] } ] : auth === 'ops' ? [ { Bearer : [ ] } ] : auth === 'oidc' ? [ { SetupCookie : [ ] , Bearer : [ ] } ] : auth === 'web' ? [ { SetupCookie : [ ] } ] : auth === 'account' ? [ { AccountCookie : [ ] } , { Bearer : [ ] } ] : [ ] ;
const operation = { operationId : 'onboarding' + title . replace ( /[^a-zA-Z0-9]/g , '' ) + method + url . replace ( /[^a-zA-Z0-9]/g , '' ) , summary : title , tags : [ 'Onboarding' ] , description : auth === 'device' ? 'HTTPS,无query参数;正文不超过8KiB。随机nonce每次发送更新,X-Request-ID在重试时保持。' : auth === 'public' ? '专用HTTP或HTTPS校时;无需时间戳及请求签名;成功直接返回payload_b64和mac_hex,设备必须核验TIME1。' : auth === 'ops' ? '必须使用运维OIDC身份。对账仅ops_admin;其它写操作ops_admin或ops_operator。' : 'HTTPS,同源浏览器,Cookie不可读,CSRF仅保存在当前标签页。' , security , parameters : params , responses : { '200' : { description : '成功;业务状态请读取data' , content : { 'application/json' : { schema : ref ( output + 'Response' ) } } } , default : { description : '失败;400/401/403/409/410/413/415/429/500/503,见code恢复;禁止根据超时推断供应商没有执行' , content : { 'application/json' : { schema : ref ( 'Failure' ) } } } } } ;
if ( input ) operation . requestBody = { required : true , content : { 'application/json' : { schema : ref ( input ) } } } ;
( spec . paths [ url ] ? ? = { } ) [ method . toLowerCase ( ) ] = operation ;
}
fs . writeFileSync ( path . join ( root , 'openapi/onboarding.yaml' ) , yaml . dump ( spec , { lineWidth : 140 , noRefs : true } ) ) ;
const mainPath = path . join ( root , 'openapi/openapi.yaml' ) ; let main = fs . readFileSync ( mainPath , 'utf8' ) . replace ( /\r\n/g , '\n' ) . replace ( 'version: 0.77.0' , 'version: 0.78.0' ) ;
if ( ! main . includes ( './onboarding.yaml#/paths/' ) ) main = main . replace ( 'paths:\n' , 'paths:\n' + Object . keys ( spec . paths ) . map ( url => ` ${ url } : \n $ ref: './onboarding.yaml#/paths/ ${ url . replaceAll ( '~' , '~0' ) . replaceAll ( '/' , '~1' ) } ' \n ` ) . join ( '' ) ) ;
if ( ! main . includes ( 'intent: { type: string, enum: [activation_test]' ) ) main = main . replace ( ' HardwareAgentCreateRequest:\n' , ' HardwareAgentCreateRequest:\n' ) . replace ( /( HardwareAgentCreateRequest:[\s\S]*? properties:\n)/ , '$1 intent: { type: string, enum: [activation_test], description: 仅physical_confirm首通测试填写;普通会话省略。 }\n activation_id: { type: string, format: uuid }\n attempt_id: { type: string, format: uuid }\n permit_token: { type: string, description: 历史接口兼容字段;ONBOARDING-1.1不下发且无需填写。 }\n' ) ;
main = main . replace ( 'enum: [session_started, session_updated, session_ended, session_failed' , 'enum: [session_started, first_audio_received, session_updated, session_ended, session_failed' ) ;
main = main . replace ( 'config_revision, claim, local_actions' , 'config_revision, local_actions' ) ;
if ( ! main . includes ( 'maximum_duration_seconds:' ) ) main = main . replace ( /( ConversationSession:[\s\S]*? properties:\n)/ , '$1 maximum_duration_seconds: { type: integer, minimum: 1, description: 首通测试固定60秒;普通会话省略。 }\n' ) ;
fs . writeFileSync ( mainPath , main ) ;
const doc = [ '# 首次接入接口字段清单' , '' , '由 `node scripts/generate-onboarding-contract.mjs` 根据实际Go请求结构生成。共 ' + endpoints . length + ' 项操作。完整响应结构见 [OpenAPI](../openapi/onboarding.yaml),流程、签名、错误恢复见 [实施说明](onboarding-implementation.md)。' , '' ] ;
for ( const [ method , url , title , input , output , auth ] of endpoints ) { doc . push ( '## ' + title , '' , '`' + method + ' ' + url + '`' , '' , '调用身份:' + ( { device : '设备DEVREQ1签名' , public : '校时请求,无请求签名;设备必须核验TIME1响应MAC' , web : '设置Cookie + 写请求CSRF' , oidc : '设置Cookie + CSRF + OIDC Bearer' , exchange : '同源网页;一次性token和verifier' , account : '账号Cookie;设备列表也支持OIDC Bearer' , ops : '运维OIDC Bearer + X-Tenant-ID' } [ auth ] ) , '' ) ; if ( input && input !== 'Empty' ) { doc . push ( '| 字段 | 类型 | 必填 | 用途 |' , '|---|---|---|---|' ) ; for ( const [ name , p ] of Object . entries ( schemas [ input ] . properties ) ) doc . push ( ` | ${ name } | ${ p . type ? ? 'object' } | ${ schemas [ input ] . required ? . includes ( name ) ? '是' : '条件或可选' } | ${ p . description } | ` ) ; } else doc . push ( method === 'POST' ? '正文:`{}`。' : '无请求正文。' ) ; doc . push ( '' , output === 'TimeChallenge' ? '响应:根对象直接返回 `{payload_b64,mac_hex}`。' : '响应:成功外层 `{ok,code,request_id,server_time_ms,data}`;失败 `{ok,code,request_id,message}`。data结构为 `' + output + 'Response.data`。' , '' ) ; }
fs . writeFileSync ( path . join ( root , 'docs/onboarding-api-fields.md' ) , doc . join ( '\n' ) ) ;
console . log ( 'Generated onboarding OpenAPI and field reference: ' + endpoints . length + ' operations' ) ;