feat(server/desktop): ingest desktop account health reports
Add a POST /desktop/accounts/health-reports endpoint that buffers reports in Redis, dedupes via signature, and publishes change events to a new desktop.account.health RabbitMQ queue. A sink worker drains the queue, and the account service overlays runtime health onto database health when serving desktop accounts. Publish job creation now consults runtime health so a stale DB row no longer blocks publishing once the desktop client reports it healthy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,11 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -11,14 +15,18 @@ import (
|
||||
goredis "github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||
)
|
||||
|
||||
const desktopAccountHealthReportTTL = 24 * time.Hour
|
||||
|
||||
type DesktopAccountService struct {
|
||||
repo repository.DesktopAccountRepository
|
||||
clientRepo repository.DesktopClientRepository
|
||||
redis *goredis.Client
|
||||
rabbitMQ *rabbitmq.Client
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
@@ -26,11 +34,13 @@ func NewDesktopAccountService(
|
||||
repo repository.DesktopAccountRepository,
|
||||
clientRepo repository.DesktopClientRepository,
|
||||
redis *goredis.Client,
|
||||
rabbitMQClient *rabbitmq.Client,
|
||||
) *DesktopAccountService {
|
||||
return &DesktopAccountService{
|
||||
repo: repo,
|
||||
clientRepo: clientRepo,
|
||||
redis: redis,
|
||||
rabbitMQ: rabbitMQClient,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
@@ -42,6 +52,13 @@ type DesktopAccountView struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
Health string `json:"health"`
|
||||
RuntimeHealth *string `json:"runtime_health"`
|
||||
RuntimeVerifiedAt *time.Time `json:"runtime_verified_at"`
|
||||
RuntimeCheckedAt *time.Time `json:"runtime_checked_at"`
|
||||
RuntimeAuthState *string `json:"runtime_auth_state"`
|
||||
RuntimeProbeState *string `json:"runtime_probe_state"`
|
||||
RuntimeAuthReason *string `json:"runtime_auth_reason"`
|
||||
HealthSource string `json:"health_source"`
|
||||
ClientID *string `json:"client_id"`
|
||||
AccountFingerprint *string `json:"account_fingerprint"`
|
||||
VerifiedAt *time.Time `json:"verified_at"`
|
||||
@@ -74,6 +91,48 @@ type PatchDesktopAccountRequest struct {
|
||||
IfSyncVersion int64 `json:"if_sync_version" binding:"required"`
|
||||
}
|
||||
|
||||
type DesktopAccountHealthReportRequest struct {
|
||||
AccountID string `json:"account_id" binding:"required"`
|
||||
Platform string `json:"platform" binding:"required"`
|
||||
PlatformUID string `json:"platform_uid" binding:"required"`
|
||||
Health string `json:"health" binding:"required,oneof=live expired captcha risk"`
|
||||
AuthState string `json:"auth_state" binding:"required"`
|
||||
ProbeState string `json:"probe_state" binding:"required"`
|
||||
AuthReason *string `json:"auth_reason"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
VerifiedAt *time.Time `json:"verified_at"`
|
||||
CheckedAt time.Time `json:"checked_at" binding:"required"`
|
||||
}
|
||||
|
||||
type ReportDesktopAccountHealthRequest struct {
|
||||
Reports []DesktopAccountHealthReportRequest `json:"reports" binding:"required,min=1,max=100"`
|
||||
}
|
||||
|
||||
type ReportDesktopAccountHealthResponse struct {
|
||||
AcceptedCount int `json:"accepted_count"`
|
||||
BufferedCount int `json:"buffered_count"`
|
||||
}
|
||||
|
||||
type bufferedDesktopAccountHealthReport struct {
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
WorkspaceID int64 `json:"workspace_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
ClientID string `json:"client_id"`
|
||||
AccountID string `json:"account_id"`
|
||||
Platform string `json:"platform"`
|
||||
PlatformUID string `json:"platform_uid"`
|
||||
Health string `json:"health"`
|
||||
AuthState string `json:"auth_state"`
|
||||
ProbeState string `json:"probe_state"`
|
||||
AuthReason *string `json:"auth_reason"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
VerifiedAt *time.Time `json:"verified_at"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
}
|
||||
|
||||
func (s *DesktopAccountService) ListByUser(ctx context.Context, client *repository.DesktopClient) ([]DesktopAccountView, error) {
|
||||
if client == nil {
|
||||
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
||||
@@ -89,6 +148,7 @@ func (s *DesktopAccountService) ListByUser(ctx context.Context, client *reposito
|
||||
for _, item := range rows {
|
||||
items = append(items, s.buildDesktopAccountView(&item, clientMap, presenceMap))
|
||||
}
|
||||
s.applyRuntimeHealth(ctx, client.WorkspaceID, items)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
@@ -107,6 +167,7 @@ func (s *DesktopAccountService) ListForActor(ctx context.Context, actor auth.Act
|
||||
for _, item := range rows {
|
||||
items = append(items, s.buildDesktopAccountView(&item, clientMap, presenceMap))
|
||||
}
|
||||
s.applyRuntimeHealth(ctx, actor.PrimaryWorkspaceID, items)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
@@ -154,6 +215,124 @@ func (s *DesktopAccountService) Upsert(ctx context.Context, client *repository.D
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func (s *DesktopAccountService) ReportHealth(ctx context.Context, client *repository.DesktopClient, req ReportDesktopAccountHealthRequest) (*ReportDesktopAccountHealthResponse, error) {
|
||||
if client == nil {
|
||||
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
||||
}
|
||||
if len(req.Reports) == 0 {
|
||||
return nil, response.ErrBadRequest(40087, "empty_account_health_reports", "at least one account health report is required")
|
||||
}
|
||||
if len(req.Reports) > 100 {
|
||||
return nil, response.ErrBadRequest(40087, "too_many_account_health_reports", "at most 100 account health reports are allowed")
|
||||
}
|
||||
|
||||
accepted := 0
|
||||
buffered := 0
|
||||
now := s.now().UTC()
|
||||
type preparedHealthReport struct {
|
||||
accountID uuid.UUID
|
||||
latestKey string
|
||||
signature string
|
||||
payload []byte
|
||||
report bufferedDesktopAccountHealthReport
|
||||
}
|
||||
prepared := make([]preparedHealthReport, 0, len(req.Reports))
|
||||
for _, item := range req.Reports {
|
||||
accountID, err := uuid.Parse(strings.TrimSpace(item.AccountID))
|
||||
if err != nil {
|
||||
return nil, response.ErrBadRequest(40087, "invalid_account_health_report_account_id", "account_id must be a uuid")
|
||||
}
|
||||
platform := strings.TrimSpace(item.Platform)
|
||||
platformUID := strings.TrimSpace(item.PlatformUID)
|
||||
if platform == "" || platformUID == "" {
|
||||
return nil, response.ErrBadRequest(40087, "invalid_account_health_report_identity", "platform and platform_uid are required")
|
||||
}
|
||||
|
||||
report := bufferedDesktopAccountHealthReport{
|
||||
TenantID: client.TenantID,
|
||||
WorkspaceID: client.WorkspaceID,
|
||||
UserID: client.UserID,
|
||||
ClientID: client.ID.String(),
|
||||
AccountID: accountID.String(),
|
||||
Platform: platform,
|
||||
PlatformUID: platformUID,
|
||||
Health: item.Health,
|
||||
AuthState: strings.TrimSpace(item.AuthState),
|
||||
ProbeState: strings.TrimSpace(item.ProbeState),
|
||||
AuthReason: trimOptionalString(item.AuthReason),
|
||||
DisplayName: trimOptionalString(item.DisplayName),
|
||||
AvatarURL: trimOptionalString(item.AvatarURL),
|
||||
VerifiedAt: item.VerifiedAt,
|
||||
CheckedAt: item.CheckedAt.UTC(),
|
||||
ReceivedAt: now,
|
||||
}
|
||||
if report.AuthState == "" || report.ProbeState == "" || report.CheckedAt.IsZero() {
|
||||
return nil, response.ErrBadRequest(40087, "invalid_account_health_report_state", "auth_state, probe_state and checked_at are required")
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50087, "account_health_report_encode_failed", "failed to encode account health report")
|
||||
}
|
||||
|
||||
latestKey := desktopAccountHealthLatestKey(client.WorkspaceID, accountID)
|
||||
prepared = append(prepared, preparedHealthReport{
|
||||
accountID: accountID,
|
||||
latestKey: latestKey,
|
||||
signature: desktopAccountHealthSignature(report),
|
||||
payload: payload,
|
||||
report: report,
|
||||
})
|
||||
accepted += 1
|
||||
}
|
||||
changed := make([]preparedHealthReport, 0, len(prepared))
|
||||
if s.redis != nil {
|
||||
signaturePipe := s.redis.Pipeline()
|
||||
signatureCommands := make(map[uuid.UUID]*goredis.StringCmd, len(prepared))
|
||||
for _, item := range prepared {
|
||||
signatureCommands[item.accountID] = signaturePipe.Get(ctx, item.latestKey+":signature")
|
||||
}
|
||||
if _, err := signaturePipe.Exec(ctx); err != nil && !errors.Is(err, goredis.Nil) {
|
||||
return nil, response.ErrInternal(50087, "account_health_report_cache_failed", "failed to inspect account health report cache")
|
||||
}
|
||||
|
||||
pipe := s.redis.Pipeline()
|
||||
for _, item := range prepared {
|
||||
previousSignature, signatureErr := signatureCommands[item.accountID].Result()
|
||||
if signatureErr != nil && !errors.Is(signatureErr, goredis.Nil) {
|
||||
return nil, response.ErrInternal(50087, "account_health_report_cache_failed", "failed to inspect account health report cache")
|
||||
}
|
||||
|
||||
pipe.Set(ctx, item.latestKey, item.payload, desktopAccountHealthReportTTL)
|
||||
if previousSignature != item.signature {
|
||||
changed = append(changed, item)
|
||||
}
|
||||
}
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
return nil, response.ErrInternal(50087, "account_health_report_buffer_failed", "failed to buffer account health reports")
|
||||
}
|
||||
} else {
|
||||
changed = append(changed, prepared...)
|
||||
}
|
||||
|
||||
if s.rabbitMQ == nil {
|
||||
return &ReportDesktopAccountHealthResponse{AcceptedCount: accepted, BufferedCount: 0}, nil
|
||||
}
|
||||
|
||||
for _, item := range changed {
|
||||
if err := s.rabbitMQ.PublishDesktopAccountHealth(ctx, item.payload); err != nil {
|
||||
return nil, response.ErrInternal(50087, "account_health_report_queue_failed", "failed to enqueue account health report")
|
||||
}
|
||||
buffered += 1
|
||||
if s.redis != nil {
|
||||
if err := s.redis.Set(ctx, item.latestKey+":signature", item.signature, desktopAccountHealthReportTTL).Err(); err != nil {
|
||||
return nil, response.ErrInternal(50087, "account_health_report_signature_failed", "failed to record account health report signature")
|
||||
}
|
||||
}
|
||||
}
|
||||
return &ReportDesktopAccountHealthResponse{AcceptedCount: accepted, BufferedCount: buffered}, nil
|
||||
}
|
||||
|
||||
func (s *DesktopAccountService) Patch(ctx context.Context, client *repository.DesktopClient, desktopID uuid.UUID, req PatchDesktopAccountRequest) (*DesktopAccountView, error) {
|
||||
if client == nil {
|
||||
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
||||
@@ -222,6 +401,128 @@ func (s *DesktopAccountService) RequestDelete(ctx context.Context, actor auth.Ac
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func desktopAccountHealthLatestKey(workspaceID int64, accountID uuid.UUID) string {
|
||||
return fmt.Sprintf("desktop:account-health:latest:%d:%s", workspaceID, accountID.String())
|
||||
}
|
||||
|
||||
func desktopAccountHealthSignature(report bufferedDesktopAccountHealthReport) string {
|
||||
payload, _ := json.Marshal(struct {
|
||||
AccountID string `json:"account_id"`
|
||||
Platform string `json:"platform"`
|
||||
PlatformUID string `json:"platform_uid"`
|
||||
Health string `json:"health"`
|
||||
AuthState string `json:"auth_state"`
|
||||
ProbeState string `json:"probe_state"`
|
||||
AuthReason *string `json:"auth_reason"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
}{
|
||||
AccountID: report.AccountID,
|
||||
Platform: report.Platform,
|
||||
PlatformUID: report.PlatformUID,
|
||||
Health: report.Health,
|
||||
AuthState: report.AuthState,
|
||||
ProbeState: report.ProbeState,
|
||||
AuthReason: report.AuthReason,
|
||||
DisplayName: report.DisplayName,
|
||||
AvatarURL: report.AvatarURL,
|
||||
})
|
||||
sum := sha256.Sum256(payload)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func trimOptionalString(value *string) *string {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(*value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return &trimmed
|
||||
}
|
||||
|
||||
func loadDesktopAccountRuntimeHealth(
|
||||
ctx context.Context,
|
||||
redis *goredis.Client,
|
||||
workspaceID int64,
|
||||
accountIDs []uuid.UUID,
|
||||
) map[uuid.UUID]bufferedDesktopAccountHealthReport {
|
||||
if redis == nil || len(accountIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
uniqueIDs := uniqueUUIDs(accountIDs)
|
||||
pipe := redis.Pipeline()
|
||||
commands := make(map[uuid.UUID]*goredis.StringCmd, len(uniqueIDs))
|
||||
for _, accountID := range uniqueIDs {
|
||||
commands[accountID] = pipe.Get(ctx, desktopAccountHealthLatestKey(workspaceID, accountID))
|
||||
}
|
||||
|
||||
if _, err := pipe.Exec(ctx); err != nil && !errors.Is(err, goredis.Nil) {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make(map[uuid.UUID]bufferedDesktopAccountHealthReport, len(commands))
|
||||
for accountID, cmd := range commands {
|
||||
payload, err := cmd.Bytes()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var report bufferedDesktopAccountHealthReport
|
||||
if err := json.Unmarshal(payload, &report); err != nil {
|
||||
continue
|
||||
}
|
||||
if report.WorkspaceID != workspaceID || report.AccountID != accountID.String() || report.CheckedAt.IsZero() {
|
||||
continue
|
||||
}
|
||||
result[accountID] = report
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *DesktopAccountService) applyRuntimeHealth(ctx context.Context, workspaceID int64, items []DesktopAccountView) {
|
||||
if s.redis == nil || len(items) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
accountIDs := make([]uuid.UUID, 0, len(items))
|
||||
accountIndex := make(map[uuid.UUID]int, len(items))
|
||||
for index, item := range items {
|
||||
accountID, err := uuid.Parse(item.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
accountIDs = append(accountIDs, accountID)
|
||||
accountIndex[accountID] = index
|
||||
}
|
||||
|
||||
reports := loadDesktopAccountRuntimeHealth(ctx, s.redis, workspaceID, accountIDs)
|
||||
for accountID, report := range reports {
|
||||
index, ok := accountIndex[accountID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
health := report.Health
|
||||
authState := report.AuthState
|
||||
probeState := report.ProbeState
|
||||
checkedAt := report.CheckedAt
|
||||
items[index].Health = health
|
||||
items[index].RuntimeHealth = &health
|
||||
items[index].RuntimeVerifiedAt = report.VerifiedAt
|
||||
items[index].RuntimeCheckedAt = &checkedAt
|
||||
items[index].RuntimeAuthState = &authState
|
||||
items[index].RuntimeProbeState = &probeState
|
||||
items[index].RuntimeAuthReason = report.AuthReason
|
||||
items[index].HealthSource = "runtime"
|
||||
if report.VerifiedAt != nil {
|
||||
items[index].VerifiedAt = report.VerifiedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DesktopAccountService) buildDesktopAccountView(
|
||||
account *repository.DesktopAccount,
|
||||
clientMap map[uuid.UUID]*repository.DesktopClient,
|
||||
@@ -266,6 +567,7 @@ func (s *DesktopAccountService) buildDesktopAccountView(
|
||||
DisplayName: account.DisplayName,
|
||||
AvatarURL: account.AvatarURL,
|
||||
Health: account.Health,
|
||||
HealthSource: "database",
|
||||
ClientID: clientID,
|
||||
AccountFingerprint: account.AccountFingerprint,
|
||||
VerifiedAt: account.VerifiedAt,
|
||||
|
||||
Reference in New Issue
Block a user