feat(desktop): isolate platform accounts per desktop client
Frontend CI / Frontend (push) Successful in 3m4s
Backend CI / Backend (push) Successful in 14m24s

Scope desktop media accounts by the authenticated desktop client_id so
the same user logging in from Mac and Windows no longer sees a shared
account list. The list, identity lookup, upsert, patch, tombstone and
async health sink paths now all require matching client_id, and the
active uniqueness index moves from (workspace, platform, uid) to
(workspace, client, platform, uid).

Also strengthen the desktop client_id seed: when the OS machine id is
unavailable, fall back to a hashed fingerprint of stable hardware MAC
addresses before the random installation id, so the same hardware
keeps the same client across reinstalls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 13:13:11 +08:00
parent f6aed87e44
commit 1ba29b9a09
14 changed files with 378 additions and 24 deletions
@@ -155,6 +155,9 @@ func validateBufferedDesktopAccountHealthReport(report bufferedDesktopAccountHea
if _, err := uuid.Parse(strings.TrimSpace(report.AccountID)); err != nil {
return fmt.Errorf("invalid account_id: %w", err)
}
if _, err := uuid.Parse(strings.TrimSpace(report.ClientID)); err != nil {
return fmt.Errorf("invalid client_id: %w", err)
}
if report.WorkspaceID == 0 {
return fmt.Errorf("workspace_id is required")
}
@@ -188,9 +191,10 @@ SET health = $1,
updated_at = now()
WHERE desktop_id = $5::uuid
AND workspace_id = $6
AND client_id = $7::uuid
AND deleted_at IS NULL
AND (last_check_at IS NULL OR last_check_at <= $4::timestamptz)
`, report.Health, desktopAccountLegacyStatusFromHealth(report.Health), verifiedAt, checkedAt, report.AccountID, report.WorkspaceID)
`, report.Health, desktopAccountLegacyStatusFromHealth(report.Health), verifiedAt, checkedAt, report.AccountID, report.WorkspaceID, report.ClientID)
if err != nil {
return fmt.Errorf("sink desktop account health snapshot: %w", err)
}
@@ -133,12 +133,12 @@ type bufferedDesktopAccountHealthReport struct {
ReceivedAt time.Time `json:"received_at"`
}
func (s *DesktopAccountService) ListByUser(ctx context.Context, client *repository.DesktopClient) ([]DesktopAccountView, error) {
func (s *DesktopAccountService) ListByClient(ctx context.Context, client *repository.DesktopClient) ([]DesktopAccountView, error) {
if client == nil {
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
}
rows, err := s.repo.ListByUser(ctx, client.WorkspaceID, client.UserID)
rows, err := s.repo.ListByClient(ctx, client.WorkspaceID, client.ID)
if err != nil {
return nil, response.ErrInternal(50081, "desktop_accounts_query_failed", "failed to list desktop accounts")
}
@@ -177,7 +177,7 @@ func (s *DesktopAccountService) Upsert(ctx context.Context, client *repository.D
}
if req.IfSyncVersion != nil {
existing, err := s.repo.GetByIdentity(ctx, client.WorkspaceID, req.Platform, req.PlatformUID)
existing, err := s.repo.GetByIdentity(ctx, client.WorkspaceID, client.ID, req.Platform, req.PlatformUID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, response.ErrInternal(50082, "desktop_account_lookup_failed", "failed to inspect desktop account before upsert")
}
@@ -346,6 +346,7 @@ func (s *DesktopAccountService) Patch(ctx context.Context, client *repository.De
VerifiedAt: req.VerifiedAt,
Tags: req.Tags,
IfSyncVersion: req.IfSyncVersion,
ClientID: client.ID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -363,7 +364,7 @@ func (s *DesktopAccountService) Tombstone(ctx context.Context, client *repositor
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
}
account, err := s.repo.Tombstone(ctx, desktopID, client.WorkspaceID, ifSyncVersion)
account, err := s.repo.Tombstone(ctx, desktopID, client.WorkspaceID, client.ID, ifSyncVersion)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, response.ErrConflict(40982, "desktop_account_sync_conflict", "desktop account sync version conflict")
@@ -52,6 +52,7 @@ type UpsertDesktopAccountParams struct {
type PatchDesktopAccountParams struct {
DesktopID uuid.UUID
WorkspaceID int64
ClientID uuid.UUID
DisplayName *string
Health *string
VerifiedAt *time.Time
@@ -60,12 +61,13 @@ type PatchDesktopAccountParams struct {
}
type DesktopAccountRepository interface {
ListByClient(ctx context.Context, workspaceID int64, clientID uuid.UUID) ([]DesktopAccount, error)
ListByUser(ctx context.Context, workspaceID, userID int64) ([]DesktopAccount, error)
GetByDesktopID(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopAccount, error)
GetByIdentity(ctx context.Context, workspaceID int64, platform, platformUID string) (*DesktopAccount, error)
GetByIdentity(ctx context.Context, workspaceID int64, clientID uuid.UUID, platform, platformUID string) (*DesktopAccount, error)
Upsert(ctx context.Context, params UpsertDesktopAccountParams) (*DesktopAccount, error)
Patch(ctx context.Context, params PatchDesktopAccountParams) (*DesktopAccount, error)
Tombstone(ctx context.Context, desktopID uuid.UUID, workspaceID, ifSyncVersion int64) (*DesktopAccount, error)
Tombstone(ctx context.Context, desktopID uuid.UUID, workspaceID int64, clientID uuid.UUID, ifSyncVersion int64) (*DesktopAccount, error)
MarkDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopAccount, error)
ClearDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopAccount, error)
}
@@ -78,6 +80,26 @@ func NewDesktopAccountRepository(db generated.DBTX) DesktopAccountRepository {
return &desktopAccountRepository{q: newQuerier(db)}
}
func (r *desktopAccountRepository) ListByClient(ctx context.Context, workspaceID int64, clientID uuid.UUID) ([]DesktopAccount, error) {
rows, err := r.q.ListDesktopAccountsByClient(ctx, generated.ListDesktopAccountsByClientParams{
WorkspaceID: workspaceID,
ClientID: pgUUID(clientID),
})
if err != nil {
return nil, err
}
items := make([]DesktopAccount, 0, len(rows))
for _, row := range rows {
item, err := desktopAccountFromClientListRow(row)
if err != nil {
return nil, err
}
items = append(items, *item)
}
return items, nil
}
func (r *desktopAccountRepository) ListByUser(ctx context.Context, workspaceID, userID int64) ([]DesktopAccount, error) {
rows, err := r.q.ListDesktopAccountsByUser(ctx, generated.ListDesktopAccountsByUserParams{
WorkspaceID: workspaceID,
@@ -109,9 +131,10 @@ func (r *desktopAccountRepository) GetByDesktopID(ctx context.Context, desktopID
return desktopAccountFromGetRow(row)
}
func (r *desktopAccountRepository) GetByIdentity(ctx context.Context, workspaceID int64, platform, platformUID string) (*DesktopAccount, error) {
func (r *desktopAccountRepository) GetByIdentity(ctx context.Context, workspaceID int64, clientID uuid.UUID, platform, platformUID string) (*DesktopAccount, error) {
row, err := r.q.GetDesktopAccountByIdentity(ctx, generated.GetDesktopAccountByIdentityParams{
WorkspaceID: workspaceID,
ClientID: pgUUID(clientID),
PlatformID: platform,
PlatformUid: platformUID,
})
@@ -177,6 +200,7 @@ func (r *desktopAccountRepository) Patch(ctx context.Context, params PatchDeskto
Tags: tagsJSON,
DesktopID: pgUUID(params.DesktopID),
WorkspaceID: params.WorkspaceID,
ClientID: pgUUID(params.ClientID),
IfSyncVersion: params.IfSyncVersion,
})
if err != nil {
@@ -185,10 +209,11 @@ func (r *desktopAccountRepository) Patch(ctx context.Context, params PatchDeskto
return desktopAccountFromPatchRow(row)
}
func (r *desktopAccountRepository) Tombstone(ctx context.Context, desktopID uuid.UUID, workspaceID, ifSyncVersion int64) (*DesktopAccount, error) {
func (r *desktopAccountRepository) Tombstone(ctx context.Context, desktopID uuid.UUID, workspaceID int64, clientID uuid.UUID, ifSyncVersion int64) (*DesktopAccount, error) {
row, err := r.q.TombstoneDesktopAccount(ctx, generated.TombstoneDesktopAccountParams{
DesktopID: pgUUID(desktopID),
WorkspaceID: workspaceID,
ClientID: pgUUID(clientID),
IfSyncVersion: ifSyncVersion,
})
if err != nil {
@@ -219,6 +244,29 @@ func (r *desktopAccountRepository) ClearDeleteRequested(ctx context.Context, des
return desktopAccountFromClearDeleteRow(row)
}
func desktopAccountFromClientListRow(row generated.ListDesktopAccountsByClientRow) (*DesktopAccount, error) {
return buildDesktopAccount(
row.DesktopID,
row.TenantID,
row.WorkspaceID,
row.UserID,
row.ClientID,
row.PlatformID,
row.PlatformUid,
row.AccountFingerprint,
row.DisplayName,
row.AvatarUrl,
row.Health,
row.VerifiedAt,
row.Tags,
row.SyncVersion,
row.DeletedAt,
row.DeleteRequestedAt,
row.CreatedAt,
row.UpdatedAt,
)
}
func desktopAccountFromListRow(row generated.ListDesktopAccountsByUserRow) (*DesktopAccount, error) {
return buildDesktopAccount(
row.DesktopID,
@@ -192,16 +192,18 @@ SELECT
updated_at
FROM platform_accounts
WHERE workspace_id = $1
AND platform_id = $2
AND platform_uid = $3
AND client_id = $2
AND platform_id = $3
AND platform_uid = $4
AND deleted_at IS NULL
LIMIT 1
`
type GetDesktopAccountByIdentityParams struct {
WorkspaceID int64 `json:"workspace_id"`
PlatformID string `json:"platform_id"`
PlatformUid string `json:"platform_uid"`
WorkspaceID int64 `json:"workspace_id"`
ClientID pgtype.UUID `json:"client_id"`
PlatformID string `json:"platform_id"`
PlatformUid string `json:"platform_uid"`
}
type GetDesktopAccountByIdentityRow struct {
@@ -226,7 +228,12 @@ type GetDesktopAccountByIdentityRow struct {
}
func (q *Queries) GetDesktopAccountByIdentity(ctx context.Context, arg GetDesktopAccountByIdentityParams) (GetDesktopAccountByIdentityRow, error) {
row := q.db.QueryRow(ctx, getDesktopAccountByIdentity, arg.WorkspaceID, arg.PlatformID, arg.PlatformUid)
row := q.db.QueryRow(ctx, getDesktopAccountByIdentity,
arg.WorkspaceID,
arg.ClientID,
arg.PlatformID,
arg.PlatformUid,
)
var i GetDesktopAccountByIdentityRow
err := row.Scan(
&i.DesktopID,
@@ -251,6 +258,98 @@ func (q *Queries) GetDesktopAccountByIdentity(ctx context.Context, arg GetDeskto
return i, err
}
const listDesktopAccountsByClient = `-- name: ListDesktopAccountsByClient :many
SELECT
desktop_id,
tenant_id,
workspace_id,
user_id,
client_id,
platform_id,
platform_uid,
account_fingerprint,
display_name,
avatar_url,
health,
verified_at,
tags,
sync_version,
deleted_at,
delete_requested_at,
created_at,
updated_at
FROM platform_accounts
WHERE workspace_id = $1
AND client_id = $2
AND deleted_at IS NULL
ORDER BY platform_id, display_name, created_at DESC
`
type ListDesktopAccountsByClientParams struct {
WorkspaceID int64 `json:"workspace_id"`
ClientID pgtype.UUID `json:"client_id"`
}
type ListDesktopAccountsByClientRow struct {
DesktopID pgtype.UUID `json:"desktop_id"`
TenantID int64 `json:"tenant_id"`
WorkspaceID int64 `json:"workspace_id"`
UserID int64 `json:"user_id"`
ClientID pgtype.UUID `json:"client_id"`
PlatformID string `json:"platform_id"`
PlatformUid string `json:"platform_uid"`
AccountFingerprint pgtype.Text `json:"account_fingerprint"`
DisplayName string `json:"display_name"`
AvatarUrl pgtype.Text `json:"avatar_url"`
Health string `json:"health"`
VerifiedAt pgtype.Timestamptz `json:"verified_at"`
Tags []byte `json:"tags"`
SyncVersion int64 `json:"sync_version"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
DeleteRequestedAt pgtype.Timestamptz `json:"delete_requested_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ListDesktopAccountsByClient(ctx context.Context, arg ListDesktopAccountsByClientParams) ([]ListDesktopAccountsByClientRow, error) {
rows, err := q.db.Query(ctx, listDesktopAccountsByClient, arg.WorkspaceID, arg.ClientID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListDesktopAccountsByClientRow
for rows.Next() {
var i ListDesktopAccountsByClientRow
if err := rows.Scan(
&i.DesktopID,
&i.TenantID,
&i.WorkspaceID,
&i.UserID,
&i.ClientID,
&i.PlatformID,
&i.PlatformUid,
&i.AccountFingerprint,
&i.DisplayName,
&i.AvatarUrl,
&i.Health,
&i.VerifiedAt,
&i.Tags,
&i.SyncVersion,
&i.DeletedAt,
&i.DeleteRequestedAt,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listDesktopAccountsByUser = `-- name: ListDesktopAccountsByUser :many
SELECT
desktop_id,
@@ -437,7 +536,8 @@ SET display_name = COALESCE($1::text, display_name),
updated_at = now()
WHERE desktop_id = $6
AND workspace_id = $7
AND sync_version = $8
AND client_id = $8
AND sync_version = $9
AND deleted_at IS NULL
RETURNING
desktop_id,
@@ -468,6 +568,7 @@ type PatchDesktopAccountParams struct {
Tags []byte `json:"tags"`
DesktopID pgtype.UUID `json:"desktop_id"`
WorkspaceID int64 `json:"workspace_id"`
ClientID pgtype.UUID `json:"client_id"`
IfSyncVersion int64 `json:"if_sync_version"`
}
@@ -501,6 +602,7 @@ func (q *Queries) PatchDesktopAccount(ctx context.Context, arg PatchDesktopAccou
arg.Tags,
arg.DesktopID,
arg.WorkspaceID,
arg.ClientID,
arg.IfSyncVersion,
)
var i PatchDesktopAccountRow
@@ -534,7 +636,8 @@ SET deleted_at = now(),
updated_at = now()
WHERE desktop_id = $1
AND workspace_id = $2
AND sync_version = $3
AND client_id = $3
AND sync_version = $4
AND deleted_at IS NULL
RETURNING
desktop_id,
@@ -560,6 +663,7 @@ RETURNING
type TombstoneDesktopAccountParams struct {
DesktopID pgtype.UUID `json:"desktop_id"`
WorkspaceID int64 `json:"workspace_id"`
ClientID pgtype.UUID `json:"client_id"`
IfSyncVersion int64 `json:"if_sync_version"`
}
@@ -585,7 +689,12 @@ type TombstoneDesktopAccountRow struct {
}
func (q *Queries) TombstoneDesktopAccount(ctx context.Context, arg TombstoneDesktopAccountParams) (TombstoneDesktopAccountRow, error) {
row := q.db.QueryRow(ctx, tombstoneDesktopAccount, arg.DesktopID, arg.WorkspaceID, arg.IfSyncVersion)
row := q.db.QueryRow(ctx, tombstoneDesktopAccount,
arg.DesktopID,
arg.WorkspaceID,
arg.ClientID,
arg.IfSyncVersion,
)
var i TombstoneDesktopAccountRow
err := row.Scan(
&i.DesktopID,
@@ -651,7 +760,7 @@ VALUES (
$15,
1
)
ON CONFLICT (workspace_id, platform_id, platform_uid) WHERE deleted_at IS NULL
ON CONFLICT (workspace_id, client_id, platform_id, platform_uid) WHERE deleted_at IS NULL
DO UPDATE SET
client_id = EXCLUDED.client_id,
user_id = EXCLUDED.user_id,
@@ -669,6 +778,7 @@ DO UPDATE SET
delete_requested_at = NULL,
updated_at = now()
WHERE platform_accounts.workspace_id = EXCLUDED.workspace_id
AND platform_accounts.client_id = EXCLUDED.client_id
AND (
$16::bigint IS NULL
OR platform_accounts.sync_version = $16::bigint
@@ -115,6 +115,7 @@ type Querier interface {
ListArticles(ctx context.Context, arg ListArticlesParams) ([]ListArticlesRow, error)
ListBrands(ctx context.Context, tenantID int64) ([]ListBrandsRow, error)
ListCompetitors(ctx context.Context, arg ListCompetitorsParams) ([]ListCompetitorsRow, error)
ListDesktopAccountsByClient(ctx context.Context, arg ListDesktopAccountsByClientParams) ([]ListDesktopAccountsByClientRow, error)
ListDesktopAccountsByUser(ctx context.Context, arg ListDesktopAccountsByUserParams) ([]ListDesktopAccountsByUserRow, error)
ListDesktopClientsByWorkspace(ctx context.Context, workspaceID int64) ([]DesktopClient, error)
ListImageAssets(ctx context.Context, arg ListImageAssetsParams) ([]ListImageAssetsRow, error)
@@ -1,3 +1,29 @@
-- name: ListDesktopAccountsByClient :many
SELECT
desktop_id,
tenant_id,
workspace_id,
user_id,
client_id,
platform_id,
platform_uid,
account_fingerprint,
display_name,
avatar_url,
health,
verified_at,
tags,
sync_version,
deleted_at,
delete_requested_at,
created_at,
updated_at
FROM platform_accounts
WHERE workspace_id = sqlc.arg(workspace_id)
AND client_id = sqlc.arg(client_id)
AND deleted_at IS NULL
ORDER BY platform_id, display_name, created_at DESC;
-- name: ListDesktopAccountsByUser :many
SELECT
desktop_id,
@@ -71,6 +97,7 @@ SELECT
updated_at
FROM platform_accounts
WHERE workspace_id = sqlc.arg(workspace_id)
AND client_id = sqlc.arg(client_id)
AND platform_id = sqlc.arg(platform_id)
AND platform_uid = sqlc.arg(platform_uid)
AND deleted_at IS NULL
@@ -117,7 +144,7 @@ VALUES (
sqlc.narg(tags),
1
)
ON CONFLICT (workspace_id, platform_id, platform_uid) WHERE deleted_at IS NULL
ON CONFLICT (workspace_id, client_id, platform_id, platform_uid) WHERE deleted_at IS NULL
DO UPDATE SET
client_id = EXCLUDED.client_id,
user_id = EXCLUDED.user_id,
@@ -135,6 +162,7 @@ DO UPDATE SET
delete_requested_at = NULL,
updated_at = now()
WHERE platform_accounts.workspace_id = EXCLUDED.workspace_id
AND platform_accounts.client_id = EXCLUDED.client_id
AND (
sqlc.narg(if_sync_version)::bigint IS NULL
OR platform_accounts.sync_version = sqlc.narg(if_sync_version)::bigint
@@ -172,6 +200,7 @@ SET display_name = COALESCE(sqlc.narg(display_name)::text, display_name),
updated_at = now()
WHERE desktop_id = sqlc.arg(desktop_id)
AND workspace_id = sqlc.arg(workspace_id)
AND client_id = sqlc.arg(client_id)
AND sync_version = sqlc.arg(if_sync_version)
AND deleted_at IS NULL
RETURNING
@@ -201,6 +230,7 @@ SET deleted_at = now(),
updated_at = now()
WHERE desktop_id = sqlc.arg(desktop_id)
AND workspace_id = sqlc.arg(workspace_id)
AND client_id = sqlc.arg(client_id)
AND sync_version = sqlc.arg(if_sync_version)
AND deleted_at IS NULL
RETURNING
@@ -34,7 +34,7 @@ func (h *DesktopAccountHandler) List(c *gin.Context) {
return
}
data, err := h.svc.ListByUser(c.Request.Context(), MustDesktopClient(c.Request.Context()))
data, err := h.svc.ListByClient(c.Request.Context(), MustDesktopClient(c.Request.Context()))
if err != nil {
response.Error(c, err)
return
@@ -0,0 +1,5 @@
DROP INDEX IF EXISTS uniq_platform_accounts_workspace_identity_active;
CREATE UNIQUE INDEX IF NOT EXISTS uniq_platform_accounts_workspace_identity_active
ON platform_accounts (workspace_id, platform_id, platform_uid)
WHERE deleted_at IS NULL;
@@ -0,0 +1,5 @@
DROP INDEX IF EXISTS uniq_platform_accounts_workspace_identity_active;
CREATE UNIQUE INDEX IF NOT EXISTS uniq_platform_accounts_workspace_identity_active
ON platform_accounts (workspace_id, client_id, platform_id, platform_uid)
WHERE deleted_at IS NULL;