package onboarding import ( "context" "data-terminal-cloud/internal/devicefleet" "data-terminal-cloud/internal/devicereset" "data-terminal-cloud/internal/identity" "encoding/json" "errors" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "io" "net" "net/http" "net/netip" "net/url" "strings" "time" ) func (s *Service) cookieName(account bool) string { if s.operatorLogin { if s.options.AllowLocalHTTP { return "platform-operator" } return "__Host-platform-operator" } 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) if err == nil && prefix.Contains(ip) { proto := r.Header.Get("X-Forwarded-Proto") return proto == "https" || (s.options.AllowLocalHTTP && proto == "http") } } 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) { 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") 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") } 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"} { 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") 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") 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 } 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})) if host == "" { host = "unknown" } if err := s.limit(r.Context(), "ip:"+host, 300, time.Minute); err != nil { s.write(w, id, nil, err) return } if strings.HasSuffix(r.URL.Path, "/sms/send") { if err := s.limit(r.Context(), "sms:ip:"+host, 20, time.Hour); err != nil { s.write(w, id, nil, err) return } } 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 } } var data any var err error switch kind { 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) 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) case "account-password": data, err = s.accountPasswordHTTP(r) case "logout": data, err = s.logoutHTTP(w, r) case "customer-login": data, err = s.customerLoginHTTP(w, r, id) } 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 { 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) }) 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) { 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())}) } 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) { if strings.HasPrefix(r.Header.Get("Authorization"), "Mini ") { return s.miniPrincipal(r) } 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 } // 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) if e != nil { return nil, e } out, e := s.AuthLink(ctx, ws, p) if e == nil { s.issueCookies(w, out) } return out, e 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) }) }) 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) { p, err := s.operatorView().browserPrincipal(r, r.Method != http.MethodGet) 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", "接口不存在") }