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. 文档信息
@@ -6,7 +8,7 @@
| --- | --- |
| 文档名称 | AI 品牌曝光监测系统技术方案 V4(插件采集 + 配额约束) |
| 文档版本 | V4.0 |
| 文档状态 | 待评审 |
| 文档状态 | 旧版设计(保留工作记录) |
| 创建日期 | 2026-04-08 |
| 基线文档 | `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. 文档信息
@@ -6,7 +8,7 @@
| -------- | --------------------------------------------------------------------- |
| 文档名称 | AI 品牌曝光监测系统技术方案 V5(插件采样首期交付 / API 精确采集预留) |
| 文档版本 | V5.9 |
| 文档状态 | 待评审 |
| 文档状态 | 旧版设计(保留工作记录) |
| 创建日期 | 2026-04-09 |
| 基线文档 | `docs/ai-brand-monitoring-tech-design-v4.md` |
| 适用范围 | 数据追踪模块全部页面 |
@@ -1,5 +1,7 @@
# GEO 双后台与后端技术架构方案 V1
> 工作记录说明:本文仍保留作为历史架构记录。其中涉及浏览器插件执行链路、`/api/callback/plugin/*`、`plugin_sessions`、`installation_token` 的相关章节,自 2026-04-20 起视为旧版设计;当前实现请以 desktop client 方案和最新代码为准。
## 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 统一错误响应与错误码`。若两份文档存在任何冲突,以主架构文档为准。
+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
@@ -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"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"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/tenant/prompts"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
type seedState struct {
TenantID int64
UserID int64
WorkspaceID int64
TestTenantID int64
TestUserID int64
PlanID int64
BrandID int64
KeywordPrimaryID int64
KeywordSecondaryID int64
QuestionPrimaryID int64
QuestionSecondaryID int64
QuestionTertiaryID int64
ArticleID int64
PluginInstallationID int64
TenantID int64
UserID int64
WorkspaceID int64
TestTenantID int64
TestUserID int64
PlanID int64
BrandID int64
KeywordPrimaryID int64
KeywordSecondaryID int64
QuestionPrimaryID int64
QuestionSecondaryID int64
QuestionTertiaryID int64
ArticleID int64
DesktopClientID uuid.UUID
}
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(" Plan ID: %d (free)\n", state.PlanID)
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(" 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
}
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 {
return nil, err
}
@@ -641,47 +641,55 @@ func ensureArticle(ctx context.Context, tx pgx.Tx, tenantID int64) (int64, error
return articleID, nil
}
func ensurePluginInstallation(ctx context.Context, tx pgx.Tx, tenantID, userID int64) (int64, error) {
installationKey := "demo-monitoring-installation"
tokenHash := sharedauth.HashToken("demo-monitoring-installation-token")
func ensureDesktopClient(ctx context.Context, tx pgx.Tx, tenantID, workspaceID, userID int64) (uuid.UUID, error) {
clientID := uuid.NewSHA1(uuid.NameSpaceOID, []byte("geo-rankly-demo-monitoring-client"))
tokenHash := sha256.Sum256([]byte("demo-monitoring-client-token"))
var installationID int64
var ensuredID uuid.UUID
err := tx.QueryRow(ctx, `
WITH ensured AS (
INSERT INTO plugin_installations (
tenant_id, user_id, installation_key, installation_name, browser_name,
client_version, installation_token_hash, status, last_seen_at
INSERT INTO desktop_clients (
id, tenant_id, workspace_id, user_id, token_hash, device_name, os, cpu_arch, client_version, channel, last_seen_at
)
VALUES (
$1, $2, $3, 'GEO Monitoring Chrome', 'Chrome',
'0.1.0', $4, 'active', NOW()
$1, $2, $3, $4, $5, 'GEO Monitoring Desktop', 'macOS', 'arm64', '0.1.0', 'stable', 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
)
SELECT id FROM ensured
UNION ALL
SELECT id
FROM plugin_installations
WHERE tenant_id = $1 AND installation_key = $3 AND deleted_at IS NULL
FROM desktop_clients
WHERE id = $1
LIMIT 1
`, tenantID, userID, installationKey, tokenHash).Scan(&installationID)
`, clientID, tenantID, workspaceID, userID, tokenHash[:]).Scan(&ensuredID)
if err != nil {
return 0, wrapSeedErr("insert plugin installation", err)
return uuid.Nil, wrapSeedErr("insert desktop client", err)
}
_, err = tx.Exec(ctx, `
UPDATE plugin_installations
SET status = 'active',
installation_token_hash = $2,
UPDATE desktop_clients
SET token_hash = $2,
last_seen_at = NOW(),
updated_at = NOW()
revoked_at = NULL
WHERE id = $1
`, installationID, tokenHash)
`, ensuredID, tokenHash[:])
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 {
@@ -689,20 +697,20 @@ func ensureMonitoringQuota(ctx context.Context, tx pgx.Tx, state *seedState) err
_, err := tx.Exec(ctx, `
INSERT INTO tenant_monitoring_quotas (
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
SET workspace_id = COALESCE(tenant_monitoring_quotas.workspace_id, EXCLUDED.workspace_id),
SET workspace_id = EXCLUDED.workspace_id,
collection_mode = EXCLUDED.collection_mode,
question_selection_mode = EXCLUDED.question_selection_mode,
collect_frequency = EXCLUDED.collect_frequency,
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,
plan_tier = EXCLUDED.plan_tier,
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)
}
@@ -799,11 +807,11 @@ func insertPlatformAccessSnapshots(ctx context.Context, tx pgx.Tx, state *seedSt
for _, item := range entries {
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, 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)
}
}
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")
@@ -1,6 +1,4 @@
DROP TABLE IF EXISTS publish_records;
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 media_platforms;
@@ -53,50 +53,6 @@ CREATE UNIQUE INDEX uk_platform_accounts_identity_active
WHERE deleted_at IS NULL;
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 (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
@@ -1,4 +1 @@
-- 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.
-- Legacy no-op rollback.
@@ -1,39 +1 @@
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 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 $$;
-- Legacy no-op.
@@ -1,11 +1,11 @@
CREATE TABLE tenant_monitoring_quotas (
tenant_id BIGINT PRIMARY KEY REFERENCES tenants(id),
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',
collect_frequency VARCHAR(20) NOT NULL DEFAULT 'daily',
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,
plan_tier VARCHAR(20) NOT NULL DEFAULT 'free',
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 (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
installation_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,
@@ -32,7 +32,7 @@ CREATE TABLE monitoring_platform_access_snapshots (
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)
CONSTRAINT uk_platform_access_snapshot UNIQUE (tenant_id, client_id, ai_platform_id, business_date)
);
CREATE INDEX idx_platform_access_snapshot_lookup
@@ -45,9 +45,9 @@ CREATE TABLE monitoring_collect_tasks (
question_id BIGINT NOT NULL,
question_hash BYTEA 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',
run_mode VARCHAR(30) NOT NULL DEFAULT 'plugin_standard',
run_mode VARCHAR(30) NOT NULL DEFAULT 'desktop_standard',
business_date DATE NOT NULL,
planned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
@@ -77,9 +77,9 @@ CREATE TABLE question_monitor_runs (
question_text_snapshot TEXT NOT NULL,
question_hash BYTEA 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',
run_mode VARCHAR(30) NOT NULL DEFAULT 'plugin_standard',
run_mode VARCHAR(30) NOT NULL DEFAULT 'desktop_standard',
business_date DATE NOT NULL,
provider_model VARCHAR(100),
provider_request_id VARCHAR(100),
@@ -187,7 +187,7 @@ CREATE TABLE monitoring_brand_daily (
id BIGSERIAL PRIMARY KEY,
tenant_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,
sample_status VARCHAR(20) NOT NULL,
desired_sample_count INT NOT NULL DEFAULT 0,
@@ -221,7 +221,7 @@ CREATE TABLE monitoring_brand_platform_daily (
tenant_id BIGINT NOT NULL,
brand_id BIGINT 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,
platform_sample_status VARCHAR(20) NOT NULL,
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';