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

347 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}