feat(desktop): implement workspace foundation + desktop-client skeleton

Plan 0 (workspaces): add workspaces + workspace_memberships schema, extend
JWT/Actor/claims with primary_workspace_id, seed default workspace per tenant,
thread workspace_id through tenant monitoring quota.

Plan A (desktop skeleton): new Electron app (apps/desktop-client) with main/
preload/renderer, shared Vue component package (packages/ui-shared), and server
surface — desktop client registration + token rotation + heartbeat, SSE task
event stream, desktop accounts/tasks/content handlers, publish job endpoint,
and supporting repositories, services, sqlc queries, and migrations.

Hard cutover per plan: remove browser-extension monitoring callback endpoints,
stub legacy media API in admin-web, and delete monitoring_callback_handler.go.
This commit is contained in:
2026-04-19 14:18:20 +08:00
parent 98f9e95875
commit b16e9f0bd1
141 changed files with 21533 additions and 357 deletions
+57 -4
View File
@@ -24,6 +24,7 @@ import (
type seedState struct {
TenantID int64
UserID int64
WorkspaceID int64
TestTenantID int64
TestUserID int64
PlanID int64
@@ -180,6 +181,13 @@ func seedBusinessData(ctx context.Context, pool *pgxpool.Pool, membershipCfg con
if err := ensureMembership(ctx, tx, state.TenantID, state.UserID); err != nil {
return nil, err
}
state.WorkspaceID, err = ensureDefaultWorkspace(ctx, tx, state.TenantID)
if err != nil {
return nil, err
}
if err := ensurePrimaryWorkspaceMembership(ctx, tx, state.WorkspaceID, state.TenantID, state.UserID); err != nil {
return nil, err
}
state.PlanID, err = ensurePlan(ctx, tx, defaultPlan.Code)
if err != nil {
return nil, err
@@ -355,6 +363,43 @@ func ensureMembershipWithRole(ctx context.Context, tx pgx.Tx, tenantID, userID i
return wrapSeedErr("insert membership", err)
}
func ensureDefaultWorkspace(ctx context.Context, tx pgx.Tx, tenantID int64) (int64, error) {
var workspaceID int64
err := tx.QueryRow(ctx, `
INSERT INTO workspaces (tenant_id, name, slug, is_default)
VALUES ($1, 'Default', 'default', TRUE)
ON CONFLICT (tenant_id, slug) DO UPDATE
SET name = EXCLUDED.name,
is_default = TRUE,
updated_at = NOW()
RETURNING id
`, tenantID).Scan(&workspaceID)
return workspaceID, wrapSeedErr("insert workspace", err)
}
func ensurePrimaryWorkspaceMembership(ctx context.Context, tx pgx.Tx, workspaceID, tenantID, userID int64) error {
if _, err := tx.Exec(ctx, `
UPDATE workspace_memberships
SET is_primary = FALSE
WHERE user_id = $1
AND tenant_id = $2
AND is_primary = TRUE
AND workspace_id <> $3
`, userID, tenantID, workspaceID); err != nil {
return wrapSeedErr("clear primary workspace", err)
}
_, err := tx.Exec(ctx, `
INSERT INTO workspace_memberships (workspace_id, user_id, tenant_id, role, is_primary)
VALUES ($1, $2, $3, 'member', TRUE)
ON CONFLICT (workspace_id, user_id) DO UPDATE
SET tenant_id = EXCLUDED.tenant_id,
role = EXCLUDED.role,
is_primary = TRUE
`, workspaceID, userID, tenantID)
return wrapSeedErr("insert workspace membership", err)
}
func ensureSecondaryTenantUser(
ctx context.Context,
tx pgx.Tx,
@@ -379,6 +424,13 @@ func ensureSecondaryTenantUser(
if err := ensureMembershipWithRole(ctx, tx, tenantID, userID, "tenant_admin"); err != nil {
return 0, 0, err
}
workspaceID, err := ensureDefaultWorkspace(ctx, tx, tenantID)
if err != nil {
return 0, 0, err
}
if err := ensurePrimaryWorkspaceMembership(ctx, tx, workspaceID, tenantID, userID); err != nil {
return 0, 0, err
}
if err := ensureSubscription(ctx, tx, tenantID, planID, defaultPlan.SubscriptionDuration); err != nil {
return 0, 0, err
}
@@ -636,12 +688,13 @@ func ensureMonitoringQuota(ctx context.Context, tx pgx.Tx, state *seedState) err
enabledPlatforms, _ := json.Marshal([]string{"deepseek", "qwen", "doubao"})
_, err := tx.Exec(ctx, `
INSERT INTO tenant_monitoring_quotas (
tenant_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
)
VALUES ($1, 1, 'plugin', 'best_effort_all', 'daily', $2::jsonb, $3, 36, 'free')
VALUES ($1, $2, 1, 'plugin', 'best_effort_all', 'daily', $3::jsonb, $4, 36, 'free')
ON CONFLICT (tenant_id) DO UPDATE
SET collection_mode = EXCLUDED.collection_mode,
SET workspace_id = COALESCE(tenant_monitoring_quotas.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,
@@ -649,7 +702,7 @@ func ensureMonitoringQuota(ctx context.Context, tx pgx.Tx, state *seedState) err
task_daily_hard_cap = EXCLUDED.task_daily_hard_cap,
plan_tier = EXCLUDED.plan_tier,
updated_at = NOW()
`, state.TenantID, string(enabledPlatforms), state.PluginInstallationID)
`, state.TenantID, state.WorkspaceID, string(enabledPlatforms), state.PluginInstallationID)
return wrapSeedErr("upsert monitoring quota", err)
}
+1
View File
@@ -29,6 +29,7 @@ func main() {
defer workerCancel()
app.GenerationStreams.Run(workerCtx)
app.DesktopTaskStreams.Run(workerCtx)
monitoringCallbackService := tenantapp.NewMonitoringCallbackService(app.DB, app.MonitoringDB, app.RabbitMQ, app.LLM, app.Logger)
tenantapp.NewMonitoringResultIngestWorker(monitoringCallbackService, app.RabbitMQ, app.Logger, app.Config.MonitoringWorkers).Start(workerCtx)