thread-master/backend/internal/model/publish_queue/usecase/usecase.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,
}
}