refactor(tenant): drop legacy plugin_installations, migrate monitoring to desktop_clients

Hard cutover from the browser-extension plugin flow to desktop clients:
remove plugin_installations/plugin_sessions tables and related service,
handler, router, and generated model code; migrate monitoring quotas
and collector types to desktop_clients (UUID primary_client_id);
recreate platform_access_snapshots keyed by client_id; update dev-seed
and callback types accordingly; mark legacy design docs as historical.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 13:52:35 +08:00
parent f5a1cd81b2
commit 5ff2e2e74c
25 changed files with 535 additions and 1384 deletions
File diff suppressed because it is too large Load Diff
@@ -100,7 +100,7 @@ type MonitoringHeartbeatPlatform struct {
}
type MonitoringHeartbeatResponse struct {
InstallationID int64 `json:"installation_id"`
InstallationID string `json:"installation_id"`
BusinessDate string `json:"business_date"`
UpdatedPlatformCount int `json:"updated_platform_count"`
ReconciledSkippedTaskCount int64 `json:"reconciled_skipped_task_count"`
@@ -166,8 +166,9 @@ type MonitoringSkipTaskResponse struct {
}
type monitoringInstallationIdentity struct {
ID int64
TenantID int64
ID string
TenantID int64
WorkspaceID int64
}
type monitoringPlatformAccessSnapshotState struct {
@@ -236,7 +237,7 @@ type monitoringTaskResultEnvelope struct {
Version string `json:"version"`
TaskID int64 `json:"task_id"`
TenantID int64 `json:"tenant_id"`
InstallationID int64 `json:"installation_id"`
InstallationID string `json:"installation_id"`
ReceivedAt time.Time `json:"received_at"`
QueueStatus string `json:"queue_status"`
QueueClaimedAt *time.Time `json:"queue_claimed_at"`
@@ -256,7 +257,7 @@ type monitoringTaskResultQueueMetaPatch struct {
type MonitoringTaskResultQueueMetaPatch = monitoringTaskResultQueueMetaPatch
func (s *MonitoringCallbackService) HandleTaskResult(ctx context.Context, installationID int64, installationToken string, taskID int64, req MonitoringTaskResultRequest) (*MonitoringTaskResultResponse, error) {
func (s *MonitoringCallbackService) HandleTaskResult(ctx context.Context, installationID string, installationToken string, taskID int64, req MonitoringTaskResultRequest) (*MonitoringTaskResultResponse, error) {
installation, err := s.authenticateInstallation(ctx, installationID, installationToken)
if err != nil {
return nil, err
@@ -268,10 +269,10 @@ func (s *MonitoringCallbackService) HandleTaskResult(ctx context.Context, instal
}
if task.CollectorType != monitoringCollectorType {
return nil, response.ErrConflict(40941, "collector_type_mismatch", "task collector type is not plugin")
return nil, response.ErrConflict(40941, "collector_type_mismatch", "task collector type is not desktop")
}
if task.LeasedToExecutor.Valid && strings.TrimSpace(task.LeasedToExecutor.String) != "" && task.LeasedToExecutor.String != strconv.FormatInt(installation.ID, 10) {
if task.LeasedToExecutor.Valid && strings.TrimSpace(task.LeasedToExecutor.String) != "" && task.LeasedToExecutor.String != installation.ID {
return nil, response.ErrUnauthorized(40141, "lease_owner_mismatch", "task is leased to another installation")
}
@@ -356,13 +357,13 @@ func (s *MonitoringCallbackService) HandleTaskResult(ctx context.Context, instal
}, nil
}
func (s *MonitoringCallbackService) Heartbeat(ctx context.Context, installationID int64, installationToken string, req MonitoringHeartbeatRequest) (*MonitoringHeartbeatResponse, error) {
func (s *MonitoringCallbackService) Heartbeat(ctx context.Context, installationID string, installationToken string, req MonitoringHeartbeatRequest) (*MonitoringHeartbeatResponse, error) {
installation, err := s.authenticateInstallation(ctx, installationID, installationToken)
if err != nil {
return nil, err
}
isPrimaryInstallation, err := s.resolveHeartbeatPrimaryInstallation(ctx, installation.TenantID, installation.ID)
isPrimaryInstallation, err := s.resolveHeartbeatPrimaryInstallation(ctx, installation.TenantID, installation)
if err != nil {
return nil, err
}
@@ -442,7 +443,7 @@ func (s *MonitoringCallbackService) Heartbeat(ctx context.Context, installationI
}, nil
}
func (s *MonitoringCallbackService) LeaseTasks(ctx context.Context, installationID int64, installationToken string, req MonitoringLeaseTasksRequest) (*MonitoringLeaseTasksResponse, error) {
func (s *MonitoringCallbackService) LeaseTasks(ctx context.Context, installationID string, installationToken string, req MonitoringLeaseTasksRequest) (*MonitoringLeaseTasksResponse, error) {
installation, err := s.authenticateInstallation(ctx, installationID, installationToken)
if err != nil {
return nil, err
@@ -522,7 +523,7 @@ func (s *MonitoringCallbackService) LeaseTasks(ctx context.Context, installation
}, nil
}
func (s *MonitoringCallbackService) ResumeTasks(ctx context.Context, installationID int64, installationToken string, req MonitoringResumeTasksRequest) (*MonitoringResumeTasksResponse, error) {
func (s *MonitoringCallbackService) ResumeTasks(ctx context.Context, installationID string, installationToken string, req MonitoringResumeTasksRequest) (*MonitoringResumeTasksResponse, error) {
installation, err := s.authenticateInstallation(ctx, installationID, installationToken)
if err != nil {
return nil, err
@@ -601,7 +602,7 @@ func (s *MonitoringCallbackService) ResumeTasks(ctx context.Context, installatio
}, nil
}
func (s *MonitoringCallbackService) SkipTask(ctx context.Context, installationID int64, installationToken string, taskID int64, req MonitoringSkipTaskRequest) (*MonitoringSkipTaskResponse, error) {
func (s *MonitoringCallbackService) SkipTask(ctx context.Context, installationID string, installationToken string, taskID int64, req MonitoringSkipTaskRequest) (*MonitoringSkipTaskResponse, error) {
installation, err := s.authenticateInstallation(ctx, installationID, installationToken)
if err != nil {
return nil, err
@@ -704,48 +705,47 @@ func (s *MonitoringCallbackService) ProcessQueuedTaskResult(ctx context.Context,
return s.processTaskResult(ctx, task, envelope.CallbackResult)
}
func (s *MonitoringCallbackService) authenticateInstallation(ctx context.Context, installationID int64, installationToken string) (*monitoringInstallationIdentity, error) {
tokenHash := sharedauth.HashToken(strings.TrimSpace(installationToken))
func (s *MonitoringCallbackService) authenticateInstallation(ctx context.Context, installationID string, installationToken string) (*monitoringInstallationIdentity, error) {
tokenHash := HashDesktopClientToken(strings.TrimSpace(installationToken))
row := &monitoringInstallationIdentity{}
if err := s.businessPool.QueryRow(ctx, `
SELECT id, tenant_id
FROM plugin_installations
WHERE id = $1
AND installation_token_hash = $2
AND status = 'active'
AND deleted_at IS NULL
`, installationID, tokenHash).Scan(&row.ID, &row.TenantID); err != nil {
SELECT id::text, tenant_id, workspace_id
FROM desktop_clients
WHERE id = $1::uuid
AND token_hash = $2
AND revoked_at IS NULL
`, strings.TrimSpace(installationID), tokenHash).Scan(&row.ID, &row.TenantID, &row.WorkspaceID); err != nil {
if err == pgx.ErrNoRows {
return nil, response.ErrUnauthorized(40141, "plugin_installation_unauthorized", "plugin installation is invalid or expired")
return nil, response.ErrUnauthorized(40141, "desktop_client_unauthorized", "desktop client is invalid or expired")
}
return nil, response.ErrInternal(50041, "plugin_installation_lookup_failed", "failed to validate plugin installation")
return nil, response.ErrInternal(50041, "desktop_client_lookup_failed", "failed to validate desktop client")
}
return row, nil
}
func (s *MonitoringCallbackService) resolveHeartbeatPrimaryInstallation(ctx context.Context, tenantID, installationID int64) (bool, error) {
var preferred sql.NullInt64
func (s *MonitoringCallbackService) resolveHeartbeatPrimaryInstallation(ctx context.Context, tenantID int64, installation *monitoringInstallationIdentity) (bool, error) {
var preferred sql.NullString
err := s.businessPool.QueryRow(ctx, `
SELECT primary_installation_id
SELECT primary_client_id::text
FROM tenant_monitoring_quotas
WHERE tenant_id = $1
`, tenantID).Scan(&preferred)
if err != nil && err != pgx.ErrNoRows {
return false, response.ErrInternal(50041, "query_failed", "failed to inspect primary monitoring installation")
return false, response.ErrInternal(50041, "query_failed", "failed to inspect primary monitoring desktop client")
}
if !preferred.Valid || preferred.Int64 == installationID {
if !preferred.Valid || preferred.String == installation.ID {
if !preferred.Valid {
if err := s.persistHeartbeatPrimaryInstallationID(ctx, tenantID, installationID); err != nil {
if err := s.persistHeartbeatPrimaryInstallationID(ctx, tenantID, installation); err != nil {
return false, err
}
}
return true, nil
}
online, err := s.isMonitoringInstallationRecentlyOnline(ctx, tenantID, preferred.Int64)
online, err := s.isMonitoringInstallationRecentlyOnline(ctx, tenantID, installation.WorkspaceID, preferred.String)
if err != nil {
return false, err
}
@@ -753,27 +753,27 @@ func (s *MonitoringCallbackService) resolveHeartbeatPrimaryInstallation(ctx cont
return false, nil
}
if err := s.persistHeartbeatPrimaryInstallationID(ctx, tenantID, installationID); err != nil {
if err := s.persistHeartbeatPrimaryInstallationID(ctx, tenantID, installation); err != nil {
return false, err
}
return true, nil
}
func (s *MonitoringCallbackService) isMonitoringInstallationRecentlyOnline(ctx context.Context, tenantID, installationID int64) (bool, error) {
func (s *MonitoringCallbackService) isMonitoringInstallationRecentlyOnline(ctx context.Context, tenantID, workspaceID int64, installationID string) (bool, error) {
var lastSeenAt sql.NullTime
err := s.businessPool.QueryRow(ctx, `
SELECT last_seen_at
FROM plugin_installations
WHERE id = $1
FROM desktop_clients
WHERE id = $1::uuid
AND tenant_id = $2
AND status = 'active'
AND deleted_at IS NULL
`, installationID, tenantID).Scan(&lastSeenAt)
AND workspace_id = $3
AND revoked_at IS NULL
`, strings.TrimSpace(installationID), tenantID, workspaceID).Scan(&lastSeenAt)
if err != nil {
if err == pgx.ErrNoRows {
return false, nil
}
return false, response.ErrInternal(50041, "query_failed", "failed to inspect monitoring installation heartbeat")
return false, response.ErrInternal(50041, "query_failed", "failed to inspect monitoring desktop client heartbeat")
}
if !lastSeenAt.Valid {
@@ -782,22 +782,22 @@ func (s *MonitoringCallbackService) isMonitoringInstallationRecentlyOnline(ctx c
return time.Since(lastSeenAt.Time) <= monitoringOnlineDuration, nil
}
func (s *MonitoringCallbackService) persistHeartbeatPrimaryInstallationID(ctx context.Context, tenantID, installationID int64) error {
func (s *MonitoringCallbackService) persistHeartbeatPrimaryInstallationID(ctx context.Context, tenantID int64, installation *monitoringInstallationIdentity) error {
if _, err := s.businessPool.Exec(ctx, `
INSERT INTO tenant_monitoring_quotas (
tenant_id, workspace_id, primary_installation_id
tenant_id, workspace_id, primary_client_id
)
VALUES (
$1,
(SELECT id FROM workspaces WHERE tenant_id = $1 AND is_default = TRUE LIMIT 1),
$2
$2,
$3::uuid
)
ON CONFLICT (tenant_id) DO UPDATE
SET workspace_id = COALESCE(tenant_monitoring_quotas.workspace_id, EXCLUDED.workspace_id),
primary_installation_id = EXCLUDED.primary_installation_id,
SET workspace_id = EXCLUDED.workspace_id,
primary_client_id = EXCLUDED.primary_client_id,
updated_at = NOW()
`, tenantID, installationID); err != nil {
return response.ErrInternal(50041, "update_failed", "failed to persist primary monitoring installation")
`, tenantID, installation.WorkspaceID, installation.ID); err != nil {
return response.ErrInternal(50041, "update_failed", "failed to persist primary monitoring desktop client")
}
return nil
}
@@ -1020,11 +1020,12 @@ func (s *MonitoringCallbackService) selectPendingLeaseTasks(
func (s *MonitoringCallbackService) selectLeasedTasksForInstallation(
ctx context.Context,
tx pgx.Tx,
tenantID, installationID int64,
tenantID int64,
installationID string,
businessDate string,
eligiblePlatformIDs []string,
) ([]monitoringLeasedTaskRow, error) {
queryArgs := []interface{}{tenantID, monitoringCollectorType, businessDate, strconv.FormatInt(installationID, 10)}
queryArgs := []interface{}{tenantID, monitoringCollectorType, businessDate, installationID}
query := `
SELECT
t.id,
@@ -1108,7 +1109,7 @@ func (s *MonitoringCallbackService) selectLeasedTasksForInstallation(
return result, nil
}
func (s *MonitoringCallbackService) markTaskLeased(ctx context.Context, tx pgx.Tx, installationID, taskID int64, leaseToken string, leasedAt, leaseExpiresAt time.Time) error {
func (s *MonitoringCallbackService) markTaskLeased(ctx context.Context, tx pgx.Tx, installationID string, taskID int64, leaseToken string, leasedAt, leaseExpiresAt time.Time) error {
if _, err := tx.Exec(ctx, `
UPDATE monitoring_collect_tasks
SET status = 'leased',
@@ -1120,7 +1121,7 @@ func (s *MonitoringCallbackService) markTaskLeased(ctx context.Context, tx pgx.T
error_message = NULL,
updated_at = NOW()
WHERE id = $1
`, taskID, sharedauth.HashToken(strings.TrimSpace(leaseToken)), strconv.FormatInt(installationID, 10), leasedAt, leaseExpiresAt); err != nil {
`, taskID, sharedauth.HashToken(strings.TrimSpace(leaseToken)), installationID, leasedAt, leaseExpiresAt); err != nil {
return response.ErrInternal(50041, "lease_update_failed", "failed to update monitoring lease task")
}
return nil
@@ -1165,11 +1166,11 @@ func (s *MonitoringCallbackService) upsertPlatformAccessSnapshot(ctx context.Con
if _, err := tx.Exec(ctx, `
INSERT INTO monitoring_platform_access_snapshots (
tenant_id, installation_id, ai_platform_id, business_date, access_status,
tenant_id, client_id, ai_platform_id, business_date, access_status,
connected, accessible, detected_at, reason_code, reason_message
)
VALUES ($1, $2, $3, $4::date, $5, $6, $7, $8, $9, $10)
ON CONFLICT (tenant_id, installation_id, ai_platform_id, business_date)
VALUES ($1, $2::uuid, $3, $4::date, $5, $6, $7, $8, $9, $10)
ON CONFLICT (tenant_id, client_id, ai_platform_id, business_date)
DO UPDATE SET
access_status = EXCLUDED.access_status,
connected = EXCLUDED.connected,
@@ -1195,7 +1196,7 @@ func (s *MonitoringCallbackService) upsertPlatformAccessSnapshot(ctx context.Con
return hasPlatformAccessSnapshotChanged(previous, nextState), nil
}
func (s *MonitoringCallbackService) loadPlatformAccessSnapshotState(ctx context.Context, tx pgx.Tx, tenantID, installationID int64, businessDate, aiPlatformID string) (*monitoringPlatformAccessSnapshotState, error) {
func (s *MonitoringCallbackService) loadPlatformAccessSnapshotState(ctx context.Context, tx pgx.Tx, tenantID int64, installationID string, businessDate, aiPlatformID string) (*monitoringPlatformAccessSnapshotState, error) {
item := &monitoringPlatformAccessSnapshotState{}
var reasonCode sql.NullString
var reasonMessage sql.NullString
@@ -1203,7 +1204,7 @@ func (s *MonitoringCallbackService) loadPlatformAccessSnapshotState(ctx context.
SELECT access_status, connected, accessible, reason_code, reason_message
FROM monitoring_platform_access_snapshots
WHERE tenant_id = $1
AND installation_id = $2
AND client_id = $2::uuid
AND ai_platform_id = $3
AND business_date = $4::date
`, tenantID, installationID, aiPlatformID, businessDate).Scan(
@@ -1721,11 +1722,11 @@ func (s *MonitoringCallbackService) loadExistingRunSummary(ctx context.Context,
return &value, citationSourceCount, contentCitationCount, nil
}
func (s *MonitoringCallbackService) touchInstallation(ctx context.Context, installationID int64) error {
func (s *MonitoringCallbackService) touchInstallation(ctx context.Context, installationID string) error {
_, err := s.businessPool.Exec(ctx, `
UPDATE plugin_installations
SET last_seen_at = NOW(), updated_at = NOW()
WHERE id = $1
UPDATE desktop_clients
SET last_seen_at = NOW()
WHERE id = $1::uuid
`, installationID)
return err
}
@@ -1821,7 +1822,7 @@ func buildMonitoringRawPayload(req MonitoringTaskResultRequest) ([]byte, []Monit
return data, sourceInputs
}
func newMonitoringTaskResultEnvelope(task *monitoringCollectTask, installationID int64, receivedAt time.Time, req MonitoringTaskResultRequest) monitoringTaskResultEnvelope {
func newMonitoringTaskResultEnvelope(task *monitoringCollectTask, installationID string, receivedAt time.Time, req MonitoringTaskResultRequest) monitoringTaskResultEnvelope {
return monitoringTaskResultEnvelope{
Version: "v1",
TaskID: task.ID,
@@ -2090,8 +2091,8 @@ func normalizeMonitoringAccessStatus(accessStatus *string, connected *bool, acce
return "", false, false, response.ErrBadRequest(40041, "invalid_access_status", "access status requires access_status, connected, or accessible")
}
func validateTaskLease(task *monitoringCollectTask, installationID int64, leaseToken string) error {
if task.LeasedToExecutor.Valid && strings.TrimSpace(task.LeasedToExecutor.String) != "" && task.LeasedToExecutor.String != strconv.FormatInt(installationID, 10) {
func validateTaskLease(task *monitoringCollectTask, installationID string, leaseToken string) error {
if task.LeasedToExecutor.Valid && strings.TrimSpace(task.LeasedToExecutor.String) != "" && task.LeasedToExecutor.String != installationID {
return response.ErrUnauthorized(40141, "lease_owner_mismatch", "task is leased to another installation")
}
@@ -48,12 +48,12 @@ func TestNewMonitoringTaskResultEnvelope(t *testing.T) {
TenantID: 34,
}
envelope := newMonitoringTaskResultEnvelope(task, 56, receivedAt, req)
envelope := newMonitoringTaskResultEnvelope(task, "00000000-0000-0000-0000-000000000056", receivedAt, req)
assert.Equal(t, "v1", envelope.Version)
assert.Equal(t, int64(12), envelope.TaskID)
assert.Equal(t, int64(34), envelope.TenantID)
assert.Equal(t, int64(56), envelope.InstallationID)
assert.Equal(t, "00000000-0000-0000-0000-000000000056", envelope.InstallationID)
assert.Equal(t, receivedAt, envelope.ReceivedAt)
assert.Equal(t, "pending", envelope.QueueStatus)
assert.Nil(t, envelope.QueueEnqueuedAt)
@@ -69,7 +69,7 @@ func TestDecodeMonitoringTaskResultEnvelope(t *testing.T) {
Version: "v1",
TaskID: 12,
TenantID: 34,
InstallationID: 56,
InstallationID: "00000000-0000-0000-0000-000000000056",
ReceivedAt: time.Date(2026, 4, 10, 12, 0, 0, 0, time.UTC),
QueueStatus: "queued",
CallbackResult: MonitoringTaskResultRequest{LeaseToken: "lease-token", Status: "succeeded"},
@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
@@ -20,7 +21,7 @@ import (
const (
defaultTrackingDays = 7
maxTrackingDays = 30
monitoringCollectorType = "plugin"
monitoringCollectorType = "desktop"
monitoringOnlineDuration = 20 * time.Minute
monitoringCollectNowQuestionLimit = 5
)
@@ -189,9 +190,9 @@ type MonitoringCollectNowResponse struct {
}
type monitoringQuotaConfig struct {
CollectionMode string
PrimaryInstallationID *int64
EnabledPlatforms []string
CollectionMode string
PrimaryClientID *uuid.UUID
EnabledPlatforms []string
}
type monitoringBrandIdentity struct {
@@ -301,7 +302,7 @@ func (s *MonitoringService) DashboardComposite(
return nil, err
}
accessStates, err := s.loadAccessStates(ctx, actor.TenantID, quota.PrimaryInstallationID, endDate)
accessStates, err := s.loadAccessStates(ctx, actor.TenantID, quota.PrimaryClientID, endDate)
if err != nil {
return nil, err
}
@@ -378,7 +379,7 @@ func (s *MonitoringService) QuestionDetail(ctx context.Context, brandID, questio
}
}
accessStates, err := s.loadAccessStates(ctx, actor.TenantID, quota.PrimaryInstallationID, toDate)
accessStates, err := s.loadAccessStates(ctx, actor.TenantID, quota.PrimaryClientID, toDate)
if err != nil {
return nil, err
}
@@ -497,15 +498,15 @@ func (s *MonitoringService) CollectNow(ctx context.Context, brandID int64, keywo
platforms := resolveMonitoringPlatforms(quota.EnabledPlatforms)
questionIDs := configuredQuestionIDs(configuredQuestions)
installationID, err := s.resolveCollectNowInstallationID(ctx, actor.TenantID, quota.PrimaryInstallationID)
clientID, err := s.resolveCollectNowClientID(ctx, actor.TenantID, actor.PrimaryWorkspaceID, quota.PrimaryClientID)
if err != nil {
return nil, err
}
if installationID == nil {
return nil, response.ErrConflict(40941, "installation_offline", "primary monitoring installation is offline")
if clientID == nil {
return nil, response.ErrConflict(40941, "desktop_client_offline", "primary monitoring desktop client is offline")
}
if err := s.ensureInstallationOnline(ctx, actor.TenantID, *installationID); err != nil {
if err := s.ensureClientOnline(ctx, actor.TenantID, actor.PrimaryWorkspaceID, *clientID); err != nil {
return nil, err
}
@@ -584,9 +585,9 @@ func (s *MonitoringService) CollectNow(ctx context.Context, brandID int64, keywo
}, nil
}
func (s *MonitoringService) resolveCollectNowInstallationID(ctx context.Context, tenantID int64, preferred *int64) (*int64, error) {
func (s *MonitoringService) resolveCollectNowClientID(ctx context.Context, tenantID, workspaceID int64, preferred *uuid.UUID) (*uuid.UUID, error) {
if preferred != nil {
online, err := s.isInstallationOnline(ctx, tenantID, *preferred)
online, err := s.isClientOnline(ctx, tenantID, workspaceID, *preferred)
if err != nil {
return nil, err
}
@@ -595,7 +596,7 @@ func (s *MonitoringService) resolveCollectNowInstallationID(ctx context.Context,
}
}
fallbackID, err := s.findLatestOnlineInstallation(ctx, tenantID)
fallbackID, err := s.findLatestOnlineClient(ctx, tenantID, workspaceID)
if err != nil {
return nil, err
}
@@ -604,7 +605,7 @@ func (s *MonitoringService) resolveCollectNowInstallationID(ctx context.Context,
}
if preferred == nil || *preferred != *fallbackID {
if err := s.persistPrimaryInstallationID(ctx, tenantID, *fallbackID); err != nil {
if err := s.persistPrimaryClientID(ctx, tenantID, workspaceID, *fallbackID); err != nil {
return nil, err
}
}
@@ -612,44 +613,44 @@ func (s *MonitoringService) resolveCollectNowInstallationID(ctx context.Context,
return fallbackID, nil
}
func (s *MonitoringService) findLatestOnlineInstallation(ctx context.Context, tenantID int64) (*int64, error) {
var installationID int64
func (s *MonitoringService) findLatestOnlineClient(ctx context.Context, tenantID, workspaceID int64) (*uuid.UUID, error) {
var clientID uuid.UUID
err := s.businessPool.QueryRow(ctx, `
SELECT id
FROM plugin_installations
FROM desktop_clients
WHERE tenant_id = $1
AND status = 'active'
AND deleted_at IS NULL
AND workspace_id = $2
AND revoked_at IS NULL
AND last_seen_at IS NOT NULL
AND last_seen_at >= NOW() - $2::interval
ORDER BY last_seen_at DESC, updated_at DESC, id DESC
AND last_seen_at >= NOW() - $3::interval
ORDER BY last_seen_at DESC, created_at DESC, id DESC
LIMIT 1
`, tenantID, formatPgInterval(monitoringOnlineDuration)).Scan(&installationID)
`, tenantID, workspaceID, formatPgInterval(monitoringOnlineDuration)).Scan(&clientID)
if err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
return nil, response.ErrInternal(50041, "query_failed", "failed to inspect online monitoring installation")
return nil, response.ErrInternal(50041, "query_failed", "failed to inspect online monitoring desktop client")
}
return &installationID, nil
return &clientID, nil
}
func (s *MonitoringService) persistPrimaryInstallationID(ctx context.Context, tenantID, installationID int64) error {
func (s *MonitoringService) persistPrimaryClientID(ctx context.Context, tenantID, workspaceID int64, clientID uuid.UUID) error {
if _, err := s.businessPool.Exec(ctx, `
INSERT INTO tenant_monitoring_quotas (
tenant_id, workspace_id, primary_installation_id
tenant_id, workspace_id, primary_client_id
)
VALUES (
$1,
(SELECT id FROM workspaces WHERE tenant_id = $1 AND is_default = TRUE LIMIT 1),
$2
$2,
$3
)
ON CONFLICT (tenant_id) DO UPDATE
SET workspace_id = COALESCE(tenant_monitoring_quotas.workspace_id, EXCLUDED.workspace_id),
primary_installation_id = EXCLUDED.primary_installation_id,
SET workspace_id = EXCLUDED.workspace_id,
primary_client_id = EXCLUDED.primary_client_id,
updated_at = NOW()
`, tenantID, installationID); err != nil {
return response.ErrInternal(50041, "update_failed", "failed to persist primary monitoring installation")
`, tenantID, workspaceID, clientID); err != nil {
return response.ErrInternal(50041, "update_failed", "failed to persist primary monitoring desktop client")
}
return nil
}
@@ -894,12 +895,12 @@ func (s *MonitoringService) loadQuota(ctx context.Context, tenantID int64) (moni
}
var enabledJSON []byte
var installationID sql.NullInt64
var clientID uuid.NullUUID
err := s.businessPool.QueryRow(ctx, `
SELECT collection_mode, enabled_platforms, primary_installation_id
SELECT collection_mode, enabled_platforms, primary_client_id
FROM tenant_monitoring_quotas
WHERE tenant_id = $1
`, tenantID).Scan(&cfg.CollectionMode, &enabledJSON, &installationID)
`, tenantID).Scan(&cfg.CollectionMode, &enabledJSON, &clientID)
if err != nil {
if err == pgx.ErrNoRows {
return cfg, nil
@@ -913,9 +914,9 @@ func (s *MonitoringService) loadQuota(ctx context.Context, tenantID int64) (moni
cfg.EnabledPlatforms = enabled
}
}
if installationID.Valid {
id := installationID.Int64
cfg.PrimaryInstallationID = &id
if clientID.Valid {
id := clientID.UUID
cfg.PrimaryClientID = &id
}
if strings.TrimSpace(cfg.CollectionMode) == "" {
cfg.CollectionMode = monitoringCollectorType
@@ -1700,9 +1701,9 @@ func (s *MonitoringService) loadTimeBucketsForPlatform(
return buckets, nil
}
func (s *MonitoringService) loadAccessStates(ctx context.Context, tenantID int64, installationID *int64, businessDate time.Time) (map[string]monitoringAccessState, error) {
func (s *MonitoringService) loadAccessStates(ctx context.Context, tenantID int64, clientID *uuid.UUID, businessDate time.Time) (map[string]monitoringAccessState, error) {
states := map[string]monitoringAccessState{}
if installationID == nil {
if clientID == nil {
return states, nil
}
@@ -1710,9 +1711,9 @@ func (s *MonitoringService) loadAccessStates(ctx context.Context, tenantID int64
SELECT ai_platform_id, access_status
FROM monitoring_platform_access_snapshots
WHERE tenant_id = $1
AND installation_id = $2
AND client_id = $2
AND business_date = $3::date
`, tenantID, *installationID, businessDate.Format("2006-01-02"))
`, tenantID, *clientID, businessDate.Format("2006-01-02"))
if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load platform access states")
}
@@ -2452,32 +2453,32 @@ func (s *MonitoringService) loadQuestionContentCitations(ctx context.Context, te
return items, nil
}
func (s *MonitoringService) ensureInstallationOnline(ctx context.Context, tenantID, installationID int64) error {
online, err := s.isInstallationOnline(ctx, tenantID, installationID)
func (s *MonitoringService) ensureClientOnline(ctx context.Context, tenantID, workspaceID int64, clientID uuid.UUID) error {
online, err := s.isClientOnline(ctx, tenantID, workspaceID, clientID)
if err != nil {
return err
}
if !online {
return response.ErrConflict(40941, "installation_offline", "primary monitoring installation is offline")
return response.ErrConflict(40941, "desktop_client_offline", "primary monitoring desktop client is offline")
}
return nil
}
func (s *MonitoringService) isInstallationOnline(ctx context.Context, tenantID, installationID int64) (bool, error) {
func (s *MonitoringService) isClientOnline(ctx context.Context, tenantID, workspaceID int64, clientID uuid.UUID) (bool, error) {
var lastSeenAt sql.NullTime
err := s.businessPool.QueryRow(ctx, `
SELECT last_seen_at
FROM plugin_installations
FROM desktop_clients
WHERE id = $1
AND tenant_id = $2
AND status = 'active'
AND deleted_at IS NULL
`, installationID, tenantID).Scan(&lastSeenAt)
AND workspace_id = $3
AND revoked_at IS NULL
`, clientID, tenantID, workspaceID).Scan(&lastSeenAt)
if err != nil {
if err == pgx.ErrNoRows {
return false, nil
}
return false, response.ErrInternal(50041, "query_failed", "failed to inspect monitoring installation")
return false, response.ErrInternal(50041, "query_failed", "failed to inspect monitoring desktop client")
}
if !lastSeenAt.Valid || time.Since(lastSeenAt.Time) > monitoringOnlineDuration {
@@ -19,6 +19,14 @@ type publishableArticleMeta struct {
CoverAssetURL *string
}
type platformAccountSeed struct {
ID int64
PlatformID string
PlatformName string
PlatformUID string
Nickname string
}
type desktopPublishSyncOutcome struct {
TenantID int64
ArticleID int64
@@ -242,6 +250,65 @@ func publishBatchStatusToArticleStatus(batchStatus string) string {
}
}
func normalizeStringPointer(value *string) *string {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
return &trimmed
}
func recalculatePublishBatchStatus(ctx context.Context, tx pgx.Tx, publishBatchID int64) (string, error) {
rows, err := tx.Query(ctx, `
SELECT status
FROM publish_records
WHERE publish_batch_id = $1
`, publishBatchID)
if err != nil {
return "", response.ErrInternal(50054, "publish_batch_status_query_failed", "failed to inspect publish batch status")
}
defer rows.Close()
statuses := make([]string, 0)
for rows.Next() {
var status string
if scanErr := rows.Scan(&status); scanErr != nil {
return "", response.ErrInternal(50054, "publish_batch_status_scan_failed", "failed to parse publish batch status")
}
statuses = append(statuses, normalizePublishStatus(status))
}
if err := rows.Err(); err != nil {
return "", response.ErrInternal(50054, "publish_batch_status_scan_failed", "failed to iterate publish batch status")
}
if len(statuses) == 0 {
return "publishing", nil
}
counts := map[string]int{
"success": 0,
"failed": 0,
"publishing": 0,
}
for _, status := range statuses {
counts[normalizePublishStatus(status)]++
}
switch {
case counts["publishing"] > 0:
return "publishing", nil
case counts["success"] > 0 && counts["failed"] > 0:
return "partial_success", nil
case counts["success"] > 0:
return "success", nil
default:
return "failed", nil
}
}
func resolvePublishArticleID(contentRef map[string]any) (int64, error) {
if articleID, ok := extractInt64FromMap(contentRef, "article_id", "id"); ok && articleID > 0 {
return articleID, nil
@@ -484,40 +484,6 @@ type PlatformUserRole struct {
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
}
type PluginInstallation struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
UserID int64 `json:"user_id"`
InstallationKey string `json:"installation_key"`
InstallationName string `json:"installation_name"`
BrowserName pgtype.Text `json:"browser_name"`
ClientVersion pgtype.Text `json:"client_version"`
InstallationTokenHash string `json:"installation_token_hash"`
Status string `json:"status"`
LastSeenAt pgtype.Timestamptz `json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
}
type PluginSession struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
UserID int64 `json:"user_id"`
PluginInstallationID pgtype.Int8 `json:"plugin_installation_id"`
ActionType string `json:"action_type"`
ResourceType pgtype.Text `json:"resource_type"`
ResourceID pgtype.Int8 `json:"resource_id"`
PlatformAccountID pgtype.Int8 `json:"platform_account_id"`
PlatformID string `json:"platform_id"`
SessionToken string `json:"session_token"`
ClientVersion pgtype.Text `json:"client_version"`
Status string `json:"status"`
ExpireAt pgtype.Timestamptz `json:"expire_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type PromptRule struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
@@ -681,7 +647,7 @@ type TenantMonitoringQuota struct {
QuestionSelectionMode string `json:"question_selection_mode"`
CollectFrequency string `json:"collect_frequency"`
EnabledPlatforms []byte `json:"enabled_platforms"`
PrimaryInstallationID pgtype.Int8 `json:"primary_installation_id"`
PrimaryClientID pgtype.UUID `json:"primary_client_id"`
TaskDailyHardCap int32 `json:"task_daily_hard_cap"`
PlanTier string `json:"plan_tier"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
@@ -4,7 +4,6 @@ import (
"strconv"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/geo-platform/tenant-api/internal/bootstrap"
"github.com/geo-platform/tenant-api/internal/shared/response"
@@ -12,14 +11,12 @@ import (
)
type MediaHandler struct {
svc *app.MediaService
logger *zap.Logger
svc *app.MediaService
}
func NewMediaHandler(a *bootstrap.App) *MediaHandler {
return &MediaHandler{
svc: app.NewMediaService(a.DB, a.Logger).WithCache(a.Cache),
logger: a.Logger,
svc: app.NewMediaService(a.DB),
}
}
@@ -32,75 +29,6 @@ func (h *MediaHandler) ListPlatforms(c *gin.Context) {
response.Success(c, data)
}
func (h *MediaHandler) ListPlatformAccounts(c *gin.Context) {
data, err := h.svc.ListPlatformAccounts(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *MediaHandler) DeletePlatformAccount(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "platform account id must be a number"))
return
}
if err := h.svc.DeletePlatformAccount(c.Request.Context(), id); err != nil {
response.Error(c, err)
return
}
response.Success(c, nil)
}
func (h *MediaHandler) CreatePluginSession(c *gin.Context) {
var req app.CreatePluginSessionRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
data, err := h.svc.CreatePluginSession(c.Request.Context(), req)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *MediaHandler) RegisterPluginInstallation(c *gin.Context) {
var req app.RegisterPluginInstallationRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
data, err := h.svc.RegisterPluginInstallation(c.Request.Context(), req)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *MediaHandler) CreatePublishBatch(c *gin.Context) {
articleID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "article id must be a number"))
return
}
var req app.CreatePublishBatchRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
data, err := h.svc.CreatePublishBatch(c.Request.Context(), articleID, req)
if err != nil {
response.Error(c, err)
return
}
response.SuccessWithStatus(c, 201, data)
}
func (h *MediaHandler) ListPublishRecords(c *gin.Context) {
articleID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
@@ -114,61 +42,3 @@ func (h *MediaHandler) ListPublishRecords(c *gin.Context) {
}
response.Success(c, data)
}
func (h *MediaHandler) PluginBindCallback(c *gin.Context) {
var req app.PluginBindCallbackRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
data, err := h.svc.HandleBindCallback(c.Request.Context(), req)
if err != nil {
if h.logger != nil {
appErr := response.Normalize(err)
h.logger.Warn("publisher bind callback failed",
zap.String("request_id", c.GetString("request_id")),
zap.Int64("plugin_session_id", req.PluginSessionID),
zap.String("platform_id", req.PlatformID),
zap.String("platform_uid", req.PlatformUID),
zap.Int("code", appErr.Code),
zap.String("message", appErr.Message),
zap.String("detail", appErr.Detail),
)
}
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *MediaHandler) PluginPublishCallback(c *gin.Context) {
var req app.PluginPublishCallbackRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
data, err := h.svc.HandlePublishCallback(c.Request.Context(), req)
if err != nil {
if h.logger != nil {
appErr := response.Normalize(err)
h.logger.Warn("publisher publish callback failed",
zap.String("request_id", c.GetString("request_id")),
zap.Int64("plugin_session_id", req.PluginSessionID),
zap.Int64("publish_record_id", req.PublishRecordID),
zap.Int64("platform_account_id", req.PlatformAccountID),
zap.Int64("article_id", req.ArticleID),
zap.String("platform_id", req.PlatformID),
zap.String("status", req.Status),
zap.Stringp("error_message", req.ErrorMessage),
zap.Int("code", appErr.Code),
zap.String("message", appErr.Message),
zap.String("detail", appErr.Detail),
zap.Any("request_payload", req.RequestPayload),
zap.Any("response_payload", req.ResponsePayload),
)
}
response.Error(c, err)
return
}
response.Success(c, data)
}
+3 -7
View File
@@ -16,10 +16,7 @@ func RegisterRoutes(app *bootstrap.App) {
publicAssets := NewAssetHandler(app)
app.Engine.GET("/api/public/assets/:token", publicAssets.Serve)
pluginHandler := NewMediaHandler(app)
callbacks := app.Engine.Group("/api/callback/plugin")
callbacks.POST("/bind", pluginHandler.PluginBindCallback)
callbacks.POST("/publish", pluginHandler.PluginPublishCallback)
mediaHandler := NewMediaHandler(app)
protected := app.Engine.Group("/api")
protected.Use(auth.Middleware(app.JWT, app.Sessions))
@@ -65,7 +62,7 @@ func RegisterRoutes(app *bootstrap.App) {
tenantProtected.POST("/publish-jobs", publishJobHandler.Create)
media := tenantProtected.Group("/media")
media.GET("/platforms", pluginHandler.ListPlatforms)
media.GET("/platforms", mediaHandler.ListPlatforms)
workspace := tenantProtected.Group("/workspace")
wsHandler := NewWorkspaceHandler(app)
@@ -149,8 +146,7 @@ func RegisterRoutes(app *bootstrap.App) {
articles.PUT("/:id", artHandler.Update)
articles.GET("/:id/stream", artHandler.Stream)
articles.GET("/:id/versions", artHandler.Versions)
articles.GET("/:id/publish-records", pluginHandler.ListPublishRecords)
articles.POST("/:id/publish-batches", pluginHandler.CreatePublishBatch)
articles.GET("/:id/publish-records", mediaHandler.ListPublishRecords)
articles.DELETE("/:id", artHandler.Delete)
brands := tenantProtected.Group("/brands")