feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s

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.
This commit is contained in:
Codex Local
2026-10-08 10:06:44 +08:00
parent 98fb2965d0
commit bf1233d5df
861 changed files with 94614 additions and 3209 deletions
+167 -7
View File
@@ -1,10 +1,14 @@
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"
@@ -16,6 +20,12 @@ import (
)
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"
@@ -42,8 +52,9 @@ func (s *Service) secure(r *http.Request) bool {
}
for _, cidr := range s.options.TrustedProxyCIDRs {
prefix, err := netip.ParsePrefix(cidr)
if err == nil && prefix.Contains(ip) && r.Header.Get("X-Forwarded-Proto") == "https" {
return true
if err == nil && prefix.Contains(ip) {
proto := r.Header.Get("X-Forwarded-Proto")
return proto == "https" || (s.options.AllowLocalHTTP && proto == "http")
}
}
return false
@@ -53,16 +64,56 @@ func (s *Service) sameOrigin(r *http.Request) bool {
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/sms/send", "auth/sms/verify", "setup/auth-link", "claims", "setup/status", "setup/ack", "claims/cancel", "profiles", "profiles/create"} {
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"} {
@@ -90,7 +141,12 @@ func (s *Service) serve(w http.ResponseWriter, r *http.Request, kind string) {
s.write(w, id, nil, fail(400, "HTTPS_REQUIRED", "此接口必须通过HTTPS访问"))
return
}
host, _, _ := net.SplitHostPort(r.RemoteAddr)
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"
}
@@ -98,15 +154,35 @@ func (s *Service) serve(w http.ResponseWriter, r *http.Request, kind string) {
s.write(w, id, nil, err)
return
}
if strings.HasSuffix(r.URL.Path, "/auth/sms/send") {
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":
@@ -123,8 +199,12 @@ func (s *Service) serve(w http.ResponseWriter, r *http.Request, kind string) {
}
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")
@@ -219,6 +299,63 @@ func (s *Service) deviceHTTP(r *http.Request, id string) (any, error) {
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":
@@ -253,6 +390,9 @@ func (s *Service) browserSession(r *http.Request) (WebSession, error) {
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
@@ -264,6 +404,9 @@ func (s *Service) issueCookies(w http.ResponseWriter, out BrowserCredentials) {
}
}
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", "请先登录")
}
@@ -338,7 +481,9 @@ func (s *Service) webHTTP(w http.ResponseWriter, r *http.Request, id string) (an
if err = StrictJSON(raw, &struct{}{}); err != nil {
return nil, err
}
p, e := s.bearer(r)
// 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
}
@@ -347,6 +492,21 @@ func (s *Service) webHTTP(w http.ResponseWriter, r *http.Request, id string) (an
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":
@@ -403,7 +563,7 @@ func (s *Service) bootstrapHTTP(r *http.Request) (any, error) {
return map[string]any{"csrf_token": token, "data": data}, nil
}
func (s *Service) opsHTTP(r *http.Request, id string) (any, error) {
p, err := s.bearer(r)
p, err := s.operatorView().browserPrincipal(r, r.Method != http.MethodGet)
if err != nil {
return nil, err
}