354 lines
10 KiB
Go
354 lines
10 KiB
Go
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/library/threadspost"
|
||
"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)
|
||
imageURLs := req.ImageURLs
|
||
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 == "" && len(imageURLs) == 0 {
|
||
return nil, app.For(code.ThreadsAccount).InputMissingRequired("text or image_keys is required")
|
||
}
|
||
if err := threadspost.ValidateReplyContent(text, len(imageURLs) > 0); err != nil {
|
||
return nil, app.For(code.ThreadsAccount).InputInvalidFormat(err.Error())
|
||
}
|
||
|
||
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,
|
||
ImageURLs: imageURLs,
|
||
})
|
||
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
|
||
}
|