feat(desktop): drop parked review flow, add publish management

SaaS 侧人工审核后才创建 publish job,desktop 不再做二次审核:移除
manual/waiting_user/parked/from_parked 状态机与 LeaseFromParked 查询,
desktop client 只执行发布并新增"发布管理"页(待发布队列 / 历史 / 再次
发送)。同时抽离 @geo/publisher-platforms 共享适配器包、新增 Redis-based
desktop_presence 与 publish_record_support,刷新 admin-web 发布弹窗与
媒体库;plan A / spec 文档同步口径。
This commit is contained in:
2026-04-20 09:52:48 +08:00
parent b16e9f0bd1
commit a617d39a4a
93 changed files with 5519 additions and 3594 deletions
@@ -3,10 +3,12 @@ package app
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
goredis "github.com/redis/go-redis/v9"
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/response"
@@ -14,11 +16,23 @@ import (
)
type DesktopAccountService struct {
repo repository.DesktopAccountRepository
repo repository.DesktopAccountRepository
clientRepo repository.DesktopClientRepository
redis *goredis.Client
now func() time.Time
}
func NewDesktopAccountService(repo repository.DesktopAccountRepository) *DesktopAccountService {
return &DesktopAccountService{repo: repo}
func NewDesktopAccountService(
repo repository.DesktopAccountRepository,
clientRepo repository.DesktopClientRepository,
redis *goredis.Client,
) *DesktopAccountService {
return &DesktopAccountService{
repo: repo,
clientRepo: clientRepo,
redis: redis,
now: time.Now,
}
}
type DesktopAccountView struct {
@@ -26,6 +40,7 @@ type DesktopAccountView struct {
Platform string `json:"platform"`
PlatformUID string `json:"platform_uid"`
DisplayName string `json:"display_name"`
AvatarURL *string `json:"avatar_url"`
Health string `json:"health"`
ClientID *string `json:"client_id"`
AccountFingerprint *string `json:"account_fingerprint"`
@@ -34,6 +49,9 @@ type DesktopAccountView struct {
SyncVersion int64 `json:"sync_version"`
DeletedAt *time.Time `json:"deleted_at"`
DeleteRequestedAt *time.Time `json:"delete_requested_at"`
ClientOnline *bool `json:"client_online"`
ClientLastSeenAt *time.Time `json:"client_last_seen_at"`
ClientDeviceName *string `json:"client_device_name"`
}
type UpsertDesktopAccountRequest struct {
@@ -41,6 +59,7 @@ type UpsertDesktopAccountRequest struct {
PlatformUID string `json:"platform_uid" binding:"required"`
AccountFingerprint *string `json:"account_fingerprint"`
DisplayName string `json:"display_name" binding:"required"`
AvatarURL *string `json:"avatar_url"`
Health string `json:"health" binding:"required,oneof=live expired captcha risk"`
VerifiedAt *time.Time `json:"verified_at"`
Tags []string `json:"tags"`
@@ -55,19 +74,38 @@ type PatchDesktopAccountRequest struct {
IfSyncVersion int64 `json:"if_sync_version" binding:"required"`
}
func (s *DesktopAccountService) ListByClient(ctx context.Context, client *repository.DesktopClient) ([]DesktopAccountView, error) {
func (s *DesktopAccountService) ListByUser(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.ListByClient(ctx, client.WorkspaceID, client.ID)
rows, err := s.repo.ListByUser(ctx, client.WorkspaceID, client.UserID)
if err != nil {
return nil, response.ErrInternal(50081, "desktop_accounts_query_failed", "failed to list desktop accounts")
}
clientMap, presenceMap := s.loadClientState(ctx, client.WorkspaceID, rows)
items := make([]DesktopAccountView, 0, len(rows))
for _, item := range rows {
items = append(items, buildDesktopAccountView(&item))
items = append(items, s.buildDesktopAccountView(&item, clientMap, presenceMap))
}
return items, nil
}
func (s *DesktopAccountService) ListForActor(ctx context.Context, actor auth.Actor) ([]DesktopAccountView, error) {
if actor.PrimaryWorkspaceID == 0 || actor.UserID == 0 {
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
}
rows, err := s.repo.ListByUser(ctx, actor.PrimaryWorkspaceID, actor.UserID)
if err != nil {
return nil, response.ErrInternal(50081, "desktop_accounts_query_failed", "failed to list desktop accounts")
}
clientMap, presenceMap := s.loadClientState(ctx, actor.PrimaryWorkspaceID, rows)
items := make([]DesktopAccountView, 0, len(rows))
for _, item := range rows {
items = append(items, s.buildDesktopAccountView(&item, clientMap, presenceMap))
}
return items, nil
}
@@ -97,6 +135,7 @@ func (s *DesktopAccountService) Upsert(ctx context.Context, client *repository.D
PlatformUID: req.PlatformUID,
AccountFingerprint: req.AccountFingerprint,
DisplayName: req.DisplayName,
AvatarURL: req.AvatarURL,
Health: req.Health,
VerifiedAt: req.VerifiedAt,
Tags: req.Tags,
@@ -109,7 +148,7 @@ func (s *DesktopAccountService) Upsert(ctx context.Context, client *repository.D
return nil, response.ErrInternal(50083, "desktop_account_upsert_failed", "failed to upsert desktop account")
}
view := buildDesktopAccountView(account)
view := s.buildDesktopAccountView(account, nil, nil)
return &view, nil
}
@@ -134,7 +173,7 @@ func (s *DesktopAccountService) Patch(ctx context.Context, client *repository.De
return nil, response.ErrInternal(50084, "desktop_account_patch_failed", "failed to patch desktop account")
}
view := buildDesktopAccountView(account)
view := s.buildDesktopAccountView(account, nil, nil)
return &view, nil
}
@@ -151,7 +190,7 @@ func (s *DesktopAccountService) Tombstone(ctx context.Context, client *repositor
return nil, response.ErrInternal(50085, "desktop_account_delete_failed", "failed to delete desktop account")
}
view := buildDesktopAccountView(account)
view := s.buildDesktopAccountView(account, nil, nil)
return &view, nil
}
@@ -177,22 +216,53 @@ func (s *DesktopAccountService) RequestDelete(ctx context.Context, actor auth.Ac
return nil, response.ErrInternal(50086, "desktop_account_request_delete_failed", "failed to update desktop account delete request")
}
view := buildDesktopAccountView(account)
view := s.buildDesktopAccountView(account, nil, nil)
return &view, nil
}
func buildDesktopAccountView(account *repository.DesktopAccount) DesktopAccountView {
func (s *DesktopAccountService) buildDesktopAccountView(
account *repository.DesktopAccount,
clientMap map[uuid.UUID]*repository.DesktopClient,
presenceClientMap map[uuid.UUID]uuid.UUID,
) DesktopAccountView {
var clientID *string
if account.ClientID != nil {
value := account.ClientID.String()
var clientOnline *bool
var clientLastSeenAt *time.Time
var clientDeviceName *string
resolvedClientID := account.ClientID
if presenceClientMap != nil {
if activeClientID, ok := presenceClientMap[account.DesktopID]; ok {
resolvedClientID = &activeClientID
}
}
if resolvedClientID != nil {
value := resolvedClientID.String()
clientID = &value
online := false
clientOnline = &online
if clientMap != nil {
if client, ok := clientMap[*resolvedClientID]; ok && client != nil {
clientLastSeenAt = client.LastSeenAt
clientDeviceName = client.DeviceName
if presenceClientMap != nil {
if activeClientID, ok := presenceClientMap[account.DesktopID]; ok && activeClientID == *resolvedClientID {
online = true
}
}
}
}
}
return DesktopAccountView{
ID: account.DesktopID.String(),
Platform: account.Platform,
PlatformUID: account.PlatformUID,
PlatformUID: normalizePlatformUID(account.PlatformUID),
DisplayName: account.DisplayName,
AvatarURL: account.AvatarURL,
Health: account.Health,
ClientID: clientID,
AccountFingerprint: account.AccountFingerprint,
@@ -201,5 +271,82 @@ func buildDesktopAccountView(account *repository.DesktopAccount) DesktopAccountV
SyncVersion: account.SyncVersion,
DeletedAt: account.DeletedAt,
DeleteRequestedAt: account.DeleteRequestedAt,
ClientOnline: clientOnline,
ClientLastSeenAt: clientLastSeenAt,
ClientDeviceName: clientDeviceName,
}
}
func (s *DesktopAccountService) loadClientState(
ctx context.Context,
workspaceID int64,
accounts []repository.DesktopAccount,
) (map[uuid.UUID]*repository.DesktopClient, map[uuid.UUID]uuid.UUID) {
clientIDs := make([]uuid.UUID, 0, len(accounts)*2)
accountIDs := make([]uuid.UUID, 0, len(accounts))
seen := make(map[uuid.UUID]struct{}, len(accounts)*2)
for _, account := range accounts {
accountIDs = append(accountIDs, account.DesktopID)
if account.ClientID == nil {
continue
}
if _, ok := seen[*account.ClientID]; ok {
continue
}
seen[*account.ClientID] = struct{}{}
clientIDs = append(clientIDs, *account.ClientID)
}
presenceClientMap := loadDesktopAccountPresence(ctx, s.redis, accountIDs)
for _, clientID := range presenceClientMap {
if _, ok := seen[clientID]; ok {
continue
}
seen[clientID] = struct{}{}
clientIDs = append(clientIDs, clientID)
}
clientMap := make(map[uuid.UUID]*repository.DesktopClient, len(clientIDs))
if s.clientRepo != nil {
for _, clientID := range clientIDs {
client, err := s.clientRepo.GetByID(ctx, clientID, workspaceID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
continue
}
continue
}
clientMap[clientID] = client
}
}
return clientMap, presenceClientMap
}
func resolveDesktopClientOnline(
client *repository.DesktopClient,
presence bool,
presenceKnown bool,
now time.Time,
) bool {
if client == nil || client.RevokedAt != nil {
return false
}
if presenceKnown && presence {
return true
}
if client.LastSeenAt == nil {
return false
}
return now.UTC().Sub(client.LastSeenAt.UTC()) <= desktopClientPresenceTTL
}
func normalizePlatformUID(value string) string {
normalized := strings.TrimSpace(value)
for strings.HasPrefix(strings.ToLower(normalized), "platform:") {
normalized = strings.TrimSpace(normalized[len("platform:"):])
}
return normalized
}
@@ -0,0 +1,119 @@
package app
import (
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
func TestResolveDesktopClientOnline_PresenceHitWins(t *testing.T) {
now := time.Date(2026, 4, 19, 21, 30, 0, 0, time.UTC)
online := resolveDesktopClientOnline(&repository.DesktopClient{
ID: uuid.New(),
LastSeenAt: nil,
}, true, true, now)
assert.True(t, online)
}
func TestResolveDesktopClientOnline_FallsBackToRecentHeartbeatWhenPresenceMisses(t *testing.T) {
now := time.Date(2026, 4, 19, 21, 30, 0, 0, time.UTC)
lastSeen := now.Add(-30 * time.Second)
online := resolveDesktopClientOnline(&repository.DesktopClient{
ID: uuid.New(),
LastSeenAt: &lastSeen,
}, false, true, now)
assert.True(t, online)
}
func TestResolveDesktopClientOnline_StaleHeartbeatIsOffline(t *testing.T) {
now := time.Date(2026, 4, 19, 21, 30, 0, 0, time.UTC)
lastSeen := now.Add(-(desktopClientPresenceTTL + time.Second))
online := resolveDesktopClientOnline(&repository.DesktopClient{
ID: uuid.New(),
LastSeenAt: &lastSeen,
}, false, false, now)
assert.False(t, online)
}
func TestBuildDesktopAccountView_PrefersAccountPresenceClient(t *testing.T) {
service := &DesktopAccountService{}
accountID := uuid.New()
staleClientID := uuid.New()
activeClientID := uuid.New()
lastSeen := time.Date(2026, 4, 19, 21, 30, 0, 0, time.UTC)
deviceName := "Desktop MacIntel"
view := service.buildDesktopAccountView(
&repository.DesktopAccount{
DesktopID: accountID,
ClientID: &staleClientID,
Platform: "toutiaohao",
PlatformUID: "platform:4181862206",
DisplayName: "ireader",
Health: "live",
},
map[uuid.UUID]*repository.DesktopClient{
activeClientID: {
ID: activeClientID,
LastSeenAt: &lastSeen,
DeviceName: &deviceName,
},
},
map[uuid.UUID]uuid.UUID{
accountID: activeClientID,
},
)
if assert.NotNil(t, view.ClientID) {
assert.Equal(t, activeClientID.String(), *view.ClientID)
}
if assert.NotNil(t, view.ClientOnline) {
assert.True(t, *view.ClientOnline)
}
assert.Equal(t, "4181862206", view.PlatformUID)
assert.Equal(t, &lastSeen, view.ClientLastSeenAt)
assert.Equal(t, &deviceName, view.ClientDeviceName)
}
func TestBuildDesktopAccountView_FallsBackToStoredClientWhenNoPresence(t *testing.T) {
service := &DesktopAccountService{}
accountID := uuid.New()
storedClientID := uuid.New()
lastSeen := time.Date(2026, 4, 19, 21, 30, 0, 0, time.UTC)
view := service.buildDesktopAccountView(
&repository.DesktopAccount{
DesktopID: accountID,
ClientID: &storedClientID,
Platform: "toutiaohao",
PlatformUID: "4181862206",
DisplayName: "ireader",
Health: "live",
},
map[uuid.UUID]*repository.DesktopClient{
storedClientID: {
ID: storedClientID,
LastSeenAt: &lastSeen,
},
},
nil,
)
if assert.NotNil(t, view.ClientID) {
assert.Equal(t, storedClientID.String(), *view.ClientID)
}
if assert.NotNil(t, view.ClientOnline) {
assert.False(t, *view.ClientOnline)
}
assert.Equal(t, &lastSeen, view.ClientLastSeenAt)
}
@@ -6,10 +6,12 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
goredis "github.com/redis/go-redis/v9"
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/response"
@@ -19,14 +21,16 @@ import (
const DesktopClientTokenTTL = 30 * 24 * time.Hour
type DesktopClientService struct {
repo repository.DesktopClientRepository
repo repository.DesktopClientRepository
redis *goredis.Client
}
func NewDesktopClientService(repo repository.DesktopClientRepository) *DesktopClientService {
return &DesktopClientService{repo: repo}
func NewDesktopClientService(repo repository.DesktopClientRepository, redis *goredis.Client) *DesktopClientService {
return &DesktopClientService{repo: repo, redis: redis}
}
type RegisterDesktopClientRequest struct {
ClientID string `json:"client_id"`
DeviceName string `json:"device_name" binding:"required"`
OS string `json:"os" binding:"required"`
CPUArch string `json:"cpu_arch"`
@@ -35,11 +39,12 @@ type RegisterDesktopClientRequest struct {
}
type HeartbeatDesktopClientRequest struct {
DeviceName *string `json:"device_name"`
OS *string `json:"os"`
CPUArch *string `json:"cpu_arch"`
ClientVersion *string `json:"client_version"`
Channel *string `json:"channel"`
DeviceName *string `json:"device_name"`
OS *string `json:"os"`
CPUArch *string `json:"cpu_arch"`
ClientVersion *string `json:"client_version"`
Channel *string `json:"channel"`
AccountIDs []string `json:"account_ids"`
}
type DesktopClientView struct {
@@ -76,6 +81,10 @@ type HeartbeatDesktopClientResponse struct {
ServerTime time.Time `json:"server_time"`
}
type OfflineDesktopClientResponse struct {
ServerTime time.Time `json:"server_time"`
}
func HashDesktopClientToken(raw string) []byte {
sum := sha256.Sum256([]byte(raw))
return sum[:]
@@ -91,8 +100,9 @@ func (s *DesktopClientService) Register(ctx context.Context, actor auth.Actor, r
return nil, response.ErrInternal(50071, "desktop_client_token_failed", "failed to generate desktop client token")
}
clientID := resolveRegisterClientID(req.ClientID)
client, err := s.repo.Register(ctx, repository.RegisterDesktopClientParams{
ID: uuid.New(),
ID: clientID,
TenantID: actor.TenantID,
WorkspaceID: actor.PrimaryWorkspaceID,
UserID: actor.UserID,
@@ -107,6 +117,8 @@ func (s *DesktopClientService) Register(ctx context.Context, actor auth.Actor, r
return nil, response.ErrInternal(50072, "desktop_client_register_failed", "failed to persist desktop client")
}
markDesktopClientPresent(ctx, s.redis, client.ID)
return &RegisterDesktopClientResponse{
ClientID: client.ID.String(),
ClientToken: rawToken,
@@ -137,6 +149,8 @@ func (s *DesktopClientService) Rotate(ctx context.Context, client *repository.De
return nil, response.ErrInternal(50074, "desktop_client_rotate_failed", "failed to rotate desktop client token")
}
markDesktopClientPresent(ctx, s.redis, updated.ID)
return &RotateDesktopClientResponse{
ClientToken: rawToken,
ExpiresAt: time.Now().Add(DesktopClientTokenTTL).Unix(),
@@ -165,6 +179,9 @@ func (s *DesktopClientService) Heartbeat(ctx context.Context, client *repository
return nil, response.ErrInternal(50075, "desktop_client_heartbeat_failed", "failed to persist desktop client heartbeat")
}
markDesktopClientPresent(ctx, s.redis, updated.ID)
markDesktopAccountsPresent(ctx, s.redis, updated.ID, parseDesktopAccountIDs(req.AccountIDs))
return &HeartbeatDesktopClientResponse{
Client: buildDesktopClientView(updated),
ServerTime: time.Now().UTC(),
@@ -184,10 +201,24 @@ func (s *DesktopClientService) Revoke(ctx context.Context, client *repository.De
return nil, response.ErrInternal(50076, "desktop_client_revoke_failed", "failed to revoke desktop client")
}
clearDesktopClientPresenceState(ctx, s.redis, updated.ID)
view := buildDesktopClientView(updated)
return &view, nil
}
func (s *DesktopClientService) Offline(ctx context.Context, client *repository.DesktopClient) (*OfflineDesktopClientResponse, error) {
if client == nil {
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
}
clearDesktopClientPresenceState(ctx, s.redis, client.ID)
return &OfflineDesktopClientResponse{
ServerTime: time.Now().UTC(),
}, nil
}
func newDesktopClientToken() (string, []byte, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
@@ -227,3 +258,27 @@ func trimmedStringPtr(value string) *string {
trimmed := value
return &trimmed
}
func parseDesktopAccountIDs(values []string) []uuid.UUID {
if len(values) == 0 {
return nil
}
accountIDs := make([]uuid.UUID, 0, len(values))
for _, value := range values {
parsed, err := uuid.Parse(strings.TrimSpace(value))
if err != nil {
continue
}
accountIDs = append(accountIDs, parsed)
}
return accountIDs
}
func resolveRegisterClientID(value string) uuid.UUID {
parsed, err := uuid.Parse(strings.TrimSpace(value))
if err == nil && parsed != uuid.Nil {
return parsed
}
return uuid.New()
}
@@ -0,0 +1,251 @@
package app
import (
"context"
"strconv"
"time"
"github.com/google/uuid"
goredis "github.com/redis/go-redis/v9"
)
const desktopClientPresenceTTL = 90 * time.Second
const desktopAccountPresenceTTL = 90 * time.Second
func desktopClientPresenceKey(clientID uuid.UUID) string {
return "desktop:presence:client:" + clientID.String()
}
func desktopAccountPresenceKey(accountID uuid.UUID) string {
return "desktop:presence:account:" + accountID.String()
}
func desktopClientAccountsPresenceKey(clientID uuid.UUID) string {
return "desktop:presence:client_accounts:" + clientID.String()
}
func markDesktopClientPresent(ctx context.Context, redis *goredis.Client, clientID uuid.UUID) {
if redis == nil {
return
}
_ = redis.Set(ctx, desktopClientPresenceKey(clientID), time.Now().UTC().Format(time.RFC3339), desktopClientPresenceTTL).Err()
}
func clearDesktopClientPresence(ctx context.Context, redis *goredis.Client, clientID uuid.UUID) {
if redis == nil {
return
}
_ = redis.Del(ctx, desktopClientPresenceKey(clientID)).Err()
}
func loadDesktopClientPresence(ctx context.Context, redis *goredis.Client, clientIDs []uuid.UUID) map[uuid.UUID]bool {
if redis == nil || len(clientIDs) == 0 {
return nil
}
uniqueIDs := make([]uuid.UUID, 0, len(clientIDs))
seen := make(map[uuid.UUID]struct{}, len(clientIDs))
for _, clientID := range clientIDs {
if _, ok := seen[clientID]; ok {
continue
}
seen[clientID] = struct{}{}
uniqueIDs = append(uniqueIDs, clientID)
}
pipe := redis.Pipeline()
cmds := make(map[uuid.UUID]*goredis.IntCmd, len(uniqueIDs))
for _, clientID := range uniqueIDs {
cmds[clientID] = pipe.Exists(ctx, desktopClientPresenceKey(clientID))
}
if _, err := pipe.Exec(ctx); err != nil && err != goredis.Nil {
return nil
}
result := make(map[uuid.UUID]bool, len(cmds))
for clientID, cmd := range cmds {
result[clientID] = cmd.Val() > 0
}
return result
}
func markDesktopAccountsPresent(ctx context.Context, redis *goredis.Client, clientID uuid.UUID, accountIDs []uuid.UUID) {
if redis == nil {
return
}
uniqueIDs := uniqueUUIDs(accountIDs)
trackedAccountIDs := loadTrackedDesktopClientAccountIDs(ctx, redis, clientID)
nextAccountSet := make(map[uuid.UUID]struct{}, len(uniqueIDs))
for _, accountID := range uniqueIDs {
nextAccountSet[accountID] = struct{}{}
}
now := float64(time.Now().UTC().UnixMilli())
staleBefore := strconv.FormatInt(time.Now().UTC().Add(-desktopAccountPresenceTTL).UnixMilli(), 10)
clientAccountsKey := desktopClientAccountsPresenceKey(clientID)
clientMember := clientID.String()
pipe := redis.Pipeline()
pipe.Del(ctx, clientAccountsKey)
if len(uniqueIDs) > 0 {
members := make([]interface{}, 0, len(uniqueIDs))
for _, accountID := range uniqueIDs {
members = append(members, accountID.String())
}
pipe.SAdd(ctx, clientAccountsKey, members...)
pipe.Expire(ctx, clientAccountsKey, desktopAccountPresenceTTL*4)
}
for _, accountID := range trackedAccountIDs {
if _, ok := nextAccountSet[accountID]; ok {
continue
}
pipe.ZRem(ctx, desktopAccountPresenceKey(accountID), clientMember)
}
for _, accountID := range uniqueIDs {
key := desktopAccountPresenceKey(accountID)
pipe.ZRemRangeByScore(ctx, key, "-inf", staleBefore)
pipe.ZAdd(ctx, key, goredis.Z{
Score: now,
Member: clientMember,
})
pipe.Expire(ctx, key, desktopAccountPresenceTTL*4)
}
_, _ = pipe.Exec(ctx)
cleanupDesktopAccountPresenceKeys(ctx, redis, trackedAccountIDs)
}
func clearDesktopClientPresenceState(ctx context.Context, redis *goredis.Client, clientID uuid.UUID) {
if redis == nil {
return
}
clearDesktopClientPresence(ctx, redis, clientID)
trackedAccountIDs := loadTrackedDesktopClientAccountIDs(ctx, redis, clientID)
if len(trackedAccountIDs) == 0 {
_ = redis.Del(ctx, desktopClientAccountsPresenceKey(clientID)).Err()
return
}
clientMember := clientID.String()
pipe := redis.Pipeline()
for _, accountID := range trackedAccountIDs {
pipe.ZRem(ctx, desktopAccountPresenceKey(accountID), clientMember)
}
pipe.Del(ctx, desktopClientAccountsPresenceKey(clientID))
_, _ = pipe.Exec(ctx)
cleanupDesktopAccountPresenceKeys(ctx, redis, trackedAccountIDs)
}
func loadDesktopAccountPresence(ctx context.Context, redis *goredis.Client, accountIDs []uuid.UUID) map[uuid.UUID]uuid.UUID {
if redis == nil || len(accountIDs) == 0 {
return nil
}
uniqueIDs := uniqueUUIDs(accountIDs)
staleBefore := strconv.FormatInt(time.Now().UTC().Add(-desktopAccountPresenceTTL).UnixMilli(), 10)
pipe := redis.Pipeline()
commands := make(map[uuid.UUID]*goredis.ZSliceCmd, len(uniqueIDs))
for _, accountID := range uniqueIDs {
key := desktopAccountPresenceKey(accountID)
pipe.ZRemRangeByScore(ctx, key, "-inf", staleBefore)
commands[accountID] = pipe.ZRevRangeWithScores(ctx, key, 0, 0)
}
if _, err := pipe.Exec(ctx); err != nil && err != goredis.Nil {
return nil
}
result := make(map[uuid.UUID]uuid.UUID, len(commands))
for accountID, cmd := range commands {
values := cmd.Val()
if len(values) == 0 {
continue
}
clientIDText, ok := values[0].Member.(string)
if !ok || clientIDText == "" {
continue
}
clientID, err := uuid.Parse(clientIDText)
if err != nil {
continue
}
result[accountID] = clientID
}
return result
}
func uniqueUUIDs(values []uuid.UUID) []uuid.UUID {
if len(values) == 0 {
return nil
}
unique := make([]uuid.UUID, 0, len(values))
seen := make(map[uuid.UUID]struct{}, len(values))
for _, value := range values {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
unique = append(unique, value)
}
return unique
}
func loadTrackedDesktopClientAccountIDs(ctx context.Context, redis *goredis.Client, clientID uuid.UUID) []uuid.UUID {
if redis == nil {
return nil
}
values, err := redis.SMembers(ctx, desktopClientAccountsPresenceKey(clientID)).Result()
if err != nil || len(values) == 0 {
return nil
}
accountIDs := make([]uuid.UUID, 0, len(values))
for _, value := range values {
accountID, parseErr := uuid.Parse(value)
if parseErr != nil {
continue
}
accountIDs = append(accountIDs, accountID)
}
return uniqueUUIDs(accountIDs)
}
func cleanupDesktopAccountPresenceKeys(ctx context.Context, redis *goredis.Client, accountIDs []uuid.UUID) {
if redis == nil || len(accountIDs) == 0 {
return
}
uniqueIDs := uniqueUUIDs(accountIDs)
pipe := redis.Pipeline()
commands := make(map[uuid.UUID]*goredis.IntCmd, len(uniqueIDs))
for _, accountID := range uniqueIDs {
commands[accountID] = pipe.ZCard(ctx, desktopAccountPresenceKey(accountID))
}
if _, err := pipe.Exec(ctx); err != nil && err != goredis.Nil {
return
}
deletePipe := redis.Pipeline()
shouldDelete := false
for accountID, cmd := range commands {
if cmd.Val() > 0 {
continue
}
deletePipe.Del(ctx, desktopAccountPresenceKey(accountID))
shouldDelete = true
}
if shouldDelete {
_, _ = deletePipe.Exec(ctx)
}
}
@@ -9,28 +9,39 @@ import (
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"go.uber.org/zap"
"github.com/geo-platform/tenant-api/internal/shared/auth"
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
type DesktopTaskService struct {
pool *pgxpool.Pool
repo repository.DesktopTaskRepository
cache sharedcache.Cache
messaging *rabbitmq.Client
logger *zap.Logger
}
func NewDesktopTaskService(repo repository.DesktopTaskRepository, messaging *rabbitmq.Client, logger *zap.Logger) *DesktopTaskService {
func NewDesktopTaskService(pool *pgxpool.Pool, messaging *rabbitmq.Client, logger *zap.Logger) *DesktopTaskService {
return &DesktopTaskService{
repo: repo,
pool: pool,
repo: repository.NewDesktopTaskRepository(pool),
messaging: messaging,
logger: logger,
}
}
func (s *DesktopTaskService) WithCache(c sharedcache.Cache) *DesktopTaskService {
s.cache = c
return s
}
type DesktopTaskView struct {
ID string `json:"id"`
JobID string `json:"job_id"`
@@ -46,7 +57,6 @@ type DesktopTaskView struct {
ActiveAttemptID *string `json:"active_attempt_id"`
LeaseExpiresAt *time.Time `json:"lease_expires_at"`
Attempts int `json:"attempts"`
ParkedReason *string `json:"parked_reason"`
Result json.RawMessage `json:"result"`
Error json.RawMessage `json:"error"`
CreatedAt time.Time `json:"created_at"`
@@ -69,11 +79,6 @@ type ExtendDesktopTaskRequest struct {
LeaseToken string `json:"lease_token" binding:"required"`
}
type ParkDesktopTaskRequest struct {
LeaseToken string `json:"lease_token" binding:"required"`
Reason string `json:"reason" binding:"required,oneof=waiting_user captcha"`
}
type CompleteDesktopTaskRequest struct {
LeaseToken string `json:"lease_token" binding:"required"`
Status string `json:"status" binding:"required,oneof=succeeded failed unknown"`
@@ -92,7 +97,22 @@ type ReconcileDesktopTaskRequest struct {
Error map[string]any `json:"error"`
}
func (s *DesktopTaskService) Lease(ctx context.Context, client *repository.DesktopClient, req LeaseDesktopTaskRequest, routeTaskID *uuid.UUID, fromParked bool) (*LeaseDesktopTaskResponse, error) {
type ListPublishTasksRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
Title string `form:"title"`
}
type DesktopPublishTaskList struct {
Items []DesktopTaskView `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int `json:"total"`
PendingCount int `json:"pending_count"`
HistoryTotal int `json:"history_total"`
}
func (s *DesktopTaskService) Lease(ctx context.Context, client *repository.DesktopClient, req LeaseDesktopTaskRequest, routeTaskID *uuid.UUID) (*LeaseDesktopTaskResponse, error) {
if client == nil {
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
}
@@ -119,11 +139,6 @@ func (s *DesktopTaskService) Lease(ctx context.Context, client *repository.Deskt
var task *repository.DesktopTask
switch {
case fromParked:
if taskID == nil {
return nil, response.ErrBadRequest(40085, "missing_desktop_task_id", "task id is required when from_parked=true")
}
task, err = s.repo.LeaseParked(ctx, *taskID, client.ID, leaseParams)
case taskID != nil:
task, err = s.repo.LeaseQueuedByDesktopID(ctx, *taskID, client.ID, leaseParams)
default:
@@ -131,10 +146,10 @@ func (s *DesktopTaskService) Lease(ctx context.Context, client *repository.Deskt
}
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
if taskID == nil && !fromParked {
if taskID == nil {
return &LeaseDesktopTaskResponse{}, nil
}
return nil, s.classifyLeaseError(ctx, client, taskID, fromParked)
return nil, s.classifyLeaseError(ctx, client, taskID)
}
return nil, response.ErrInternal(50088, "desktop_task_lease_failed", "failed to lease desktop task")
}
@@ -179,46 +194,11 @@ func (s *DesktopTaskService) Extend(ctx context.Context, client *repository.Desk
return &view, nil
}
func (s *DesktopTaskService) Park(ctx context.Context, client *repository.DesktopClient, desktopID uuid.UUID, req ParkDesktopTaskRequest) (*DesktopTaskView, error) {
if client == nil {
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
}
previousTask, _ := s.repo.GetByDesktopID(ctx, desktopID, client.WorkspaceID)
previousAttemptID := activeAttemptID(previousTask)
task, err := s.repo.Park(ctx, desktopID, HashDesktopClientToken(strings.TrimSpace(req.LeaseToken)), req.Reason)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, response.ErrConflict(40983, "desktop_task_lease_invalid", "desktop task lease token is invalid or expired")
}
return nil, response.ErrInternal(50090, "desktop_task_park_failed", "failed to park desktop task")
}
if previousAttemptID != nil {
if finishErr := s.repo.FinishAttempt(ctx, repository.FinishDesktopTaskAttemptParams{
TaskID: task.DesktopID,
AttemptID: *previousAttemptID,
FinalStatus: literalStringPtr("waiting_user"),
}); finishErr != nil {
s.logWarn("desktop task attempt finish failed after park", finishErr, zap.String("task_id", task.DesktopID.String()))
}
}
s.publishTaskEvent(ctx, task, "task_parked")
view := buildDesktopTaskView(task)
return &view, nil
}
func (s *DesktopTaskService) Complete(ctx context.Context, client *repository.DesktopClient, desktopID uuid.UUID, req CompleteDesktopTaskRequest) (*DesktopTaskView, error) {
if client == nil {
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
}
previousTask, _ := s.repo.GetByDesktopID(ctx, desktopID, client.WorkspaceID)
previousAttemptID := activeAttemptID(previousTask)
resultJSON, err := marshalOptionalJSON(req.Payload)
if err != nil {
return nil, response.ErrBadRequest(40086, "invalid_desktop_task_payload", "payload must be serializable")
@@ -228,7 +208,17 @@ func (s *DesktopTaskService) Complete(ctx context.Context, client *repository.De
return nil, response.ErrBadRequest(40087, "invalid_desktop_task_error", "error must be serializable")
}
task, err := s.repo.Complete(ctx, desktopID, HashDesktopClientToken(strings.TrimSpace(req.LeaseToken)), req.Status, resultJSON, errorJSON)
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, response.ErrInternal(50103, "desktop_task_complete_begin_failed", "failed to start desktop task completion")
}
defer tx.Rollback(ctx)
taskRepo := repository.NewDesktopTaskRepository(tx)
previousTask, _ := taskRepo.GetByDesktopID(ctx, desktopID, client.WorkspaceID)
previousAttemptID := activeAttemptID(previousTask)
task, err := taskRepo.Complete(ctx, desktopID, HashDesktopClientToken(strings.TrimSpace(req.LeaseToken)), req.Status, resultJSON, errorJSON)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, response.ErrConflict(40983, "desktop_task_lease_invalid", "desktop task lease token is invalid or expired")
@@ -237,7 +227,7 @@ func (s *DesktopTaskService) Complete(ctx context.Context, client *repository.De
}
if previousAttemptID != nil {
if finishErr := s.repo.FinishAttempt(ctx, repository.FinishDesktopTaskAttemptParams{
if finishErr := taskRepo.FinishAttempt(ctx, repository.FinishDesktopTaskAttemptParams{
TaskID: task.DesktopID,
AttemptID: *previousAttemptID,
FinalStatus: &req.Status,
@@ -247,6 +237,19 @@ func (s *DesktopTaskService) Complete(ctx context.Context, client *repository.De
}
}
publishOutcome, err := syncDesktopPublishTaskState(ctx, tx, task)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(50104, "desktop_task_complete_commit_failed", "failed to commit desktop task completion")
}
if publishOutcome != nil {
invalidateArticleCaches(ctx, s.cache, publishOutcome.TenantID, &publishOutcome.ArticleID)
}
s.publishTaskEvent(ctx, task, "task_completed")
view := buildDesktopTaskView(task)
@@ -340,7 +343,14 @@ func (s *DesktopTaskService) Reconcile(ctx context.Context, actor auth.Actor, de
return nil, response.ErrBadRequest(40090, "invalid_desktop_task_reconcile_error", "error must be serializable")
}
task, err := s.repo.Reconcile(ctx, desktopID, actor.PrimaryWorkspaceID, req.Status, resultJSON, errorJSON)
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, response.ErrInternal(50105, "desktop_task_reconcile_begin_failed", "failed to start desktop task reconcile")
}
defer tx.Rollback(ctx)
taskRepo := repository.NewDesktopTaskRepository(tx)
task, err := taskRepo.Reconcile(ctx, desktopID, actor.PrimaryWorkspaceID, req.Status, resultJSON, errorJSON)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, response.ErrConflict(40985, "desktop_task_reconcile_conflict", "desktop task is not waiting for reconcile")
@@ -348,13 +358,280 @@ func (s *DesktopTaskService) Reconcile(ctx context.Context, actor auth.Actor, de
return nil, response.ErrInternal(50094, "desktop_task_reconcile_failed", "failed to reconcile desktop task")
}
publishOutcome, err := syncDesktopPublishTaskState(ctx, tx, task)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(50106, "desktop_task_reconcile_commit_failed", "failed to commit desktop task reconcile")
}
if publishOutcome != nil {
invalidateArticleCaches(ctx, s.cache, publishOutcome.TenantID, &publishOutcome.ArticleID)
}
s.publishTaskEvent(ctx, task, "task_reconciled")
view := buildDesktopTaskView(task)
return &view, nil
}
func (s *DesktopTaskService) classifyLeaseError(ctx context.Context, client *repository.DesktopClient, taskID *uuid.UUID, fromParked bool) error {
func (s *DesktopTaskService) ListPublishTasks(ctx context.Context, client *repository.DesktopClient, req ListPublishTasksRequest) (*DesktopPublishTaskList, error) {
if client == nil {
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
}
if s.pool == nil {
return nil, response.ErrServiceUnavailable(50342, "desktop_publish_job_store_unavailable", "desktop publish job store is unavailable")
}
page := req.Page
if page <= 0 {
page = 1
}
pageSize := req.PageSize
if pageSize <= 0 || pageSize > 50 {
pageSize = 10
}
title := strings.TrimSpace(req.Title)
pendingStatuses := []string{"queued", "in_progress"}
historyStatuses := []string{"succeeded", "failed", "unknown", "aborted"}
pendingItems, err := s.listPublishTasksByStatuses(
ctx,
client,
pendingStatuses,
title,
0,
0,
`CASE WHEN status = 'in_progress' THEN 0 ELSE 1 END ASC, created_at ASC, desktop_id DESC`,
)
if err != nil {
return nil, err
}
historyTotal, err := s.countPublishTasksByStatuses(ctx, client, historyStatuses, title)
if err != nil {
return nil, err
}
offset := (page - 1) * pageSize
items := make([]DesktopTaskView, 0, pageSize)
total := len(pendingItems) + historyTotal
if offset < len(pendingItems) {
end := offset + pageSize
if end > len(pendingItems) {
end = len(pendingItems)
}
items = append(items, pendingItems[offset:end]...)
}
remaining := pageSize - len(items)
historyOffset := offset - len(pendingItems)
if historyOffset < 0 {
historyOffset = 0
}
if remaining > 0 && historyOffset < historyTotal {
historyItems, listErr := s.listPublishTasksByStatuses(
ctx,
client,
historyStatuses,
title,
remaining,
historyOffset,
`updated_at DESC, desktop_id DESC`,
)
if listErr != nil {
return nil, listErr
}
items = append(items, historyItems...)
}
return &DesktopPublishTaskList{
Items: items,
Page: page,
PageSize: pageSize,
Total: total,
PendingCount: len(pendingItems),
HistoryTotal: historyTotal,
}, nil
}
func (s *DesktopTaskService) listPublishTasksByStatuses(
ctx context.Context,
client *repository.DesktopClient,
statuses []string,
title string,
limit int,
offset int,
orderBy string,
) ([]DesktopTaskView, error) {
query := `
SELECT
desktop_id,
job_id,
tenant_id,
workspace_id,
target_account_id,
target_client_id,
platform_id,
kind,
payload,
status,
dedup_key,
active_attempt_id,
lease_expires_at,
attempts,
result,
error,
created_at,
updated_at
FROM desktop_tasks
WHERE workspace_id = $1
AND target_client_id = $2
AND kind = 'publish'
AND status = ANY($3)
AND ($4 = '' OR COALESCE(payload->>'title', '') ILIKE '%' || $4 || '%')
`
args := []any{client.WorkspaceID, client.ID, statuses, title}
if strings.TrimSpace(orderBy) != "" {
query += "\nORDER BY " + orderBy
}
if limit > 0 {
query += "\nLIMIT $5 OFFSET $6"
args = append(args, limit, offset)
}
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, response.ErrInternal(50109, "desktop_publish_tasks_query_failed", "failed to list desktop publish tasks")
}
defer rows.Close()
return scanDesktopTaskRows(rows)
}
func (s *DesktopTaskService) countPublishTasksByStatuses(
ctx context.Context,
client *repository.DesktopClient,
statuses []string,
title string,
) (int, error) {
var count int
err := s.pool.QueryRow(ctx, `
SELECT COUNT(1)
FROM desktop_tasks
WHERE workspace_id = $1
AND target_client_id = $2
AND kind = 'publish'
AND status = ANY($3)
AND ($4 = '' OR COALESCE(payload->>'title', '') ILIKE '%' || $4 || '%')
`, client.WorkspaceID, client.ID, statuses, title).Scan(&count)
if err != nil {
return 0, response.ErrInternal(50109, "desktop_publish_tasks_query_failed", "failed to list desktop publish tasks")
}
return count, nil
}
func scanDesktopTaskRows(rows pgx.Rows) ([]DesktopTaskView, error) {
items := make([]DesktopTaskView, 0)
for rows.Next() {
var (
desktopID uuid.UUID
jobID uuid.UUID
tenantID int64
workspaceID int64
targetAccountID uuid.UUID
targetClientID uuid.UUID
platform string
kind string
payload []byte
status string
dedupKey pgtype.Text
activeAttemptID pgtype.UUID
leaseExpiresAt pgtype.Timestamptz
attempts int32
resultJSON []byte
errorJSON []byte
createdAt time.Time
updatedAt time.Time
)
if err := rows.Scan(
&desktopID,
&jobID,
&tenantID,
&workspaceID,
&targetAccountID,
&targetClientID,
&platform,
&kind,
&payload,
&status,
&dedupKey,
&activeAttemptID,
&leaseExpiresAt,
&attempts,
&resultJSON,
&errorJSON,
&createdAt,
&updatedAt,
); err != nil {
return nil, response.ErrInternal(50110, "desktop_publish_tasks_scan_failed", "failed to scan desktop publish tasks")
}
var dedupKeyText *string
if dedupKey.Valid {
value := dedupKey.String
dedupKeyText = &value
}
var activeAttemptIDText *string
if activeAttemptID.Valid {
value, convErr := uuid.FromBytes(activeAttemptID.Bytes[:])
if convErr == nil {
text := value.String()
activeAttemptIDText = &text
}
}
var leaseExpiresAtValue *time.Time
if leaseExpiresAt.Valid {
value := leaseExpiresAt.Time
leaseExpiresAtValue = &value
}
items = append(items, DesktopTaskView{
ID: desktopID.String(),
JobID: jobID.String(),
TenantID: tenantID,
WorkspaceID: workspaceID,
TargetAccountID: targetAccountID.String(),
TargetClientID: targetClientID.String(),
Platform: platform,
Kind: kind,
Payload: json.RawMessage(payload),
Status: status,
DedupKey: dedupKeyText,
ActiveAttemptID: activeAttemptIDText,
LeaseExpiresAt: leaseExpiresAtValue,
Attempts: int(attempts),
Result: json.RawMessage(resultJSON),
Error: json.RawMessage(errorJSON),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
})
}
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50109, "desktop_publish_tasks_query_failed", "failed to list desktop publish tasks")
}
return items, nil
}
func (s *DesktopTaskService) classifyLeaseError(ctx context.Context, client *repository.DesktopClient, taskID *uuid.UUID) error {
if taskID == nil {
return response.ErrConflict(40986, "desktop_task_unavailable", "no desktop task is currently available")
}
@@ -371,19 +648,6 @@ func (s *DesktopTaskService) classifyLeaseError(ctx context.Context, client *rep
return response.ErrForbidden(40381, "desktop_task_not_target_client", "desktop task is assigned to another desktop client")
}
if fromParked {
switch task.Status {
case "aborted":
return response.ErrConflict(40987, "desktop_task_aborted", "desktop task has already been aborted")
case "succeeded", "failed", "unknown":
return response.ErrConflict(40988, "desktop_task_terminal", "desktop task is already in a terminal state")
case "in_progress":
return response.ErrConflict(40989, "desktop_task_already_in_progress", "desktop task is already in progress")
default:
return response.ErrConflict(40990, "desktop_task_not_parked", "desktop task is not waiting_user")
}
}
if task.Status == "in_progress" {
return response.ErrConflict(40989, "desktop_task_already_in_progress", "desktop task is already in progress")
}
@@ -413,7 +677,6 @@ func buildDesktopTaskView(task *repository.DesktopTask) DesktopTaskView {
ActiveAttemptID: activeAttemptID,
LeaseExpiresAt: task.LeaseExpiresAt,
Attempts: task.Attempts,
ParkedReason: task.ParkedReason,
Result: json.RawMessage(task.Result),
Error: json.RawMessage(task.Error),
CreatedAt: task.CreatedAt,
+23 -42
View File
@@ -468,51 +468,40 @@ func (s *MediaService) CreatePublishBatch(ctx context.Context, articleID int64,
}
defer tx.Rollback(ctx)
var articleExists bool
var generateStatus string
if err := tx.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM articles WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL),
COALESCE((SELECT generate_status FROM articles WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL), '')
`, articleID, actor.TenantID).Scan(&articleExists, &generateStatus); err != nil {
return nil, response.ErrInternal(50037, "article_check_failed", "failed to validate article")
}
if !articleExists {
return nil, response.ErrNotFound(40411, "article_not_found", "article not found")
}
if generateStatus != "completed" {
return nil, response.ErrConflict(40913, "article_not_publishable", "only completed articles can be published")
articleMeta, err := loadPublishableArticleMeta(ctx, tx, actor.TenantID, articleID)
if err != nil {
return nil, err
}
accountSeeds, err := loadPlatformAccountsForPublish(ctx, tx, actor.TenantID, req.PlatformAccountIDs)
if err != nil {
return nil, err
}
if publishBatchRequiresCover(accountSeeds) && strings.TrimSpace(pointerStringValue(req.CoverAssetURL)) == "" {
coverAssetURL := normalizeStringPointer(req.CoverAssetURL)
if publishBatchRequiresCover(accountSeeds) && strings.TrimSpace(pointerStringValue(coverAssetURL)) == "" {
return nil, response.ErrBadRequest(40044, "publish_cover_required", "baijiahao publishing requires a cover image")
}
var publishBatchID int64
if err := tx.QueryRow(ctx, `
INSERT INTO publish_batches (tenant_id, article_id, initiator_user_id, status, publish_type, cover_asset_url)
VALUES ($1, $2, $3, 'publishing', $4, $5)
RETURNING id
`, actor.TenantID, articleID, actor.UserID, publishType, normalizeStringPointer(req.CoverAssetURL)).Scan(&publishBatchID); err != nil {
return nil, response.ErrInternal(50038, "publish_batch_create_failed", "failed to create publish batch")
if coverAssetURL == nil {
coverAssetURL = articleMeta.CoverAssetURL
}
publishBatchID, publishRecordIDs, err := createPublishBatchRecords(
ctx,
tx,
actor.TenantID,
actor.UserID,
articleID,
publishType,
coverAssetURL,
accountSeeds,
)
if err != nil {
return nil, err
}
tasks := make([]PublishBatchTask, 0, len(accountSeeds))
for _, account := range accountSeeds {
var publishRecordID int64
if err := tx.QueryRow(ctx, `
INSERT INTO publish_records (
tenant_id, publish_batch_id, article_id, platform_account_id, platform_id, status
)
VALUES ($1, $2, $3, $4, $5, 'queued')
RETURNING id
`, actor.TenantID, publishBatchID, articleID, account.ID, account.PlatformID).Scan(&publishRecordID); err != nil {
return nil, response.ErrInternal(50039, "publish_record_create_failed", "failed to create publish record")
}
token, tokenErr := newSessionToken()
if tokenErr != nil {
return nil, response.ErrInternal(50034, "session_token_failed", "failed to generate plugin session token")
@@ -527,12 +516,12 @@ func (s *MediaService) CreatePublishBatch(ctx context.Context, articleID int64,
)
VALUES ($1, $2, $3, 'publish', 'publish_record', $4, $5, $6, $7, $8, 'pending')
RETURNING id
`, actor.TenantID, actor.UserID, req.PluginInstallationID, publishRecordID, account.ID, account.PlatformID, token, expireAt).Scan(&pluginSessionID); err != nil {
`, actor.TenantID, actor.UserID, req.PluginInstallationID, publishRecordIDs[account.ID], account.ID, account.PlatformID, token, expireAt).Scan(&pluginSessionID); err != nil {
return nil, response.ErrInternal(50040, "publish_session_create_failed", "failed to create publish session")
}
tasks = append(tasks, PublishBatchTask{
PublishRecordID: publishRecordID,
PublishRecordID: publishRecordIDs[account.ID],
PlatformAccountID: account.ID,
PlatformID: account.PlatformID,
PlatformName: account.PlatformName,
@@ -544,14 +533,6 @@ func (s *MediaService) CreatePublishBatch(ctx context.Context, articleID int64,
})
}
if _, err := tx.Exec(ctx, `
UPDATE articles
SET publish_status = 'publishing', updated_at = NOW()
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, articleID, actor.TenantID); err != nil {
return nil, response.ErrInternal(50041, "article_publish_status_failed", "failed to update article publish status")
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(50042, "publish_batch_commit_failed", "failed to commit publish batch")
}
+181 -27
View File
@@ -9,9 +9,11 @@ import (
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
goredis "github.com/redis/go-redis/v9"
"go.uber.org/zap"
"github.com/geo-platform/tenant-api/internal/shared/auth"
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
@@ -20,20 +22,32 @@ import (
type PublishJobService struct {
pool *pgxpool.Pool
messaging *rabbitmq.Client
redis *goredis.Client
logger *zap.Logger
cache sharedcache.Cache
}
func NewPublishJobService(pool *pgxpool.Pool, messaging *rabbitmq.Client, logger *zap.Logger) *PublishJobService {
func NewPublishJobService(
pool *pgxpool.Pool,
messaging *rabbitmq.Client,
redis *goredis.Client,
logger *zap.Logger,
) *PublishJobService {
return &PublishJobService{
pool: pool,
messaging: messaging,
redis: redis,
logger: logger,
}
}
func (s *PublishJobService) WithCache(c sharedcache.Cache) *PublishJobService {
s.cache = c
return s
}
type CreatePublishJobAccountRequest struct {
AccountID string `json:"account_id" binding:"required"`
Mode string `json:"mode" binding:"required,oneof=auto manual"`
}
type CreatePublishJobRequest struct {
@@ -61,6 +75,10 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
if err != nil {
return nil, response.ErrBadRequest(40091, "invalid_content_ref", "content_ref must be serializable")
}
articleID, err := resolvePublishArticleID(req.ContentRef)
if err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
@@ -70,6 +88,50 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
accountRepo := repository.NewDesktopAccountRepository(tx)
taskRepo := repository.NewDesktopTaskRepository(tx)
articleMeta, err := loadPublishableArticleMeta(ctx, tx, actor.TenantID, articleID)
if err != nil {
return nil, err
}
type publishTarget struct {
account *repository.DesktopAccount
accountSeed *platformAccountSeed
targetClientID uuid.UUID
}
targets := make([]publishTarget, 0, len(req.Accounts))
for _, accountReq := range req.Accounts {
accountID, parseErr := uuid.Parse(strings.TrimSpace(accountReq.AccountID))
if parseErr != nil {
return nil, response.ErrBadRequest(40092, "invalid_desktop_account_id", "account_id must be a uuid")
}
account, lookupErr := accountRepo.GetByDesktopID(ctx, accountID, actor.PrimaryWorkspaceID)
if lookupErr != nil {
if errors.Is(lookupErr, pgx.ErrNoRows) {
return nil, response.ErrBadRequest(40093, "desktop_account_not_found", "one or more selected desktop accounts do not exist")
}
return nil, response.ErrInternal(50098, "desktop_account_lookup_failed", "failed to load selected desktop account")
}
if account.Health != "live" {
return nil, response.ErrConflict(40993, "desktop_account_not_publishable", "one or more selected desktop accounts require re-authorization before publishing")
}
targetClientID := s.resolveTargetClientID(ctx, account)
if targetClientID == nil {
return nil, response.ErrConflict(40992, "desktop_account_client_missing", "one or more selected desktop accounts are not currently available on any desktop client")
}
accountSeed, seedErr := loadPlatformAccountSeedByDesktopID(ctx, tx, actor.TenantID, actor.PrimaryWorkspaceID, account.DesktopID)
if seedErr != nil {
return nil, seedErr
}
targets = append(targets, publishTarget{
account: account,
accountSeed: accountSeed,
targetClientID: *targetClientID,
})
}
jobID := uuid.New()
job, err := taskRepo.CreatePublishJob(ctx, repository.CreateDesktopPublishJobParams{
@@ -85,31 +147,41 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
return nil, response.ErrInternal(50097, "desktop_publish_job_create_failed", "failed to create desktop publish job")
}
taskIDs := make([]string, 0, len(req.Accounts))
createdTasks := make([]*repository.DesktopTask, 0, len(req.Accounts))
for _, accountReq := range req.Accounts {
accountID, parseErr := uuid.Parse(strings.TrimSpace(accountReq.AccountID))
if parseErr != nil {
return nil, response.ErrBadRequest(40092, "invalid_desktop_account_id", "account_id must be a uuid")
}
accountSeeds := make([]platformAccountSeed, 0, len(targets))
for _, target := range targets {
accountSeeds = append(accountSeeds, *target.accountSeed)
}
if publishBatchRequiresCover(accountSeeds) && strings.TrimSpace(pointerStringValue(articleMeta.CoverAssetURL)) == "" {
return nil, response.ErrBadRequest(40044, "publish_cover_required", "baijiahao publishing requires a cover image")
}
account, lookupErr := accountRepo.GetByDesktopID(ctx, accountID, actor.PrimaryWorkspaceID)
if lookupErr != nil {
if errors.Is(lookupErr, pgx.ErrNoRows) {
return nil, response.ErrBadRequest(40093, "desktop_account_not_found", "one or more selected desktop accounts do not exist")
}
return nil, response.ErrInternal(50098, "desktop_account_lookup_failed", "failed to load selected desktop account")
}
if account.ClientID == nil {
return nil, response.ErrConflict(40992, "desktop_account_client_missing", "one or more selected desktop accounts are not currently bound to a desktop client")
}
publishBatchID, publishRecordIDs, err := createPublishBatchRecords(
ctx,
tx,
actor.TenantID,
actor.UserID,
articleID,
"publish",
articleMeta.CoverAssetURL,
accountSeeds,
)
if err != nil {
return nil, err
}
taskIDs := make([]string, 0, len(targets))
createdTasks := make([]*repository.DesktopTask, 0, len(targets))
for _, target := range targets {
publishRecordID := publishRecordIDs[target.accountSeed.ID]
payloadJSON, payloadErr := marshalOptionalJSON(map[string]any{
"title": req.Title,
"content_ref": req.ContentRef,
"mode": accountReq.Mode,
"account_id": account.DesktopID.String(),
"platform": account.Platform,
"title": req.Title,
"content_ref": req.ContentRef,
"account_id": target.account.DesktopID.String(),
"platform": target.account.Platform,
"article_id": articleID,
"publish_batch_id": publishBatchID,
"publish_record_id": publishRecordID,
"platform_account_id": target.accountSeed.ID,
})
if payloadErr != nil {
return nil, response.ErrBadRequest(40094, "invalid_desktop_task_payload", "publish job payload must be serializable")
@@ -120,9 +192,9 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
JobID: job.DesktopID,
TenantID: actor.TenantID,
WorkspaceID: actor.PrimaryWorkspaceID,
TargetAccountID: account.DesktopID,
TargetClientID: *account.ClientID,
Platform: account.Platform,
TargetAccountID: target.account.DesktopID,
TargetClientID: target.targetClientID,
Platform: target.account.Platform,
Kind: "publish",
Payload: payloadJSON,
Status: "queued",
@@ -138,6 +210,7 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(50100, "desktop_publish_job_commit_failed", "failed to commit desktop publish job")
}
invalidateArticleCaches(ctx, s.cache, actor.TenantID, &articleID)
for _, task := range createdTasks {
if publishErr := publishDesktopTaskEvent(context.Background(), s.messaging, DesktopTaskEvent{
@@ -160,6 +233,62 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
}, nil
}
func (s *PublishJobService) RetryByDesktopTask(
ctx context.Context,
client *repository.DesktopClient,
taskID uuid.UUID,
) (*CreatePublishJobResponse, error) {
if client == nil {
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
}
if s.pool == nil {
return nil, response.ErrServiceUnavailable(50342, "desktop_publish_job_store_unavailable", "desktop publish job store is unavailable")
}
taskRepo := repository.NewDesktopTaskRepository(s.pool)
task, err := taskRepo.GetByDesktopID(ctx, taskID, client.WorkspaceID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, response.ErrNotFound(40482, "desktop_task_not_found", "desktop task not found")
}
return nil, response.ErrInternal(50095, "desktop_task_lookup_failed", "failed to inspect desktop task state")
}
if task.Kind != "publish" {
return nil, response.ErrBadRequest(40095, "desktop_task_not_publish", "only publish tasks can be retried")
}
if task.TargetClientID != client.ID {
return nil, response.ErrForbidden(40381, "desktop_task_not_target_client", "desktop task is assigned to another desktop client")
}
if task.Status == "queued" || task.Status == "in_progress" {
return nil, response.ErrConflict(40994, "desktop_task_retry_conflict", "desktop task is still pending or running")
}
payload := unmarshalJSONObject(task.Payload)
articleID, ok := extractInt64FromMap(payload, "article_id")
if !ok || articleID <= 0 {
return nil, response.ErrBadRequest(40096, "desktop_task_article_missing", "desktop publish task is missing article_id")
}
title := strings.TrimSpace(stringPointerValue(extractStringPointer(payload, "title")))
if title == "" {
title = "文章发布"
}
return s.Create(ctx, auth.Actor{
TenantID: client.TenantID,
UserID: client.UserID,
PrimaryWorkspaceID: client.WorkspaceID,
}, CreatePublishJobRequest{
Title: title,
ContentRef: map[string]any{
"article_id": articleID,
},
Accounts: []CreatePublishJobAccountRequest{
{AccountID: task.TargetAccountID.String()},
},
})
}
func (s *PublishJobService) logWarn(message string, err error, fields ...zap.Field) {
if s.logger == nil || err == nil {
return
@@ -167,3 +296,28 @@ func (s *PublishJobService) logWarn(message string, err error, fields ...zap.Fie
fields = append(fields, zap.Error(err))
s.logger.Warn(message, fields...)
}
func (s *PublishJobService) resolveTargetClientID(ctx context.Context, account *repository.DesktopAccount) *uuid.UUID {
if account == nil {
return nil
}
presence := loadDesktopAccountPresence(ctx, s.redis, []uuid.UUID{account.DesktopID})
if clientID, ok := presence[account.DesktopID]; ok {
return &clientID
}
if account.ClientID == nil {
return nil
}
clientID := *account.ClientID
return &clientID
}
func stringPointerValue(value *string) string {
if value == nil {
return ""
}
return strings.TrimSpace(*value)
}
@@ -0,0 +1,358 @@
package app
import (
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
type publishableArticleMeta struct {
CoverAssetURL *string
}
type desktopPublishSyncOutcome struct {
TenantID int64
ArticleID int64
}
func loadPublishableArticleMeta(ctx context.Context, tx pgx.Tx, tenantID, articleID int64) (*publishableArticleMeta, error) {
var generateStatus string
var wizardStateJSON []byte
if err := tx.QueryRow(ctx, `
SELECT generate_status, wizard_state_json
FROM articles
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, articleID, tenantID).Scan(&generateStatus, &wizardStateJSON); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, response.ErrNotFound(40411, "article_not_found", "article not found")
}
return nil, response.ErrInternal(50037, "article_check_failed", "failed to validate article")
}
if generateStatus != "completed" {
return nil, response.ErrConflict(40913, "article_not_publishable", "only completed articles can be published")
}
return &publishableArticleMeta{
CoverAssetURL: resolveArticleCoverAssetURL(wizardStateJSON),
}, nil
}
func createPublishBatchRecords(
ctx context.Context,
tx pgx.Tx,
tenantID int64,
initiatorUserID int64,
articleID int64,
publishType string,
coverAssetURL *string,
accounts []platformAccountSeed,
) (int64, map[int64]int64, error) {
var publishBatchID int64
if err := tx.QueryRow(ctx, `
INSERT INTO publish_batches (tenant_id, article_id, initiator_user_id, status, publish_type, cover_asset_url)
VALUES ($1, $2, $3, 'publishing', $4, $5)
RETURNING id
`, tenantID, articleID, initiatorUserID, publishType, normalizeStringPointer(coverAssetURL)).Scan(&publishBatchID); err != nil {
return 0, nil, response.ErrInternal(50038, "publish_batch_create_failed", "failed to create publish batch")
}
recordIDs := make(map[int64]int64, len(accounts))
for _, account := range accounts {
var publishRecordID int64
if err := tx.QueryRow(ctx, `
INSERT INTO publish_records (
tenant_id, publish_batch_id, article_id, platform_account_id, platform_id, status
)
VALUES ($1, $2, $3, $4, $5, 'queued')
RETURNING id
`, tenantID, publishBatchID, articleID, account.ID, account.PlatformID).Scan(&publishRecordID); err != nil {
return 0, nil, response.ErrInternal(50039, "publish_record_create_failed", "failed to create publish record")
}
recordIDs[account.ID] = publishRecordID
}
if _, err := tx.Exec(ctx, `
UPDATE articles
SET publish_status = 'publishing', updated_at = NOW()
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, articleID, tenantID); err != nil {
return 0, nil, response.ErrInternal(50041, "article_publish_status_failed", "failed to update article publish status")
}
return publishBatchID, recordIDs, nil
}
func syncDesktopPublishTaskState(
ctx context.Context,
tx pgx.Tx,
task *repository.DesktopTask,
) (*desktopPublishSyncOutcome, error) {
if task == nil || task.Kind != "publish" {
return nil, nil
}
payload := unmarshalJSONObject(task.Payload)
publishRecordID, ok := extractInt64FromMap(payload, "publish_record_id")
if !ok || publishRecordID <= 0 {
return nil, nil
}
var publishBatchID int64
var articleID int64
if err := tx.QueryRow(ctx, `
SELECT publish_batch_id, article_id
FROM publish_records
WHERE id = $1 AND tenant_id = $2
`, publishRecordID, task.TenantID).Scan(&publishBatchID, &articleID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, response.ErrNotFound(40434, "publish_record_not_found", "publish record not found")
}
return nil, response.ErrInternal(50101, "desktop_publish_record_lookup_failed", "failed to lookup desktop publish record")
}
resultPayload := unmarshalJSONObject(task.Result)
errorPayload := unmarshalJSONObject(task.Error)
publishStatus := desktopTaskToPublishStatus(task.Status)
var publishedAt *time.Time
if publishStatus == "success" {
now := time.Now().UTC()
publishedAt = &now
}
if _, err := tx.Exec(ctx, `
UPDATE publish_records
SET status = $1,
external_article_id = $2,
external_article_url = $3,
external_manage_url = $4,
published_at = $5,
request_payload_json = $6,
response_payload_json = $7,
error_message = $8,
updated_at = NOW()
WHERE id = $9 AND tenant_id = $10
`, publishStatus,
extractStringPointer(resultPayload, "external_article_id", "externalArticleId"),
extractStringPointer(resultPayload, "external_article_url", "externalArticleUrl"),
extractStringPointer(resultPayload, "external_manage_url", "externalManageUrl", "review_url", "reviewUrl"),
publishedAt,
nullableJSON(task.Payload),
nullableJSON(task.Result),
desktopTaskErrorMessage(errorPayload),
publishRecordID,
task.TenantID,
); err != nil {
return nil, response.ErrInternal(50102, "desktop_publish_record_update_failed", "failed to update desktop publish record")
}
batchStatus, err := recalculatePublishBatchStatus(ctx, tx, publishBatchID)
if err != nil {
return nil, err
}
if _, err := tx.Exec(ctx, `
UPDATE publish_batches
SET status = $1, updated_at = NOW()
WHERE id = $2
`, batchStatus, publishBatchID); err != nil {
return nil, response.ErrInternal(50054, "publish_batch_update_failed", "failed to update publish batch")
}
articleStatus := publishBatchStatusToArticleStatus(batchStatus)
if _, err := tx.Exec(ctx, `
UPDATE articles
SET publish_status = $1, updated_at = NOW()
WHERE id = $2 AND tenant_id = $3 AND deleted_at IS NULL
`, articleStatus, articleID, task.TenantID); err != nil {
return nil, response.ErrInternal(50055, "article_publish_update_failed", "failed to update article publish status")
}
return &desktopPublishSyncOutcome{
TenantID: task.TenantID,
ArticleID: articleID,
}, nil
}
func loadPlatformAccountSeedByDesktopID(
ctx context.Context,
tx pgx.Tx,
tenantID int64,
workspaceID int64,
desktopID uuid.UUID,
) (*platformAccountSeed, error) {
var item platformAccountSeed
if err := tx.QueryRow(ctx, `
SELECT pa.id, pa.platform_id, mp.name, pa.platform_uid, pa.nickname
FROM platform_accounts pa
JOIN media_platforms mp ON mp.platform_id = pa.platform_id
WHERE pa.tenant_id = $1
AND pa.workspace_id = $2
AND pa.desktop_id = $3
AND pa.deleted_at IS NULL
LIMIT 1
`, tenantID, workspaceID, desktopID).Scan(
&item.ID,
&item.PlatformID,
&item.PlatformName,
&item.PlatformUID,
&item.Nickname,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, response.ErrBadRequest(40037, "invalid_platform_accounts", "one or more selected platform accounts are unavailable")
}
return nil, response.ErrInternal(50058, "platform_account_publish_query_failed", "failed to load platform accounts")
}
return &item, nil
}
func desktopTaskToPublishStatus(taskStatus string) string {
switch strings.TrimSpace(taskStatus) {
case "queued":
return "queued"
case "in_progress":
return "publishing"
case "unknown":
return "pending_review"
case "succeeded":
return "success"
case "failed", "aborted":
return "failed"
default:
return "failed"
}
}
func publishBatchStatusToArticleStatus(batchStatus string) string {
normalized := normalizePublishStatus(batchStatus)
switch normalized {
case "success":
return "success"
case "failed":
return "failed"
default:
return normalized
}
}
func resolvePublishArticleID(contentRef map[string]any) (int64, error) {
if articleID, ok := extractInt64FromMap(contentRef, "article_id", "id"); ok && articleID > 0 {
return articleID, nil
}
return 0, response.ErrBadRequest(40091, "invalid_content_ref", "content_ref.article_id is required")
}
func unmarshalJSONObject(raw []byte) map[string]any {
if len(raw) == 0 {
return nil
}
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
return nil
}
return payload
}
func nullableJSON(raw []byte) []byte {
if len(raw) == 0 {
return nil
}
return raw
}
func extractInt64FromMap(payload map[string]any, keys ...string) (int64, bool) {
if len(payload) == 0 {
return 0, false
}
for _, key := range keys {
value, ok := extractInt64FromAny(payload[key])
if ok {
return value, true
}
}
return 0, false
}
func extractInt64FromAny(value any) (int64, bool) {
switch typed := value.(type) {
case nil:
return 0, false
case int:
return int64(typed), true
case int8:
return int64(typed), true
case int16:
return int64(typed), true
case int32:
return int64(typed), true
case int64:
return typed, true
case float32:
return int64(typed), true
case float64:
return int64(typed), true
case json.Number:
parsed, err := typed.Int64()
if err != nil {
return 0, false
}
return parsed, true
case string:
trimmed := strings.TrimSpace(typed)
if trimmed == "" {
return 0, false
}
parsed, err := strconv.ParseInt(trimmed, 10, 64)
if err != nil {
return 0, false
}
return parsed, true
default:
return 0, false
}
}
func extractStringPointer(payload map[string]any, keys ...string) *string {
if len(payload) == 0 {
return nil
}
for _, key := range keys {
value, ok := payload[key]
if !ok {
continue
}
switch typed := value.(type) {
case string:
trimmed := strings.TrimSpace(typed)
if trimmed != "" {
return &trimmed
}
case json.Number:
trimmed := strings.TrimSpace(typed.String())
if trimmed != "" {
return &trimmed
}
}
}
return nil
}
func desktopTaskErrorMessage(payload map[string]any) *string {
if message := extractStringPointer(payload, "message"); message != nil {
return message
}
if detail := extractStringPointer(payload, "detail"); detail != nil {
return detail
}
if code := extractStringPointer(payload, "code"); code != nil {
return code
}
return nil
}