2026-09-12 08:57:29 +08:00
package onboarding
import (
2026-10-08 10:06:44 +08:00
"context"
"data-terminal-cloud/internal/devicefleet"
"data-terminal-cloud/internal/devicereset"
2026-09-12 08:57:29 +08:00
"data-terminal-cloud/internal/identity"
"encoding/json"
"errors"
"github.com/google/uuid"
2026-10-08 10:06:44 +08:00
"github.com/jackc/pgx/v5"
2026-09-12 08:57:29 +08:00
"github.com/jackc/pgx/v5/pgconn"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"time"
)
func ( s * Service ) cookieName ( account bool ) string {
2026-10-08 10:06:44 +08:00
if s . operatorLogin {
if s . options . AllowLocalHTTP {
return "platform-operator"
}
return "__Host-platform-operator"
}
2026-09-12 08:57:29 +08:00
n := "__Host-onboarding"
if account {
n = "__Host-platform-onboarding"
}
if s . options . AllowLocalHTTP {
return strings . TrimPrefix ( n , "__Host-" )
}
return n
}
func ( s * Service ) secure ( r * http . Request ) bool {
if r . TLS != nil {
return true
}
host , _ , err := net . SplitHostPort ( r . RemoteAddr )
if err != nil {
host = r . RemoteAddr
}
ip , err := netip . ParseAddr ( host )
if err != nil {
return false
}
if s . options . AllowLocalHTTP && ip . IsLoopback () {
return true
}
for _ , cidr := range s . options . TrustedProxyCIDRs {
prefix , err := netip . ParsePrefix ( cidr )
2026-10-08 10:06:44 +08:00
if err == nil && prefix . Contains ( ip ) {
proto := r . Header . Get ( "X-Forwarded-Proto" )
return proto == "https" || ( s . options . AllowLocalHTTP && proto == "http" )
2026-09-12 08:57:29 +08:00
}
}
return false
}
func ( s * Service ) sameOrigin ( r * http . Request ) bool {
entry , _ := url . Parse ( s . options . EntryURL )
return r . Header . Get ( "Origin" ) == entry . Scheme + "://" + entry . Host
}
func ( s * Service ) RegisterRoutes ( mux * http . ServeMux ) {
2026-10-08 10:06:44 +08:00
s . mount ( mux , "GET /api/v1/ops/login-captcha-policy" , "captcha-policy" )
s . mount ( mux , "POST /api/v1/ops/login-captcha-policy" , "captcha-policy" )
for _ , name := range [] string { "login/session" , "login/captcha" , "login/password" , "login/sms/send" , "login/sms/verify" , "login/wechat" , "login/wechat-link" , "logout" , "ble-session" , "ble-device-state" , "ble-bind-tokens" , "ble-bind-status" , "device-subject" } {
s . mount ( mux , "POST /api/v1/mini/" + name , "mini" )
}
s . mount ( mux , "GET /api/v1/mini/account" , "mini" )
for _ , name := range [] string { "ble-bind-tokens" , "ble-bind-status" , "ble-device-state" , "device-subject" } {
s . mount ( mux , "POST /api/v1/web/" + name , "ble-web" )
}
s . mount ( mux , "POST /api/v1/device/ble-bind" , "device" )
s . mount ( mux , "POST /api/v1/device/mqtt-config" , "device" )
s . mount ( mux , "POST /api/v1/device/control/sync" , "device" )
s . mount ( mux , "POST /api/v1/device/control/ack" , "device" )
s . mount ( mux , "POST /api/v1/device/factory-reset/state" , "device" )
s . mount ( mux , "POST /api/v1/device/factory-reset" , "device" )
s . mount ( mux , "POST /api/v1/device/ota/report" , "device" )
s . mount ( mux , "POST /api/v1/device/ota/manifest" , "device" )
s . mount ( mux , "POST /api/v1/device/ble-state" , "device" )
s . mount ( mux , "POST /api/v1/device/voice-runtime" , "device" )
s . mount ( mux , "POST /api/v1/device/voice-config" , "device" )
s . mount ( mux , "POST /api/v1/device/voice-credentials" , "device" )
for _ , name := range [] string { "session" , "captcha" , "captcha/verify" , "password" , "sms/send" , "sms/verify" } {
method := "POST"
if name == "session" {
method = "GET"
}
s . mount ( mux , method + " /api/v1/web/operator/login/" + name , "operator-login" )
}
s . mount ( mux , "GET /api/v1/web/operator/account/session" , "operator-session" )
s . mount ( mux , "GET /api/v1/web/operator/account/password" , "operator-password" )
s . mount ( mux , "POST /api/v1/web/operator/account/password" , "operator-password" )
s . mount ( mux , "POST /api/v1/web/operator/auth/logout" , "operator-logout" )
2026-09-12 08:57:29 +08:00
for _ , name := range [] string { "time-challenges" , "register" , "setup-sessions" , "handoffs" , "poll" , "claim-confirmations" , "state-acks" , "activation-attempts" , "activation-results" , "activation-acks" , "setup-close" } {
s . mount ( mux , "POST /api/v1/device/" + name , "device" )
}
2026-10-08 10:06:44 +08:00
for _ , name := range [] string { "handoffs/exchange" , "auth/captcha" , "auth/captcha/verify" , "auth/sms/send" , "auth/sms/verify" , "setup/auth-link" , "claims" , "setup/status" , "setup/ack" , "claims/cancel" , "profiles" , "profiles/create" } {
2026-09-12 08:57:29 +08:00
s . mount ( mux , "POST /api/v1/web/" + name , "web" )
}
s . mount ( mux , "GET /api/v1/web/setup/session" , "bootstrap" )
s . mount ( mux , "GET /api/v1/web/onboarding/devices" , "account" )
s . mount ( mux , "GET /api/v1/web/account/session" , "account-session" )
s . mount ( mux , "POST /api/v1/web/auth/logout" , "logout" )
2026-10-08 10:06:44 +08:00
s . mount ( mux , "GET /api/v1/web/login/session" , "customer-login" )
s . mount ( mux , "POST /api/v1/web/login/sms/send" , "customer-login" )
s . mount ( mux , "POST /api/v1/web/login/sms/verify" , "customer-login" )
s . mount ( mux , "POST /api/v1/web/login/captcha" , "customer-login" )
s . mount ( mux , "POST /api/v1/web/login/captcha/verify" , "customer-login" )
s . mount ( mux , "POST /api/v1/web/login/password" , "customer-login" )
s . mount ( mux , "GET /api/v1/web/account/password" , "account-password" )
s . mount ( mux , "POST /api/v1/web/account/password" , "account-password" )
2026-09-12 08:57:29 +08:00
s . mount ( mux , "POST /api/v1/ops/device-inventory" , "ops" )
s . mount ( mux , "GET /api/v1/ops/device-inventory" , "ops" )
for _ , name := range [] string { "credential-deliveries" , "release" , "credential-rotations" } {
s . mount ( mux , "POST /api/v1/ops/device-inventory/{id}/" + name , "ops" )
}
s . mount ( mux , "POST /api/v1/ops/onboarding-activations/{id}/reconcile" , "ops" )
}
func ( s * Service ) mount ( mux * http . ServeMux , pattern , kind string ) {
mux . HandleFunc ( pattern , func ( w http . ResponseWriter , r * http . Request ) { s . serve ( w , r , kind ) })
}
func ( s * Service ) serve ( w http . ResponseWriter , r * http . Request , kind string ) {
w . Header (). Set ( "Cache-Control" , "no-store" )
w . Header (). Set ( "Pragma" , "no-cache" )
w . Header (). Set ( "Referrer-Policy" , "no-referrer" )
w . Header (). Set ( "X-Content-Type-Options" , "nosniff" )
id := r . Header . Get ( "X-Request-ID" )
if id == "" && ( r . Method == http . MethodGet || r . URL . Path == "/api/v1/device/time-challenges" ) {
id = uuid . NewString ()
}
if ! uuidV4 ( id ) {
s . write ( w , id , nil , fail ( 400 , "REQUEST_ID_REQUIRED" , "X-Request-ID必须是UUID v4" ))
return
}
if r . URL . Path != "/api/v1/device/time-challenges" && ! s . secure ( r ) {
s . write ( w , id , nil , fail ( 400 , "HTTPS_REQUIRED" , "此接口必须通过HTTPS访问" ))
return
}
2026-10-08 10:06:44 +08:00
client := "saas"
if kind == "mini" {
client = "mini_program"
}
host := s . clientIP ( r )
r = r . WithContext ( context . WithValue ( r . Context (), captchaRequestKey {}, captchaRequestInfo { client : client , ip : host }))
2026-09-12 08:57:29 +08:00
if host == "" {
host = "unknown"
}
if err := s . limit ( r . Context (), "ip:" + host , 300 , time . Minute ); err != nil {
s . write ( w , id , nil , err )
return
}
2026-10-08 10:06:44 +08:00
if strings . HasSuffix ( r . URL . Path , "/sms/send" ) {
2026-09-12 08:57:29 +08:00
if err := s . limit ( r . Context (), "sms:ip:" + host , 20 , time . Hour ); err != nil {
s . write ( w , id , nil , err )
return
}
}
2026-10-08 10:06:44 +08:00
if strings . HasSuffix ( r . URL . Path , "/login/password" ) || strings . HasSuffix ( r . URL . Path , "/login/wechat" ) || strings . HasSuffix ( r . URL . Path , "/login/wechat-link" ) || strings . HasSuffix ( r . URL . Path , "/captcha" ) || strings . HasSuffix ( r . URL . Path , "/captcha/verify" ) {
if err := s . limit ( r . Context (), "password:ip:" + host , 120 , 10 * time . Minute ); err != nil {
s . write ( w , id , nil , err )
return
}
}
2026-09-12 08:57:29 +08:00
var data any
var err error
switch kind {
2026-10-08 10:06:44 +08:00
case "captcha-policy" :
data , err = s . captchaPolicyHTTP ( r , id )
case "mini" :
data , err = s . miniHTTP ( r , id )
case "ble-web" :
data , err = s . bleWebHTTP ( r )
case "operator-login" :
data , err = s . operatorView (). customerLoginHTTP ( w , r , id )
case "operator-session" :
data , err = s . operatorView (). accountSessionHTTP ( r )
case "operator-password" :
data , err = s . operatorView (). accountPasswordHTTP ( r )
case "operator-logout" :
data , err = s . operatorView (). logoutHTTP ( w , r )
2026-09-12 08:57:29 +08:00
case "device" :
data , err = s . deviceHTTP ( r , id )
case "web" :
data , err = s . webHTTP ( w , r , id )
case "bootstrap" :
data , err = s . bootstrapHTTP ( r )
case "ops" :
data , err = s . opsHTTP ( r , id )
case "account" :
var p identity . Principal
p , err = s . browserPrincipal ( r , false )
if err == nil {
data , err = s . AccountDevices ( r . Context (), p )
}
case "account-session" :
data , err = s . accountSessionHTTP ( r )
2026-10-08 10:06:44 +08:00
case "account-password" :
data , err = s . accountPasswordHTTP ( r )
2026-09-12 08:57:29 +08:00
case "logout" :
data , err = s . logoutHTTP ( w , r )
2026-10-08 10:06:44 +08:00
case "customer-login" :
data , err = s . customerLoginHTTP ( w , r , id )
2026-09-12 08:57:29 +08:00
}
if r . URL . Path == "/api/v1/device/time-challenges" && err == nil {
w . Header (). Set ( "Content-Type" , "application/json; charset=utf-8" )
_ = json . NewEncoder ( w ). Encode ( data )
return
}
s . write ( w , id , data , err )
}
func ( s * Service ) write ( w http . ResponseWriter , id string , data any , err error ) {
status := 200
code , message := "OK" , ""
if err != nil {
data = nil
var e * Error
if errors . As ( err , & e ) {
status = e . Status
code = e . Code
message = e . Message
} else {
var db * pgconn . PgError
if errors . As ( err , & db ) && ( db . Code == "23505" || db . Code == "P0001" ) {
status = 409
code = "RESOURCE_STATE_CONFLICT"
message = "资源状态已变化,请刷新后重试"
} else {
status = 500
code = "INTERNAL_ERROR"
message = "服务暂不可用,请按原请求号重试"
kind := "internal"
if errors . As ( err , & db ) {
kind = "postgres:" + db . Code
}
s . options . Logger . Error ( "onboarding request failed" , "request_id" , id , "error_type" , kind )
}
}
}
if status == 429 {
w . Header (). Set ( "Retry-After" , "60" )
}
if status == 503 {
w . Header (). Set ( "Retry-After" , "2" )
}
w . Header (). Set ( "Content-Type" , "application/json; charset=utf-8" )
w . WriteHeader ( status )
if err != nil {
_ = json . NewEncoder ( w ). Encode ( map [ string ] any { "ok" : false , "code" : code , "message" : message , "request_id" : id })
return
}
_ = json . NewEncoder ( w ). Encode ( map [ string ] any { "ok" : true , "code" : code , "request_id" : id , "server_time_ms" : s . now (). UnixMilli (), "data" : data })
}
func readBody ( r * http . Request ) ([] byte , error ) {
if ! strings . HasPrefix ( strings . ToLower ( r . Header . Get ( "Content-Type" )), "application/json" ) {
return nil , fail ( 415 , "JSON_REQUIRED" , "请使用application/json" )
}
raw , err := io . ReadAll ( io . LimitReader ( r . Body , maxBody + 1 ))
if err != nil {
return nil , fail ( 400 , "INVALID_JSON" , "请求正文读取失败" )
}
if len ( raw ) > maxBody {
return nil , fail ( 413 , "REQUEST_TOO_LARGE" , "请求正文过长" )
}
return raw , nil
}
func decodeCall [ T any ]( raw [] byte , fn func ( T ) ( map [ string ] any , error )) ( map [ string ] any , error ) {
var in T
if err := StrictJSON ( raw , & in ); err != nil {
return nil , err
}
return fn ( in )
}
func ( s * Service ) deviceHTTP ( r * http . Request , id string ) ( any , error ) {
if r . URL . RawQuery != "" {
return nil , fail ( 400 , "QUERY_FORBIDDEN" , "设备请求不接受query参数" )
}
ctx := r . Context ()
path := strings . TrimPrefix ( r . URL . Path , "/api/v1/device/" )
if path == "time-challenges" {
raw , err := readBody ( r )
if err != nil {
return nil , err
}
return decodeDeviceCall ( raw , func ( in TimeChallengeInput ) ( map [ string ] any , error ) { return s . TimeChallenge ( ctx , in ) })
}
d , raw , err := s . authenticate ( r )
if err != nil {
return nil , err
}
if err = s . limit ( ctx , "device:" + d . ID , 180 , time . Minute ); err != nil {
return nil , err
}
if ! strings . HasPrefix ( strings . ToLower ( r . Header . Get ( "Content-Type" )), "application/json" ) {
return nil , fail ( 415 , "JSON_REQUIRED" , "请使用application/json" )
}
switch path {
2026-10-08 10:06:44 +08:00
case "factory-reset/state" :
return decodeCall ( raw , func ( in struct {}) ( map [ string ] any , error ) { return s . DeviceResetState ( ctx , d ) })
case "factory-reset" :
var in devicereset . Input
if err := StrictJSON ( raw , & in ); err != nil {
return nil , err
}
return s . DeviceFactoryReset ( ctx , d , id , raw , in )
case "control/sync" :
return decodeCall ( raw , func ( in DeviceControlReport ) ( map [ string ] any , error ) { return s . DeviceControlSync ( ctx , d , in ) })
case "control/ack" :
return decodeCall ( raw , func ( in DeviceControlAck ) ( map [ string ] any , error ) { return s . DeviceControlAcknowledge ( ctx , d , in ) })
case "mqtt-config" , "ota/report" , "ota/manifest" :
if s . Fleet == nil {
return nil , fail ( 503 , "MQTT_UNAVAILABLE" , "MQTT服务未配置" )
}
if ! deviceAllowed ( d ) {
return nil , fail ( 409 , "DEVICE_UNAVAILABLE" , "设备状态不允许此操作" )
}
var result map [ string ] any
var fleetErr error
if path == "mqtt-config" {
if err := StrictJSON ( raw , & struct {}{}); err != nil {
return nil , err
}
result , fleetErr = s . Fleet . Config ( ctx , d . ID , d . FactoryID , d . Key )
} else if path == "ota/manifest" {
var in struct {
TaskID string `json:"task_id"`
}
if err := StrictJSON ( raw , & in ); err != nil {
return nil , err
}
result , fleetErr = s . Fleet . Manifest ( ctx , d . ID , in . TaskID )
} else {
var in devicefleet . Report
if err := StrictJSON ( raw , & in ); err != nil {
return nil , err
}
fleetErr = s . Fleet . Report ( ctx , d . ID , in )
result = map [ string ] any { "status" : "ok" }
}
var fe * devicefleet . Error
if errors . As ( fleetErr , & fe ) {
return nil , fail ( 409 , fe . Code , fe . Message )
}
return result , fleetErr
case "voice-config" :
return decodeCall ( raw , func ( in struct {}) ( map [ string ] any , error ) { return s . VoiceConfig ( ctx , d ) })
case "voice-credentials" :
return decodeCall ( raw , func ( in DeviceVoiceCredential ) ( map [ string ] any , error ) { return s . ImportVoiceCredential ( ctx , d , in ) })
case "voice-runtime" :
return decodeDeviceCall ( raw , func ( in VoiceRuntimeInput ) ( map [ string ] any , error ) { return s . VoiceRuntime ( ctx , d , in ) })
case "ble-state" :
return decodeCall ( raw , func ( in struct {}) ( map [ string ] any , error ) { return s . DeviceBLEState ( ctx , d ) })
case "ble-bind" :
return decodeCall ( raw , func ( in BLEBindInput ) ( map [ string ] any , error ) { return s . BindBLE ( ctx , d , id , in ) })
2026-09-12 08:57:29 +08:00
case "register" :
return decodeDeviceCall ( raw , func ( in RegisterInput ) ( map [ string ] any , error ) { return s . Register ( ctx , d , id , raw , in ) })
case "setup-sessions" :
return decodeDeviceCall ( raw , func ( in SetupInput ) ( map [ string ] any , error ) { return s . Setup ( ctx , d , id , raw , in ) })
case "handoffs" :
if ! s . Enabled () {
return nil , fail ( 503 , "ENROLLMENT_PAUSED" , "首次绑定入口暂时暂停" )
}
return decodeDeviceCall ( raw , func ( in HandoffInput ) ( map [ string ] any , error ) { return s . Handoff ( ctx , d , id , raw , in ) })
case "poll" :
return decodeDeviceCall ( raw , func ( in PollInput ) ( map [ string ] any , error ) { return s . Poll ( ctx , d , in ) })
case "claim-confirmations" :
return decodeDeviceCall ( raw , func ( in ConfirmationInput ) ( map [ string ] any , error ) { return s . Confirm ( ctx , d , id , raw , in ) })
case "state-acks" :
return decodeDeviceCall ( raw , func ( in StateAckInput ) ( map [ string ] any , error ) { return s . StateAck ( ctx , d , id , raw , in ) })
case "activation-attempts" :
return decodeDeviceCall ( raw , func ( in AttemptInput ) ( map [ string ] any , error ) { return s . Attempt ( ctx , d , id , raw , in ) })
case "activation-results" :
return decodeDeviceCall ( raw , func ( in ResultInput ) ( map [ string ] any , error ) { return s . ActivationResult ( ctx , d , id , raw , in ) })
case "activation-acks" :
return decodeDeviceCall ( raw , func ( in ActivationAckInput ) ( map [ string ] any , error ) { return s . ActivationAck ( ctx , d , id , raw , in ) })
case "setup-close" :
return decodeDeviceCall ( raw , func ( in CloseInput ) ( map [ string ] any , error ) { return s . Close ( ctx , d , id , raw , in ) })
}
return nil , fail ( 404 , "NOT_FOUND" , "接口不存在" )
}
func ( s * Service ) browserSession ( r * http . Request ) ( WebSession , error ) {
c , err := r . Cookie ( s . cookieName ( false ))
if err != nil || ! validB64 ( c . Value , 32 ) {
return WebSession {}, fail ( 401 , "WEB_SESSION_EXPIRED" , "请从设备打开设置入口" )
}
return s . webSession ( r . Context (), s . pool , digest ([] byte ( c . Value )))
}
func ( s * Service ) issueCookies ( w http . ResponseWriter , out BrowserCredentials ) {
2026-10-08 10:06:44 +08:00
if out . TrustedDeviceToken != "" {
http . SetCookie ( w , & http . Cookie { Name : s . trustedLoginCookieName (), Value : out . TrustedDeviceToken , Path : "/" , Secure : ! s . options . AllowLocalHTTP , HttpOnly : true , SameSite : http . SameSiteLaxMode , MaxAge : int ( trustedLoginLifetime . Seconds ())})
}
2026-09-12 08:57:29 +08:00
for _ , item := range [] struct {
value string
account bool
age int
}{{ out . Token , false , 1800 }, { out . AccountToken , true , 86400 }} {
if item . value != "" {
http . SetCookie ( w , & http . Cookie { Name : s . cookieName ( item . account ), Value : item . value , Path : "/" , Secure : ! s . options . AllowLocalHTTP , HttpOnly : true , SameSite : http . SameSiteLaxMode , MaxAge : item . age })
}
}
}
func ( s * Service ) bearer ( r * http . Request ) ( identity . Principal , error ) {
2026-10-08 10:06:44 +08:00
if strings . HasPrefix ( r . Header . Get ( "Authorization" ), "Mini " ) {
return s . miniPrincipal ( r )
}
2026-09-12 08:57:29 +08:00
if s . options . TokenVerifier == nil || ! strings . HasPrefix ( r . Header . Get ( "Authorization" ), "Bearer " ) {
return identity . Principal {}, fail ( 401 , "LOGIN_REQUIRED" , "请先登录" )
}
p , err := s . options . TokenVerifier . Verify ( r . Context (), strings . TrimPrefix ( r . Header . Get ( "Authorization" ), "Bearer " ))
if err != nil {
return p , fail ( 401 , "LOGIN_REQUIRED" , "登录已失效,请重新登录" )
}
return p , nil
}
func ( s * Service ) browserPrincipal ( r * http . Request , write bool ) ( identity . Principal , error ) {
if r . Header . Get ( "Authorization" ) != "" {
return s . bearer ( r )
}
if write && ! s . sameOrigin ( r ) {
return identity . Principal {}, errAccess
}
c , err := r . Cookie ( s . cookieName ( true ))
if err != nil {
return identity . Principal {}, fail ( 401 , "LOGIN_REQUIRED" , "请先登录" )
}
return s . AccountPrincipal ( r . Context (), c . Value , r . Header . Get ( "X-CSRF-Token" ), write )
}
// Only explicitly selected customer routes receive this cookie adapter.
func ( s * Service ) CustomerAuth ( next http . Handler ) http . Handler {
return http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
if ! s . secure ( r ) {
s . write ( w , r . Header . Get ( "X-Request-ID" ), nil , fail ( 400 , "HTTPS_REQUIRED" , "此接口必须通过HTTPS访问" ))
return
}
p , err := s . browserPrincipal ( r , r . Method != http . MethodGet && r . Method != http . MethodHead )
if err != nil {
s . write ( w , r . Header . Get ( "X-Request-ID" ), nil , err )
return
}
next . ServeHTTP ( w , r . WithContext ( identity . WithPrincipal ( r . Context (), p )))
})
}
func ( s * Service ) webHTTP ( w http . ResponseWriter , r * http . Request , id string ) ( any , error ) {
if ! s . sameOrigin ( r ) || r . URL . RawQuery != "" {
return nil , errAccess
}
raw , err := readBody ( r )
if err != nil {
return nil , err
}
path := strings . TrimPrefix ( r . URL . Path , "/api/v1/web/" )
ctx := r . Context ()
if path == "handoffs/exchange" {
if ! s . Enabled () {
return nil , fail ( 503 , "ENROLLMENT_PAUSED" , "首次绑定入口暂时暂停" )
}
var in ExchangeInput
if err = StrictJSON ( raw , & in ); err != nil {
return nil , err
}
out , e := s . Exchange ( ctx , in )
if e == nil {
s . issueCookies ( w , out )
}
return out , e
}
ws , err := s . browserSession ( r )
if err != nil {
return nil , err
}
if err = s . checkCSRF ( ctx , ws . Hash , r . Header . Get ( "X-CSRF-Token" )); err != nil {
return nil , err
}
switch path {
case "setup/auth-link" :
if err = StrictJSON ( raw , & struct {}{}); err != nil {
return nil , err
}
2026-10-08 10:06:44 +08:00
// Same-origin and setup CSRF were verified above. The account cookie
// supplies identity when returning from the standalone SMS login page.
p , e := s . browserPrincipal ( r , false )
2026-09-12 08:57:29 +08:00
if e != nil {
return nil , e
}
out , e := s . AuthLink ( ctx , ws , p )
if e == nil {
s . issueCookies ( w , out )
}
return out , e
2026-10-08 10:06:44 +08:00
case "auth/captcha" :
if err = StrictJSON ( raw , & struct {}{}); err != nil {
return nil , err
}
return s . webCaptcha ( ctx , ws )
case "auth/captcha/verify" :
return decodeCall ( raw , func ( in SlideCaptchaVerifyInput ) ( map [ string ] any , error ) {
return s . verifySlideCaptcha ( ctx , ws . Hash , in , func ( tx pgx . Tx ) error {
d , e := readDevice ( ctx , tx , ws . Device . ID , true )
if e != nil {
return e
}
return s . checkWebActor ( ctx , tx , ws . Hash , d )
})
})
2026-09-12 08:57:29 +08:00
case "auth/sms/send" :
return decodeCall ( raw , func ( in SMSSendInput ) ( map [ string ] any , error ) { return s . SendSMS ( ctx , ws , id , in ) })
case "auth/sms/verify" :
var in SMSVerifyInput
if err = StrictJSON ( raw , & in ); err != nil {
return nil , err
}
out , e := s . VerifySMS ( ctx , ws , in )
if e == nil {
s . issueCookies ( w , out )
}
return out , e
case "claims" :
if ! s . Enabled () || s . options . PauseClaims {
return nil , fail ( 503 , "CLAIMS_PAUSED" , "新的绑定申请暂时暂停" )
}
return decodeCall ( raw , func ( in ClaimInput ) ( map [ string ] any , error ) { return s . Claim ( ctx , ws , id , raw , in ) })
case "claims/cancel" :
return decodeCall ( raw , func ( in CancelInput ) ( map [ string ] any , error ) { return s . Cancel ( ctx , ws , id , raw , in ) })
case "setup/status" :
return decodeCall ( raw , func ( in struct {}) ( map [ string ] any , error ) { return s . WebStatus ( ctx , ws ) })
case "setup/ack" :
return decodeCall ( raw , func ( in WebAckInput ) ( map [ string ] any , error ) { return s . WebAck ( ctx , ws , id , raw , in ) })
case "profiles" :
return decodeCall ( raw , func ( in struct {}) ( map [ string ] any , error ) { return s . Profiles ( ctx , ws ) })
case "profiles/create" :
return decodeCall ( raw , func ( in ProfileInput ) ( map [ string ] any , error ) { return s . CreateProfile ( ctx , ws , id , raw , in ) })
}
return nil , fail ( 404 , "NOT_FOUND" , "接口不存在" )
}
func ( s * Service ) bootstrapHTTP ( r * http . Request ) ( any , error ) {
if r . URL . RawQuery != "" || r . Header . Get ( "Sec-Fetch-Site" ) == "cross-site" {
return nil , errAccess
}
ws , err := s . browserSession ( r )
if err != nil {
return nil , err
}
token := randomToken ( 32 )
_ , err = s . pool . Exec ( r . Context (), `UPDATE onboarding_web_session SET csrf_hash=$2 WHERE token_hash=$1 AND revoked_at IS NULL AND expires_at>now()` , ws . Hash , digest ([] byte ( token )))
if err != nil {
return nil , err
}
data , err := s . WebStatus ( r . Context (), ws )
if err != nil {
return nil , err
}
if c , e := r . Cookie ( s . cookieName ( true )); e == nil && validB64 ( c . Value , 32 ) && ws . UserID != "" {
_ , err = s . pool . Exec ( r . Context (), `UPDATE onboarding_account_session SET csrf_hash=$2 WHERE token_hash=$1 AND user_id=$3::uuid AND expires_at>now() AND revoked_at IS NULL` , digest ([] byte ( c . Value )), digest ([] byte ( token )), ws . UserID )
if err != nil {
return nil , err
}
}
return map [ string ] any { "csrf_token" : token , "data" : data }, nil
}
func ( s * Service ) opsHTTP ( r * http . Request , id string ) ( any , error ) {
2026-10-08 10:06:44 +08:00
p , err := s . operatorView (). browserPrincipal ( r , r . Method != http . MethodGet )
2026-09-12 08:57:29 +08:00
if err != nil {
return nil , err
}
tenant := r . Header . Get ( "X-Tenant-ID" )
ctx := r . Context ()
if r . Method == http . MethodGet {
return s . OpsList ( ctx , p , tenant )
}
raw , err := readBody ( r )
if err != nil {
return nil , err
}
device := r . PathValue ( "id" )
switch {
case r . URL . Path == "/api/v1/ops/device-inventory" :
if ! s . Enabled () {
return nil , fail ( 503 , "ENROLLMENT_PAUSED" , "生产登记入口暂停" )
}
return decodeCall ( raw , func ( in InventoryInput ) ( map [ string ] any , error ) {
return s . CreateInventory ( ctx , p , tenant , id , r . Header . Get ( "Idempotency-Key" ), raw , in )
})
case strings . HasSuffix ( r . URL . Path , "/credential-deliveries" ):
return decodeCall ( raw , func ( in DeliveryInput ) ( map [ string ] any , error ) {
return s . DeliverCredential ( ctx , p , tenant , device , id , in )
})
case strings . HasSuffix ( r . URL . Path , "/release" ):
return decodeCall ( raw , func ( in ReleaseInput ) ( map [ string ] any , error ) {
return s . ReleaseInventory ( ctx , p , tenant , device , id , in )
})
case strings . HasSuffix ( r . URL . Path , "/credential-rotations" ):
return decodeCall ( raw , func ( in RotationInput ) ( map [ string ] any , error ) {
return s . Rotate ( ctx , p , tenant , device , r . Header . Get ( "Idempotency-Key" ), id , raw , in )
})
case strings . HasSuffix ( r . URL . Path , "/reconcile" ):
return decodeCall ( raw , func ( in ReconcileInput ) ( map [ string ] any , error ) { return s . Reconcile ( ctx , p , tenant , device , id , in ) })
}
return nil , fail ( 404 , "NOT_FOUND" , "接口不存在" )
}