Files
story/internal/onboarding/maintenance.go
T
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

79 lines
2.7 KiB
Go

package onboarding
import (
"context"
"data-terminal-cloud/internal/outbox"
"encoding/json"
"github.com/google/uuid"
"time"
)
// Recover durable jobs after an outbox retry budget was exhausted during a
// pause. ProcessActivation owns the device lock and the no-repeat fence.
func (s *Service) RunMaintenance(ctx context.Context) {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
if err := s.MaintainOnce(ctx); err != nil && ctx.Err() == nil {
s.options.Logger.Error("onboarding recovery scan failed")
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (s *Service) MaintainOnce(ctx context.Context) error {
rows, err := s.pool.Query(ctx, `SELECT j.device_id::text,j.id::text,j.binding_generation FROM device_activation_job j JOIN device d ON d.id=j.device_id JOIN device_binding b ON b.device_id=d.id AND b.unbound_at IS NULL AND b.generation=j.binding_generation WHERE d.status='registered' AND d.activation_state NOT IN ('LOST','REVOKED') AND j.binding_generation=d.binding_generation AND j.updated_at<now()-interval '3 minutes' AND (j.status IN ('NOT_STARTED','RESERVED','CONTACT_POSSIBLE') OR (j.status='REGISTERED_OBSERVED' AND j.config_ciphertext IS NULL)) ORDER BY j.updated_at LIMIT 10`)
if err != nil {
return err
}
type item struct {
DeviceID string `json:"device_id"`
ActivationID string `json:"activation_id"`
Generation int64 `json:"binding_generation"`
}
var items []item
for rows.Next() {
var v item
if err = rows.Scan(&v.DeviceID, &v.ActivationID, &v.Generation); err != nil {
rows.Close()
return err
}
items = append(items, v)
}
rows.Close()
if err = rows.Err(); err != nil {
return err
}
if s.options.ActivationEnabled {
for _, v := range items {
raw, _ := json.Marshal(v)
if err = s.ProcessActivation(ctx, outbox.Event{ID: uuid.NewString(), Payload: raw}); err != nil {
return err
}
}
}
// Retain business attempts and idempotency tombstones for replay protection.
_, err = s.pool.Exec(ctx, `DELETE FROM device_handoff WHERE expires_at<now()-interval '1 day'`)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `DELETE FROM device_time_challenge WHERE expires_at<now()-interval '1 day'`)
if err != nil {
return err
}
// Keep one day for rate limiting; delete expired OTP ciphertext with its session.
_, err = s.pool.Exec(ctx, `DELETE FROM customer_login_session WHERE expires_at<now()-interval '1 day'`)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `DELETE FROM operator_login_session WHERE expires_at<now()-interval '1 day'`)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `DELETE FROM trusted_login_device WHERE expires_at<now() OR revoked_at<now()-interval '1 day'`)
return err
}