464 lines
14 KiB
Go
464 lines
14 KiB
Go
package usecase
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"time"
|
|
|
|
"haixun-backend/internal/library/clock"
|
|
app "haixun-backend/internal/library/errors"
|
|
"haixun-backend/internal/library/errors/code"
|
|
libthreads "haixun-backend/internal/library/threadsapi"
|
|
"haixun-backend/internal/library/threadspost"
|
|
padomain "haixun-backend/internal/model/publish_analytics/domain/usecase"
|
|
"haixun-backend/internal/model/publish_queue/domain/entity"
|
|
domrepo "haixun-backend/internal/model/publish_queue/domain/repository"
|
|
domusecase "haixun-backend/internal/model/publish_queue/domain/usecase"
|
|
threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type publishQueueUseCase struct {
|
|
repo domrepo.Repository
|
|
threadsAccount threadsaccountdomain.UseCase
|
|
publishAnalytics padomain.UseCase
|
|
}
|
|
|
|
func NewUseCase(
|
|
repo domrepo.Repository,
|
|
threadsAccount threadsaccountdomain.UseCase,
|
|
publishAnalytics padomain.UseCase,
|
|
) domusecase.UseCase {
|
|
return &publishQueueUseCase{
|
|
repo: repo,
|
|
threadsAccount: threadsAccount,
|
|
publishAnalytics: publishAnalytics,
|
|
}
|
|
}
|
|
|
|
func (u *publishQueueUseCase) Create(ctx context.Context, req domusecase.CreateRequest) (*domusecase.QueueItemSummary, error) {
|
|
tenantID := strings.TrimSpace(req.TenantID)
|
|
ownerUID := strings.TrimSpace(req.OwnerUID)
|
|
accountID := strings.TrimSpace(req.AccountID)
|
|
text := strings.TrimSpace(req.Text)
|
|
if tenantID == "" || ownerUID == "" {
|
|
return nil, app.For(code.Auth).AuthUnauthorized("missing actor")
|
|
}
|
|
if accountID == "" {
|
|
return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id is required")
|
|
}
|
|
if text == "" {
|
|
return nil, app.For(code.ThreadsAccount).InputMissingRequired("text is required")
|
|
}
|
|
if err := threadspost.ValidatePublish(text); err != nil {
|
|
return nil, app.For(code.ThreadsAccount).InputInvalidFormat(err.Error())
|
|
}
|
|
if _, err := u.threadsAccount.Get(ctx, tenantID, ownerUID, accountID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
now := clock.NowUnixNano()
|
|
scheduledAt := req.ScheduledAt
|
|
if scheduledAt <= 0 {
|
|
scheduledAt = now
|
|
}
|
|
|
|
item := &entity.QueueItem{
|
|
ID: uuid.NewString(),
|
|
TenantID: tenantID,
|
|
OwnerUID: ownerUID,
|
|
AccountID: accountID,
|
|
Text: text,
|
|
ScheduledAt: scheduledAt,
|
|
Status: entity.StatusScheduled,
|
|
CreateAt: now,
|
|
UpdateAt: now,
|
|
}
|
|
if err := u.repo.Create(ctx, item); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if scheduledAt <= now {
|
|
_, _ = u.DispatchDue(ctx, 1)
|
|
fresh, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, item.ID)
|
|
if err == nil && fresh != nil {
|
|
out := toSummary(*fresh)
|
|
return &out, nil
|
|
}
|
|
}
|
|
|
|
out := toSummary(*item)
|
|
return &out, nil
|
|
}
|
|
|
|
func (u *publishQueueUseCase) RecordPublished(ctx context.Context, req domusecase.RecordPublishedRequest) (*domusecase.QueueItemSummary, error) {
|
|
tenantID := strings.TrimSpace(req.TenantID)
|
|
ownerUID := strings.TrimSpace(req.OwnerUID)
|
|
accountID := strings.TrimSpace(req.AccountID)
|
|
mediaID := strings.TrimSpace(req.MediaID)
|
|
if err := requireActor(tenantID, ownerUID); err != nil {
|
|
return nil, err
|
|
}
|
|
if accountID == "" || mediaID == "" {
|
|
return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id and media_id are required")
|
|
}
|
|
if _, err := u.threadsAccount.Get(ctx, tenantID, ownerUID, accountID); err != nil {
|
|
return nil, err
|
|
}
|
|
now := clock.NowUnixNano()
|
|
publishedAt := req.PublishedAt
|
|
if publishedAt <= 0 {
|
|
publishedAt = now
|
|
}
|
|
item := &entity.QueueItem{
|
|
ID: uuid.NewString(),
|
|
TenantID: tenantID,
|
|
OwnerUID: ownerUID,
|
|
AccountID: accountID,
|
|
Text: strings.TrimSpace(req.Text),
|
|
ScheduledAt: publishedAt,
|
|
Status: entity.StatusPublished,
|
|
MediaID: mediaID,
|
|
Permalink: strings.TrimSpace(req.Permalink),
|
|
PublishedAt: publishedAt,
|
|
CreateAt: now,
|
|
UpdateAt: now,
|
|
}
|
|
if err := u.repo.Create(ctx, item); err != nil {
|
|
return nil, err
|
|
}
|
|
_ = u.publishAnalytics.ScheduleCheckpoints(ctx, padomain.ScheduleCheckpointsRequest{
|
|
TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID,
|
|
MediaID: mediaID, Permalink: item.Permalink, PublishedAt: publishedAt,
|
|
})
|
|
out := toSummary(*item)
|
|
return &out, nil
|
|
}
|
|
|
|
func (u *publishQueueUseCase) Get(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*domusecase.QueueItemSummary, error) {
|
|
if err := requireActor(tenantID, ownerUID); err != nil {
|
|
return nil, err
|
|
}
|
|
item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, queueID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := toSummary(*item)
|
|
return &out, nil
|
|
}
|
|
|
|
func (u *publishQueueUseCase) List(ctx context.Context, req domusecase.ListRequest) (*domusecase.ListResult, error) {
|
|
if err := requireActor(req.TenantID, req.OwnerUID); err != nil {
|
|
return nil, err
|
|
}
|
|
page, pageSize := req.Page, req.PageSize
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
if pageSize <= 0 {
|
|
pageSize = 10
|
|
}
|
|
if pageSize > 50 {
|
|
pageSize = 50
|
|
}
|
|
result, err := u.repo.List(ctx, domrepo.ListFilter{
|
|
TenantID: req.TenantID,
|
|
OwnerUID: req.OwnerUID,
|
|
AccountID: req.AccountID,
|
|
Status: req.Status,
|
|
}, page, pageSize)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]domusecase.QueueItemSummary, 0, len(result.Items))
|
|
for _, item := range result.Items {
|
|
items = append(items, toSummary(item))
|
|
}
|
|
totalPages := 0
|
|
if pageSize > 0 {
|
|
totalPages = int((result.Total + int64(pageSize) - 1) / int64(pageSize))
|
|
}
|
|
return &domusecase.ListResult{
|
|
Items: items,
|
|
Total: result.Total,
|
|
Page: page,
|
|
PageSize: pageSize,
|
|
TotalPages: totalPages,
|
|
}, nil
|
|
}
|
|
|
|
func (u *publishQueueUseCase) Update(ctx context.Context, req domusecase.UpdateRequest) (*domusecase.QueueItemSummary, error) {
|
|
if err := requireActor(req.TenantID, req.OwnerUID); err != nil {
|
|
return nil, err
|
|
}
|
|
item, err := u.repo.Get(ctx, req.TenantID, req.OwnerUID, req.AccountID, req.QueueID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if item.Status != entity.StatusScheduled {
|
|
return nil, app.For(code.ThreadsAccount).ResInvalidState("僅排程中的項目可修改")
|
|
}
|
|
if req.Text != nil {
|
|
text := strings.TrimSpace(*req.Text)
|
|
if text == "" {
|
|
return nil, app.For(code.ThreadsAccount).InputMissingRequired("text is required")
|
|
}
|
|
if err := threadspost.ValidatePublish(text); err != nil {
|
|
return nil, app.For(code.ThreadsAccount).InputInvalidFormat(err.Error())
|
|
}
|
|
item.Text = text
|
|
}
|
|
if req.ScheduledAt != nil && *req.ScheduledAt > 0 {
|
|
item.ScheduledAt = *req.ScheduledAt
|
|
}
|
|
item.UpdateAt = clock.NowUnixNano()
|
|
updated, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusScheduled})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := toSummary(*updated)
|
|
return &out, nil
|
|
}
|
|
|
|
func (u *publishQueueUseCase) Cancel(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*domusecase.QueueItemSummary, error) {
|
|
if err := requireActor(tenantID, ownerUID); err != nil {
|
|
return nil, err
|
|
}
|
|
item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, queueID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if item.Status != entity.StatusScheduled {
|
|
return nil, app.For(code.ThreadsAccount).ResInvalidState("僅排程中的項目可取消")
|
|
}
|
|
item.Status = entity.StatusCancelled
|
|
item.UpdateAt = clock.NowUnixNano()
|
|
updated, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusScheduled})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := toSummary(*updated)
|
|
return &out, nil
|
|
}
|
|
|
|
func (u *publishQueueUseCase) Delete(ctx context.Context, tenantID, ownerUID, accountID, queueID string) error {
|
|
if err := requireActor(tenantID, ownerUID); err != nil {
|
|
return err
|
|
}
|
|
item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, queueID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch item.Status {
|
|
case entity.StatusPublishing:
|
|
return app.For(code.ThreadsAccount).ResInvalidState("發佈進行中,無法刪除")
|
|
case entity.StatusPublished:
|
|
return app.For(code.ThreadsAccount).ResInvalidState("已發佈項目不可刪除")
|
|
case entity.StatusScheduled:
|
|
_, err = u.Cancel(ctx, tenantID, ownerUID, accountID, queueID)
|
|
return err
|
|
default:
|
|
return u.repo.Delete(ctx, tenantID, ownerUID, accountID, queueID)
|
|
}
|
|
}
|
|
|
|
func (u *publishQueueUseCase) DispatchDue(ctx context.Context, limit int) (int, error) {
|
|
if u.repo == nil {
|
|
return 0, nil
|
|
}
|
|
now := clock.NowUnixNano()
|
|
due, err := u.repo.ListDue(ctx, now, limit)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
dispatched := 0
|
|
for _, item := range due {
|
|
if err := u.dispatchOne(ctx, &item); err != nil {
|
|
continue
|
|
}
|
|
dispatched++
|
|
}
|
|
return dispatched, nil
|
|
}
|
|
|
|
func (u *publishQueueUseCase) dispatchOne(ctx context.Context, item *entity.QueueItem) error {
|
|
item.Status = entity.StatusPublishing
|
|
item.UpdateAt = clock.NowUnixNano()
|
|
claimed, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusScheduled})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
account, err := u.threadsAccount.Get(ctx, claimed.TenantID, claimed.OwnerUID, claimed.AccountID)
|
|
if err != nil {
|
|
return u.markFailed(ctx, claimed, "取得帳號失敗:"+err.Error())
|
|
}
|
|
token, err := u.threadsAccount.ResolveAccountAPIAccessToken(ctx, claimed.TenantID, claimed.OwnerUID, claimed.AccountID)
|
|
if err != nil {
|
|
return u.markFailed(ctx, claimed, err.Error())
|
|
}
|
|
userID := strings.TrimSpace(account.ThreadsUserID)
|
|
if userID == "" || token == "" {
|
|
return u.markFailed(ctx, claimed, "Threads API 未連線")
|
|
}
|
|
|
|
result, err := libthreads.PublishText(ctx, libthreads.PublishTextInput{
|
|
ThreadsUserID: userID,
|
|
AccessToken: token,
|
|
Text: claimed.Text,
|
|
})
|
|
if err != nil {
|
|
return u.markFailed(ctx, claimed, "發文失敗:"+err.Error())
|
|
}
|
|
|
|
now := clock.NowUnixNano()
|
|
claimed.Status = entity.StatusPublished
|
|
claimed.MediaID = result.MediaID
|
|
claimed.Permalink = result.Permalink
|
|
claimed.PublishedAt = now
|
|
claimed.UpdateAt = now
|
|
claimed.ErrorMessage = ""
|
|
if _, err := u.repo.UpdateIfStatus(ctx, claimed, []string{entity.StatusPublishing}); err != nil {
|
|
return err
|
|
}
|
|
_ = u.publishAnalytics.ScheduleCheckpoints(ctx, padomain.ScheduleCheckpointsRequest{
|
|
TenantID: claimed.TenantID, OwnerUID: claimed.OwnerUID, AccountID: claimed.AccountID,
|
|
MediaID: result.MediaID, Permalink: result.Permalink, PublishedAt: now,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (u *publishQueueUseCase) markFailed(ctx context.Context, item *entity.QueueItem, message string) error {
|
|
item.Status = entity.StatusFailed
|
|
item.ErrorMessage = message
|
|
item.UpdateAt = clock.NowUnixNano()
|
|
_, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusPublishing, entity.StatusScheduled})
|
|
return err
|
|
}
|
|
|
|
func (u *publishQueueUseCase) ListPublishHealth(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*domusecase.PublishHealthResult, error) {
|
|
if err := requireActor(tenantID, ownerUID); err != nil {
|
|
return nil, err
|
|
}
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
if pageSize <= 0 {
|
|
pageSize = 10
|
|
}
|
|
if pageSize > 30 {
|
|
pageSize = 30
|
|
}
|
|
|
|
pending, _ := u.repo.CountByStatus(ctx, tenantID, ownerUID, accountID, entity.StatusScheduled)
|
|
failed, _ := u.repo.CountByStatus(ctx, tenantID, ownerUID, accountID, entity.StatusFailed)
|
|
|
|
publishedList, err := u.repo.List(ctx, domrepo.ListFilter{
|
|
TenantID: tenantID,
|
|
OwnerUID: ownerUID,
|
|
AccountID: accountID,
|
|
Status: entity.StatusPublished,
|
|
}, page, pageSize)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cutoff7d := clock.NowUnixNano() - int64(7*24*time.Hour)
|
|
var published7d int64
|
|
var totalLikes7d, totalReplies7d int
|
|
|
|
items := make([]domusecase.PublishHealthItem, 0, len(publishedList.Items))
|
|
for _, row := range publishedList.Items {
|
|
if row.PublishedAt >= cutoff7d {
|
|
published7d++
|
|
}
|
|
checkpoints, err := u.publishAnalytics.ListByMedia(ctx, tenantID, ownerUID, row.MediaID)
|
|
if err != nil {
|
|
checkpoints = nil
|
|
}
|
|
healthCPs := make([]domusecase.PublishHealthCheckpoint, 0, len(checkpoints))
|
|
var latestLike, latestReply, latestRepost, latestQuote, latestViews, latestShares int
|
|
var latestChecked int64
|
|
for _, cp := range checkpoints {
|
|
healthCPs = append(healthCPs, domusecase.PublishHealthCheckpoint{
|
|
Checkpoint: cp.Checkpoint,
|
|
LikeCount: cp.LikeCount,
|
|
ReplyCount: cp.ReplyCount,
|
|
RepostCount: cp.RepostCount,
|
|
QuoteCount: cp.QuoteCount,
|
|
Views: cp.ViewCount,
|
|
CheckedAt: cp.CheckedAt,
|
|
})
|
|
if cp.CheckedAt >= latestChecked {
|
|
latestChecked = cp.CheckedAt
|
|
latestLike = cp.LikeCount
|
|
latestReply = cp.ReplyCount
|
|
latestRepost = cp.RepostCount
|
|
latestQuote = cp.QuoteCount
|
|
latestViews = cp.ViewCount
|
|
latestShares = cp.ShareCount
|
|
}
|
|
}
|
|
if row.PublishedAt >= cutoff7d {
|
|
totalLikes7d += latestLike
|
|
totalReplies7d += latestReply
|
|
}
|
|
items = append(items, domusecase.PublishHealthItem{
|
|
MediaID: row.MediaID,
|
|
Permalink: row.Permalink,
|
|
Text: row.Text,
|
|
PublishedAt: row.PublishedAt,
|
|
LikeCount: latestLike,
|
|
ReplyCount: latestReply,
|
|
RepostCount: latestRepost,
|
|
QuoteCount: latestQuote,
|
|
Views: latestViews,
|
|
Shares: latestShares,
|
|
Checkpoints: healthCPs,
|
|
})
|
|
}
|
|
|
|
totalPages := 0
|
|
if pageSize > 0 {
|
|
totalPages = int((publishedList.Total + int64(pageSize) - 1) / int64(pageSize))
|
|
}
|
|
return &domusecase.PublishHealthResult{
|
|
Summary: domusecase.PublishHealthSummary{
|
|
PendingScheduled: pending,
|
|
FailedCount: failed,
|
|
Published7d: published7d,
|
|
TotalLikes7d: totalLikes7d,
|
|
TotalReplies7d: totalReplies7d,
|
|
},
|
|
Items: items,
|
|
Total: publishedList.Total,
|
|
Page: page,
|
|
PageSize: pageSize,
|
|
TotalPages: totalPages,
|
|
}, nil
|
|
}
|
|
|
|
func requireActor(tenantID, ownerUID string) error {
|
|
if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
|
|
return app.For(code.Auth).AuthUnauthorized("missing actor")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func toSummary(item entity.QueueItem) domusecase.QueueItemSummary {
|
|
return domusecase.QueueItemSummary{
|
|
ID: item.ID,
|
|
AccountID: item.AccountID,
|
|
Text: item.Text,
|
|
ScheduledAt: item.ScheduledAt,
|
|
Status: item.Status,
|
|
MediaID: item.MediaID,
|
|
Permalink: item.Permalink,
|
|
PublishedAt: item.PublishedAt,
|
|
ErrorMessage: item.ErrorMessage,
|
|
CreateAt: item.CreateAt,
|
|
UpdateAt: item.UpdateAt,
|
|
}
|
|
}
|