Files
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
2026-10-08 10:06:44 +08:00

605 lines
23 KiB
Go

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", "接口不存在")
}