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

348 lines
10 KiB
Go
Raw Normal View History

2026-07-01 08:42:51 +00:00
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
2026-07-03 14:42:19 +00:00
}