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
+4 -2
View File
@@ -1,4 +1,6 @@
# AI 品牌曝光监测系统技术方案 V4 # AI 品牌曝光监测系统技术方案 V4(旧版设计)
> 旧版设计说明:本文描述的是基于浏览器插件采集、`installation_token`、`/api/plugin/monitoring/*` 的旧监控方案。自 2026-04-20 起,当前实现已切换到 `desktop_clients` / desktop client 架构。本文保留用于工作记录与方案追溯,不再作为当前实现依据。
## 1. 文档信息 ## 1. 文档信息
@@ -6,7 +8,7 @@
| --- | --- | | --- | --- |
| 文档名称 | AI 品牌曝光监测系统技术方案 V4(插件采集 + 配额约束) | | 文档名称 | AI 品牌曝光监测系统技术方案 V4(插件采集 + 配额约束) |
| 文档版本 | V4.0 | | 文档版本 | V4.0 |
| 文档状态 | 待评审 | | 文档状态 | 旧版设计(保留工作记录) |
| 创建日期 | 2026-04-08 | | 创建日期 | 2026-04-08 |
| 基线文档 | `docs/ai-brand-monitoring-tech-design-v3.md` | | 基线文档 | `docs/ai-brand-monitoring-tech-design-v3.md` |
| 适用范围 | 数据追踪模块全部页面 | | 适用范围 | 数据追踪模块全部页面 |
+4 -2
View File
@@ -1,4 +1,6 @@
# AI 品牌曝光监测系统技术方案 V5 # AI 品牌曝光监测系统技术方案 V5(旧版设计)
> 旧版设计说明:本文描述的是基于浏览器插件采样、`plugin_installations`、`primary_installation_id`、`/api/plugin/monitoring/*` 的旧监控方案。自 2026-04-20 起,当前实现已切换到 `desktop_clients` / desktop client 架构。本文保留用于工作记录与方案追溯,不再作为当前实现依据。
## 1. 文档信息 ## 1. 文档信息
@@ -6,7 +8,7 @@
| -------- | --------------------------------------------------------------------- | | -------- | --------------------------------------------------------------------- |
| 文档名称 | AI 品牌曝光监测系统技术方案 V5(插件采样首期交付 / API 精确采集预留) | | 文档名称 | AI 品牌曝光监测系统技术方案 V5(插件采样首期交付 / API 精确采集预留) |
| 文档版本 | V5.9 | | 文档版本 | V5.9 |
| 文档状态 | 待评审 | | 文档状态 | 旧版设计(保留工作记录) |
| 创建日期 | 2026-04-09 | | 创建日期 | 2026-04-09 |
| 基线文档 | `docs/ai-brand-monitoring-tech-design-v4.md` | | 基线文档 | `docs/ai-brand-monitoring-tech-design-v4.md` |
| 适用范围 | 数据追踪模块全部页面 | | 适用范围 | 数据追踪模块全部页面 |
@@ -1,5 +1,7 @@
# GEO 双后台与后端技术架构方案 V1 # GEO 双后台与后端技术架构方案 V1
> 工作记录说明:本文仍保留作为历史架构记录。其中涉及浏览器插件执行链路、`/api/callback/plugin/*`、`plugin_sessions`、`installation_token` 的相关章节,自 2026-04-20 起视为旧版设计;当前实现请以 desktop client 方案和最新代码为准。
## 1. 文档信息 ## 1. 文档信息
| 项目 | 内容 | | 项目 | 内容 |
+3 -1
View File
@@ -1,4 +1,6 @@
# V1 技术方案:绑定媒体账号与手动发送文章 # V1 技术方案:绑定媒体账号与手动发送文章(旧版设计)
> 旧版设计说明:本文描述的是基于浏览器插件回调、`plugin_sessions`、`/api/callback/plugin/*` 的旧方案。自 2026-04-20 起,当前实现已切换到 desktop client 架构。本文保留用于工作记录与方案追溯,不再作为当前实现依据。
> 口径说明:本文的插件执行流程、接口路径与会话模型已同步到主架构文档 [geo-platform-ops-admin-tech-architecture-v1.md](D:/project/geo/docs/geo-platform-ops-admin-tech-architecture-v1.md) 的 `§13.8 浏览器插件执行面闭环`、`§13.6.3 插件回调与内部接口`、`§13.6.4 统一错误响应与错误码`。若两份文档存在任何冲突,以主架构文档为准。 > 口径说明:本文的插件执行流程、接口路径与会话模型已同步到主架构文档 [geo-platform-ops-admin-tech-architecture-v1.md](D:/project/geo/docs/geo-platform-ops-admin-tech-architecture-v1.md) 的 `§13.8 浏览器插件执行面闭环`、`§13.6.3 插件回调与内部接口`、`§13.6.4 统一错误响应与错误码`。若两份文档存在任何冲突,以主架构文档为准。
+3 -1
View File
@@ -1,4 +1,6 @@
# Media Publisher Extension Runtime V1 # Media Publisher Extension Runtime V1(旧版设计)
> 旧版设计说明:本文描述的是基于浏览器插件、`plugin_installations`、`plugin_sessions` 的旧执行方案。自 2026-04-20 起,当前实现已切换到 `desktop_clients` / desktop client 架构。本文保留用于工作记录与方案追溯,不再作为当前实现依据。
## Goal ## Goal
@@ -0,0 +1,36 @@
# 2026-04-20 Legacy Plugin Design Status
## 背景
2026-04-20 这次代码与数据库清理,已经移除了旧浏览器插件链路中的 `plugin_installations``plugin_sessions` 及其主要业务引用,并将监控主链路切换到 `desktop_clients` / desktop client 体系。
## 决策
本次**不删除**历史设计文档。
保留原因:
- 作为架构演进的工作记录,方便回溯旧方案的边界、假设和历史实现背景
- 便于对照为什么当前实现从 browser extension 迁移到 desktop client
- 避免后续排查历史字段、旧迁移、旧接口时失去上下文
## 本次文档处理方式
对明显描述旧插件执行模型的文档,统一补充“旧版设计”标识;对仍有其他参考价值的综合架构文档,补充“相关章节为旧版设计”的说明,而不删除正文内容。
## 已标记文档
- `docs/media-publisher-extension-runtime-v1.md`
- `docs/media-binding-and-publish-v1.md`
- `docs/ai-brand-monitoring-tech-design-v4.md`
- `docs/ai-brand-monitoring-tech-design-v5.md`
- `docs/geo-platform-ops-admin-tech-architecture-v1.md`
## 当前口径
当前实现以 desktop client 架构为准,旧插件设计文档仅用于历史追溯,不再作为新开发或现状判断依据。
建议优先参考:
- `docs/superpowers/specs/2026-04-18-electron-desktop-client-design.md`
- 代码中的 `desktop_clients``/api/desktop/*`、监控与发布最新实现
+53 -45
View File
@@ -11,31 +11,31 @@ import (
"strings" "strings"
"time" "time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
sharedauth "github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/config" "github.com/geo-platform/tenant-api/internal/shared/config"
"github.com/geo-platform/tenant-api/internal/tenant/prompts" "github.com/geo-platform/tenant-api/internal/tenant/prompts"
"github.com/geo-platform/tenant-api/internal/tenant/repository" "github.com/geo-platform/tenant-api/internal/tenant/repository"
) )
type seedState struct { type seedState struct {
TenantID int64 TenantID int64
UserID int64 UserID int64
WorkspaceID int64 WorkspaceID int64
TestTenantID int64 TestTenantID int64
TestUserID int64 TestUserID int64
PlanID int64 PlanID int64
BrandID int64 BrandID int64
KeywordPrimaryID int64 KeywordPrimaryID int64
KeywordSecondaryID int64 KeywordSecondaryID int64
QuestionPrimaryID int64 QuestionPrimaryID int64
QuestionSecondaryID int64 QuestionSecondaryID int64
QuestionTertiaryID int64 QuestionTertiaryID int64
ArticleID int64 ArticleID int64
PluginInstallationID int64 DesktopClientID uuid.UUID
} }
type monitoringRunSeed struct { type monitoringRunSeed struct {
@@ -137,7 +137,7 @@ func main() {
fmt.Printf(" Test User ID: %d (test@geo.local / Test@123)\n", state.TestUserID) fmt.Printf(" Test User ID: %d (test@geo.local / Test@123)\n", state.TestUserID)
fmt.Printf(" Plan ID: %d (free)\n", state.PlanID) fmt.Printf(" Plan ID: %d (free)\n", state.PlanID)
fmt.Printf(" Brand ID: %d (轻氧口腔)\n", state.BrandID) fmt.Printf(" Brand ID: %d (轻氧口腔)\n", state.BrandID)
fmt.Printf(" Plugin Installation ID: %d\n", state.PluginInstallationID) fmt.Printf(" Desktop Client ID: %s\n", state.DesktopClientID.String())
fmt.Println(" Templates: 4 platform presets") fmt.Println(" Templates: 4 platform presets")
fmt.Println(" Monitoring: brand daily snapshots + question runs + citation facts") fmt.Println(" Monitoring: brand daily snapshots + question runs + citation facts")
} }
@@ -241,7 +241,7 @@ func seedBusinessData(ctx context.Context, pool *pgxpool.Pool, membershipCfg con
return nil, err return nil, err
} }
state.PluginInstallationID, err = ensurePluginInstallation(ctx, tx, state.TenantID, state.UserID) state.DesktopClientID, err = ensureDesktopClient(ctx, tx, state.TenantID, state.WorkspaceID, state.UserID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -641,47 +641,55 @@ func ensureArticle(ctx context.Context, tx pgx.Tx, tenantID int64) (int64, error
return articleID, nil return articleID, nil
} }
func ensurePluginInstallation(ctx context.Context, tx pgx.Tx, tenantID, userID int64) (int64, error) { func ensureDesktopClient(ctx context.Context, tx pgx.Tx, tenantID, workspaceID, userID int64) (uuid.UUID, error) {
installationKey := "demo-monitoring-installation" clientID := uuid.NewSHA1(uuid.NameSpaceOID, []byte("geo-rankly-demo-monitoring-client"))
tokenHash := sharedauth.HashToken("demo-monitoring-installation-token") tokenHash := sha256.Sum256([]byte("demo-monitoring-client-token"))
var installationID int64 var ensuredID uuid.UUID
err := tx.QueryRow(ctx, ` err := tx.QueryRow(ctx, `
WITH ensured AS ( WITH ensured AS (
INSERT INTO plugin_installations ( INSERT INTO desktop_clients (
tenant_id, user_id, installation_key, installation_name, browser_name, id, tenant_id, workspace_id, user_id, token_hash, device_name, os, cpu_arch, client_version, channel, last_seen_at
client_version, installation_token_hash, status, last_seen_at
) )
VALUES ( VALUES (
$1, $2, $3, 'GEO Monitoring Chrome', 'Chrome', $1, $2, $3, $4, $5, 'GEO Monitoring Desktop', 'macOS', 'arm64', '0.1.0', 'stable', NOW()
'0.1.0', $4, 'active', NOW()
) )
ON CONFLICT DO NOTHING ON CONFLICT (id) DO UPDATE
SET tenant_id = EXCLUDED.tenant_id,
workspace_id = EXCLUDED.workspace_id,
user_id = EXCLUDED.user_id,
token_hash = EXCLUDED.token_hash,
device_name = EXCLUDED.device_name,
os = EXCLUDED.os,
cpu_arch = EXCLUDED.cpu_arch,
client_version = EXCLUDED.client_version,
channel = EXCLUDED.channel,
last_seen_at = NOW(),
revoked_at = NULL
RETURNING id RETURNING id
) )
SELECT id FROM ensured SELECT id FROM ensured
UNION ALL UNION ALL
SELECT id SELECT id
FROM plugin_installations FROM desktop_clients
WHERE tenant_id = $1 AND installation_key = $3 AND deleted_at IS NULL WHERE id = $1
LIMIT 1 LIMIT 1
`, tenantID, userID, installationKey, tokenHash).Scan(&installationID) `, clientID, tenantID, workspaceID, userID, tokenHash[:]).Scan(&ensuredID)
if err != nil { if err != nil {
return 0, wrapSeedErr("insert plugin installation", err) return uuid.Nil, wrapSeedErr("insert desktop client", err)
} }
_, err = tx.Exec(ctx, ` _, err = tx.Exec(ctx, `
UPDATE plugin_installations UPDATE desktop_clients
SET status = 'active', SET token_hash = $2,
installation_token_hash = $2,
last_seen_at = NOW(), last_seen_at = NOW(),
updated_at = NOW() revoked_at = NULL
WHERE id = $1 WHERE id = $1
`, installationID, tokenHash) `, ensuredID, tokenHash[:])
if err != nil { if err != nil {
return 0, wrapSeedErr("touch plugin installation", err) return uuid.Nil, wrapSeedErr("touch desktop client", err)
} }
return installationID, nil return ensuredID, nil
} }
func ensureMonitoringQuota(ctx context.Context, tx pgx.Tx, state *seedState) error { func ensureMonitoringQuota(ctx context.Context, tx pgx.Tx, state *seedState) error {
@@ -689,20 +697,20 @@ func ensureMonitoringQuota(ctx context.Context, tx pgx.Tx, state *seedState) err
_, err := tx.Exec(ctx, ` _, err := tx.Exec(ctx, `
INSERT INTO tenant_monitoring_quotas ( INSERT INTO tenant_monitoring_quotas (
tenant_id, workspace_id, max_brands, collection_mode, question_selection_mode, collect_frequency, tenant_id, workspace_id, max_brands, collection_mode, question_selection_mode, collect_frequency,
enabled_platforms, primary_installation_id, task_daily_hard_cap, plan_tier enabled_platforms, primary_client_id, task_daily_hard_cap, plan_tier
) )
VALUES ($1, $2, 1, 'plugin', 'best_effort_all', 'daily', $3::jsonb, $4, 36, 'free') VALUES ($1, $2, 1, 'desktop', 'best_effort_all', 'daily', $3::jsonb, $4, 36, 'free')
ON CONFLICT (tenant_id) DO UPDATE ON CONFLICT (tenant_id) DO UPDATE
SET workspace_id = COALESCE(tenant_monitoring_quotas.workspace_id, EXCLUDED.workspace_id), SET workspace_id = EXCLUDED.workspace_id,
collection_mode = EXCLUDED.collection_mode, collection_mode = EXCLUDED.collection_mode,
question_selection_mode = EXCLUDED.question_selection_mode, question_selection_mode = EXCLUDED.question_selection_mode,
collect_frequency = EXCLUDED.collect_frequency, collect_frequency = EXCLUDED.collect_frequency,
enabled_platforms = EXCLUDED.enabled_platforms, enabled_platforms = EXCLUDED.enabled_platforms,
primary_installation_id = EXCLUDED.primary_installation_id, primary_client_id = EXCLUDED.primary_client_id,
task_daily_hard_cap = EXCLUDED.task_daily_hard_cap, task_daily_hard_cap = EXCLUDED.task_daily_hard_cap,
plan_tier = EXCLUDED.plan_tier, plan_tier = EXCLUDED.plan_tier,
updated_at = NOW() updated_at = NOW()
`, state.TenantID, state.WorkspaceID, string(enabledPlatforms), state.PluginInstallationID) `, state.TenantID, state.WorkspaceID, string(enabledPlatforms), state.DesktopClientID)
return wrapSeedErr("upsert monitoring quota", err) return wrapSeedErr("upsert monitoring quota", err)
} }
@@ -799,11 +807,11 @@ func insertPlatformAccessSnapshots(ctx context.Context, tx pgx.Tx, state *seedSt
for _, item := range entries { for _, item := range entries {
if _, err := tx.Exec(ctx, ` if _, err := tx.Exec(ctx, `
INSERT INTO monitoring_platform_access_snapshots ( 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 connected, accessible, detected_at, reason_code, reason_message
) )
VALUES ($1, $2, $3, $4::date, $5, $6, $7, NOW(), $8, $9) VALUES ($1, $2, $3, $4::date, $5, $6, $7, NOW(), $8, $9)
`, state.TenantID, state.PluginInstallationID, item.PlatformID, businessDate, item.AccessStatus, item.Connected, item.Accessible, item.ReasonCode, item.ReasonMessage); err != nil { `, state.TenantID, state.DesktopClientID, item.PlatformID, businessDate, item.AccessStatus, item.Connected, item.Accessible, item.ReasonCode, item.ReasonMessage); err != nil {
return wrapSeedErr("insert platform access snapshot", err) return wrapSeedErr("insert platform access snapshot", err)
} }
} }
File diff suppressed because it is too large Load Diff
@@ -100,7 +100,7 @@ type MonitoringHeartbeatPlatform struct {
} }
type MonitoringHeartbeatResponse struct { type MonitoringHeartbeatResponse struct {
InstallationID int64 `json:"installation_id"` InstallationID string `json:"installation_id"`
BusinessDate string `json:"business_date"` BusinessDate string `json:"business_date"`
UpdatedPlatformCount int `json:"updated_platform_count"` UpdatedPlatformCount int `json:"updated_platform_count"`
ReconciledSkippedTaskCount int64 `json:"reconciled_skipped_task_count"` ReconciledSkippedTaskCount int64 `json:"reconciled_skipped_task_count"`
@@ -166,8 +166,9 @@ type MonitoringSkipTaskResponse struct {
} }
type monitoringInstallationIdentity struct { type monitoringInstallationIdentity struct {
ID int64 ID string
TenantID int64 TenantID int64
WorkspaceID int64
} }
type monitoringPlatformAccessSnapshotState struct { type monitoringPlatformAccessSnapshotState struct {
@@ -236,7 +237,7 @@ type monitoringTaskResultEnvelope struct {
Version string `json:"version"` Version string `json:"version"`
TaskID int64 `json:"task_id"` TaskID int64 `json:"task_id"`
TenantID int64 `json:"tenant_id"` TenantID int64 `json:"tenant_id"`
InstallationID int64 `json:"installation_id"` InstallationID string `json:"installation_id"`
ReceivedAt time.Time `json:"received_at"` ReceivedAt time.Time `json:"received_at"`
QueueStatus string `json:"queue_status"` QueueStatus string `json:"queue_status"`
QueueClaimedAt *time.Time `json:"queue_claimed_at"` QueueClaimedAt *time.Time `json:"queue_claimed_at"`
@@ -256,7 +257,7 @@ type monitoringTaskResultQueueMetaPatch struct {
type MonitoringTaskResultQueueMetaPatch = monitoringTaskResultQueueMetaPatch 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) installation, err := s.authenticateInstallation(ctx, installationID, installationToken)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -268,10 +269,10 @@ func (s *MonitoringCallbackService) HandleTaskResult(ctx context.Context, instal
} }
if task.CollectorType != monitoringCollectorType { 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") 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 }, 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) installation, err := s.authenticateInstallation(ctx, installationID, installationToken)
if err != nil { if err != nil {
return nil, err return nil, err
} }
isPrimaryInstallation, err := s.resolveHeartbeatPrimaryInstallation(ctx, installation.TenantID, installation.ID) isPrimaryInstallation, err := s.resolveHeartbeatPrimaryInstallation(ctx, installation.TenantID, installation)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -442,7 +443,7 @@ func (s *MonitoringCallbackService) Heartbeat(ctx context.Context, installationI
}, nil }, 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) installation, err := s.authenticateInstallation(ctx, installationID, installationToken)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -522,7 +523,7 @@ func (s *MonitoringCallbackService) LeaseTasks(ctx context.Context, installation
}, nil }, 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) installation, err := s.authenticateInstallation(ctx, installationID, installationToken)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -601,7 +602,7 @@ func (s *MonitoringCallbackService) ResumeTasks(ctx context.Context, installatio
}, nil }, 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) installation, err := s.authenticateInstallation(ctx, installationID, installationToken)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -704,48 +705,47 @@ func (s *MonitoringCallbackService) ProcessQueuedTaskResult(ctx context.Context,
return s.processTaskResult(ctx, task, envelope.CallbackResult) return s.processTaskResult(ctx, task, envelope.CallbackResult)
} }
func (s *MonitoringCallbackService) authenticateInstallation(ctx context.Context, installationID int64, installationToken string) (*monitoringInstallationIdentity, error) { func (s *MonitoringCallbackService) authenticateInstallation(ctx context.Context, installationID string, installationToken string) (*monitoringInstallationIdentity, error) {
tokenHash := sharedauth.HashToken(strings.TrimSpace(installationToken)) tokenHash := HashDesktopClientToken(strings.TrimSpace(installationToken))
row := &monitoringInstallationIdentity{} row := &monitoringInstallationIdentity{}
if err := s.businessPool.QueryRow(ctx, ` if err := s.businessPool.QueryRow(ctx, `
SELECT id, tenant_id SELECT id::text, tenant_id, workspace_id
FROM plugin_installations FROM desktop_clients
WHERE id = $1 WHERE id = $1::uuid
AND installation_token_hash = $2 AND token_hash = $2
AND status = 'active' AND revoked_at IS NULL
AND deleted_at IS NULL `, strings.TrimSpace(installationID), tokenHash).Scan(&row.ID, &row.TenantID, &row.WorkspaceID); err != nil {
`, installationID, tokenHash).Scan(&row.ID, &row.TenantID); err != nil {
if err == pgx.ErrNoRows { 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 return row, nil
} }
func (s *MonitoringCallbackService) resolveHeartbeatPrimaryInstallation(ctx context.Context, tenantID, installationID int64) (bool, error) { func (s *MonitoringCallbackService) resolveHeartbeatPrimaryInstallation(ctx context.Context, tenantID int64, installation *monitoringInstallationIdentity) (bool, error) {
var preferred sql.NullInt64 var preferred sql.NullString
err := s.businessPool.QueryRow(ctx, ` err := s.businessPool.QueryRow(ctx, `
SELECT primary_installation_id SELECT primary_client_id::text
FROM tenant_monitoring_quotas FROM tenant_monitoring_quotas
WHERE tenant_id = $1 WHERE tenant_id = $1
`, tenantID).Scan(&preferred) `, tenantID).Scan(&preferred)
if err != nil && err != pgx.ErrNoRows { 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 !preferred.Valid {
if err := s.persistHeartbeatPrimaryInstallationID(ctx, tenantID, installationID); err != nil { if err := s.persistHeartbeatPrimaryInstallationID(ctx, tenantID, installation); err != nil {
return false, err return false, err
} }
} }
return true, nil return true, nil
} }
online, err := s.isMonitoringInstallationRecentlyOnline(ctx, tenantID, preferred.Int64) online, err := s.isMonitoringInstallationRecentlyOnline(ctx, tenantID, installation.WorkspaceID, preferred.String)
if err != nil { if err != nil {
return false, err return false, err
} }
@@ -753,27 +753,27 @@ func (s *MonitoringCallbackService) resolveHeartbeatPrimaryInstallation(ctx cont
return false, nil 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 false, err
} }
return true, nil 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 var lastSeenAt sql.NullTime
err := s.businessPool.QueryRow(ctx, ` err := s.businessPool.QueryRow(ctx, `
SELECT last_seen_at SELECT last_seen_at
FROM plugin_installations FROM desktop_clients
WHERE id = $1 WHERE id = $1::uuid
AND tenant_id = $2 AND tenant_id = $2
AND status = 'active' AND workspace_id = $3
AND deleted_at IS NULL AND revoked_at IS NULL
`, installationID, tenantID).Scan(&lastSeenAt) `, strings.TrimSpace(installationID), tenantID, workspaceID).Scan(&lastSeenAt)
if err != nil { if err != nil {
if err == pgx.ErrNoRows { if err == pgx.ErrNoRows {
return false, nil 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 { if !lastSeenAt.Valid {
@@ -782,22 +782,22 @@ func (s *MonitoringCallbackService) isMonitoringInstallationRecentlyOnline(ctx c
return time.Since(lastSeenAt.Time) <= monitoringOnlineDuration, nil 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, ` if _, err := s.businessPool.Exec(ctx, `
INSERT INTO tenant_monitoring_quotas ( INSERT INTO tenant_monitoring_quotas (
tenant_id, workspace_id, primary_installation_id tenant_id, workspace_id, primary_client_id
) )
VALUES ( VALUES (
$1, $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 ON CONFLICT (tenant_id) DO UPDATE
SET workspace_id = COALESCE(tenant_monitoring_quotas.workspace_id, EXCLUDED.workspace_id), SET workspace_id = EXCLUDED.workspace_id,
primary_installation_id = EXCLUDED.primary_installation_id, primary_client_id = EXCLUDED.primary_client_id,
updated_at = NOW() updated_at = NOW()
`, tenantID, installationID); err != nil { `, tenantID, installation.WorkspaceID, installation.ID); err != nil {
return response.ErrInternal(50041, "update_failed", "failed to persist primary monitoring installation") return response.ErrInternal(50041, "update_failed", "failed to persist primary monitoring desktop client")
} }
return nil return nil
} }
@@ -1020,11 +1020,12 @@ func (s *MonitoringCallbackService) selectPendingLeaseTasks(
func (s *MonitoringCallbackService) selectLeasedTasksForInstallation( func (s *MonitoringCallbackService) selectLeasedTasksForInstallation(
ctx context.Context, ctx context.Context,
tx pgx.Tx, tx pgx.Tx,
tenantID, installationID int64, tenantID int64,
installationID string,
businessDate string, businessDate string,
eligiblePlatformIDs []string, eligiblePlatformIDs []string,
) ([]monitoringLeasedTaskRow, error) { ) ([]monitoringLeasedTaskRow, error) {
queryArgs := []interface{}{tenantID, monitoringCollectorType, businessDate, strconv.FormatInt(installationID, 10)} queryArgs := []interface{}{tenantID, monitoringCollectorType, businessDate, installationID}
query := ` query := `
SELECT SELECT
t.id, t.id,
@@ -1108,7 +1109,7 @@ func (s *MonitoringCallbackService) selectLeasedTasksForInstallation(
return result, nil 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, ` if _, err := tx.Exec(ctx, `
UPDATE monitoring_collect_tasks UPDATE monitoring_collect_tasks
SET status = 'leased', SET status = 'leased',
@@ -1120,7 +1121,7 @@ func (s *MonitoringCallbackService) markTaskLeased(ctx context.Context, tx pgx.T
error_message = NULL, error_message = NULL,
updated_at = NOW() updated_at = NOW()
WHERE id = $1 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 response.ErrInternal(50041, "lease_update_failed", "failed to update monitoring lease task")
} }
return nil return nil
@@ -1165,11 +1166,11 @@ func (s *MonitoringCallbackService) upsertPlatformAccessSnapshot(ctx context.Con
if _, err := tx.Exec(ctx, ` if _, err := tx.Exec(ctx, `
INSERT INTO monitoring_platform_access_snapshots ( 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 connected, accessible, detected_at, reason_code, reason_message
) )
VALUES ($1, $2, $3, $4::date, $5, $6, $7, $8, $9, $10) VALUES ($1, $2::uuid, $3, $4::date, $5, $6, $7, $8, $9, $10)
ON CONFLICT (tenant_id, installation_id, ai_platform_id, business_date) ON CONFLICT (tenant_id, client_id, ai_platform_id, business_date)
DO UPDATE SET DO UPDATE SET
access_status = EXCLUDED.access_status, access_status = EXCLUDED.access_status,
connected = EXCLUDED.connected, connected = EXCLUDED.connected,
@@ -1195,7 +1196,7 @@ func (s *MonitoringCallbackService) upsertPlatformAccessSnapshot(ctx context.Con
return hasPlatformAccessSnapshotChanged(previous, nextState), nil 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{} item := &monitoringPlatformAccessSnapshotState{}
var reasonCode sql.NullString var reasonCode sql.NullString
var reasonMessage 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 SELECT access_status, connected, accessible, reason_code, reason_message
FROM monitoring_platform_access_snapshots FROM monitoring_platform_access_snapshots
WHERE tenant_id = $1 WHERE tenant_id = $1
AND installation_id = $2 AND client_id = $2::uuid
AND ai_platform_id = $3 AND ai_platform_id = $3
AND business_date = $4::date AND business_date = $4::date
`, tenantID, installationID, aiPlatformID, businessDate).Scan( `, tenantID, installationID, aiPlatformID, businessDate).Scan(
@@ -1721,11 +1722,11 @@ func (s *MonitoringCallbackService) loadExistingRunSummary(ctx context.Context,
return &value, citationSourceCount, contentCitationCount, nil 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, ` _, err := s.businessPool.Exec(ctx, `
UPDATE plugin_installations UPDATE desktop_clients
SET last_seen_at = NOW(), updated_at = NOW() SET last_seen_at = NOW()
WHERE id = $1 WHERE id = $1::uuid
`, installationID) `, installationID)
return err return err
} }
@@ -1821,7 +1822,7 @@ func buildMonitoringRawPayload(req MonitoringTaskResultRequest) ([]byte, []Monit
return data, sourceInputs 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{ return monitoringTaskResultEnvelope{
Version: "v1", Version: "v1",
TaskID: task.ID, 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") 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 { func validateTaskLease(task *monitoringCollectTask, installationID string, leaseToken string) error {
if task.LeasedToExecutor.Valid && strings.TrimSpace(task.LeasedToExecutor.String) != "" && task.LeasedToExecutor.String != strconv.FormatInt(installationID, 10) { 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") return response.ErrUnauthorized(40141, "lease_owner_mismatch", "task is leased to another installation")
} }
@@ -48,12 +48,12 @@ func TestNewMonitoringTaskResultEnvelope(t *testing.T) {
TenantID: 34, 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, "v1", envelope.Version)
assert.Equal(t, int64(12), envelope.TaskID) assert.Equal(t, int64(12), envelope.TaskID)
assert.Equal(t, int64(34), envelope.TenantID) 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, receivedAt, envelope.ReceivedAt)
assert.Equal(t, "pending", envelope.QueueStatus) assert.Equal(t, "pending", envelope.QueueStatus)
assert.Nil(t, envelope.QueueEnqueuedAt) assert.Nil(t, envelope.QueueEnqueuedAt)
@@ -69,7 +69,7 @@ func TestDecodeMonitoringTaskResultEnvelope(t *testing.T) {
Version: "v1", Version: "v1",
TaskID: 12, TaskID: 12,
TenantID: 34, TenantID: 34,
InstallationID: 56, InstallationID: "00000000-0000-0000-0000-000000000056",
ReceivedAt: time.Date(2026, 4, 10, 12, 0, 0, 0, time.UTC), ReceivedAt: time.Date(2026, 4, 10, 12, 0, 0, 0, time.UTC),
QueueStatus: "queued", QueueStatus: "queued",
CallbackResult: MonitoringTaskResultRequest{LeaseToken: "lease-token", Status: "succeeded"}, CallbackResult: MonitoringTaskResultRequest{LeaseToken: "lease-token", Status: "succeeded"},
@@ -10,6 +10,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@@ -20,7 +21,7 @@ import (
const ( const (
defaultTrackingDays = 7 defaultTrackingDays = 7
maxTrackingDays = 30 maxTrackingDays = 30
monitoringCollectorType = "plugin" monitoringCollectorType = "desktop"
monitoringOnlineDuration = 20 * time.Minute monitoringOnlineDuration = 20 * time.Minute
monitoringCollectNowQuestionLimit = 5 monitoringCollectNowQuestionLimit = 5
) )
@@ -189,9 +190,9 @@ type MonitoringCollectNowResponse struct {
} }
type monitoringQuotaConfig struct { type monitoringQuotaConfig struct {
CollectionMode string CollectionMode string
PrimaryInstallationID *int64 PrimaryClientID *uuid.UUID
EnabledPlatforms []string EnabledPlatforms []string
} }
type monitoringBrandIdentity struct { type monitoringBrandIdentity struct {
@@ -301,7 +302,7 @@ func (s *MonitoringService) DashboardComposite(
return nil, err 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 { if err != nil {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
@@ -497,15 +498,15 @@ func (s *MonitoringService) CollectNow(ctx context.Context, brandID int64, keywo
platforms := resolveMonitoringPlatforms(quota.EnabledPlatforms) platforms := resolveMonitoringPlatforms(quota.EnabledPlatforms)
questionIDs := configuredQuestionIDs(configuredQuestions) 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 { if err != nil {
return nil, err return nil, err
} }
if installationID == nil { if clientID == nil {
return nil, response.ErrConflict(40941, "installation_offline", "primary monitoring installation is offline") 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 return nil, err
} }
@@ -584,9 +585,9 @@ func (s *MonitoringService) CollectNow(ctx context.Context, brandID int64, keywo
}, nil }, 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 { if preferred != nil {
online, err := s.isInstallationOnline(ctx, tenantID, *preferred) online, err := s.isClientOnline(ctx, tenantID, workspaceID, *preferred)
if err != nil { if err != nil {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
@@ -604,7 +605,7 @@ func (s *MonitoringService) resolveCollectNowInstallationID(ctx context.Context,
} }
if preferred == nil || *preferred != *fallbackID { 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 return nil, err
} }
} }
@@ -612,44 +613,44 @@ func (s *MonitoringService) resolveCollectNowInstallationID(ctx context.Context,
return fallbackID, nil return fallbackID, nil
} }
func (s *MonitoringService) findLatestOnlineInstallation(ctx context.Context, tenantID int64) (*int64, error) { func (s *MonitoringService) findLatestOnlineClient(ctx context.Context, tenantID, workspaceID int64) (*uuid.UUID, error) {
var installationID int64 var clientID uuid.UUID
err := s.businessPool.QueryRow(ctx, ` err := s.businessPool.QueryRow(ctx, `
SELECT id SELECT id
FROM plugin_installations FROM desktop_clients
WHERE tenant_id = $1 WHERE tenant_id = $1
AND status = 'active' AND workspace_id = $2
AND deleted_at IS NULL AND revoked_at IS NULL
AND last_seen_at IS NOT NULL AND last_seen_at IS NOT NULL
AND last_seen_at >= NOW() - $2::interval AND last_seen_at >= NOW() - $3::interval
ORDER BY last_seen_at DESC, updated_at DESC, id DESC ORDER BY last_seen_at DESC, created_at DESC, id DESC
LIMIT 1 LIMIT 1
`, tenantID, formatPgInterval(monitoringOnlineDuration)).Scan(&installationID) `, tenantID, workspaceID, formatPgInterval(monitoringOnlineDuration)).Scan(&clientID)
if err != nil { if err != nil {
if err == pgx.ErrNoRows { if err == pgx.ErrNoRows {
return nil, nil 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, ` if _, err := s.businessPool.Exec(ctx, `
INSERT INTO tenant_monitoring_quotas ( INSERT INTO tenant_monitoring_quotas (
tenant_id, workspace_id, primary_installation_id tenant_id, workspace_id, primary_client_id
) )
VALUES ( VALUES (
$1, $1,
(SELECT id FROM workspaces WHERE tenant_id = $1 AND is_default = TRUE LIMIT 1), $2,
$2 $3
) )
ON CONFLICT (tenant_id) DO UPDATE ON CONFLICT (tenant_id) DO UPDATE
SET workspace_id = COALESCE(tenant_monitoring_quotas.workspace_id, EXCLUDED.workspace_id), SET workspace_id = EXCLUDED.workspace_id,
primary_installation_id = EXCLUDED.primary_installation_id, primary_client_id = EXCLUDED.primary_client_id,
updated_at = NOW() updated_at = NOW()
`, tenantID, installationID); err != nil { `, tenantID, workspaceID, clientID); err != nil {
return response.ErrInternal(50041, "update_failed", "failed to persist primary monitoring installation") return response.ErrInternal(50041, "update_failed", "failed to persist primary monitoring desktop client")
} }
return nil return nil
} }
@@ -894,12 +895,12 @@ func (s *MonitoringService) loadQuota(ctx context.Context, tenantID int64) (moni
} }
var enabledJSON []byte var enabledJSON []byte
var installationID sql.NullInt64 var clientID uuid.NullUUID
err := s.businessPool.QueryRow(ctx, ` 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 FROM tenant_monitoring_quotas
WHERE tenant_id = $1 WHERE tenant_id = $1
`, tenantID).Scan(&cfg.CollectionMode, &enabledJSON, &installationID) `, tenantID).Scan(&cfg.CollectionMode, &enabledJSON, &clientID)
if err != nil { if err != nil {
if err == pgx.ErrNoRows { if err == pgx.ErrNoRows {
return cfg, nil return cfg, nil
@@ -913,9 +914,9 @@ func (s *MonitoringService) loadQuota(ctx context.Context, tenantID int64) (moni
cfg.EnabledPlatforms = enabled cfg.EnabledPlatforms = enabled
} }
} }
if installationID.Valid { if clientID.Valid {
id := installationID.Int64 id := clientID.UUID
cfg.PrimaryInstallationID = &id cfg.PrimaryClientID = &id
} }
if strings.TrimSpace(cfg.CollectionMode) == "" { if strings.TrimSpace(cfg.CollectionMode) == "" {
cfg.CollectionMode = monitoringCollectorType cfg.CollectionMode = monitoringCollectorType
@@ -1700,9 +1701,9 @@ func (s *MonitoringService) loadTimeBucketsForPlatform(
return buckets, nil 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{} states := map[string]monitoringAccessState{}
if installationID == nil { if clientID == nil {
return states, nil return states, nil
} }
@@ -1710,9 +1711,9 @@ func (s *MonitoringService) loadAccessStates(ctx context.Context, tenantID int64
SELECT ai_platform_id, access_status SELECT ai_platform_id, access_status
FROM monitoring_platform_access_snapshots FROM monitoring_platform_access_snapshots
WHERE tenant_id = $1 WHERE tenant_id = $1
AND installation_id = $2 AND client_id = $2
AND business_date = $3::date AND business_date = $3::date
`, tenantID, *installationID, businessDate.Format("2006-01-02")) `, tenantID, *clientID, businessDate.Format("2006-01-02"))
if err != nil { if err != nil {
return nil, response.ErrInternal(50041, "query_failed", "failed to load platform access states") 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 return items, nil
} }
func (s *MonitoringService) ensureInstallationOnline(ctx context.Context, tenantID, installationID int64) error { func (s *MonitoringService) ensureClientOnline(ctx context.Context, tenantID, workspaceID int64, clientID uuid.UUID) error {
online, err := s.isInstallationOnline(ctx, tenantID, installationID) online, err := s.isClientOnline(ctx, tenantID, workspaceID, clientID)
if err != nil { if err != nil {
return err return err
} }
if !online { 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 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 var lastSeenAt sql.NullTime
err := s.businessPool.QueryRow(ctx, ` err := s.businessPool.QueryRow(ctx, `
SELECT last_seen_at SELECT last_seen_at
FROM plugin_installations FROM desktop_clients
WHERE id = $1 WHERE id = $1
AND tenant_id = $2 AND tenant_id = $2
AND status = 'active' AND workspace_id = $3
AND deleted_at IS NULL AND revoked_at IS NULL
`, installationID, tenantID).Scan(&lastSeenAt) `, clientID, tenantID, workspaceID).Scan(&lastSeenAt)
if err != nil { if err != nil {
if err == pgx.ErrNoRows { if err == pgx.ErrNoRows {
return false, nil 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 { if !lastSeenAt.Valid || time.Since(lastSeenAt.Time) > monitoringOnlineDuration {
@@ -19,6 +19,14 @@ type publishableArticleMeta struct {
CoverAssetURL *string CoverAssetURL *string
} }
type platformAccountSeed struct {
ID int64
PlatformID string
PlatformName string
PlatformUID string
Nickname string
}
type desktopPublishSyncOutcome struct { type desktopPublishSyncOutcome struct {
TenantID int64 TenantID int64
ArticleID 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) { func resolvePublishArticleID(contentRef map[string]any) (int64, error) {
if articleID, ok := extractInt64FromMap(contentRef, "article_id", "id"); ok && articleID > 0 { if articleID, ok := extractInt64FromMap(contentRef, "article_id", "id"); ok && articleID > 0 {
return articleID, nil return articleID, nil
@@ -484,40 +484,6 @@ type PlatformUserRole struct {
DeletedAt pgtype.Timestamptz `json:"deleted_at"` 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 { type PromptRule struct {
ID int64 `json:"id"` ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"` TenantID int64 `json:"tenant_id"`
@@ -681,7 +647,7 @@ type TenantMonitoringQuota struct {
QuestionSelectionMode string `json:"question_selection_mode"` QuestionSelectionMode string `json:"question_selection_mode"`
CollectFrequency string `json:"collect_frequency"` CollectFrequency string `json:"collect_frequency"`
EnabledPlatforms []byte `json:"enabled_platforms"` 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"` TaskDailyHardCap int32 `json:"task_daily_hard_cap"`
PlanTier string `json:"plan_tier"` PlanTier string `json:"plan_tier"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
@@ -4,7 +4,6 @@ import (
"strconv" "strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/geo-platform/tenant-api/internal/bootstrap" "github.com/geo-platform/tenant-api/internal/bootstrap"
"github.com/geo-platform/tenant-api/internal/shared/response" "github.com/geo-platform/tenant-api/internal/shared/response"
@@ -12,14 +11,12 @@ import (
) )
type MediaHandler struct { type MediaHandler struct {
svc *app.MediaService svc *app.MediaService
logger *zap.Logger
} }
func NewMediaHandler(a *bootstrap.App) *MediaHandler { func NewMediaHandler(a *bootstrap.App) *MediaHandler {
return &MediaHandler{ return &MediaHandler{
svc: app.NewMediaService(a.DB, a.Logger).WithCache(a.Cache), svc: app.NewMediaService(a.DB),
logger: a.Logger,
} }
} }
@@ -32,75 +29,6 @@ func (h *MediaHandler) ListPlatforms(c *gin.Context) {
response.Success(c, data) 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) { func (h *MediaHandler) ListPublishRecords(c *gin.Context) {
articleID, err := strconv.ParseInt(c.Param("id"), 10, 64) articleID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil { if err != nil {
@@ -114,61 +42,3 @@ func (h *MediaHandler) ListPublishRecords(c *gin.Context) {
} }
response.Success(c, data) 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) publicAssets := NewAssetHandler(app)
app.Engine.GET("/api/public/assets/:token", publicAssets.Serve) app.Engine.GET("/api/public/assets/:token", publicAssets.Serve)
pluginHandler := NewMediaHandler(app) mediaHandler := NewMediaHandler(app)
callbacks := app.Engine.Group("/api/callback/plugin")
callbacks.POST("/bind", pluginHandler.PluginBindCallback)
callbacks.POST("/publish", pluginHandler.PluginPublishCallback)
protected := app.Engine.Group("/api") protected := app.Engine.Group("/api")
protected.Use(auth.Middleware(app.JWT, app.Sessions)) protected.Use(auth.Middleware(app.JWT, app.Sessions))
@@ -65,7 +62,7 @@ func RegisterRoutes(app *bootstrap.App) {
tenantProtected.POST("/publish-jobs", publishJobHandler.Create) tenantProtected.POST("/publish-jobs", publishJobHandler.Create)
media := tenantProtected.Group("/media") media := tenantProtected.Group("/media")
media.GET("/platforms", pluginHandler.ListPlatforms) media.GET("/platforms", mediaHandler.ListPlatforms)
workspace := tenantProtected.Group("/workspace") workspace := tenantProtected.Group("/workspace")
wsHandler := NewWorkspaceHandler(app) wsHandler := NewWorkspaceHandler(app)
@@ -149,8 +146,7 @@ func RegisterRoutes(app *bootstrap.App) {
articles.PUT("/:id", artHandler.Update) articles.PUT("/:id", artHandler.Update)
articles.GET("/:id/stream", artHandler.Stream) articles.GET("/:id/stream", artHandler.Stream)
articles.GET("/:id/versions", artHandler.Versions) articles.GET("/:id/versions", artHandler.Versions)
articles.GET("/:id/publish-records", pluginHandler.ListPublishRecords) articles.GET("/:id/publish-records", mediaHandler.ListPublishRecords)
articles.POST("/:id/publish-batches", pluginHandler.CreatePublishBatch)
articles.DELETE("/:id", artHandler.Delete) articles.DELETE("/:id", artHandler.Delete)
brands := tenantProtected.Group("/brands") brands := tenantProtected.Group("/brands")
@@ -1,6 +1,4 @@
DROP TABLE IF EXISTS publish_records; DROP TABLE IF EXISTS publish_records;
DROP TABLE IF EXISTS publish_batches; DROP TABLE IF EXISTS publish_batches;
DROP TABLE IF EXISTS plugin_sessions;
DROP TABLE IF EXISTS plugin_installations;
DROP TABLE IF EXISTS platform_accounts; DROP TABLE IF EXISTS platform_accounts;
DROP TABLE IF EXISTS media_platforms; DROP TABLE IF EXISTS media_platforms;
@@ -53,50 +53,6 @@ CREATE UNIQUE INDEX uk_platform_accounts_identity_active
WHERE deleted_at IS NULL; WHERE deleted_at IS NULL;
CREATE INDEX idx_platform_accounts_tenant_platform ON platform_accounts(tenant_id, platform_id, status, created_at DESC); CREATE INDEX idx_platform_accounts_tenant_platform ON platform_accounts(tenant_id, platform_id, status, created_at DESC);
CREATE TABLE plugin_installations (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
user_id BIGINT NOT NULL REFERENCES users(id),
installation_key VARCHAR(128) NOT NULL,
installation_name VARCHAR(255) NOT NULL,
browser_name VARCHAR(64),
client_version VARCHAR(32),
installation_token_hash VARCHAR(128) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
last_seen_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX uk_plugin_installations_key_active
ON plugin_installations(tenant_id, installation_key)
WHERE deleted_at IS NULL;
CREATE INDEX idx_plugin_installations_tenant_status
ON plugin_installations(tenant_id, status, created_at DESC);
CREATE TABLE plugin_sessions (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
user_id BIGINT NOT NULL REFERENCES users(id),
plugin_installation_id BIGINT REFERENCES plugin_installations(id),
action_type VARCHAR(20) NOT NULL,
resource_type VARCHAR(30),
resource_id BIGINT,
platform_account_id BIGINT REFERENCES platform_accounts(id),
platform_id VARCHAR(64) NOT NULL REFERENCES media_platforms(platform_id),
session_token VARCHAR(128) NOT NULL,
client_version VARCHAR(32),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
expire_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uk_plugin_sessions_token ON plugin_sessions(session_token);
CREATE INDEX idx_plugin_sessions_status_expire ON plugin_sessions(status, expire_at);
CREATE INDEX idx_plugin_sessions_tenant_action ON plugin_sessions(tenant_id, action_type, created_at DESC);
CREATE TABLE publish_batches ( CREATE TABLE publish_batches (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id), tenant_id BIGINT NOT NULL REFERENCES tenants(id),
@@ -1,4 +1 @@
-- No-op rollback. -- Legacy no-op rollback.
-- This migration repairs databases that already applied an older variant of
-- 20260402140000 without plugin_installations. Dropping these objects on
-- rollback would break databases created from the current baseline migration.
@@ -1,39 +1 @@
CREATE TABLE IF NOT EXISTS plugin_installations ( -- Legacy no-op.
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
user_id BIGINT NOT NULL REFERENCES users(id),
installation_key VARCHAR(128) NOT NULL,
installation_name VARCHAR(255) NOT NULL,
browser_name VARCHAR(64),
client_version VARCHAR(32),
installation_token_hash VARCHAR(128) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
last_seen_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_plugin_installations_key_active
ON plugin_installations(tenant_id, installation_key)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_plugin_installations_tenant_status
ON plugin_installations(tenant_id, status, created_at DESC);
ALTER TABLE plugin_sessions
ADD COLUMN IF NOT EXISTS plugin_installation_id BIGINT;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'plugin_sessions_plugin_installation_id_fkey'
) THEN
ALTER TABLE plugin_sessions
ADD CONSTRAINT plugin_sessions_plugin_installation_id_fkey
FOREIGN KEY (plugin_installation_id)
REFERENCES plugin_installations(id);
END IF;
END $$;
@@ -1,11 +1,11 @@
CREATE TABLE tenant_monitoring_quotas ( CREATE TABLE tenant_monitoring_quotas (
tenant_id BIGINT PRIMARY KEY REFERENCES tenants(id), tenant_id BIGINT PRIMARY KEY REFERENCES tenants(id),
max_brands INT NOT NULL DEFAULT 1, max_brands INT NOT NULL DEFAULT 1,
collection_mode VARCHAR(20) NOT NULL DEFAULT 'plugin', collection_mode VARCHAR(20) NOT NULL DEFAULT 'desktop',
question_selection_mode VARCHAR(20) NOT NULL DEFAULT 'best_effort_all', question_selection_mode VARCHAR(20) NOT NULL DEFAULT 'best_effort_all',
collect_frequency VARCHAR(20) NOT NULL DEFAULT 'daily', collect_frequency VARCHAR(20) NOT NULL DEFAULT 'daily',
enabled_platforms JSONB NOT NULL DEFAULT '["deepseek","qwen","doubao"]'::jsonb, enabled_platforms JSONB NOT NULL DEFAULT '["deepseek","qwen","doubao"]'::jsonb,
primary_installation_id BIGINT REFERENCES plugin_installations(id), primary_client_id UUID,
task_daily_hard_cap INT NOT NULL DEFAULT 36, task_daily_hard_cap INT NOT NULL DEFAULT 36,
plan_tier VARCHAR(20) NOT NULL DEFAULT 'free', plan_tier VARCHAR(20) NOT NULL DEFAULT 'free',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@@ -0,0 +1,53 @@
CREATE TABLE IF NOT EXISTS plugin_installations (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
user_id BIGINT NOT NULL REFERENCES users(id),
installation_key VARCHAR(128) NOT NULL,
installation_name VARCHAR(255) NOT NULL,
browser_name VARCHAR(64),
client_version VARCHAR(32),
installation_token_hash VARCHAR(128) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
last_seen_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_plugin_installations_key_active
ON plugin_installations(tenant_id, installation_key)
WHERE deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS plugin_sessions (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
user_id BIGINT NOT NULL REFERENCES users(id),
plugin_installation_id BIGINT REFERENCES plugin_installations(id),
action_type VARCHAR(20) NOT NULL,
resource_type VARCHAR(30),
resource_id BIGINT,
platform_account_id BIGINT REFERENCES platform_accounts(id),
platform_id VARCHAR(64) NOT NULL REFERENCES media_platforms(platform_id),
session_token VARCHAR(128) NOT NULL,
client_version VARCHAR(32),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
expire_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE tenant_monitoring_quotas
DROP CONSTRAINT IF EXISTS tenant_monitoring_quotas_primary_client_id_fkey;
ALTER TABLE tenant_monitoring_quotas
ADD COLUMN IF NOT EXISTS primary_installation_id BIGINT REFERENCES plugin_installations(id);
UPDATE tenant_monitoring_quotas
SET collection_mode = 'plugin'
WHERE collection_mode = 'desktop';
ALTER TABLE tenant_monitoring_quotas
ALTER COLUMN collection_mode SET DEFAULT 'plugin';
ALTER TABLE tenant_monitoring_quotas
DROP COLUMN IF EXISTS primary_client_id;
@@ -0,0 +1,32 @@
ALTER TABLE tenant_monitoring_quotas
DROP CONSTRAINT IF EXISTS tenant_monitoring_quotas_primary_installation_id_fkey;
ALTER TABLE tenant_monitoring_quotas
ADD COLUMN IF NOT EXISTS primary_client_id UUID;
UPDATE tenant_monitoring_quotas
SET collection_mode = 'desktop'
WHERE collection_mode = 'plugin';
ALTER TABLE tenant_monitoring_quotas
ALTER COLUMN collection_mode SET DEFAULT 'desktop';
ALTER TABLE tenant_monitoring_quotas
DROP COLUMN IF EXISTS primary_installation_id;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'tenant_monitoring_quotas_primary_client_id_fkey'
) THEN
ALTER TABLE tenant_monitoring_quotas
ADD CONSTRAINT tenant_monitoring_quotas_primary_client_id_fkey
FOREIGN KEY (primary_client_id)
REFERENCES desktop_clients(id);
END IF;
END $$;
DROP TABLE IF EXISTS plugin_sessions;
DROP TABLE IF EXISTS plugin_installations;
@@ -21,7 +21,7 @@ CREATE INDEX idx_monitoring_question_snapshot_active
CREATE TABLE monitoring_platform_access_snapshots ( CREATE TABLE monitoring_platform_access_snapshots (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL,
installation_id BIGINT NOT NULL, client_id UUID NOT NULL,
ai_platform_id VARCHAR(30) NOT NULL, ai_platform_id VARCHAR(30) NOT NULL,
business_date DATE NOT NULL, business_date DATE NOT NULL,
access_status VARCHAR(20) NOT NULL, access_status VARCHAR(20) NOT NULL,
@@ -32,7 +32,7 @@ CREATE TABLE monitoring_platform_access_snapshots (
reason_message TEXT, reason_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uk_platform_access_snapshot UNIQUE (tenant_id, installation_id, ai_platform_id, business_date) CONSTRAINT uk_platform_access_snapshot UNIQUE (tenant_id, client_id, ai_platform_id, business_date)
); );
CREATE INDEX idx_platform_access_snapshot_lookup CREATE INDEX idx_platform_access_snapshot_lookup
@@ -45,9 +45,9 @@ CREATE TABLE monitoring_collect_tasks (
question_id BIGINT NOT NULL, question_id BIGINT NOT NULL,
question_hash BYTEA NOT NULL, question_hash BYTEA NOT NULL,
ai_platform_id VARCHAR(30) NOT NULL, ai_platform_id VARCHAR(30) NOT NULL,
collector_type VARCHAR(20) NOT NULL DEFAULT 'plugin', collector_type VARCHAR(20) NOT NULL DEFAULT 'desktop',
trigger_source VARCHAR(20) NOT NULL DEFAULT 'automatic', trigger_source VARCHAR(20) NOT NULL DEFAULT 'automatic',
run_mode VARCHAR(30) NOT NULL DEFAULT 'plugin_standard', run_mode VARCHAR(30) NOT NULL DEFAULT 'desktop_standard',
business_date DATE NOT NULL, business_date DATE NOT NULL,
planned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), planned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status VARCHAR(20) NOT NULL DEFAULT 'pending', status VARCHAR(20) NOT NULL DEFAULT 'pending',
@@ -77,9 +77,9 @@ CREATE TABLE question_monitor_runs (
question_text_snapshot TEXT NOT NULL, question_text_snapshot TEXT NOT NULL,
question_hash BYTEA NOT NULL, question_hash BYTEA NOT NULL,
ai_platform_id VARCHAR(30) NOT NULL, ai_platform_id VARCHAR(30) NOT NULL,
collector_type VARCHAR(20) NOT NULL DEFAULT 'plugin', collector_type VARCHAR(20) NOT NULL DEFAULT 'desktop',
trigger_source VARCHAR(20) NOT NULL DEFAULT 'automatic', trigger_source VARCHAR(20) NOT NULL DEFAULT 'automatic',
run_mode VARCHAR(30) NOT NULL DEFAULT 'plugin_standard', run_mode VARCHAR(30) NOT NULL DEFAULT 'desktop_standard',
business_date DATE NOT NULL, business_date DATE NOT NULL,
provider_model VARCHAR(100), provider_model VARCHAR(100),
provider_request_id VARCHAR(100), provider_request_id VARCHAR(100),
@@ -187,7 +187,7 @@ CREATE TABLE monitoring_brand_daily (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL,
brand_id BIGINT NOT NULL, brand_id BIGINT NOT NULL,
collector_type VARCHAR(20) NOT NULL DEFAULT 'plugin', collector_type VARCHAR(20) NOT NULL DEFAULT 'desktop',
business_date DATE NOT NULL, business_date DATE NOT NULL,
sample_status VARCHAR(20) NOT NULL, sample_status VARCHAR(20) NOT NULL,
desired_sample_count INT NOT NULL DEFAULT 0, desired_sample_count INT NOT NULL DEFAULT 0,
@@ -221,7 +221,7 @@ CREATE TABLE monitoring_brand_platform_daily (
tenant_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL,
brand_id BIGINT NOT NULL, brand_id BIGINT NOT NULL,
ai_platform_id VARCHAR(30) NOT NULL, ai_platform_id VARCHAR(30) NOT NULL,
collector_type VARCHAR(20) NOT NULL DEFAULT 'plugin', collector_type VARCHAR(20) NOT NULL DEFAULT 'desktop',
business_date DATE NOT NULL, business_date DATE NOT NULL,
platform_sample_status VARCHAR(20) NOT NULL, platform_sample_status VARCHAR(20) NOT NULL,
planned_sample_count INT NOT NULL DEFAULT 0, planned_sample_count INT NOT NULL DEFAULT 0,
@@ -0,0 +1,63 @@
DROP TABLE IF EXISTS monitoring_platform_access_snapshots;
CREATE TABLE monitoring_platform_access_snapshots (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
installation_id BIGINT NOT NULL,
ai_platform_id VARCHAR(30) NOT NULL,
business_date DATE NOT NULL,
access_status VARCHAR(20) NOT NULL,
connected BOOLEAN NOT NULL DEFAULT false,
accessible BOOLEAN NOT NULL DEFAULT false,
detected_at TIMESTAMPTZ NOT NULL,
reason_code VARCHAR(50),
reason_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uk_platform_access_snapshot UNIQUE (tenant_id, installation_id, ai_platform_id, business_date)
);
CREATE INDEX idx_platform_access_snapshot_lookup
ON monitoring_platform_access_snapshots(tenant_id, business_date, ai_platform_id, access_status);
ALTER TABLE monitoring_collect_tasks
ALTER COLUMN collector_type SET DEFAULT 'plugin';
ALTER TABLE monitoring_collect_tasks
ALTER COLUMN run_mode SET DEFAULT 'plugin_standard';
UPDATE monitoring_collect_tasks
SET collector_type = 'plugin'
WHERE collector_type = 'desktop';
UPDATE monitoring_collect_tasks
SET run_mode = 'plugin_standard'
WHERE run_mode = 'desktop_standard';
ALTER TABLE question_monitor_runs
ALTER COLUMN collector_type SET DEFAULT 'plugin';
ALTER TABLE question_monitor_runs
ALTER COLUMN run_mode SET DEFAULT 'plugin_standard';
UPDATE question_monitor_runs
SET collector_type = 'plugin'
WHERE collector_type = 'desktop';
UPDATE question_monitor_runs
SET run_mode = 'plugin_standard'
WHERE run_mode = 'desktop_standard';
ALTER TABLE monitoring_brand_daily
ALTER COLUMN collector_type SET DEFAULT 'plugin';
UPDATE monitoring_brand_daily
SET collector_type = 'plugin'
WHERE collector_type = 'desktop';
ALTER TABLE monitoring_brand_platform_daily
ALTER COLUMN collector_type SET DEFAULT 'plugin';
UPDATE monitoring_brand_platform_daily
SET collector_type = 'plugin'
WHERE collector_type = 'desktop';
@@ -0,0 +1,63 @@
DROP TABLE IF EXISTS monitoring_platform_access_snapshots;
CREATE TABLE monitoring_platform_access_snapshots (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
client_id UUID NOT NULL,
ai_platform_id VARCHAR(30) NOT NULL,
business_date DATE NOT NULL,
access_status VARCHAR(20) NOT NULL,
connected BOOLEAN NOT NULL DEFAULT false,
accessible BOOLEAN NOT NULL DEFAULT false,
detected_at TIMESTAMPTZ NOT NULL,
reason_code VARCHAR(50),
reason_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uk_platform_access_snapshot UNIQUE (tenant_id, client_id, ai_platform_id, business_date)
);
CREATE INDEX idx_platform_access_snapshot_lookup
ON monitoring_platform_access_snapshots(tenant_id, business_date, ai_platform_id, access_status);
ALTER TABLE monitoring_collect_tasks
ALTER COLUMN collector_type SET DEFAULT 'desktop';
ALTER TABLE monitoring_collect_tasks
ALTER COLUMN run_mode SET DEFAULT 'desktop_standard';
UPDATE monitoring_collect_tasks
SET collector_type = 'desktop'
WHERE collector_type = 'plugin';
UPDATE monitoring_collect_tasks
SET run_mode = 'desktop_standard'
WHERE run_mode = 'plugin_standard';
ALTER TABLE question_monitor_runs
ALTER COLUMN collector_type SET DEFAULT 'desktop';
ALTER TABLE question_monitor_runs
ALTER COLUMN run_mode SET DEFAULT 'desktop_standard';
UPDATE question_monitor_runs
SET collector_type = 'desktop'
WHERE collector_type = 'plugin';
UPDATE question_monitor_runs
SET run_mode = 'desktop_standard'
WHERE run_mode = 'plugin_standard';
ALTER TABLE monitoring_brand_daily
ALTER COLUMN collector_type SET DEFAULT 'desktop';
UPDATE monitoring_brand_daily
SET collector_type = 'desktop'
WHERE collector_type = 'plugin';
ALTER TABLE monitoring_brand_platform_daily
ALTER COLUMN collector_type SET DEFAULT 'desktop';
UPDATE monitoring_brand_platform_daily
SET collector_type = 'desktop'
WHERE collector_type = 'plugin';