package usecase import ( "context" "strings" "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/model/mention_inbox/domain/entity" domrepo "haixun-backend/internal/model/mention_inbox/domain/repository" domusecase "haixun-backend/internal/model/mention_inbox/domain/usecase" threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" ) type mentionInboxUseCase struct { repo domrepo.Repository threadsAccounts threadsaccountdomain.UseCase } func NewUseCase(repo domrepo.Repository, threadsAccounts threadsaccountdomain.UseCase) domusecase.UseCase { return &mentionInboxUseCase{repo: repo, threadsAccounts: threadsAccounts} } func (u *mentionInboxUseCase) SyncFromAPI(ctx context.Context, req domusecase.SyncRequest) (*domusecase.SyncResult, error) { tenantID := strings.TrimSpace(req.TenantID) ownerUID := strings.TrimSpace(req.OwnerUID) accountID := strings.TrimSpace(req.AccountID) 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") } account, err := u.threadsAccounts.Get(ctx, tenantID, ownerUID, accountID) if err != nil { return nil, err } token, err := u.threadsAccounts.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID) if err != nil { return nil, err } client := libthreads.NewClient(token) if !client.Enabled() { return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads API 未連線") } userID := strings.TrimSpace(account.ThreadsUserID) if userID == "" { if profile, perr := libthreads.GetMeProfile(ctx, token); perr == nil && profile != nil { userID = profile.ID.String() } } if userID == "" { return nil, app.For(code.ThreadsAccount).InputMissingRequired("缺少 threads_user_id") } perPage := req.Limit if perPage <= 0 { perPage = 25 } if perPage > 100 { perPage = 100 } maxPages := req.MaxPages if maxPages <= 0 { maxPages = 4 } if maxPages > 20 { maxPages = 20 } now := clock.NowUnixNano() synced := 0 ready := 0 after := "" for page := 0; page < maxPages; page++ { mentions, nextAfter, err := client.UserMentionsPage(ctx, userID, perPage, after) if err != nil { return nil, err } for _, hit := range mentions { item := u.buildMentionEntity(tenantID, ownerUID, accountID, hit, now) u.fillContext(ctx, client, hit, item) u.primeReplyTarget(ctx, client, hit, item) if _, err := u.repo.Upsert(ctx, item); err != nil { return nil, err } synced++ if item.ReplyReady { ready++ } } if nextAfter == "" || len(mentions) == 0 { break } after = nextAfter } total, err := u.repo.CountByAccount(ctx, tenantID, ownerUID, accountID) if err != nil { return nil, err } return &domusecase.SyncResult{ Synced: synced, Ready: ready, Total: total, }, nil } func (u *mentionInboxUseCase) List(ctx context.Context, req domusecase.ListRequest) (*domusecase.ListResult, error) { tenantID := strings.TrimSpace(req.TenantID) ownerUID := strings.TrimSpace(req.OwnerUID) accountID := strings.TrimSpace(req.AccountID) 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 _, err := u.threadsAccounts.Get(ctx, tenantID, ownerUID, accountID); err != nil { return nil, err } result, err := u.repo.ListByAccount(ctx, tenantID, ownerUID, accountID, req.Page, req.PageSize) if err != nil { return nil, err } page, pageSize := normalizeListPage(req.Page, req.PageSize) return &domusecase.ListResult{ List: toSummaries(result.Items), Pagination: domusecase.Pagination{ Total: result.Total, ReadyTotal: result.ReadyTotal, Page: int64(page), PageSize: int64(pageSize), TotalPages: mentionTotalPages(result.Total, pageSize), NewestSyncedAt: result.NewestSyncedAt, }, }, nil } func normalizeListPage(page, pageSize int) (int, int) { if page <= 0 { page = 1 } if pageSize <= 0 { pageSize = 10 } if pageSize > 50 { pageSize = 50 } return page, pageSize } func mentionTotalPages(total int64, pageSize int) int64 { if total <= 0 { return 0 } return (total + int64(pageSize) - 1) / int64(pageSize) } func (u *mentionInboxUseCase) PublishReply(ctx context.Context, req domusecase.PublishReplyRequest) (*domusecase.PublishReplyResult, error) { tenantID := strings.TrimSpace(req.TenantID) ownerUID := strings.TrimSpace(req.OwnerUID) accountID := strings.TrimSpace(req.AccountID) mediaID := strings.TrimSpace(req.MediaID) text := strings.TrimSpace(req.Text) if tenantID == "" || ownerUID == "" { return nil, app.For(code.Auth).AuthUnauthorized("missing actor") } if accountID == "" || mediaID == "" { return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id and media_id are required") } if text == "" { return nil, app.For(code.ThreadsAccount).InputMissingRequired("text is required") } item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, mediaID) if err != nil { return nil, err } if !item.ReplyReady { msg := strings.TrimSpace(item.ReplyReadyMsg) if msg == "" { msg = "此提及尚未完成回覆註冊,請先重新同步提及清單" } return nil, app.For(code.ThreadsAccount).InputInvalidFormat(msg) } account, err := u.threadsAccounts.Get(ctx, tenantID, ownerUID, accountID) if err != nil { return nil, err } token, err := u.threadsAccounts.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID) if err != nil { return nil, err } userID := strings.TrimSpace(account.ThreadsUserID) if userID == "" { if profile, perr := libthreads.GetMeProfile(ctx, token); perr == nil && profile != nil { userID = profile.ID.String() } } if userID == "" { return nil, app.For(code.ThreadsAccount).InputMissingRequired("缺少 threads_user_id") } result, err := libthreads.PublishReply(ctx, libthreads.PublishReplyInput{ ThreadsUserID: userID, AccessToken: token, ReplyToID: mediaID, Text: text, }) if err != nil { return nil, err } out := &domusecase.PublishReplyResult{} if result != nil { out.MediaID = strings.TrimSpace(result.MediaID) out.Permalink = strings.TrimSpace(result.Permalink) } return out, nil } func (u *mentionInboxUseCase) buildMentionEntity( tenantID, ownerUID, accountID string, hit libthreads.MentionItem, now int64, ) *entity.Mention { mediaID := strings.TrimSpace(hit.ID) return &entity.Mention{ ID: entity.DocID(accountID, mediaID), TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID, MediaID: mediaID, AuthorUsername: strings.TrimSpace(hit.Username), Text: strings.TrimSpace(hit.Text), Permalink: strings.TrimSpace(hit.Permalink), Shortcode: shortcodeFromPermalink(hit.Permalink), Timestamp: strings.TrimSpace(hit.Timestamp), MediaType: strings.TrimSpace(hit.MediaType), IsReply: hit.IsReply, IsQuotePost: hit.IsQuotePost, HasReplies: hit.HasReplies, RootPostID: strings.TrimSpace(hit.RootPostID), ParentID: strings.TrimSpace(hit.ParentID), SyncedAt: now, UpdateAt: now, } } func (u *mentionInboxUseCase) fillContext(ctx context.Context, client *libthreads.Client, hit libthreads.MentionItem, item *entity.Mention) { mentionText := strings.TrimSpace(hit.Text) if !hit.IsReply && !hit.IsQuotePost { item.ThreadPostText = mentionText return } rootID := strings.TrimSpace(hit.RootPostID) if rootID == "" { rootID = strings.TrimSpace(hit.ID) } if rootID != "" { if detail, err := client.GetMediaDetail(ctx, rootID); err == nil && detail != nil { item.ThreadPostText = strings.TrimSpace(detail.Text) } } if item.ThreadPostText == "" { item.ThreadPostText = mentionText } parentID := strings.TrimSpace(hit.ParentID) mediaID := strings.TrimSpace(hit.ID) if hit.IsReply && parentID != "" && parentID != mediaID && parentID != rootID { if detail, err := client.GetMediaDetail(ctx, parentID); err == nil && detail != nil { item.ParentReplyText = strings.TrimSpace(detail.Text) } } } func (u *mentionInboxUseCase) primeReplyTarget(ctx context.Context, client *libthreads.Client, hit libthreads.MentionItem, item *entity.Mention) { permalink := strings.TrimSpace(hit.Permalink) mediaID := strings.TrimSpace(hit.ID) if permalink == "" { item.ReplyReady = false item.ReplyReadyMsg = "缺少 permalink,無法註冊回覆目標" return } if err := client.PrimeReplyTargetForPublish(ctx, libthreads.ResolveReplyMediaInput{ ExternalID: mediaID, Permalink: permalink, HintText: strings.TrimSpace(hit.Text), }, mediaID); err != nil { item.ReplyReady = false item.ReplyReadyMsg = err.Error() return } item.ReplyReady = true item.ReplyReadyMsg = "" } func shortcodeFromPermalink(permalink string) string { permalink = strings.Split(strings.TrimSpace(permalink), "?")[0] permalink = strings.Split(permalink, "#")[0] parts := strings.Split(permalink, "/post/") if len(parts) < 2 { return "" } return strings.TrimSpace(parts[len(parts)-1]) } func toSummary(item *entity.Mention) domusecase.MentionSummary { if item == nil { return domusecase.MentionSummary{} } return domusecase.MentionSummary{ MediaID: item.MediaID, AuthorUsername: item.AuthorUsername, Text: item.Text, Permalink: item.Permalink, Shortcode: item.Shortcode, Timestamp: item.Timestamp, MediaType: item.MediaType, IsReply: item.IsReply, IsQuotePost: item.IsQuotePost, HasReplies: item.HasReplies, RootPostID: item.RootPostID, ParentID: item.ParentID, ThreadPostText: item.ThreadPostText, ParentReplyText: item.ParentReplyText, ReplyReady: item.ReplyReady, ReplyReadyMsg: item.ReplyReadyMsg, SyncedAt: item.SyncedAt, } } func toSummaries(items []entity.Mention) []domusecase.MentionSummary { out := make([]domusecase.MentionSummary, 0, len(items)) for i := range items { out = append(out, toSummary(&items[i])) } return out }