thread-master/backend/internal/model/publish_queue/usecase/usecase.go

783 lines
26 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"
copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
padomain "haixun-backend/internal/model/publish_analytics/domain/usecase"
guarddomain "haixun-backend/internal/model/publish_guard/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"
evententity "haixun-backend/internal/model/publish_queue_event/domain/entity"
eventdomain "haixun-backend/internal/model/publish_queue_event/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
guard guarddomain.UseCase
events eventdomain.UseCase
copyDraft copydraftdomain.UseCase
}
func NewUseCase(
repo domrepo.Repository,
threadsAccount threadsaccountdomain.UseCase,
publishAnalytics padomain.UseCase,
guard guarddomain.UseCase,
events eventdomain.UseCase,
copyDraft copydraftdomain.UseCase,
) domusecase.UseCase {
return &publishQueueUseCase{
repo: repo,
threadsAccount: threadsAccount,
publishAnalytics: publishAnalytics,
guard: guard,
events: events,
copyDraft: copyDraft,
}
}
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,
PersonaID: strings.TrimSpace(req.PersonaID),
CopyMissionID: strings.TrimSpace(req.CopyMissionID),
CopyDraftID: strings.TrimSpace(req.CopyDraftID),
Text: text,
TopicTag: normalizeTopicTag(req.TopicTag),
ScheduledAt: scheduledAt,
Status: entity.StatusScheduled,
CreateAt: now,
UpdateAt: now,
}
if err := u.repo.Create(ctx, item); err != nil {
return nil, err
}
u.recordEvent(ctx, item, evententity.EventCreated, "", item.Status, "加入發文庫存")
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,
PersonaID: strings.TrimSpace(req.PersonaID),
CopyMissionID: strings.TrimSpace(req.CopyMissionID),
CopyDraftID: strings.TrimSpace(req.CopyDraftID),
Text: strings.TrimSpace(req.Text),
TopicTag: normalizeTopicTag(req.TopicTag),
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.recordEvent(ctx, item, evententity.EventPublished, "", item.Status, "已記錄發布結果")
_ = 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) ListByIDs(ctx context.Context, tenantID, ownerUID string, ids []string) ([]domusecase.QueueItemSummary, error) {
if err := requireActor(tenantID, ownerUID); err != nil {
return nil, err
}
items, err := u.repo.ListByIDs(ctx, tenantID, ownerUID, ids)
if err != nil {
return nil, err
}
out := make([]domusecase.QueueItemSummary, 0, len(items))
for _, item := range items {
out = append(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) ListRange(ctx context.Context, req domusecase.RangeRequest) (*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 = 50
}
if pageSize > 200 {
pageSize = 200
}
result, err := u.repo.List(ctx, domrepo.ListFilter{
TenantID: req.TenantID, OwnerUID: req.OwnerUID, AccountID: req.AccountID,
Status: req.Status, StartAt: req.StartAt, EndAt: req.EndAt,
}, 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
}
if req.TopicTag != nil {
item.TopicTag = normalizeTopicTag(*req.TopicTag)
}
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
}
u.recordEvent(ctx, updated, evententity.EventCancelled, entity.StatusScheduled, updated.Status, "已取消排程")
out := toSummary(*updated)
return &out, nil
}
func (u *publishQueueUseCase) Retry(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.StatusFailed && item.Status != entity.StatusCancelled && item.Status != entity.StatusMissed {
return nil, app.For(code.ThreadsAccount).ResInvalidState("僅 failed/cancelled/missed 項目可重試")
}
from := item.Status
item.Status = entity.StatusScheduled
item.ErrorMessage = ""
item.PausedReason = ""
item.MissedAt = 0
item.RetryCount++
item.NextAttemptAt = 0
now := clock.NowUnixNano()
item.UpdateAt = now
if item.ScheduledAt < now {
item.ScheduledAt = now
}
updated, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusFailed, entity.StatusCancelled, entity.StatusMissed})
if err != nil {
return nil, err
}
u.recordEvent(ctx, updated, evententity.EventRetry, from, updated.Status, "已重新排程")
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 {
if result, err := u.checkGuard(ctx, item); err == nil && result != nil && !result.Allowed {
if result.ShouldPause && u.guard != nil {
_, _ = u.guard.Pause(ctx, item.TenantID, item.OwnerUID, item.AccountID, result.Reason)
}
item.PausedReason = result.Reason
if result.DelayUntil > 0 {
item.ScheduledAt = result.DelayUntil
} else {
item.Status = entity.StatusFailed
item.ErrorMessage = result.Reason
}
item.UpdateAt = clock.NowUnixNano()
updated, updateErr := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusScheduled})
if updateErr != nil {
return updateErr
}
u.recordEvent(ctx, updated, evententity.EventGuardDelayed, entity.StatusScheduled, updated.Status, result.Reason)
return nil
}
item.Status = entity.StatusPublishing
item.LastAttemptAt = clock.NowUnixNano()
item.UpdateAt = clock.NowUnixNano()
claimed, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusScheduled})
if err != nil {
return err
}
u.recordEvent(ctx, claimed, evententity.EventPublishing, entity.StatusScheduled, entity.StatusPublishing, "開始發布")
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,
TopicTag: claimed.TopicTag,
})
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.recordEvent(ctx, claimed, evententity.EventPublished, entity.StatusPublishing, entity.StatusPublished, "Threads API 發布完成")
if claimed.CopyDraftID != "" && u.copyDraft != nil {
_, _ = u.copyDraft.MarkPublished(ctx, copydraftdomain.MarkPublishedRequest{
TenantID: claimed.TenantID, OwnerUID: claimed.OwnerUID, PersonaID: claimed.PersonaID,
DraftID: claimed.CopyDraftID, MediaID: result.MediaID, Permalink: result.Permalink,
})
}
_ = 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.LastAttemptAt = clock.NowUnixNano()
item.UpdateAt = clock.NowUnixNano()
updated, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusPublishing, entity.StatusScheduled})
if err == nil {
u.recordEvent(ctx, updated, evententity.EventFailed, entity.StatusPublishing, entity.StatusFailed, message)
failureStreak := u.failureStreak(ctx, item.TenantID, item.OwnerUID, item.AccountID)
if u.guard != nil && failureStreak >= 3 {
_, _ = u.guard.Pause(ctx, item.TenantID, item.OwnerUID, item.AccountID, "連續發文失敗,已暫停帳號")
}
}
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 (u *publishQueueUseCase) MarkMissed(ctx context.Context, graceNs int64, limit int) (int, error) {
if graceNs <= 0 {
graceNs = int64(10 * time.Minute)
}
before := clock.NowUnixNano() - graceNs
items, err := u.repo.ListMissedCandidates(ctx, before, limit)
if err != nil {
return 0, err
}
count := 0
for _, item := range items {
item.Status = entity.StatusMissed
item.MissedAt = clock.NowUnixNano()
item.UpdateAt = item.MissedAt
updated, err := u.repo.UpdateIfStatus(ctx, &item, []string{entity.StatusScheduled})
if err != nil {
continue
}
u.recordEvent(ctx, updated, evententity.EventMissed, entity.StatusScheduled, entity.StatusMissed, "超過 grace period 未發布")
count++
}
return count, nil
}
func (u *publishQueueUseCase) ListAlerts(ctx context.Context, tenantID, ownerUID, accountID string) ([]domusecase.Alert, error) {
if err := requireActor(tenantID, ownerUID); err != nil {
return nil, err
}
result, err := u.repo.List(ctx, domrepo.ListFilter{TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID}, 1, 50)
if err != nil {
return nil, err
}
alerts := make([]domusecase.Alert, 0)
for _, item := range result.Items {
switch item.Status {
case entity.StatusFailed:
alerts = append(alerts, domusecase.Alert{Type: "failed", Severity: "danger", Message: firstNonEmpty(item.ErrorMessage, "發文失敗"), QueueID: item.ID, AccountID: item.AccountID, CreateAt: item.UpdateAt})
case entity.StatusMissed:
alerts = append(alerts, domusecase.Alert{Type: "missed", Severity: "warning", Message: "排程已漏發", QueueID: item.ID, AccountID: item.AccountID, CreateAt: item.MissedAt})
}
if item.PausedReason != "" {
alerts = append(alerts, domusecase.Alert{Type: "guard", Severity: "warning", Message: item.PausedReason, QueueID: item.ID, AccountID: item.AccountID, CreateAt: item.UpdateAt})
}
}
return alerts, nil
}
func (u *publishQueueUseCase) ListSlotInsights(ctx context.Context, tenantID, ownerUID, accountID, timezone string) ([]domusecase.SlotInsight, error) {
if err := requireActor(tenantID, ownerUID); err != nil {
return nil, err
}
health, err := u.ListPublishHealth(ctx, tenantID, ownerUID, accountID, 1, 30)
if err != nil {
return nil, err
}
type agg struct {
likes7d int
replies7d int
samples int
}
slots := map[string]*agg{}
for _, item := range health.Items {
t := time.Unix(0, item.PublishedAt).UTC()
key := t.Format("15:04")
if _, ok := slots[key]; !ok {
slots[key] = &agg{}
}
slots[key].likes7d += item.LikeCount
slots[key].replies7d += item.ReplyCount
slots[key].samples++
}
out := make([]domusecase.SlotInsight, 0, len(slots))
for key, item := range slots {
avgLikes := 0
avgReplies := 0
if item.samples > 0 {
avgLikes = item.likes7d / item.samples
avgReplies = item.replies7d / item.samples
}
recommendation := "neutral"
if avgLikes+avgReplies >= 5 {
recommendation = "recommended"
} else if item.samples >= 2 {
recommendation = "low"
}
out = append(out, domusecase.SlotInsight{
Weekday: -1, Time: key, AvgLikes1h: avgLikes, AvgReplies1h: avgReplies,
AvgLikes24h: avgLikes, AvgReplies24h: avgReplies, AvgLikes7d: avgLikes, AvgReplies7d: avgReplies,
SampleSize: item.samples, Recommendation: recommendation,
})
}
if len(out) == 0 {
out = []domusecase.SlotInsight{
{Weekday: 1, Time: "09:30", Recommendation: "recommended"},
{Weekday: 3, Time: "12:30", Recommendation: "neutral"},
{Weekday: 5, Time: "18:30", Recommendation: "recommended"},
}
}
return out, nil
}
func (u *publishQueueUseCase) checkGuard(ctx context.Context, item *entity.QueueItem) (*guarddomain.CheckResult, error) {
if u.guard == nil || item == nil {
return &guarddomain.CheckResult{Allowed: true}, nil
}
now := clock.NowUnixNano()
publishedToday, _ := u.repo.CountPublishedSince(ctx, item.TenantID, item.OwnerUID, item.AccountID, startOfUTCDay(now))
lastPublishedAt, _ := u.repo.LastPublishedAt(ctx, item.TenantID, item.OwnerUID, item.AccountID)
return u.guard.Check(ctx, guarddomain.CheckRequest{
TenantID: item.TenantID, OwnerUID: item.OwnerUID, AccountID: item.AccountID,
Now: now, PublishedToday: publishedToday, LastPublishedAt: lastPublishedAt, FailureStreak: u.failureStreak(ctx, item.TenantID, item.OwnerUID, item.AccountID),
})
}
func (u *publishQueueUseCase) failureStreak(ctx context.Context, tenantID, ownerUID, accountID string) int {
result, err := u.repo.List(ctx, domrepo.ListFilter{TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID}, 1, 10)
if err != nil {
return 0
}
streak := 0
for _, item := range result.Items {
if item.Status == entity.StatusFailed {
streak++
continue
}
if item.Status == entity.StatusPublished {
break
}
}
return streak
}
func (u *publishQueueUseCase) recordEvent(ctx context.Context, item *entity.QueueItem, eventType, fromStatus, toStatus, message string) {
if u.events == nil || item == nil {
return
}
_ = u.events.Record(ctx, eventdomain.RecordRequest{
TenantID: item.TenantID, OwnerUID: item.OwnerUID, AccountID: item.AccountID, QueueID: item.ID,
EventType: eventType, FromStatus: fromStatus, ToStatus: toStatus, Message: message,
})
}
func startOfUTCDay(ns int64) int64 {
t := time.Unix(0, ns).UTC()
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC).UnixNano()
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
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,
PersonaID: item.PersonaID,
CopyMissionID: item.CopyMissionID,
CopyDraftID: item.CopyDraftID,
Text: item.Text,
TopicTag: item.TopicTag,
ScheduledAt: item.ScheduledAt,
Status: item.Status,
MediaID: item.MediaID,
Permalink: item.Permalink,
PublishedAt: item.PublishedAt,
ErrorMessage: item.ErrorMessage,
RetryCount: item.RetryCount,
LastAttemptAt: item.LastAttemptAt,
NextAttemptAt: item.NextAttemptAt,
MissedAt: item.MissedAt,
PausedReason: item.PausedReason,
CreateAt: item.CreateAt,
UpdateAt: item.UpdateAt,
}
}
func normalizeTopicTag(value string) string {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "#")
value = strings.TrimSpace(value)
if len([]rune(value)) > 40 {
runes := []rune(value)
value = string(runes[:40])
}
return value
}