2831 lines
87 KiB
Go
2831 lines
87 KiB
Go
package usecase
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"apps/backend/internal/module/ai"
|
||
fsDomain "apps/backend/internal/module/filestorage/domain"
|
||
"apps/backend/internal/module/studio/domain"
|
||
"apps/backend/internal/module/studio/publish"
|
||
threadsDomain "apps/backend/internal/module/threads/domain"
|
||
usageDomain "apps/backend/internal/module/usage/domain"
|
||
usageUC "apps/backend/internal/module/usage/usecase"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/zeromicro/go-zero/core/logx"
|
||
)
|
||
|
||
// AccountLookup resolves Threads accounts for ownership + token.
|
||
type AccountLookup interface {
|
||
Get(ctx context.Context, id string) (*threadsDomain.Account, error)
|
||
List(ctx context.Context, ownerUID int64) ([]*threadsDomain.Account, error)
|
||
// DecryptAccess returns plaintext access token for publish (or fake token).
|
||
AccessToken(ctx context.Context, a *threadsDomain.Account) (string, error)
|
||
}
|
||
|
||
// CrawlerSessionSource optional Playwright storageState for profile crawl.
|
||
type CrawlerSessionSource interface {
|
||
GetCrawlerSessionToken(ctx context.Context, ownerUID int64) (string, error)
|
||
}
|
||
|
||
// ThreadsMediaSource — 真 Threads Graph:列貼文/insights/對話回覆/提及/公開人設。
|
||
// 未設定時 SyncOwnPosts 退回假 seed(測試/無 Meta)。
|
||
type ThreadsMediaSource interface {
|
||
ListThreads(ctx context.Context, accessToken string, limit int) ([]FetchedThread, error)
|
||
GetInsights(ctx context.Context, accessToken, mediaID string) (FetchedInsights, error)
|
||
ListConversation(ctx context.Context, accessToken, mediaID string, limit int) ([]FetchedReply, error)
|
||
ListMentions(ctx context.Context, accessToken, threadsUserID string, limit int) ([]FetchedMention, error)
|
||
// ListProfilePosts — 公開 @username 貼文(threads_profile_discovery)
|
||
ListProfilePosts(ctx context.Context, accessToken, username string, limit int) ([]FetchedThread, error)
|
||
}
|
||
|
||
// Fetched* 與 provider 解耦,避免 studio 依賴 provider 包。
|
||
type FetchedThread struct {
|
||
ID, Text, MediaType, MediaURL, ThumbnailURL, Permalink, Shortcode, TopicTag, Username string
|
||
PublishedAt int64 // unix ns
|
||
}
|
||
|
||
type FetchedInsights struct {
|
||
Views, Likes, Replies, Reposts, Quotes, Shares int
|
||
Status, ErrorMsg string
|
||
}
|
||
|
||
type FetchedReply struct {
|
||
ID, Text, Username, ParentMediaID string
|
||
PublishedAt int64
|
||
IsMine bool
|
||
LikeCount int
|
||
}
|
||
|
||
// FetchedMention — Graph /{user-id}/mentions
|
||
type FetchedMention struct {
|
||
ID, Text, Username, Permalink, RootPostID, ParentID string
|
||
IsReply, IsQuotePost bool
|
||
PublishedAt int64
|
||
}
|
||
|
||
// AIKeySource resolves provider/model/apiKey for a member (settings + platform).
|
||
type AIKeySource interface {
|
||
// ResolveAI returns provider, model, apiKey for LLM calls.
|
||
ResolveAI(ctx context.Context, ownerUID int64) (provider, model, apiKey string, err error)
|
||
}
|
||
|
||
// PersonaAnalyzeScheduler enqueues durable background analyze jobs (leave-page safe).
|
||
// When nil (unit tests), AnalyzeFrom* runs synchronously.
|
||
type PersonaAnalyzeScheduler interface {
|
||
SchedulePersonaAnalyzeAccount(ctx context.Context, ownerUID int64, personaID, username, lang string) (jobID string, err error)
|
||
SchedulePersonaAnalyzeText(ctx context.Context, ownerUID int64, personaID, rawText, sourceLabel, lang string) (jobID string, err error)
|
||
}
|
||
|
||
// Service is the M4 studio facade (personas/plays/outbox/compose/ownposts/mentions).
|
||
type Service struct {
|
||
Repo domain.Repository
|
||
Transport publish.Transport
|
||
Accounts AccountLookup
|
||
Usage *usageUC.Service
|
||
AI ai.Client // tests / fake fallback
|
||
// AIRegistry real xai / opencode-go clients
|
||
AIRegistry *ai.Registry
|
||
// Keys optional; when set, persona analyze uses member AI settings
|
||
Keys AIKeySource
|
||
// Crawler optional: Chrome extension sync'd storageState for public profile scrape
|
||
Crawler CrawlerSessionSource
|
||
// Jobs optional: when set, analyze APIs only enqueue; worker runs Execute*
|
||
Jobs PersonaAnalyzeScheduler
|
||
// Media optional: Meta Threads 拉「我的貼文」
|
||
Media ThreadsMediaSource
|
||
// Storage optional: 發文暫存圖(temp/*)發成功後刪除
|
||
Storage fsDomain.Storage
|
||
// StoragePublicBase — 與 ObjectStorage.PublicBaseURL 對齊,用來從公開 URL 反推 object key
|
||
StoragePublicBase string
|
||
// optional key resolver path via Usage.PrepareCall + Static/Settings
|
||
KeyModeDefault string // if Usage nil, use this for tests
|
||
}
|
||
|
||
func New(repo domain.Repository, transport publish.Transport) *Service {
|
||
return &Service{Repo: repo, Transport: transport}
|
||
}
|
||
|
||
// ---------- Personas (PE) ----------
|
||
|
||
func (s *Service) ListPersonas(ctx context.Context, ownerUID int64) ([]*domain.Persona, error) {
|
||
return s.Repo.ListPersonas(ctx, ownerUID)
|
||
}
|
||
|
||
func (s *Service) GetPersona(ctx context.Context, ownerUID int64, id string) (*domain.Persona, error) {
|
||
p, err := s.Repo.GetPersona(ctx, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if p.OwnerUID != ownerUID {
|
||
return nil, domain.ErrForbidden
|
||
}
|
||
return p, nil
|
||
}
|
||
|
||
func (s *Service) SavePersona(ctx context.Context, ownerUID int64, p *domain.Persona) (*domain.Persona, error) {
|
||
if ownerUID <= 0 {
|
||
return nil, domain.ErrForbidden
|
||
}
|
||
now := domain.NowNano()
|
||
if p.ID == "" {
|
||
p.ID = "pe_" + uuid.NewString()[:12]
|
||
p.CreatedAt = now
|
||
} else {
|
||
existing, err := s.Repo.GetPersona(ctx, p.ID)
|
||
if err == nil {
|
||
if existing.OwnerUID != ownerUID {
|
||
return nil, domain.ErrForbidden
|
||
}
|
||
p.CreatedAt = existing.CreatedAt
|
||
p.OwnerUID = existing.OwnerUID
|
||
} else if err != domain.ErrNotFound {
|
||
return nil, err
|
||
} else {
|
||
p.CreatedAt = now
|
||
}
|
||
}
|
||
p.OwnerUID = ownerUID
|
||
if p.Status == "" {
|
||
p.Status = domain.PersonaEmpty
|
||
}
|
||
if p.Guard.MaxChars == 0 {
|
||
p.Guard.MaxChars = 500
|
||
}
|
||
p.UpdatedAt = now
|
||
if err := s.Repo.SavePersona(ctx, p); err != nil {
|
||
return nil, err
|
||
}
|
||
// first persona becomes active if none
|
||
aid, _ := s.Repo.GetActivePersonaID(ctx, ownerUID)
|
||
if aid == "" {
|
||
_ = s.Repo.SetActivePersonaID(ctx, ownerUID, p.ID)
|
||
}
|
||
return p, nil
|
||
}
|
||
|
||
func (s *Service) RemovePersona(ctx context.Context, ownerUID int64, id string) error {
|
||
p, err := s.GetPersona(ctx, ownerUID, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := s.Repo.DeletePersona(ctx, p.ID); err != nil {
|
||
return err
|
||
}
|
||
aid, _ := s.Repo.GetActivePersonaID(ctx, ownerUID)
|
||
if aid == id {
|
||
list, _ := s.Repo.ListPersonas(ctx, ownerUID)
|
||
next := ""
|
||
if len(list) > 0 {
|
||
next = list[0].ID
|
||
}
|
||
_ = s.Repo.SetActivePersonaID(ctx, ownerUID, next)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) GetActivePersonaID(ctx context.Context, ownerUID int64) (string, error) {
|
||
return s.Repo.GetActivePersonaID(ctx, ownerUID)
|
||
}
|
||
|
||
func (s *Service) SetActivePersonaID(ctx context.Context, ownerUID int64, id string) error {
|
||
if id != "" {
|
||
if _, err := s.GetPersona(ctx, ownerUID, id); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return s.Repo.SetActivePersonaID(ctx, ownerUID, id)
|
||
}
|
||
|
||
// AnalyzeFromText — API 路徑:有 Jobs 則只入列背景任務(離開頁面不中斷);無 Jobs(測試)同步跑完。
|
||
func (s *Service) AnalyzeFromText(ctx context.Context, ownerUID int64, id, rawText, sourceLabel string) (*domain.Persona, error) {
|
||
p, err := s.GetPersona(ctx, ownerUID, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
rawText = strings.TrimSpace(rawText)
|
||
if rawText == "" {
|
||
return nil, fmt.Errorf("%w: empty text", domain.ErrValidation)
|
||
}
|
||
samples := splitSamples(rawText, 12)
|
||
if len(samples) < 2 {
|
||
return nil, fmt.Errorf("%w: 請至少貼 2 段參考文字(可用 --- 分隔),每段至少約 10 字", domain.ErrValidation)
|
||
}
|
||
if s.Jobs != nil {
|
||
lang := ai.ResponseLanguageFrom(ctx)
|
||
p.Status = domain.PersonaAnalyzing
|
||
p.UpdatedAt = domain.NowNano()
|
||
if err := s.Repo.SavePersona(ctx, p); err != nil {
|
||
return nil, err
|
||
}
|
||
if _, err := s.Jobs.SchedulePersonaAnalyzeText(ctx, ownerUID, p.ID, rawText, sourceLabel, lang); err != nil {
|
||
p.Status = domain.PersonaEmpty
|
||
p.UpdatedAt = domain.NowNano()
|
||
_ = s.Repo.SavePersona(ctx, p)
|
||
return nil, err
|
||
}
|
||
return p, nil
|
||
}
|
||
return s.ExecuteAnalyzeFromText(ctx, ownerUID, id, rawText, sourceLabel, nil)
|
||
}
|
||
|
||
// AnalyzeFromAccount — API 路徑:有 Jobs 則入列(爬文+分析在 worker);無 Jobs 同步。
|
||
func (s *Service) AnalyzeFromAccount(ctx context.Context, ownerUID int64, id, username string) (*domain.Persona, error) {
|
||
p, err := s.GetPersona(ctx, ownerUID, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
username = strings.TrimPrefix(strings.TrimSpace(username), "@")
|
||
if username == "" {
|
||
return nil, fmt.Errorf("%w: empty username", domain.ErrValidation)
|
||
}
|
||
if strings.ContainsAny(username, " /") || strings.Contains(username, "threads.") {
|
||
return nil, fmt.Errorf("%w: username 請只填帳號,例如 ultralab_tw", domain.ErrValidation)
|
||
}
|
||
if s.Jobs != nil {
|
||
lang := ai.ResponseLanguageFrom(ctx)
|
||
p.Status = domain.PersonaAnalyzing
|
||
p.Style.BenchmarkUsername = username
|
||
p.UpdatedAt = domain.NowNano()
|
||
if err := s.Repo.SavePersona(ctx, p); err != nil {
|
||
return nil, err
|
||
}
|
||
if _, err := s.Jobs.SchedulePersonaAnalyzeAccount(ctx, ownerUID, p.ID, username, lang); err != nil {
|
||
p.Status = domain.PersonaEmpty
|
||
p.UpdatedAt = domain.NowNano()
|
||
_ = s.Repo.SavePersona(ctx, p)
|
||
return nil, err
|
||
}
|
||
return p, nil
|
||
}
|
||
return s.ExecuteAnalyzeFromAccount(ctx, ownerUID, id, username, nil)
|
||
}
|
||
|
||
// ExecuteAnalyzeFromText — worker/測試:扣費 + LLM 分析 + 存檔 ready。
|
||
func (s *Service) ExecuteAnalyzeFromText(ctx context.Context, ownerUID int64, id, rawText, sourceLabel string, onProgress ProgressFn) (*domain.Persona, error) {
|
||
report := func(pct int, sum string) {
|
||
if onProgress != nil {
|
||
onProgress(pct, sum)
|
||
}
|
||
}
|
||
p, err := s.GetPersona(ctx, ownerUID, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
rawText = strings.TrimSpace(rawText)
|
||
if rawText == "" {
|
||
return nil, fmt.Errorf("%w: empty text", domain.ErrValidation)
|
||
}
|
||
samples := splitSamples(rawText, 12)
|
||
if len(samples) < 2 {
|
||
_ = s.markPersonaAnalyzeFailed(ctx, p, "參考文字不足 2 段")
|
||
return nil, fmt.Errorf("%w: 請至少貼 2 段參考文字(可用 --- 分隔),每段至少約 10 字", domain.ErrValidation)
|
||
}
|
||
report(25, "人設分析 · 檢查文字樣本…")
|
||
if err := s.billAI(ctx, ownerUID, "persona analyze text", "personas.analyzeFromText"); err != nil {
|
||
_ = s.markPersonaAnalyzeFailed(ctx, p, err.Error())
|
||
return nil, err
|
||
}
|
||
p.Status = domain.PersonaAnalyzing
|
||
p.UpdatedAt = domain.NowNano()
|
||
_ = s.Repo.SavePersona(ctx, p)
|
||
|
||
label := strings.TrimSpace(sourceLabel)
|
||
if label == "" {
|
||
label = "手動貼文"
|
||
}
|
||
report(55, fmt.Sprintf("人設分析 · AI 分析「%s」中…", label))
|
||
if err := s.finishPersonaStyleAnalysis(ctx, ownerUID, p, samples, "manual", "", label); err != nil {
|
||
_ = s.markPersonaAnalyzeFailed(ctx, p, err.Error())
|
||
return nil, err
|
||
}
|
||
report(90, "人設分析 · 寫入指紋/範本…")
|
||
if err := s.Repo.SavePersona(ctx, p); err != nil {
|
||
return nil, err
|
||
}
|
||
return p, nil
|
||
}
|
||
|
||
// ExecuteAnalyzeFromAccount — worker:爬公開貼文 + LLM + 存檔 ready。
|
||
func (s *Service) ExecuteAnalyzeFromAccount(ctx context.Context, ownerUID int64, id, username string, onProgress ProgressFn) (*domain.Persona, error) {
|
||
report := func(pct int, sum string) {
|
||
if onProgress != nil {
|
||
onProgress(pct, sum)
|
||
}
|
||
}
|
||
p, err := s.GetPersona(ctx, ownerUID, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
username = strings.TrimPrefix(strings.TrimSpace(username), "@")
|
||
if username == "" {
|
||
return nil, fmt.Errorf("%w: empty username", domain.ErrValidation)
|
||
}
|
||
if strings.ContainsAny(username, " /") || strings.Contains(username, "threads.") {
|
||
return nil, fmt.Errorf("%w: username 請只填帳號,例如 ultralab_tw", domain.ErrValidation)
|
||
}
|
||
report(15, fmt.Sprintf("人設分析 · 準備爬取 @%s…", username))
|
||
if err := s.billAI(ctx, ownerUID, "persona analyze account", "personas.analyzeFromAccount"); err != nil {
|
||
_ = s.markPersonaAnalyzeFailed(ctx, p, err.Error())
|
||
return nil, err
|
||
}
|
||
|
||
p.Status = domain.PersonaAnalyzing
|
||
p.Style.BenchmarkUsername = username
|
||
p.UpdatedAt = domain.NowNano()
|
||
_ = s.Repo.SavePersona(ctx, p)
|
||
|
||
// 取樣順序(不強制 Chrome 爬蟲):
|
||
// 1) Threads Graph profile_posts(已連帳 + threads_profile_discovery)
|
||
// 2) Playwright 公開頁(可選 extension session 提高成功率)
|
||
report(28, fmt.Sprintf("人設分析 · 讀取 @%s 公開貼文…", username))
|
||
samples, source, scrapeErr := s.collectPersonaSamples(ctx, ownerUID, username, 12)
|
||
if source != "" {
|
||
report(45, fmt.Sprintf("人設分析 · 來源 %s,已抓 %d 則…", source, len(samples)))
|
||
}
|
||
|
||
// 樣本不足 → 明確錯誤(不要再回英文假貼文)
|
||
if scrapeErr != nil || len(samples) < 2 {
|
||
hint := "建議:① 到 Crew 連 Threads 帳號(需重新 OAuth 以取得公開人設權限)後再試;② 或改用「貼上參考文字」;③ 可選:設定頁同步 Chrome Session 提高公開頁爬取成功率。"
|
||
var msg string
|
||
if scrapeErr != nil {
|
||
msg = fmt.Sprintf("無法讀取 @%s 公開貼文(%v)。%s", username, scrapeErr, hint)
|
||
} else {
|
||
msg = fmt.Sprintf("@%s 可讀貼文不足 2 篇(目前 %d)。%s", username, len(samples), hint)
|
||
}
|
||
_ = s.markPersonaAnalyzeFailed(ctx, p, msg)
|
||
return nil, fmt.Errorf("%w: %s", domain.ErrValidation, msg)
|
||
}
|
||
|
||
report(60, fmt.Sprintf("人設分析 · 已抓 %d 則,AI 分析中…", len(samples)))
|
||
if err := s.finishPersonaStyleAnalysis(ctx, ownerUID, p, samples, "benchmark", username, "@"+username); err != nil {
|
||
_ = s.markPersonaAnalyzeFailed(ctx, p, err.Error())
|
||
return nil, err
|
||
}
|
||
report(90, "人設分析 · 寫入指紋/範本…")
|
||
if err := s.Repo.SavePersona(ctx, p); err != nil {
|
||
return nil, err
|
||
}
|
||
return p, nil
|
||
}
|
||
|
||
func (s *Service) markPersonaAnalyzeFailed(ctx context.Context, p *domain.Persona, msg string) error {
|
||
if p == nil {
|
||
return nil
|
||
}
|
||
p.Status = domain.PersonaEmpty
|
||
if msg != "" {
|
||
p.Notes = "⚠️ 分析失敗:" + truncate(msg, 240)
|
||
}
|
||
p.UpdatedAt = domain.NowNano()
|
||
return s.Repo.SavePersona(ctx, p)
|
||
}
|
||
|
||
// collectPersonaSamples — Graph API 優先,Playwright 公開頁後備(Chrome session 可選)。
|
||
// source: graph | playwright | ""
|
||
func (s *Service) collectPersonaSamples(ctx context.Context, ownerUID int64, username string, limit int) (samples []string, source string, err error) {
|
||
// 1) Meta Graph profile_posts(用任一可用連帳 token)
|
||
if s.Media != nil && s.Accounts != nil {
|
||
if texts, gerr := s.fetchProfilePostsViaGraph(ctx, ownerUID, username, limit); gerr == nil && len(texts) >= 2 {
|
||
return texts, "graph", nil
|
||
} else if gerr != nil {
|
||
err = gerr // 保留最後錯誤,若 scrape 也失敗再回
|
||
} else if len(texts) > 0 && len(texts) < 2 {
|
||
err = fmt.Errorf("Graph API 只拿到 %d 則", len(texts))
|
||
}
|
||
}
|
||
|
||
// 2) Playwright 公開頁(不必有 Chrome session;有則帶入)
|
||
storageState := ""
|
||
if s.Crawler != nil {
|
||
if tok, cerr := s.Crawler.GetCrawlerSessionToken(ctx, ownerUID); cerr == nil {
|
||
storageState = strings.TrimSpace(tok)
|
||
}
|
||
}
|
||
texts, serr := fetchProfilePostTexts(ctx, username, storageState, limit)
|
||
if serr == nil && len(texts) >= 2 {
|
||
return texts, "playwright", nil
|
||
}
|
||
if serr != nil {
|
||
if err != nil {
|
||
return nil, "", fmt.Errorf("Graph: %v; 公開頁: %v", err, serr)
|
||
}
|
||
return nil, "", serr
|
||
}
|
||
if len(texts) > 0 {
|
||
return texts, "playwright", nil // 可能 <2,上層再判
|
||
}
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
return nil, "", fmt.Errorf("找不到公開貼文")
|
||
}
|
||
|
||
func (s *Service) fetchProfilePostsViaGraph(ctx context.Context, ownerUID int64, username string, limit int) ([]string, error) {
|
||
if s.Media == nil || s.Accounts == nil {
|
||
return nil, fmt.Errorf("media/accounts not configured")
|
||
}
|
||
accs, err := s.Accounts.List(ctx, ownerUID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var lastErr error
|
||
for _, acc := range accs {
|
||
if acc == nil || !acc.IsUsable {
|
||
continue
|
||
}
|
||
token, terr := s.Accounts.AccessToken(ctx, acc)
|
||
if terr != nil || strings.TrimSpace(token) == "" || strings.HasPrefix(token, "fake-") {
|
||
continue
|
||
}
|
||
list, lerr := s.Media.ListProfilePosts(ctx, token, username, limit)
|
||
if lerr != nil {
|
||
lastErr = lerr
|
||
continue
|
||
}
|
||
out := make([]string, 0, len(list))
|
||
seen := map[string]struct{}{}
|
||
for _, th := range list {
|
||
t := strings.TrimSpace(th.Text)
|
||
if len(t) < 8 {
|
||
continue
|
||
}
|
||
if _, ok := seen[t]; ok {
|
||
continue
|
||
}
|
||
seen[t] = struct{}{}
|
||
out = append(out, t)
|
||
}
|
||
if len(out) > 0 {
|
||
return out, nil
|
||
}
|
||
}
|
||
if lastErr != nil {
|
||
return nil, lastErr
|
||
}
|
||
return nil, fmt.Errorf("無可用 Threads 帳號 token 或 profile_posts 為空(請重新連帳以取得 threads_profile_discovery)")
|
||
}
|
||
|
||
// ProgressFn reports job progress from long-running execute (worker → MarkRunningProgress).
|
||
type ProgressFn func(percent int, summary string)
|
||
|
||
// ExecutePersonaAnalyzeJob parses job payload and runs account/text analyze (worker entry).
|
||
func (s *Service) ExecutePersonaAnalyzeJob(ctx context.Context, templateType string, ownerUID int64, personaID, payloadJSON string, onProgress ProgressFn) error {
|
||
report := func(pct int, sum string) {
|
||
if onProgress != nil {
|
||
onProgress(pct, sum)
|
||
}
|
||
}
|
||
switch templateType {
|
||
case "persona_analyze_account":
|
||
var pl struct {
|
||
Username string `json:"username"`
|
||
Lang string `json:"lang,omitempty"`
|
||
}
|
||
if err := json.Unmarshal([]byte(payloadJSON), &pl); err != nil {
|
||
return fmt.Errorf("invalid persona analyze account payload: %w", err)
|
||
}
|
||
if pl.Lang != "" {
|
||
ctx = ai.WithResponseLanguage(ctx, pl.Lang)
|
||
}
|
||
_, err := s.ExecuteAnalyzeFromAccount(ctx, ownerUID, personaID, pl.Username, report)
|
||
return err
|
||
case "persona_analyze_text":
|
||
var pl struct {
|
||
RawText string `json:"raw_text"`
|
||
SourceLabel string `json:"source_label,omitempty"`
|
||
Lang string `json:"lang,omitempty"`
|
||
}
|
||
if err := json.Unmarshal([]byte(payloadJSON), &pl); err != nil {
|
||
return fmt.Errorf("invalid persona analyze text payload: %w", err)
|
||
}
|
||
if pl.Lang != "" {
|
||
ctx = ai.WithResponseLanguage(ctx, pl.Lang)
|
||
}
|
||
_, err := s.ExecuteAnalyzeFromText(ctx, ownerUID, personaID, pl.RawText, pl.SourceLabel, report)
|
||
return err
|
||
default:
|
||
return fmt.Errorf("unknown persona analyze template: %s", templateType)
|
||
}
|
||
}
|
||
|
||
// finishPersonaStyleAnalysis:先規則底稿,再以會員設定的 provider/model 做真 LLM 分析覆寫。
|
||
func (s *Service) finishPersonaStyleAnalysis(
|
||
ctx context.Context,
|
||
ownerUID int64,
|
||
p *domain.Persona,
|
||
samples []string,
|
||
source, username, sourceLabel string,
|
||
) error {
|
||
// 底稿(LLM 失敗時仍有可用結果)
|
||
applyStyleFromSamples(p, samples, source, username, sourceLabel)
|
||
|
||
provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID)
|
||
if kerr != nil || strings.TrimSpace(apiKey) == "" || isSyntheticAIKey(apiKey) {
|
||
// 無真實 key:標註非 LLM
|
||
p.Notes = "⚠️ 規則摘要(非正式 AI)。請到設定填寫 xAI 或 OpenCode Go API Key 後重跑分析。"
|
||
p.UpdatedAt = domain.NowNano()
|
||
return nil
|
||
}
|
||
|
||
raw, aerr := s.completeLLM(ctx, provider, model, apiKey, buildStyleAnalysisPrompt(samples, username, sourceLabel))
|
||
if aerr != nil {
|
||
p.Notes = "⚠️ AI 分析失敗,已保留規則摘要:" + truncate(aerr.Error(), 160)
|
||
p.UpdatedAt = domain.NowNano()
|
||
return nil // 不整段失敗;規則結果仍可用
|
||
}
|
||
if !applyAIStyleJSON(p, raw, samples, source, username, sourceLabel) {
|
||
// AI 回了非 JSON:仍把全文放 notes,draft 用規則
|
||
p.Notes = "AI 原文(未解析為結構化 JSON):" + truncate(raw, 500)
|
||
p.UpdatedAt = domain.NowNano()
|
||
return nil
|
||
}
|
||
p.Notes = fmt.Sprintf("LLM 分析 · %s / %s · 樣本 %d 則", provider, model, len(samples))
|
||
p.UpdatedAt = domain.NowNano()
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) resolveUserAI(ctx context.Context, ownerUID int64) (provider, model, apiKey string, err error) {
|
||
if s.Keys != nil {
|
||
return s.Keys.ResolveAI(ctx, ownerUID)
|
||
}
|
||
// tests:FakeClient + any key
|
||
if s.AI != nil {
|
||
return "xai", "grok-3", "test-key", nil
|
||
}
|
||
return "", "", "", fmt.Errorf("AI not configured")
|
||
}
|
||
|
||
func (s *Service) completeLLM(ctx context.Context, provider, model, apiKey, prompt string) (string, error) {
|
||
// system 語言 prompt 由 ai.Client 依 ctx 強制注入(見 openai_compatible.Complete)
|
||
// real registry first
|
||
if s.AIRegistry != nil && !isSyntheticAIKey(apiKey) {
|
||
if c, err := s.AIRegistry.Client(provider); err == nil {
|
||
return c.Complete(ctx, apiKey, model, prompt)
|
||
}
|
||
}
|
||
if s.AI != nil {
|
||
return s.AI.Complete(ctx, apiKey, model, prompt)
|
||
}
|
||
return "", fmt.Errorf("no AI client")
|
||
}
|
||
|
||
func isSyntheticAIKey(key string) bool {
|
||
k := strings.TrimSpace(strings.ToLower(key))
|
||
return k == "" || strings.HasPrefix(k, "fake-") || k == "platform-key" || k == "test-key"
|
||
}
|
||
|
||
// ---------- Plays (PL) ----------
|
||
|
||
func (s *Service) ListPlays(ctx context.Context, ownerUID int64) ([]*domain.Play, error) {
|
||
return s.Repo.ListPlays(ctx, ownerUID)
|
||
}
|
||
|
||
func (s *Service) GetPlay(ctx context.Context, ownerUID int64, id string) (*domain.Play, error) {
|
||
p, err := s.Repo.GetPlay(ctx, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if p.OwnerUID != ownerUID {
|
||
return nil, domain.ErrForbidden
|
||
}
|
||
return p, nil
|
||
}
|
||
|
||
func (s *Service) SavePlay(ctx context.Context, ownerUID int64, p *domain.Play) (*domain.Play, error) {
|
||
if ownerUID <= 0 {
|
||
return nil, domain.ErrForbidden
|
||
}
|
||
now := domain.NowNano()
|
||
if p.ID == "" {
|
||
p.ID = "play_" + uuid.NewString()[:12]
|
||
p.CreatedAt = now
|
||
} else {
|
||
ex, err := s.Repo.GetPlay(ctx, p.ID)
|
||
if err == nil {
|
||
if ex.OwnerUID != ownerUID {
|
||
return nil, domain.ErrForbidden
|
||
}
|
||
p.CreatedAt = ex.CreatedAt
|
||
} else if err != domain.ErrNotFound {
|
||
return nil, err
|
||
} else {
|
||
p.CreatedAt = now
|
||
}
|
||
}
|
||
p.OwnerUID = ownerUID
|
||
// own vs external mutually exclusive
|
||
if p.TargetOwnPostID != "" {
|
||
p.TargetExternal = nil
|
||
}
|
||
if p.TargetExternal != nil && p.TargetExternal.URL != "" {
|
||
p.TargetOwnPostID = ""
|
||
p.TargetExternal.URL = normalizeURL(p.TargetExternal.URL)
|
||
}
|
||
if p.Status == "" {
|
||
p.Status = domain.PlayDraft
|
||
}
|
||
// renumber steps
|
||
for i := range p.Steps {
|
||
p.Steps[i].SortOrder = i
|
||
if p.Steps[i].ID == "" {
|
||
p.Steps[i].ID = "step_" + uuid.NewString()[:8]
|
||
}
|
||
if underPost(p) {
|
||
p.Steps[i].Kind = domain.StepReply
|
||
}
|
||
}
|
||
p.UpdatedAt = now
|
||
if err := s.Repo.SavePlay(ctx, p); err != nil {
|
||
return nil, err
|
||
}
|
||
return p, nil
|
||
}
|
||
|
||
func (s *Service) RemovePlay(ctx context.Context, ownerUID int64, id string) error {
|
||
if _, err := s.GetPlay(ctx, ownerUID, id); err != nil {
|
||
return err
|
||
}
|
||
// outbox retained (documented strategy)
|
||
return s.Repo.DeletePlay(ctx, id)
|
||
}
|
||
|
||
func (s *Service) ListPlaysByPost(ctx context.Context, ownerUID int64, ownPostID string) ([]*domain.Play, error) {
|
||
all, err := s.Repo.ListPlays(ctx, ownerUID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var out []*domain.Play
|
||
for _, p := range all {
|
||
if p.TargetOwnPostID == ownPostID {
|
||
out = append(out, p)
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *Service) ListPlaysByExternalURL(ctx context.Context, ownerUID int64, raw string) ([]*domain.Play, error) {
|
||
key := normalizeURL(raw)
|
||
if key == "" {
|
||
return nil, nil
|
||
}
|
||
all, err := s.Repo.ListPlays(ctx, ownerUID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var out []*domain.Play
|
||
for _, p := range all {
|
||
if p.TargetExternal != nil && normalizeURL(p.TargetExternal.URL) == key {
|
||
out = append(out, p)
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *Service) ResolveExternalLink(_ context.Context, raw string) (*domain.ExternalTarget, error) {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return nil, domain.ErrBadURL
|
||
}
|
||
u, err := url.Parse(raw)
|
||
if err != nil || u.Host == "" {
|
||
// allow threads-like paths without scheme
|
||
if !strings.Contains(raw, "threads.net") && !strings.HasPrefix(raw, "http") {
|
||
return nil, domain.ErrBadURL
|
||
}
|
||
u, err = url.Parse("https://" + strings.TrimPrefix(raw, "//"))
|
||
if err != nil {
|
||
return nil, domain.ErrBadURL
|
||
}
|
||
}
|
||
host := strings.ToLower(u.Host)
|
||
if !strings.Contains(host, "threads.net") && !strings.Contains(host, "threads.com") {
|
||
return nil, domain.ErrBadURL
|
||
}
|
||
norm := normalizeURL(u.String())
|
||
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||
shortcode := ""
|
||
author := ""
|
||
if len(parts) >= 1 && strings.HasPrefix(parts[0], "@") {
|
||
author = strings.TrimPrefix(parts[0], "@")
|
||
}
|
||
if len(parts) >= 3 && parts[1] == "post" {
|
||
shortcode = parts[2]
|
||
}
|
||
return &domain.ExternalTarget{
|
||
URL: norm,
|
||
RawURL: raw,
|
||
Shortcode: shortcode,
|
||
AuthorUsername: author,
|
||
TextPreview: "External thread preview · " + shortcode,
|
||
MediaID: "ext_" + shortcode,
|
||
ResolvedAt: domain.NowNano(),
|
||
}, nil
|
||
}
|
||
|
||
func (s *Service) SubmitPlay(ctx context.Context, ownerUID int64, playID string) (*domain.OutboxBundle, error) {
|
||
play, err := s.GetPlay(ctx, ownerUID, playID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := validatePlay(play); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.ensureUsableAccounts(ctx, ownerUID, play); err != nil {
|
||
return nil, err
|
||
}
|
||
// 互回/掛文:把目標 Threads media_id 寫進 outbox(否則 Meta 無法 reply_to)
|
||
replyToMedia := ""
|
||
if play.TargetExternal != nil {
|
||
replyToMedia = strings.TrimSpace(play.TargetExternal.MediaID)
|
||
if replyToMedia == "" || strings.HasPrefix(replyToMedia, "ext_") {
|
||
return nil, fmt.Errorf("%w: 外部連結尚無法解析真實 media_id,請改用「我的貼文」當目標,或先同步後貼上可回覆的貼文", domain.ErrValidation)
|
||
}
|
||
}
|
||
if play.TargetOwnPostID != "" {
|
||
post, perr := s.Repo.GetOwnPost(ctx, play.TargetOwnPostID)
|
||
if perr != nil || post == nil {
|
||
return nil, fmt.Errorf("%w: 找不到目標貼文,請先到「我的貼文」同步", domain.ErrValidation)
|
||
}
|
||
if post.OwnerUID != ownerUID {
|
||
return nil, domain.ErrForbidden
|
||
}
|
||
replyToMedia = strings.TrimSpace(post.MediaID)
|
||
if replyToMedia == "" {
|
||
return nil, fmt.Errorf("%w: 目標貼文缺少 media_id,請重新同步「我的貼文」", domain.ErrValidation)
|
||
}
|
||
}
|
||
// 每步要有正文
|
||
for _, st := range play.Steps {
|
||
if strings.TrimSpace(st.Text) == "" {
|
||
return nil, fmt.Errorf("%w: 劇本步驟不可空白,請先 AI 產文或手寫", domain.ErrValidation)
|
||
}
|
||
}
|
||
bundle := playToOutbox(ownerUID, play)
|
||
if replyToMedia != "" {
|
||
bundle.ReplyToMediaID = replyToMedia
|
||
for i := range bundle.Steps {
|
||
if bundle.Steps[i].Kind == domain.StepReply {
|
||
bundle.Steps[i].ReplyTo = replyToMedia
|
||
}
|
||
}
|
||
}
|
||
if err := s.Repo.SaveOutbox(ctx, bundle); err != nil {
|
||
return nil, err
|
||
}
|
||
play.Status = domain.PlayScheduling
|
||
play.UpdatedAt = domain.NowNano()
|
||
_ = s.Repo.SavePlay(ctx, play)
|
||
return bundle, nil
|
||
}
|
||
|
||
// GeneratePlayScript — 一次 LLM 產完整劇本並寫回 play.steps(onlyEmpty=true 只填空白步)。
|
||
// 回傳填入步數。供 worker job / 同步測試。
|
||
func (s *Service) GeneratePlayScript(ctx context.Context, ownerUID int64, playID string, onlyEmpty bool) (int, error) {
|
||
play, err := s.GetPlay(ctx, ownerUID, playID)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
if len(play.Steps) == 0 {
|
||
return 0, fmt.Errorf("%w: play has no steps", domain.ErrValidation)
|
||
}
|
||
// 需要產的步
|
||
type need struct {
|
||
idx int
|
||
id string
|
||
label string
|
||
mode string
|
||
}
|
||
var needs []need
|
||
for i, st := range play.Steps {
|
||
if onlyEmpty && strings.TrimSpace(st.Text) != "" {
|
||
continue
|
||
}
|
||
label := st.AccountID
|
||
mode := "reply"
|
||
if st.Kind == domain.StepRoot || (i == 0 && !underPost(play)) {
|
||
mode = "root"
|
||
}
|
||
// 人設標籤
|
||
if p := s.loadPersonaForGen(ctx, ownerUID, st.PersonaID); p != nil && p.Name != "" {
|
||
label = p.Name
|
||
}
|
||
needs = append(needs, need{idx: i, id: st.ID, label: label, mode: mode})
|
||
}
|
||
if len(needs) == 0 {
|
||
return 0, fmt.Errorf("%w: 沒有空白步驟可產(全部已有正文)", domain.ErrValidation)
|
||
}
|
||
if err := s.billAI(ctx, ownerUID, "play generate script", "plays.generateScript"); err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
// 主上下文:目標貼文/主題
|
||
targetCtx := strings.TrimSpace(play.Topic)
|
||
if play.TargetOwnPostID != "" {
|
||
if post, e := s.Repo.GetOwnPost(ctx, play.TargetOwnPostID); e == nil && post != nil {
|
||
if t := strings.TrimSpace(post.Text); t != "" {
|
||
targetCtx = t
|
||
}
|
||
}
|
||
}
|
||
|
||
// 選一人設當「主指紋」(優先第一空步的 persona,再 active)
|
||
personaID := ""
|
||
for _, n := range needs {
|
||
if pid := strings.TrimSpace(play.Steps[n.idx].PersonaID); pid != "" {
|
||
personaID = pid
|
||
break
|
||
}
|
||
}
|
||
persona := s.loadPersonaForGen(ctx, ownerUID, personaID)
|
||
fp := personaFingerprintBlock(persona)
|
||
if utf8.RuneCountInString(fp) > 400 {
|
||
fp = string([]rune(fp)[:400]) + "…"
|
||
}
|
||
if utf8.RuneCountInString(targetCtx) > 280 {
|
||
targetCtx = string([]rune(targetCtx)[:280]) + "…"
|
||
}
|
||
|
||
// 骨架:已有正文的步當上下文,空步標 [待產]
|
||
var skeleton strings.Builder
|
||
for i, st := range play.Steps {
|
||
skeleton.WriteString(fmt.Sprintf("%d. id=%s kind=%s speaker=%s\n", i+1, st.ID, st.Kind, st.AccountID))
|
||
if t := strings.TrimSpace(st.Text); t != "" {
|
||
if utf8.RuneCountInString(t) > 120 {
|
||
t = string([]rune(t)[:120]) + "…"
|
||
}
|
||
skeleton.WriteString(" 已有:")
|
||
skeleton.WriteString(t)
|
||
skeleton.WriteString("\n")
|
||
} else {
|
||
skeleton.WriteString(" 待產:是\n")
|
||
}
|
||
}
|
||
|
||
prompt := fmt.Sprintf(`你是 Threads 互回編劇。一次寫完下列「待產」步驟的正文。
|
||
|
||
規則:
|
||
- 只輸出 JSON(不要 markdown 圍欄):{"steps":[{"id":"步驟id","text":"正文"},...]}
|
||
- 只輸出待產步驟;id 必須與下方 id 完全一致
|
||
- 繁體中文口語、像真人互回;每則 25~100 字
|
||
- 接住主文/上一則,不要重複抄全文、不要暴露 AI
|
||
|
||
【指紋】
|
||
%s
|
||
|
||
【主文/話題】
|
||
%s
|
||
|
||
【步驟表】
|
||
%s
|
||
|
||
JSON:`, fp, targetCtx, skeleton.String())
|
||
|
||
provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID)
|
||
if kerr != nil || strings.TrimSpace(apiKey) == "" || isSyntheticAIKey(apiKey) {
|
||
if s.AI != nil {
|
||
// 測試 Fake:填固定短句
|
||
for _, n := range needs {
|
||
play.Steps[n.idx].Text = fmt.Sprintf("(測試)步驟 %d 互回", n.idx+1)
|
||
}
|
||
play.UpdatedAt = domain.NowNano()
|
||
if err := s.Repo.SavePlay(ctx, play); err != nil {
|
||
return 0, err
|
||
}
|
||
return len(needs), nil
|
||
}
|
||
return 0, fmt.Errorf("%w: 無法產文,請到設定填寫真實 AI Key", domain.ErrValidation)
|
||
}
|
||
|
||
llmCtx := ctx
|
||
cancel := func() {}
|
||
if _, ok := ctx.Deadline(); !ok {
|
||
llmCtx, cancel = context.WithTimeout(ctx, 95*time.Second)
|
||
}
|
||
defer cancel()
|
||
|
||
out, aerr := s.completeLLM(llmCtx, provider, model, apiKey, prompt)
|
||
if aerr != nil {
|
||
msg := aerr.Error()
|
||
if llmCtx.Err() != nil || strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline") {
|
||
return 0, fmt.Errorf("%w: AI 回應逾時(%s/%s)。請換較快模型", domain.ErrValidation, provider, model)
|
||
}
|
||
return 0, fmt.Errorf("%w: AI 產文失敗(%s/%s):%s", domain.ErrValidation, provider, model, truncate(msg, 180))
|
||
}
|
||
|
||
filled := applyPlayScriptJSON(play, out)
|
||
if filled == 0 {
|
||
// 解析失敗:嘗試整包當一步(不建議);直接報錯
|
||
return 0, fmt.Errorf("%w: AI 回傳無法解析成步驟(%s/%s)。請換模型後重試", domain.ErrValidation, provider, model)
|
||
}
|
||
play.UpdatedAt = domain.NowNano()
|
||
if err := s.Repo.SavePlay(ctx, play); err != nil {
|
||
return 0, err
|
||
}
|
||
return filled, nil
|
||
}
|
||
|
||
// applyPlayScriptJSON 把 {"steps":[{"id","text"}]} 寫入 play
|
||
func applyPlayScriptJSON(play *domain.Play, raw string) int {
|
||
s := strings.TrimSpace(raw)
|
||
s = strings.TrimPrefix(s, "```json")
|
||
s = strings.TrimPrefix(s, "```")
|
||
s = strings.TrimSuffix(s, "```")
|
||
s = strings.TrimSpace(s)
|
||
// 取 JSON 物件
|
||
if i := strings.Index(s, "{"); i >= 0 {
|
||
if j := strings.LastIndex(s, "}"); j > i {
|
||
s = s[i : j+1]
|
||
}
|
||
}
|
||
var parsed struct {
|
||
Steps []struct {
|
||
ID string `json:"id"`
|
||
Text string `json:"text"`
|
||
} `json:"steps"`
|
||
}
|
||
if err := json.Unmarshal([]byte(s), &parsed); err != nil || len(parsed.Steps) == 0 {
|
||
return 0
|
||
}
|
||
byID := map[string]string{}
|
||
for _, st := range parsed.Steps {
|
||
id := strings.TrimSpace(st.ID)
|
||
t := cleanGeneratedTextMax(st.Text, 280)
|
||
if id == "" || t == "" {
|
||
continue
|
||
}
|
||
byID[id] = t
|
||
}
|
||
// 也允許依順序填(若 id 對不上)
|
||
filled := 0
|
||
orderIdx := 0
|
||
ordered := make([]string, 0, len(parsed.Steps))
|
||
for _, st := range parsed.Steps {
|
||
if t := cleanGeneratedTextMax(st.Text, 280); t != "" {
|
||
ordered = append(ordered, t)
|
||
}
|
||
}
|
||
for i := range play.Steps {
|
||
id := play.Steps[i].ID
|
||
if t, ok := byID[id]; ok {
|
||
play.Steps[i].Text = t
|
||
filled++
|
||
continue
|
||
}
|
||
// 僅空白步才用順序填
|
||
if strings.TrimSpace(play.Steps[i].Text) == "" && orderIdx < len(ordered) {
|
||
play.Steps[i].Text = ordered[orderIdx]
|
||
orderIdx++
|
||
filled++
|
||
}
|
||
}
|
||
return filled
|
||
}
|
||
|
||
// GeneratePlayStep — 互回/串場劇本:依人設真 LLM 產一步正文。
|
||
// 注意:與 compose mimic 相同,OpenCode reasoning 模型可能 30~90s 且偶發空白回覆。
|
||
func (s *Service) GeneratePlayStep(ctx context.Context, ownerUID int64, personaID, contextText, topic, speakerLabel string, isLead bool, mode string) (string, error) {
|
||
contextText = strings.TrimSpace(contextText)
|
||
topic = strings.TrimSpace(topic)
|
||
if contextText == "" && topic == "" {
|
||
return "", fmt.Errorf("%w: empty context", domain.ErrValidation)
|
||
}
|
||
if err := s.billAI(ctx, ownerUID, "play generate step", "plays.generateStep"); err != nil {
|
||
return "", err
|
||
}
|
||
persona := s.loadPersonaForGen(ctx, ownerUID, personaID)
|
||
fp := personaFingerprintBlock(persona)
|
||
// 指紋過長會拖慢 reasoning 模型(仿寫已踩過)
|
||
if utf8.RuneCountInString(fp) > 500 {
|
||
fp = string([]rune(fp)[:500]) + "…"
|
||
}
|
||
if utf8.RuneCountInString(contextText) > 400 {
|
||
contextText = string([]rune(contextText)[:400]) + "…"
|
||
}
|
||
mode = strings.ToLower(strings.TrimSpace(mode))
|
||
if mode == "" {
|
||
mode = "reply"
|
||
}
|
||
prompt := buildPlayStepPrompt(fp, contextText, topic, speakerLabel, isLead, mode)
|
||
|
||
provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID)
|
||
if kerr != nil || strings.TrimSpace(apiKey) == "" || isSyntheticAIKey(apiKey) {
|
||
// 單元測試 FakeClient
|
||
if s.AI != nil {
|
||
if out, e := s.AI.Complete(ctx, "test-key", "grok-3", prompt); e == nil {
|
||
if t := cleanGeneratedTextMax(out, 400); t != "" {
|
||
return t, nil
|
||
}
|
||
}
|
||
}
|
||
return "", fmt.Errorf("%w: 無法產文,請到設定填寫真實 AI Key(並選模型)後再試", domain.ErrValidation)
|
||
}
|
||
|
||
// 低於 gateway 120s;OpenCode 慢模型常 40~90s
|
||
llmCtx := ctx
|
||
cancel := func() {}
|
||
if _, ok := ctx.Deadline(); !ok {
|
||
llmCtx, cancel = context.WithTimeout(ctx, 95*time.Second)
|
||
}
|
||
defer cancel()
|
||
|
||
out, aerr := s.completeLLM(llmCtx, provider, model, apiKey, prompt)
|
||
if aerr != nil {
|
||
msg := aerr.Error()
|
||
if llmCtx.Err() != nil || strings.Contains(msg, "context deadline") || strings.Contains(msg, "Client.Timeout") || strings.Contains(msg, "timeout") {
|
||
return "", fmt.Errorf("%w: AI 回應逾時(%s / %s)。請換較快的模型,或稍後再試", domain.ErrValidation, provider, model)
|
||
}
|
||
return "", fmt.Errorf("%w: AI 產文失敗(%s/%s):%s", domain.ErrValidation, provider, model, truncate(msg, 200))
|
||
}
|
||
text := cleanGeneratedTextMax(out, 400)
|
||
if text == "" {
|
||
text = strings.TrimSpace(out)
|
||
}
|
||
if text == "" {
|
||
// 與仿寫相同:reasoning 模型有時只吐 thinking、content 空
|
||
return "", fmt.Errorf("%w: AI 回傳空白(%s / %s)。請到設定換較快的模型(勿用過慢的 reasoning),或重試一次", domain.ErrValidation, provider, model)
|
||
}
|
||
return text, nil
|
||
}
|
||
|
||
func buildPlayStepPrompt(fp, contextText, topic, speakerLabel string, isLead bool, mode string) string {
|
||
// 精簡 prompt:降低 reasoning 模型耗時(仿寫踩過的坑)
|
||
var b strings.Builder
|
||
if mode == "root" {
|
||
b.WriteString("寫一則 Threads 主貼。只輸出正文(繁中口語),80~220字,勿標題/markdown。\n")
|
||
} else {
|
||
b.WriteString("寫一則 Threads 互回短回覆。只輸出正文(繁中口語),25~120字,接上下文,勿分析/markdown/暴露AI。\n")
|
||
}
|
||
if speakerLabel != "" {
|
||
b.WriteString("發言者:")
|
||
b.WriteString(speakerLabel)
|
||
if isLead {
|
||
b.WriteString("(主帳)")
|
||
}
|
||
b.WriteString("\n")
|
||
}
|
||
if topic != "" {
|
||
b.WriteString("話題:")
|
||
b.WriteString(topic)
|
||
b.WriteString("\n")
|
||
}
|
||
b.WriteString("【指紋】\n")
|
||
b.WriteString(fp)
|
||
b.WriteString("\n")
|
||
if contextText != "" {
|
||
b.WriteString("【上下文】\n")
|
||
b.WriteString(contextText)
|
||
b.WriteString("\n")
|
||
}
|
||
b.WriteString("正文:")
|
||
return b.String()
|
||
}
|
||
|
||
// ---------- Outbox (OB) ----------
|
||
|
||
func (s *Service) ListOutbox(ctx context.Context, ownerUID int64) ([]*domain.OutboxBundle, error) {
|
||
return s.Repo.ListOutbox(ctx, ownerUID)
|
||
}
|
||
|
||
func (s *Service) GetOutbox(ctx context.Context, ownerUID int64, id string) (*domain.OutboxBundle, error) {
|
||
b, err := s.Repo.GetOutbox(ctx, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if b.OwnerUID != ownerUID {
|
||
return nil, domain.ErrForbidden
|
||
}
|
||
return b, nil
|
||
}
|
||
|
||
func (s *Service) RemoveOutbox(ctx context.Context, ownerUID int64, id string) error {
|
||
b, err := s.GetOutbox(ctx, ownerUID, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// 未發出/失敗取消:清掉暫存圖
|
||
for i := range b.Steps {
|
||
s.cleanupEphemeralImages(ctx, b.Steps[i].ImageURLs)
|
||
}
|
||
return s.Repo.DeleteOutbox(ctx, id)
|
||
}
|
||
|
||
func (s *Service) RetryStep(ctx context.Context, ownerUID int64, bundleID, stepID string) (*domain.OutboxBundle, error) {
|
||
b, err := s.GetOutbox(ctx, ownerUID, bundleID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
idx := -1
|
||
for i := range b.Steps {
|
||
if b.Steps[i].ID == stepID {
|
||
idx = i
|
||
break
|
||
}
|
||
}
|
||
if idx < 0 {
|
||
return nil, domain.ErrNotFound
|
||
}
|
||
st := &b.Steps[idx]
|
||
// only failed (or blocked after root fixed) can retry
|
||
if st.Status != domain.StepFailed {
|
||
return nil, domain.ErrIllegalStatus
|
||
}
|
||
if st.Kind == domain.StepReply {
|
||
root := findRoot(b)
|
||
if root == nil || root.Status != domain.StepPublished {
|
||
return nil, domain.ErrRootBlocked
|
||
}
|
||
}
|
||
st.Status = domain.StepScheduled
|
||
st.Error = ""
|
||
st.ScheduledAt = domain.NowNano() // due now
|
||
// unblock later replies if root ok
|
||
if root := findRoot(b); root != nil && root.Status == domain.StepPublished {
|
||
for i := range b.Steps {
|
||
if b.Steps[i].Kind == domain.StepReply && b.Steps[i].Status == domain.StepBlocked {
|
||
b.Steps[i].Status = domain.StepScheduled
|
||
b.Steps[i].Error = ""
|
||
}
|
||
}
|
||
}
|
||
b.Status = recomputeBundle(b.Steps)
|
||
b.UpdatedAt = domain.NowNano()
|
||
if err := s.Repo.SaveOutbox(ctx, b); err != nil {
|
||
return nil, err
|
||
}
|
||
return b, nil
|
||
}
|
||
|
||
// ProcessDueSteps is the worker tick — publishes via Transport only.
|
||
func (s *Service) ProcessDueSteps(ctx context.Context, now int64) (published int, err error) {
|
||
if now <= 0 {
|
||
now = domain.NowNano()
|
||
}
|
||
all, err := s.Repo.ListAllOutbox(ctx)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
for _, b := range all {
|
||
n, e := s.processBundle(ctx, b, now)
|
||
published += n
|
||
if e != nil && err == nil {
|
||
err = e
|
||
}
|
||
}
|
||
return published, err
|
||
}
|
||
|
||
func (s *Service) processBundle(ctx context.Context, b *domain.OutboxBundle, now int64) (int, error) {
|
||
// skip removed (already not in list)
|
||
count := 0
|
||
// if root failed, do not publish replies
|
||
root := findRoot(b)
|
||
rootFailed := root != nil && root.Status == domain.StepFailed
|
||
rootPublished := root != nil && root.Status == domain.StepPublished
|
||
|
||
// worker 重啟/中斷會留下 publishing:逾時後收回可重試
|
||
const publishingStaleNs = int64(3 * time.Minute)
|
||
for i := range b.Steps {
|
||
st := &b.Steps[i]
|
||
if st.Status == domain.StepPublishing && b.UpdatedAt > 0 && now-b.UpdatedAt > publishingStaleNs {
|
||
logx.Infof("outbox reclaim stale publishing step id=%s bundle=%s", st.ID, b.ID)
|
||
st.Status = domain.StepScheduled
|
||
st.Error = ""
|
||
}
|
||
}
|
||
|
||
// sort by sort_order
|
||
for i := range b.Steps {
|
||
st := &b.Steps[i]
|
||
if st.Status != domain.StepScheduled {
|
||
continue
|
||
}
|
||
if st.ScheduledAt > now {
|
||
// OB-01: not due
|
||
continue
|
||
}
|
||
if st.Kind == domain.StepReply {
|
||
if rootFailed {
|
||
st.Status = domain.StepBlocked
|
||
st.Error = "root failed; reply blocked"
|
||
continue
|
||
}
|
||
if root != nil && !rootPublished && root.Status != domain.StepPublished {
|
||
// wait for root
|
||
continue
|
||
}
|
||
}
|
||
// publish via transport
|
||
if s.Transport == nil {
|
||
st.Status = domain.StepFailed
|
||
st.Error = "publish transport not configured"
|
||
continue
|
||
}
|
||
st.Status = domain.StepPublishing
|
||
b.Status = domain.OBActive
|
||
b.UpdatedAt = domain.NowNano()
|
||
_ = s.Repo.SaveOutbox(ctx, b)
|
||
|
||
token := "fake-token"
|
||
if s.Accounts != nil {
|
||
acc, aerr := s.Accounts.Get(ctx, st.AccountID)
|
||
if aerr != nil || acc == nil || !acc.IsUsable {
|
||
st.Status = domain.StepFailed
|
||
st.Error = "account not usable"
|
||
b.UpdatedAt = domain.NowNano()
|
||
b.Status = recomputeBundle(b.Steps)
|
||
_ = s.Repo.SaveOutbox(ctx, b)
|
||
continue
|
||
}
|
||
if t, terr := s.Accounts.AccessToken(ctx, acc); terr == nil && t != "" {
|
||
token = t
|
||
}
|
||
}
|
||
replyTo := st.ReplyTo
|
||
if replyTo == "" && st.Kind == domain.StepReply {
|
||
if root != nil && root.MediaID != "" {
|
||
replyTo = root.MediaID
|
||
} else if b.ReplyToMediaID != "" {
|
||
replyTo = b.ReplyToMediaID
|
||
}
|
||
}
|
||
res, perr := s.Transport.Publish(ctx, domain.PublishRequest{
|
||
AccessToken: token,
|
||
AccountID: st.AccountID,
|
||
Text: st.Text,
|
||
ReplyTo: replyTo,
|
||
ImageURLs: st.ImageURLs,
|
||
// 僅主貼帶話題標籤
|
||
TopicTag: func() string {
|
||
if st.Kind == domain.StepRoot {
|
||
return st.TopicTag
|
||
}
|
||
return ""
|
||
}(),
|
||
})
|
||
if perr != nil {
|
||
st.Status = domain.StepFailed
|
||
st.Error = perr.Error()
|
||
// OB-04: block replies if root failed
|
||
if st.Kind == domain.StepRoot {
|
||
for j := range b.Steps {
|
||
if b.Steps[j].Kind == domain.StepReply && b.Steps[j].Status != domain.StepPublished {
|
||
b.Steps[j].Status = domain.StepBlocked
|
||
b.Steps[j].Error = "waiting for root success"
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
st.Status = domain.StepPublished
|
||
st.Error = ""
|
||
st.PublishedAt = domain.NowNano()
|
||
if res != nil {
|
||
st.MediaID = res.MediaID
|
||
}
|
||
// Meta 已抓完圖:刪除 temp/* 暫存,outbox 不再保留 image_urls
|
||
if len(st.ImageURLs) > 0 {
|
||
s.cleanupEphemeralImages(ctx, st.ImageURLs)
|
||
st.ImageURLs = nil
|
||
}
|
||
count++
|
||
// after root success, unblock replies that were blocked only for root wait
|
||
if st.Kind == domain.StepRoot {
|
||
for j := range b.Steps {
|
||
if b.Steps[j].Kind == domain.StepReply && b.Steps[j].Status == domain.StepBlocked {
|
||
b.Steps[j].Status = domain.StepScheduled
|
||
b.Steps[j].Error = ""
|
||
}
|
||
}
|
||
}
|
||
}
|
||
b.Status = recomputeBundle(b.Steps)
|
||
b.UpdatedAt = domain.NowNano()
|
||
_ = s.Repo.SaveOutbox(ctx, b)
|
||
|
||
// re-read root flags after mutation
|
||
root = findRoot(b)
|
||
rootFailed = root != nil && root.Status == domain.StepFailed
|
||
rootPublished = root != nil && root.Status == domain.StepPublished
|
||
}
|
||
return count, nil
|
||
}
|
||
|
||
// ---------- Compose (CP) ----------
|
||
|
||
func (s *Service) Mimic(ctx context.Context, ownerUID int64, sourceText, personaID, structureNotes string) (string, error) {
|
||
sourceText = strings.TrimSpace(sourceText)
|
||
if sourceText == "" {
|
||
return "", fmt.Errorf("%w: empty source", domain.ErrValidation)
|
||
}
|
||
if err := s.billAI(ctx, ownerUID, "compose mimic", "compose.mimic"); err != nil {
|
||
return "", err
|
||
}
|
||
p := s.loadPersonaForGen(ctx, ownerUID, personaID)
|
||
fp := personaFingerprintBlock(p)
|
||
// 指紋過長會拖慢 reasoning 模型
|
||
if utf8.RuneCountInString(fp) > 800 {
|
||
fp = string([]rune(fp)[:800]) + "…"
|
||
}
|
||
notes := strings.TrimSpace(structureNotes)
|
||
// OpenCode reasoning 模型對長「結構分析」極慢;只留骨架提示
|
||
if utf8.RuneCountInString(notes) > 200 {
|
||
notes = string([]rune(notes)[:200]) + "…"
|
||
}
|
||
srcRunes := utf8.RuneCountInString(sourceText)
|
||
minLen := srcRunes * 9 / 10
|
||
if minLen < 80 {
|
||
minLen = 80
|
||
}
|
||
if minLen > 220 {
|
||
minLen = 220
|
||
}
|
||
maxLen := srcRunes + 30
|
||
if maxLen < 140 {
|
||
maxLen = 140
|
||
}
|
||
if maxLen > 320 {
|
||
maxLen = 320
|
||
}
|
||
if maxLen < minLen {
|
||
maxLen = minLen + 20
|
||
}
|
||
|
||
notesBlock := ""
|
||
if notes != "" {
|
||
notesBlock = fmt.Sprintf("\n\n(節奏提示,勿照抄)\n%s\n", notes)
|
||
}
|
||
|
||
// 精簡 prompt:單次 LLM 完成(不再二次擴寫,避免 2× 逾時)
|
||
prompt := strings.TrimSpace(fmt.Sprintf(`
|
||
你就是本人在打 Threads,不是助手。
|
||
|
||
用【說話方式】重寫【參考】的意思與節奏:口語、像滑手機邊打;勿照抄原句;勿列點;勿「首先/總結」;勿 markdown。
|
||
約 %d~%d 字、2~4 段。有問句就留好回的小問題。只輸出正文。
|
||
|
||
【說話方式】
|
||
%s
|
||
|
||
【參考】
|
||
%s
|
||
%s
|
||
`, minLen, maxLen, fp, sourceText, notesBlock))
|
||
|
||
provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID)
|
||
if kerr != nil || strings.TrimSpace(apiKey) == "" || isSyntheticAIKey(apiKey) {
|
||
if s.AI != nil && (apiKey == "test-key" || s.Keys == nil) {
|
||
if out, e := s.AI.Complete(ctx, "test-key", "grok-3", prompt); e == nil {
|
||
if t := cleanGeneratedTextMax(out, 900); t != "" {
|
||
return t, nil
|
||
}
|
||
}
|
||
}
|
||
return "", fmt.Errorf("%w: 無法仿寫:請到設定填寫真實 AI Key 後再試", domain.ErrValidation)
|
||
}
|
||
|
||
// HTTP 同步路徑:沒有外層 deadline 時限 95s(低於 gateway 120s)
|
||
// Job worker 會帶 4 分鐘 deadline,這裡不覆蓋
|
||
llmCtx := ctx
|
||
cancel := func() {}
|
||
if _, ok := ctx.Deadline(); !ok {
|
||
llmCtx, cancel = context.WithTimeout(ctx, 95*time.Second)
|
||
}
|
||
defer cancel()
|
||
|
||
out, aerr := s.completeLLM(llmCtx, provider, model, apiKey, prompt)
|
||
if aerr != nil {
|
||
msg := aerr.Error()
|
||
if llmCtx.Err() != nil || strings.Contains(msg, "context deadline") || strings.Contains(msg, "Client.Timeout") || strings.Contains(msg, "timeout") {
|
||
return "", fmt.Errorf("%w: AI 回應逾時(%s / %s)。請縮短「結構分析」備註後再試,或換較快的模型", domain.ErrValidation, provider, model)
|
||
}
|
||
return "", fmt.Errorf("%w: AI 仿寫失敗(%s/%s):%s", domain.ErrValidation, provider, model, truncate(msg, 200))
|
||
}
|
||
text := cleanGeneratedTextMax(out, 900)
|
||
if text == "" {
|
||
text = strings.TrimSpace(out)
|
||
}
|
||
if text == "" {
|
||
return "", fmt.Errorf("%w: AI 回傳空白(%s / %s)。請到設定確認模型後再試", domain.ErrValidation, provider, model)
|
||
}
|
||
return text, nil
|
||
}
|
||
|
||
func (s *Service) AnalyzeViral(ctx context.Context, ownerUID int64, text string) (*domain.ViralAnalysis, error) {
|
||
if err := s.billAI(ctx, ownerUID, "compose analyze viral", "compose.analyzeViral"); err != nil {
|
||
return nil, err
|
||
}
|
||
return s.analyzeViralUnbilled(ctx, ownerUID, text)
|
||
}
|
||
|
||
// analyzeViralUnbilled — 真 LLM 結構分析(呼叫端自行 billAI,避免雙重計費)
|
||
func (s *Service) analyzeViralUnbilled(ctx context.Context, ownerUID int64, text string) (*domain.ViralAnalysis, error) {
|
||
text = strings.TrimSpace(text)
|
||
if text == "" {
|
||
return nil, fmt.Errorf("%w: empty text", domain.ErrValidation)
|
||
}
|
||
|
||
// 規則底稿(LLM 失敗時仍有可用輸出,但會標註)
|
||
fallback := ruleViralAnalysis(text)
|
||
|
||
provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID)
|
||
if kerr != nil || strings.TrimSpace(apiKey) == "" || isSyntheticAIKey(apiKey) {
|
||
// 測試:FakeClient 可走下面;正式無 key 回規則 + 提示
|
||
if s.AI != nil {
|
||
if out, e := s.AI.Complete(ctx, "test-key", "grok-3", buildViralAnalysisPrompt(text)); e == nil {
|
||
if va, ok := parseViralAnalysisJSON(out); ok {
|
||
return va, nil
|
||
}
|
||
}
|
||
}
|
||
fallback.Summary = fallback.Summary + "(規則摘要:請到設定填 AI Key 以取得真分析)"
|
||
return fallback, nil
|
||
}
|
||
|
||
raw, aerr := s.completeLLM(ctx, provider, model, apiKey, buildViralAnalysisPrompt(text))
|
||
if aerr != nil {
|
||
fallback.Summary = fallback.Summary + "(AI 失敗,規則底稿:" + truncate(aerr.Error(), 80) + ")"
|
||
return fallback, nil
|
||
}
|
||
if va, ok := parseViralAnalysisJSON(raw); ok {
|
||
return va, nil
|
||
}
|
||
// 非 JSON:仍把 AI 重點塞進 summary
|
||
fallback.Summary = "AI 原文:" + truncate(cleanGeneratedText(raw), 400)
|
||
return fallback, nil
|
||
}
|
||
|
||
func buildViralAnalysisPrompt(text string) string {
|
||
return strings.TrimSpace(fmt.Sprintf(`
|
||
你是 Threads 內容編輯。請分析下面這則貼文的「可複製結構」,服務創作者改寫/學習。
|
||
|
||
規則:
|
||
- 只輸出 JSON(不要 markdown 圍欄、不要前後說明)
|
||
- 繁體中文(台灣用語)
|
||
- 字段都要有實質內容,勿空字串、勿敷衍套話
|
||
|
||
JSON schema:
|
||
{
|
||
"hooks": "開場鉤子怎麼抓注意力(1~3 句)",
|
||
"structure": "段落結構骨架(如:情境→痛點→經驗→提問)",
|
||
"emotion": "情緒節奏(好奇/共感/緊迫等)",
|
||
"summary": "為什麼這則有互動潛力(2~4 句)",
|
||
"cta": "結尾 CTA/互動設計",
|
||
"copyable": "別人可複製的 2~4 個要點(勿抄原文)",
|
||
"risks": "改寫時要注意的風險/禁忌"
|
||
}
|
||
|
||
【貼文】
|
||
%s
|
||
`, text))
|
||
}
|
||
|
||
func parseViralAnalysisJSON(raw string) (*domain.ViralAnalysis, bool) {
|
||
s := strings.TrimSpace(raw)
|
||
s = strings.TrimPrefix(s, "```json")
|
||
s = strings.TrimPrefix(s, "```")
|
||
s = strings.TrimSuffix(s, "```")
|
||
s = strings.TrimSpace(s)
|
||
// 取第一個 { … 最後一個 }
|
||
if i := strings.Index(s, "{"); i >= 0 {
|
||
if j := strings.LastIndex(s, "}"); j > i {
|
||
s = s[i : j+1]
|
||
}
|
||
}
|
||
var parsed struct {
|
||
Hooks string `json:"hooks"`
|
||
Structure string `json:"structure"`
|
||
Emotion string `json:"emotion"`
|
||
Summary string `json:"summary"`
|
||
CTA string `json:"cta"`
|
||
Copyable string `json:"copyable"`
|
||
Risks string `json:"risks"`
|
||
}
|
||
if err := json.Unmarshal([]byte(s), &parsed); err != nil {
|
||
return nil, false
|
||
}
|
||
if strings.TrimSpace(parsed.Hooks) == "" && strings.TrimSpace(parsed.Structure) == "" && strings.TrimSpace(parsed.Summary) == "" {
|
||
return nil, false
|
||
}
|
||
return &domain.ViralAnalysis{
|
||
Hooks: strings.TrimSpace(parsed.Hooks), Structure: strings.TrimSpace(parsed.Structure),
|
||
Emotion: strings.TrimSpace(parsed.Emotion), Summary: strings.TrimSpace(parsed.Summary),
|
||
CTA: strings.TrimSpace(parsed.CTA), Copyable: strings.TrimSpace(parsed.Copyable),
|
||
Risks: strings.TrimSpace(parsed.Risks),
|
||
}, true
|
||
}
|
||
|
||
func ruleViralAnalysis(text string) *domain.ViralAnalysis {
|
||
hasQ := strings.ContainsAny(text, "??")
|
||
hasPain := strings.ContainsAny(text, "卡煩雷痛難") || strings.Contains(text, "怎麼") || strings.Contains(text, "不會")
|
||
hooks := "開場用生活情境,降低防備"
|
||
if hasQ {
|
||
hooks = "用真心疑問收尾/開場,降低回覆門檻"
|
||
} else if hasPain {
|
||
hooks = "先丟具體痛點,讀者有代入感"
|
||
}
|
||
emotion := "好奇/認同"
|
||
if hasPain {
|
||
emotion = "共感 + 一點焦慮釋放"
|
||
}
|
||
sum := "情境清楚"
|
||
if hasPain {
|
||
sum = "痛點具體"
|
||
}
|
||
if hasQ {
|
||
sum += " + 好回的問題"
|
||
}
|
||
return &domain.ViralAnalysis{
|
||
Hooks: hooks,
|
||
Structure: "情境/痛點 → 自己經驗一句 → 邀請補充(或輕 CTA)",
|
||
Emotion: emotion,
|
||
Summary: "這則有互動潛力:" + sum + "。改寫時保留結構,換成你的經驗與語氣。",
|
||
CTA: "軟性提問或請讀者補一句經驗",
|
||
Copyable: "短段落、一個明確條件、結尾問句;勿整段抄原文",
|
||
Risks: "勿保證效果、勿硬廣、勿嘲諷讀者",
|
||
}
|
||
}
|
||
|
||
func formatViralFormulaDetail(va *domain.ViralAnalysis) string {
|
||
if va == nil {
|
||
return ""
|
||
}
|
||
var b strings.Builder
|
||
if va.Summary != "" {
|
||
b.WriteString("【為什麼可能爆】")
|
||
b.WriteString(va.Summary)
|
||
b.WriteString("\n")
|
||
}
|
||
if va.Hooks != "" {
|
||
b.WriteString("【鉤子】")
|
||
b.WriteString(va.Hooks)
|
||
b.WriteString("\n")
|
||
}
|
||
if va.Structure != "" {
|
||
b.WriteString("【結構】")
|
||
b.WriteString(va.Structure)
|
||
b.WriteString("\n")
|
||
}
|
||
if va.Emotion != "" {
|
||
b.WriteString("【情緒】")
|
||
b.WriteString(va.Emotion)
|
||
b.WriteString("\n")
|
||
}
|
||
if va.Copyable != "" {
|
||
b.WriteString("【可複製】")
|
||
b.WriteString(va.Copyable)
|
||
b.WriteString("\n")
|
||
}
|
||
if va.CTA != "" {
|
||
b.WriteString("【CTA】")
|
||
b.WriteString(va.CTA)
|
||
b.WriteString("\n")
|
||
}
|
||
if va.Risks != "" {
|
||
b.WriteString("【風險】")
|
||
b.WriteString(va.Risks)
|
||
}
|
||
return strings.TrimSpace(b.String())
|
||
}
|
||
|
||
func (s *Service) PublishSingle(ctx context.Context, ownerUID int64, accountID, text, title string, imageURLs []string, scheduleStartAt int64, topicTag string) (*domain.OutboxBundle, error) {
|
||
text = strings.TrimSpace(text)
|
||
if text == "" {
|
||
return nil, fmt.Errorf("%w: empty text", domain.ErrValidation)
|
||
}
|
||
if accountID == "" {
|
||
return nil, domain.ErrNoAccount
|
||
}
|
||
if s.Accounts != nil {
|
||
acc, err := s.Accounts.Get(ctx, accountID)
|
||
if err != nil || acc == nil || acc.OwnerUID != ownerUID || !acc.IsUsable {
|
||
return nil, domain.ErrNoAccount
|
||
}
|
||
}
|
||
now := domain.NowNano()
|
||
start := scheduleStartAt
|
||
if start <= 0 {
|
||
start = now
|
||
} else if start < now {
|
||
// datetime-local 只有分鐘精度 + 時鐘誤差:超過 1 分鐘才擋
|
||
const schedulePastGrace = int64(time.Minute)
|
||
if now-start > schedulePastGrace {
|
||
return nil, fmt.Errorf("%w: schedule_start_at is in the past", domain.ErrValidation)
|
||
}
|
||
start = now
|
||
}
|
||
// 圖必須是 Meta 可抓的 http(s);data URL 請前端先 /media/upload
|
||
cleanImgs := make([]string, 0, len(imageURLs))
|
||
for _, u := range imageURLs {
|
||
u = strings.TrimSpace(u)
|
||
if u == "" {
|
||
continue
|
||
}
|
||
if strings.HasPrefix(u, "data:") {
|
||
return nil, fmt.Errorf("%w: image_urls must be public https URLs (upload first)", domain.ErrValidation)
|
||
}
|
||
if strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "http://") {
|
||
cleanImgs = append(cleanImgs, u)
|
||
}
|
||
}
|
||
tag := normalizePublishTopicTag(topicTag)
|
||
play := &domain.Play{
|
||
ID: "play_" + uuid.NewString()[:12],
|
||
OwnerUID: ownerUID,
|
||
Title: title,
|
||
Topic: truncate(text, 80),
|
||
Status: domain.PlayScheduling,
|
||
LeadAccountID: accountID,
|
||
Steps: []domain.PlayStep{{
|
||
ID: uuid.NewString()[:8], SortOrder: 0, Kind: domain.StepRoot,
|
||
AccountID: accountID, Text: text, ImageURLs: cleanImgs,
|
||
}},
|
||
ScheduleStartAt: start,
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
}
|
||
if play.Title == "" {
|
||
play.Title = truncate(text, 24)
|
||
}
|
||
if err := s.Repo.SavePlay(ctx, play); err != nil {
|
||
return nil, err
|
||
}
|
||
bundle := playToOutbox(ownerUID, play)
|
||
// 主貼帶 topic_tag
|
||
if tag != "" && len(bundle.Steps) > 0 {
|
||
bundle.Steps[0].TopicTag = tag
|
||
}
|
||
if err := s.Repo.SaveOutbox(ctx, bundle); err != nil {
|
||
return nil, err
|
||
}
|
||
return bundle, nil
|
||
}
|
||
|
||
// QueueExternalReply persists an immediate reply to an external Threads media ID.
|
||
func (s *Service) QueueExternalReply(ctx context.Context, ownerUID int64, accountID, replyToMediaID, text, title string) (*domain.OutboxBundle, error) {
|
||
replyToMediaID = strings.TrimSpace(replyToMediaID)
|
||
if replyToMediaID == "" {
|
||
return nil, fmt.Errorf("%w: reply_to_media_id must be numeric", domain.ErrValidation)
|
||
}
|
||
for i := 0; i < len(replyToMediaID); i++ {
|
||
if replyToMediaID[i] < '0' || replyToMediaID[i] > '9' {
|
||
return nil, fmt.Errorf("%w: reply_to_media_id must be numeric", domain.ErrValidation)
|
||
}
|
||
}
|
||
if accountID == "" {
|
||
return nil, domain.ErrNoAccount
|
||
}
|
||
if s.Accounts != nil {
|
||
acc, err := s.Accounts.Get(ctx, accountID)
|
||
if err != nil || acc == nil || acc.OwnerUID != ownerUID || !acc.IsUsable {
|
||
return nil, domain.ErrNoAccount
|
||
}
|
||
}
|
||
|
||
now := domain.NowNano()
|
||
bundle := &domain.OutboxBundle{
|
||
ID: "outbox_" + uuid.NewString()[:12], OwnerUID: ownerUID, Title: title,
|
||
Status: domain.OBScheduling, ReplyToMediaID: replyToMediaID, CreatedAt: now, UpdatedAt: now,
|
||
Steps: []domain.OutboxStep{{
|
||
ID: "obstep_" + uuid.NewString()[:10], SortOrder: 0, Kind: domain.StepReply,
|
||
AccountID: accountID, Text: text, Status: domain.StepScheduled,
|
||
ScheduledAt: now, ReplyTo: replyToMediaID,
|
||
}},
|
||
}
|
||
if err := s.Repo.SaveOutbox(ctx, bundle); err != nil {
|
||
return nil, err
|
||
}
|
||
return bundle, nil
|
||
}
|
||
|
||
func normalizePublishTopicTag(raw string) string {
|
||
s := strings.TrimSpace(raw)
|
||
s = strings.TrimPrefix(s, "#")
|
||
s = strings.TrimSpace(s)
|
||
s = strings.ReplaceAll(s, ".", "")
|
||
s = strings.ReplaceAll(s, "&", "")
|
||
s = strings.TrimSpace(s)
|
||
if s == "" {
|
||
return ""
|
||
}
|
||
r := []rune(s)
|
||
if len(r) > 50 {
|
||
s = string(r[:50])
|
||
}
|
||
return s
|
||
}
|
||
|
||
// ---------- OwnPosts (OP) ----------
|
||
|
||
func (s *Service) ListOwnPosts(ctx context.Context, ownerUID int64, accountID string) ([]*domain.OwnPost, error) {
|
||
return s.Repo.ListOwnPosts(ctx, ownerUID, accountID)
|
||
}
|
||
|
||
func (s *Service) LastSyncedAt(ctx context.Context, ownerUID int64) (int64, error) {
|
||
m, err := s.Repo.GetSyncMeta(ctx, ownerUID)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return m.LastSyncedAt, nil
|
||
}
|
||
|
||
func (s *Service) SyncOwnPosts(ctx context.Context, ownerUID int64, accountID string) ([]*domain.OwnPost, error) {
|
||
if accountID == "" {
|
||
return nil, domain.ErrNoAccount
|
||
}
|
||
var acc *threadsDomain.Account
|
||
if s.Accounts != nil {
|
||
a, err := s.Accounts.Get(ctx, accountID)
|
||
if err != nil || a == nil {
|
||
return nil, domain.ErrNotFound
|
||
}
|
||
if a.OwnerUID != ownerUID {
|
||
return nil, domain.ErrForbidden
|
||
}
|
||
if !a.IsUsable {
|
||
return nil, domain.ErrNoAccount
|
||
}
|
||
acc = a
|
||
}
|
||
now := domain.NowNano()
|
||
|
||
// 真 Threads Graph 同步
|
||
if s.Media != nil && s.Accounts != nil && acc != nil {
|
||
token, terr := s.Accounts.AccessToken(ctx, acc)
|
||
if terr == nil && strings.TrimSpace(token) != "" && !strings.HasPrefix(token, "fake-") {
|
||
if err := s.syncOwnPostsFromThreads(ctx, ownerUID, accountID, token); err != nil {
|
||
return nil, err
|
||
}
|
||
_ = s.Repo.SetSyncMeta(ctx, &domain.SyncMeta{OwnerUID: ownerUID, LastSyncedAt: now})
|
||
return s.Repo.ListOwnPosts(ctx, ownerUID, accountID)
|
||
}
|
||
}
|
||
|
||
// 無 Media/假 token:保留本地假 seed(單元測試)
|
||
existing, _ := s.Repo.ListOwnPosts(ctx, ownerUID, accountID)
|
||
if len(existing) == 0 {
|
||
for i := 0; i < 2; i++ {
|
||
p := &domain.OwnPost{
|
||
ID: "op_" + uuid.NewString()[:10], OwnerUID: ownerUID, AccountID: accountID,
|
||
MediaID: "media_sync_" + uuid.NewString()[:6], Text: fmt.Sprintf("Synced post #%d from Threads", i+1),
|
||
MediaType: "TEXT_POST", LikeCount: 3 + i, ReplyCount: 1, ViewCount: 100 + i*20,
|
||
InsightsStatus: "ok",
|
||
Replies: []domain.OwnPostReply{{
|
||
ID: "or_" + uuid.NewString()[:6], Username: "fan_" + fmt.Sprint(i),
|
||
Text: "nice!", CreatedAt: now - int64(i)*time.Hour.Nanoseconds(), ReplyStatus: "pending",
|
||
}},
|
||
PublishedAt: now - int64(i+1)*time.Hour.Nanoseconds(),
|
||
}
|
||
_ = s.Repo.SaveOwnPost(ctx, p)
|
||
}
|
||
} else {
|
||
for _, p := range existing {
|
||
p.ViewCount += 5
|
||
p.LikeCount++
|
||
p.InsightsStatus = "ok"
|
||
_ = s.Repo.SaveOwnPost(ctx, p)
|
||
}
|
||
}
|
||
_ = s.Repo.SetSyncMeta(ctx, &domain.SyncMeta{OwnerUID: ownerUID, LastSyncedAt: now})
|
||
return s.Repo.ListOwnPosts(ctx, ownerUID, accountID)
|
||
}
|
||
|
||
// syncOwnPostsFromThreads — 拉 me/threads + insights + conversation,以 media_id upsert。
|
||
func (s *Service) syncOwnPostsFromThreads(ctx context.Context, ownerUID int64, accountID, accessToken string) error {
|
||
threads, err := s.Media.ListThreads(ctx, accessToken, 25)
|
||
if err != nil {
|
||
return fmt.Errorf("threads list: %w", err)
|
||
}
|
||
// 既有本地貼文:保留分析公式欄位
|
||
existing, _ := s.Repo.ListOwnPosts(ctx, ownerUID, accountID)
|
||
byMedia := map[string]*domain.OwnPost{}
|
||
for _, p := range existing {
|
||
if p.MediaID != "" {
|
||
byMedia[p.MediaID] = p
|
||
}
|
||
// 清掉早期 mock seed(Synced post #…)
|
||
if strings.HasPrefix(p.MediaID, "media_sync_") || strings.HasPrefix(p.Text, "Synced post #") {
|
||
_ = s.Repo.DeleteOwnPost(ctx, p.ID)
|
||
}
|
||
}
|
||
|
||
for _, th := range threads {
|
||
if th.ID == "" {
|
||
continue
|
||
}
|
||
// 同步只拉貼文 + 成效;留言改點開再載(避免 N 則 × conversation 打爆 API)
|
||
ins, _ := s.Media.GetInsights(ctx, accessToken, th.ID)
|
||
|
||
prev := byMedia[th.ID]
|
||
id := "op_" + th.ID
|
||
if prev != nil && prev.ID != "" {
|
||
id = prev.ID
|
||
}
|
||
pubAt := th.PublishedAt
|
||
if pubAt <= 0 {
|
||
pubAt = domain.NowNano()
|
||
}
|
||
// 保留先前已載入的留言(點開過的不因 re-sync 清掉)
|
||
var replies []domain.OwnPostReply
|
||
if prev != nil && len(prev.Replies) > 0 {
|
||
replies = append([]domain.OwnPostReply(nil), prev.Replies...)
|
||
}
|
||
p := &domain.OwnPost{
|
||
ID: id, OwnerUID: ownerUID, AccountID: accountID,
|
||
MediaID: th.ID, Text: th.Text, MediaType: th.MediaType,
|
||
MediaURL: th.MediaURL, ThumbnailURL: th.ThumbnailURL,
|
||
Permalink: th.Permalink, Shortcode: th.Shortcode, TopicTag: th.TopicTag,
|
||
LikeCount: ins.Likes, ReplyCount: ins.Replies, RepostCount: ins.Reposts,
|
||
QuoteCount: ins.Quotes, ViewCount: ins.Views, ShareCount: ins.Shares,
|
||
InsightsStatus: ins.Status,
|
||
Replies: replies,
|
||
PublishedAt: pubAt,
|
||
}
|
||
if p.MediaType == "" {
|
||
p.MediaType = "TEXT_POST"
|
||
}
|
||
// 保留本地分析結果
|
||
if prev != nil {
|
||
p.FormulaSummary = prev.FormulaSummary
|
||
p.Insight = prev.Insight
|
||
p.FormulaDetail = prev.FormulaDetail
|
||
}
|
||
if ins.Status == "error" && ins.ErrorMsg != "" && p.Insight == "" {
|
||
// 不寫 error 進 insight 以免覆蓋分析;僅 insights_status
|
||
}
|
||
if err := s.Repo.SaveOwnPost(ctx, p); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// LoadOwnPostReplies — 點開貼文時才拉留言(/{media}/replies 或 conversation)
|
||
func (s *Service) LoadOwnPostReplies(ctx context.Context, ownerUID int64, postID string) (*domain.OwnPost, error) {
|
||
post, err := s.getOwnPostOwned(ctx, ownerUID, postID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if post.MediaID == "" {
|
||
return post, nil
|
||
}
|
||
if s.Media == nil || s.Accounts == nil {
|
||
return post, nil
|
||
}
|
||
acc, err := s.Accounts.Get(ctx, post.AccountID)
|
||
if err != nil || acc == nil || acc.OwnerUID != ownerUID {
|
||
return nil, domain.ErrNoAccount
|
||
}
|
||
token, terr := s.Accounts.AccessToken(ctx, acc)
|
||
if terr != nil || strings.TrimSpace(token) == "" || strings.HasPrefix(token, "fake-") {
|
||
return post, nil
|
||
}
|
||
convo, cerr := s.Media.ListConversation(ctx, token, post.MediaID, 50)
|
||
if cerr != nil {
|
||
return nil, fmt.Errorf("load replies: %w", cerr)
|
||
}
|
||
replies := mapFetchedRepliesWithRoot(convo, post, post.MediaID)
|
||
post.Replies = replies
|
||
// 若 insights 沒給 replies 數,用實際筆數補
|
||
if post.ReplyCount < len(replies) {
|
||
post.ReplyCount = len(replies)
|
||
}
|
||
if err := s.Repo.SaveOwnPost(ctx, post); err != nil {
|
||
return nil, err
|
||
}
|
||
return post, nil
|
||
}
|
||
|
||
func mapFetchedReplies(convo []FetchedReply, prev *domain.OwnPost) []domain.OwnPostReply {
|
||
root := ""
|
||
if prev != nil {
|
||
root = prev.MediaID
|
||
}
|
||
return mapFetchedRepliesWithRoot(convo, prev, root)
|
||
}
|
||
|
||
func mapFetchedRepliesWithRoot(convo []FetchedReply, prev *domain.OwnPost, rootMediaID string) []domain.OwnPostReply {
|
||
out := make([]domain.OwnPostReply, 0, len(convo))
|
||
localStatus := map[string]domain.OwnPostReply{}
|
||
if prev != nil {
|
||
for _, r := range prev.Replies {
|
||
localStatus[r.ID] = r
|
||
}
|
||
}
|
||
for _, c := range convo {
|
||
id := c.ID
|
||
if id == "" {
|
||
continue
|
||
}
|
||
created := c.PublishedAt
|
||
if created <= 0 {
|
||
created = domain.NowNano()
|
||
}
|
||
// parent = 根貼 media id → 第一層(parent 空)
|
||
parent := c.ParentMediaID
|
||
if parent == rootMediaID || parent == "" {
|
||
parent = ""
|
||
}
|
||
// 預設 pending;載完後用對話樹標記(不靠 LLM)
|
||
status := "pending"
|
||
repliedBy := ""
|
||
var repliedAt int64
|
||
if old, ok := localStatus[id]; ok && old.ReplyStatus == "replied" {
|
||
// 本機曾送出回覆的標記先保留,稍後仍用樹再算一次
|
||
status = old.ReplyStatus
|
||
repliedBy = old.RepliedBy
|
||
repliedAt = old.RepliedAt
|
||
}
|
||
out = append(out, domain.OwnPostReply{
|
||
ID: id, Username: c.Username, Text: c.Text, CreatedAt: created,
|
||
LikeCount: c.LikeCount, ReplyStatus: status, RepliedBy: repliedBy, RepliedAt: repliedAt,
|
||
ParentReplyID: parent, IsMine: c.IsMine,
|
||
})
|
||
}
|
||
// 純資料:若某則留言底下有「我的」子回覆 → 標已回覆
|
||
return markRepliedFromTree(out)
|
||
}
|
||
|
||
// markRepliedFromTree — 不靠 LLM:對話樹裡 parent=該留言 id 且 is_mine → replied
|
||
func markRepliedFromTree(replies []domain.OwnPostReply) []domain.OwnPostReply {
|
||
// parentID → 是否有我的子回覆
|
||
mineUnder := map[string]domain.OwnPostReply{}
|
||
for _, r := range replies {
|
||
if !r.IsMine {
|
||
continue
|
||
}
|
||
pid := strings.TrimSpace(r.ParentReplyID)
|
||
if pid == "" {
|
||
continue
|
||
}
|
||
// 保留時間最新的一則當 replied_by 參考
|
||
if old, ok := mineUnder[pid]; !ok || r.CreatedAt >= old.CreatedAt {
|
||
mineUnder[pid] = r
|
||
}
|
||
}
|
||
for i := range replies {
|
||
if replies[i].IsMine {
|
||
// 自己的留言不進「待回」列表
|
||
replies[i].ReplyStatus = "replied"
|
||
continue
|
||
}
|
||
if mine, ok := mineUnder[replies[i].ID]; ok {
|
||
replies[i].ReplyStatus = "replied"
|
||
if replies[i].RepliedBy == "" {
|
||
replies[i].RepliedBy = mine.Username
|
||
}
|
||
if replies[i].RepliedAt == 0 {
|
||
replies[i].RepliedAt = mine.CreatedAt
|
||
}
|
||
} else {
|
||
replies[i].ReplyStatus = "pending"
|
||
replies[i].RepliedBy = ""
|
||
replies[i].RepliedAt = 0
|
||
}
|
||
}
|
||
return replies
|
||
}
|
||
|
||
func mergeReplyStatus(prev, next []domain.OwnPostReply) []domain.OwnPostReply {
|
||
if len(prev) == 0 {
|
||
return next
|
||
}
|
||
byID := map[string]domain.OwnPostReply{}
|
||
for _, r := range prev {
|
||
byID[r.ID] = r
|
||
}
|
||
for i := range next {
|
||
if old, ok := byID[next[i].ID]; ok && old.ReplyStatus == "replied" {
|
||
next[i].ReplyStatus = "replied"
|
||
next[i].RepliedBy = old.RepliedBy
|
||
next[i].RepliedAt = old.RepliedAt
|
||
}
|
||
}
|
||
return next
|
||
}
|
||
|
||
func (s *Service) GenerateReply(ctx context.Context, ownerUID int64, postID, replyID, personaID string) (string, error) {
|
||
post, err := s.getOwnPostOwned(ctx, ownerUID, postID)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if err := s.billAI(ctx, ownerUID, "own post reply draft", "ownPosts.generateReply"); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
// 貼文 + 要回的留言 + 人設指紋
|
||
postText := strings.TrimSpace(post.Text)
|
||
commentText := ""
|
||
commentUser := ""
|
||
if replyID != "" {
|
||
for _, r := range post.Replies {
|
||
if r.ID == replyID {
|
||
commentText = strings.TrimSpace(r.Text)
|
||
commentUser = strings.TrimSpace(r.Username)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
persona := s.loadPersonaForGen(ctx, ownerUID, personaID)
|
||
fp := personaFingerprintBlock(persona)
|
||
|
||
prompt := buildOwnPostReplyPrompt(fp, postText, commentUser, commentText)
|
||
|
||
provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID)
|
||
if kerr == nil && strings.TrimSpace(apiKey) != "" && !isSyntheticAIKey(apiKey) {
|
||
if out, aerr := s.completeLLM(ctx, provider, model, apiKey, prompt); aerr == nil {
|
||
if t := cleanGeneratedText(out); t != "" {
|
||
return t, nil
|
||
}
|
||
}
|
||
}
|
||
// 測試路徑:FakeClient
|
||
if s.AI != nil {
|
||
if out, e := s.AI.Complete(ctx, "test-key", "grok-3", prompt); e == nil && out != "" {
|
||
return cleanGeneratedText(out), nil
|
||
}
|
||
}
|
||
return "", fmt.Errorf("%w: 無法產回覆草稿,請到設定填寫 AI Key 後再試", domain.ErrValidation)
|
||
}
|
||
|
||
func (s *Service) SendReply(ctx context.Context, ownerUID int64, postID, replyID, text, accountID string, imageURLs []string) (*domain.OwnPost, error) {
|
||
post, err := s.getOwnPostOwned(ctx, ownerUID, postID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
text = strings.TrimSpace(text)
|
||
if text == "" {
|
||
return nil, fmt.Errorf("%w: empty reply", domain.ErrValidation)
|
||
}
|
||
accID := accountID
|
||
if accID == "" {
|
||
accID = post.AccountID
|
||
}
|
||
username := "me"
|
||
token := "fake-token"
|
||
if s.Accounts != nil {
|
||
acc, aerr := s.Accounts.Get(ctx, accID)
|
||
if aerr != nil || acc == nil || acc.OwnerUID != ownerUID || !acc.IsUsable {
|
||
return nil, domain.ErrNoAccount
|
||
}
|
||
username = acc.Username
|
||
if t, terr := s.Accounts.AccessToken(ctx, acc); terr == nil && t != "" {
|
||
token = t
|
||
}
|
||
}
|
||
if s.Transport == nil {
|
||
return nil, fmt.Errorf("%w: publish transport not configured", domain.ErrValidation)
|
||
}
|
||
// reply_to_id:回某則留言用留言 media id;回主貼用主貼 media id
|
||
replyTo := strings.TrimSpace(post.MediaID)
|
||
if strings.TrimSpace(replyID) != "" {
|
||
replyTo = strings.TrimSpace(replyID)
|
||
}
|
||
if replyTo == "" {
|
||
return nil, fmt.Errorf("%w: 貼文缺少 Threads media_id,請先重新同步後再回覆", domain.ErrValidation)
|
||
}
|
||
if strings.HasPrefix(token, "fake-") {
|
||
return nil, fmt.Errorf("%w: 帳號 token 無效,請重新連 Threads", domain.ErrValidation)
|
||
}
|
||
res, perr := s.Transport.Publish(ctx, domain.PublishRequest{
|
||
AccessToken: token, AccountID: accID, Text: text, ReplyTo: replyTo,
|
||
})
|
||
if perr != nil {
|
||
// OP-05: do not mark local success;回傳可讀錯誤(Biz 400)
|
||
return nil, fmt.Errorf("%w: %s", domain.ErrValidation, perr.Error())
|
||
}
|
||
now := domain.NowNano()
|
||
if replyID != "" {
|
||
for i := range post.Replies {
|
||
if post.Replies[i].ID == replyID {
|
||
post.Replies[i].ReplyStatus = "replied"
|
||
post.Replies[i].RepliedBy = username
|
||
post.Replies[i].RepliedAt = now
|
||
}
|
||
}
|
||
}
|
||
// 本地掛上我的回覆:parent = 被回留言 id(空=掛在主貼下第一層)
|
||
parent := strings.TrimSpace(replyID)
|
||
newID := "or_" + uuid.NewString()[:8]
|
||
if res != nil && res.MediaID != "" {
|
||
newID = res.MediaID
|
||
}
|
||
post.Replies = append(post.Replies, domain.OwnPostReply{
|
||
ID: newID, Username: username, Text: text,
|
||
CreatedAt: now, ReplyStatus: "replied", IsMine: true, ParentReplyID: parent,
|
||
})
|
||
// 重新標記整樹已回覆狀態
|
||
post.Replies = markRepliedFromTree(post.Replies)
|
||
if post.ReplyCount < len(post.Replies) {
|
||
post.ReplyCount = len(post.Replies)
|
||
}
|
||
_ = imageURLs
|
||
if err := s.Repo.SaveOwnPost(ctx, post); err != nil {
|
||
return nil, err
|
||
}
|
||
return post, nil
|
||
}
|
||
|
||
func (s *Service) AnalyzePost(ctx context.Context, ownerUID int64, postID string) (*domain.OwnPost, error) {
|
||
post, err := s.getOwnPostOwned(ctx, ownerUID, postID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if strings.TrimSpace(post.Text) == "" {
|
||
return nil, fmt.Errorf("%w: 貼文沒有文字可分析(圖片/純媒體貼可改貼上說明再分析)", domain.ErrValidation)
|
||
}
|
||
if err := s.billAI(ctx, ownerUID, "own post structure analyze", "ownPosts.analyze"); err != nil {
|
||
return nil, err
|
||
}
|
||
va, err := s.analyzeViralUnbilled(ctx, ownerUID, post.Text)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
post.FormulaSummary = va.Hooks
|
||
if post.FormulaSummary == "" {
|
||
post.FormulaSummary = va.Structure
|
||
}
|
||
post.Insight = va.Summary
|
||
post.FormulaDetail = formatViralFormulaDetail(va)
|
||
if err := s.Repo.SaveOwnPost(ctx, post); err != nil {
|
||
return nil, err
|
||
}
|
||
return post, nil
|
||
}
|
||
|
||
// GenerateFromFormula is intentionally removed (OP-11).
|
||
func (s *Service) GenerateFromFormula(_ context.Context, _, _ string) error {
|
||
return domain.ErrFormulaRemove
|
||
}
|
||
|
||
// ---------- Mentions ----------
|
||
|
||
func (s *Service) ListMentions(ctx context.Context, ownerUID int64, accountID string) ([]*domain.Mention, error) {
|
||
return s.Repo.ListMentions(ctx, ownerUID, accountID)
|
||
}
|
||
|
||
// SyncMentions — 從 Threads Graph 拉「@我」提及並 upsert 本地 inbox。
|
||
func (s *Service) SyncMentions(ctx context.Context, ownerUID int64, accountID string) ([]*domain.Mention, error) {
|
||
if accountID == "" {
|
||
return nil, domain.ErrNoAccount
|
||
}
|
||
if s.Accounts == nil {
|
||
return nil, domain.ErrNoAccount
|
||
}
|
||
acc, err := s.Accounts.Get(ctx, accountID)
|
||
if err != nil || acc == nil {
|
||
return nil, domain.ErrNotFound
|
||
}
|
||
if acc.OwnerUID != ownerUID {
|
||
return nil, domain.ErrForbidden
|
||
}
|
||
if !acc.IsUsable {
|
||
return nil, domain.ErrNoAccount
|
||
}
|
||
token, terr := s.Accounts.AccessToken(ctx, acc)
|
||
if terr != nil || strings.TrimSpace(token) == "" || strings.HasPrefix(token, "fake-") {
|
||
return nil, fmt.Errorf("%w: 帳號 token 無效,請重新連 Threads(需含 threads_manage_mentions)", domain.ErrValidation)
|
||
}
|
||
if s.Media == nil {
|
||
return nil, fmt.Errorf("%w: Threads Graph 未設定,無法同步提及", domain.ErrValidation)
|
||
}
|
||
|
||
userID := strings.TrimSpace(acc.ThreadsUserID)
|
||
if userID == "" {
|
||
userID = "me"
|
||
}
|
||
hits, err := s.Media.ListMentions(ctx, token, userID, 40)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("mentions sync: %w", err)
|
||
}
|
||
|
||
// 既有:保留 status / draft
|
||
existing, _ := s.Repo.ListMentions(ctx, ownerUID, accountID)
|
||
byMedia := map[string]*domain.Mention{}
|
||
for _, m := range existing {
|
||
if m.MediaID != "" {
|
||
byMedia[m.MediaID] = m
|
||
}
|
||
}
|
||
|
||
now := domain.NowNano()
|
||
for _, hit := range hits {
|
||
mediaID := strings.TrimSpace(hit.ID)
|
||
if mediaID == "" {
|
||
continue
|
||
}
|
||
prev := byMedia[mediaID]
|
||
id := "mn_" + mediaID
|
||
status := domain.MentionPending
|
||
draft := ""
|
||
created := hit.PublishedAt
|
||
if created <= 0 {
|
||
created = now
|
||
}
|
||
if prev != nil {
|
||
id = prev.ID
|
||
if prev.Status != "" {
|
||
status = prev.Status
|
||
}
|
||
draft = prev.DraftText
|
||
if prev.CreatedAt > 0 {
|
||
created = prev.CreatedAt
|
||
}
|
||
}
|
||
ctxSnippet := mentionContextSnippet(hit)
|
||
m := &domain.Mention{
|
||
ID: id, OwnerUID: ownerUID, AccountID: accountID,
|
||
MediaID: mediaID, FromUsername: hit.Username, Text: hit.Text,
|
||
ContextSnippet: ctxSnippet, Permalink: hit.Permalink,
|
||
RootPostID: hit.RootPostID, ParentID: hit.ParentID,
|
||
Status: status, DraftText: draft, CreatedAt: created,
|
||
}
|
||
if m.FromUsername == "" {
|
||
m.FromUsername = "unknown"
|
||
}
|
||
if err := s.Repo.SaveMention(ctx, m); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
return s.Repo.ListMentions(ctx, ownerUID, accountID)
|
||
}
|
||
|
||
func mentionContextSnippet(hit FetchedMention) string {
|
||
kind := "提及你"
|
||
if hit.IsQuotePost {
|
||
kind = "引用你"
|
||
} else if hit.IsReply {
|
||
kind = "回覆中 @ 你"
|
||
}
|
||
text := strings.TrimSpace(hit.Text)
|
||
if len([]rune(text)) > 120 {
|
||
text = string([]rune(text)[:120]) + "…"
|
||
}
|
||
if text == "" {
|
||
return kind
|
||
}
|
||
return kind + " · " + text
|
||
}
|
||
|
||
func (s *Service) GenerateMentionReply(ctx context.Context, ownerUID int64, id, personaID string) (*domain.Mention, error) {
|
||
m, err := s.getMentionOwned(ctx, ownerUID, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.billAI(ctx, ownerUID, "mention reply draft", "mentions.generateReply"); err != nil {
|
||
return nil, err
|
||
}
|
||
persona := s.loadPersonaForGen(ctx, ownerUID, personaID)
|
||
fp := personaFingerprintBlock(persona)
|
||
prompt := buildMentionReplyPrompt(fp, m.FromUsername, m.Text, m.ContextSnippet)
|
||
|
||
draft := ""
|
||
provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID)
|
||
if kerr == nil && strings.TrimSpace(apiKey) != "" && !isSyntheticAIKey(apiKey) {
|
||
if out, aerr := s.completeLLM(ctx, provider, model, apiKey, prompt); aerr == nil {
|
||
draft = cleanGeneratedText(out)
|
||
}
|
||
}
|
||
if draft == "" && s.AI != nil {
|
||
if out, e := s.AI.Complete(ctx, "test-key", "grok-3", prompt); e == nil {
|
||
draft = cleanGeneratedText(out)
|
||
}
|
||
}
|
||
if draft == "" {
|
||
return nil, fmt.Errorf("%w: 無法產回覆草稿,請到設定填寫 AI Key 後再試", domain.ErrValidation)
|
||
}
|
||
m.DraftText = draft
|
||
if err := s.Repo.SaveMention(ctx, m); err != nil {
|
||
return nil, err
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (s *Service) loadPersonaForGen(ctx context.Context, ownerUID int64, personaID string) *domain.Persona {
|
||
if personaID != "" {
|
||
if p, err := s.GetPersona(ctx, ownerUID, personaID); err == nil {
|
||
return p
|
||
}
|
||
}
|
||
if aid, _ := s.GetActivePersonaID(ctx, ownerUID); aid != "" {
|
||
if p, err := s.GetPersona(ctx, ownerUID, aid); err == nil {
|
||
return p
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func personaFingerprintBlock(p *domain.Persona) string {
|
||
if p == nil {
|
||
return "(未選人設,用自然口語、友善、像朋友聊天)"
|
||
}
|
||
fp := strings.TrimSpace(p.Style.DraftText)
|
||
if fp == "" {
|
||
fp = serializeDraftText(p.Style.Draft)
|
||
}
|
||
var b strings.Builder
|
||
if n := strings.TrimSpace(p.Name); n != "" {
|
||
b.WriteString("名稱:")
|
||
b.WriteString(n)
|
||
b.WriteString("\n")
|
||
}
|
||
if br := strings.TrimSpace(p.Brief); br != "" {
|
||
b.WriteString("定位:")
|
||
b.WriteString(br)
|
||
b.WriteString("\n")
|
||
}
|
||
if fp != "" {
|
||
b.WriteString("【語言指紋】\n")
|
||
b.WriteString(fp)
|
||
b.WriteString("\n")
|
||
} else if t := strings.TrimSpace(p.Style.Draft.Tone); t != "" {
|
||
b.WriteString("語氣:")
|
||
b.WriteString(t)
|
||
b.WriteString("\n")
|
||
}
|
||
if len(p.Guard.Avoid) > 0 {
|
||
b.WriteString("【禁止】")
|
||
b.WriteString(strings.Join(p.Guard.Avoid, "、"))
|
||
b.WriteString("\n")
|
||
}
|
||
out := strings.TrimSpace(b.String())
|
||
if out == "" {
|
||
return "(人設資料不足,用自然口語)"
|
||
}
|
||
return out
|
||
}
|
||
|
||
func buildOwnPostReplyPrompt(fp, postText, commentUser, commentText string) string {
|
||
var b strings.Builder
|
||
b.WriteString(`你正在扮演 Threads 帳號本人回覆(不是客服、不是分析師)。
|
||
任務:依人設指紋寫「一則短回覆草稿」。
|
||
|
||
規則(強制):
|
||
- 只輸出回覆正文,不要「回覆:」前綴、不要分析、不要 markdown。
|
||
- 繁體中文(台灣用語)、口語、像真人滑手機回。
|
||
- 嚴格遵守指紋語氣/節奏/禁忌。
|
||
- 長度約 30~180 字,可分段空行。
|
||
- 若有對方留言,要接對方的話;若只回自己主貼下,像補一句或開場邀互動。
|
||
|
||
`)
|
||
b.WriteString("【人設】\n")
|
||
b.WriteString(fp)
|
||
b.WriteString("\n\n【我的主貼】\n")
|
||
b.WriteString(strings.TrimSpace(postText))
|
||
b.WriteString("\n")
|
||
if strings.TrimSpace(commentText) != "" {
|
||
b.WriteString("\n【要回的留言】")
|
||
if commentUser != "" {
|
||
b.WriteString(" @")
|
||
b.WriteString(commentUser)
|
||
}
|
||
b.WriteString("\n")
|
||
b.WriteString(commentText)
|
||
b.WriteString("\n")
|
||
} else {
|
||
b.WriteString("\n(直接在主貼下回覆/補一句,沒有指定某則留言)\n")
|
||
}
|
||
return strings.TrimSpace(b.String())
|
||
}
|
||
|
||
func buildMentionReplyPrompt(fp, fromUser, mentionText, contextSnippet string) string {
|
||
return strings.TrimSpace(fmt.Sprintf(`
|
||
你正在扮演 Threads 帳號本人,回覆別人的「提及/@」。
|
||
任務:依人設指紋寫一則短回覆草稿。
|
||
|
||
規則(強制):
|
||
- 只輸出回覆正文,不要前綴、不要分析。
|
||
- 繁體中文、口語;嚴格遵守指紋。
|
||
- 約 30~160 字。
|
||
|
||
【人設】
|
||
%s
|
||
|
||
【對方 @你】
|
||
@%s:%s
|
||
|
||
【上下文】
|
||
%s
|
||
`, fp, fromUser, mentionText, strings.TrimSpace(contextSnippet)))
|
||
}
|
||
|
||
func (s *Service) MarkMentionReplied(ctx context.Context, ownerUID int64, id, text string, imageURLs []string) (*domain.Mention, error) {
|
||
m, err := s.getMentionOwned(ctx, ownerUID, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
text = strings.TrimSpace(text)
|
||
if text == "" {
|
||
text = strings.TrimSpace(m.DraftText)
|
||
}
|
||
if text == "" {
|
||
return nil, fmt.Errorf("%w: empty text", domain.ErrValidation)
|
||
}
|
||
// 真發文:reply_to = 提及 media_id(對方那則貼/回覆)
|
||
if s.Transport != nil {
|
||
token := "fake"
|
||
accID := m.AccountID
|
||
if s.Accounts != nil {
|
||
acc, aerr := s.Accounts.Get(ctx, accID)
|
||
if aerr != nil || acc == nil || acc.OwnerUID != ownerUID || !acc.IsUsable {
|
||
return nil, domain.ErrNoAccount
|
||
}
|
||
t, terr := s.Accounts.AccessToken(ctx, acc)
|
||
if terr != nil || strings.TrimSpace(t) == "" {
|
||
return nil, fmt.Errorf("%w: 帳號 token 無效,請重新連 Threads", domain.ErrValidation)
|
||
}
|
||
token = t
|
||
}
|
||
replyTo := strings.TrimSpace(m.MediaID)
|
||
if replyTo == "" {
|
||
// 單元測試 seed 無 media_id 時允許 FakeTransport;正式路徑必須有
|
||
if s.Accounts != nil {
|
||
return nil, fmt.Errorf("%w: 提及缺少 media_id,請先重新同步提及", domain.ErrValidation)
|
||
}
|
||
replyTo = "media_test"
|
||
}
|
||
if s.Accounts != nil && (strings.HasPrefix(token, "fake-") || token == "fake") {
|
||
return nil, fmt.Errorf("%w: 帳號 token 無效,請重新連 Threads", domain.ErrValidation)
|
||
}
|
||
_, perr := s.Transport.Publish(ctx, domain.PublishRequest{
|
||
AccessToken: token, AccountID: accID, Text: text, ReplyTo: replyTo,
|
||
})
|
||
if perr != nil {
|
||
return nil, fmt.Errorf("%w: %s", domain.ErrValidation, perr.Error())
|
||
}
|
||
}
|
||
m.Status = domain.MentionReplied
|
||
m.DraftText = text
|
||
_ = imageURLs // 提及回覆不附圖(與我的貼文回覆一致)
|
||
if err := s.Repo.SaveMention(ctx, m); err != nil {
|
||
return nil, err
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (s *Service) SkipMention(ctx context.Context, ownerUID int64, id string) (*domain.Mention, error) {
|
||
m, err := s.getMentionOwned(ctx, ownerUID, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
m.Status = domain.MentionSkipped
|
||
if err := s.Repo.SaveMention(ctx, m); err != nil {
|
||
return nil, err
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
// SeedMention for tests / sync helpers
|
||
func (s *Service) SeedMention(ctx context.Context, m *domain.Mention) error {
|
||
if m.ID == "" {
|
||
m.ID = "mn_" + uuid.NewString()[:10]
|
||
}
|
||
if m.Status == "" {
|
||
m.Status = domain.MentionPending
|
||
}
|
||
if m.CreatedAt == 0 {
|
||
m.CreatedAt = domain.NowNano()
|
||
}
|
||
return s.Repo.SaveMention(ctx, m)
|
||
}
|
||
|
||
// ---------- helpers ----------
|
||
|
||
func (s *Service) billAI(ctx context.Context, ownerUID int64, label, source string) error {
|
||
if s.Usage == nil {
|
||
return nil
|
||
}
|
||
mode, err := s.Usage.PrepareCall(ctx, ownerUID, usageDomain.MeterAICopy)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, err = s.Usage.RecordCall(ctx, ownerUID, usageDomain.MeterAICopy, mode, label, source)
|
||
return err
|
||
}
|
||
|
||
func (s *Service) getOwnPostOwned(ctx context.Context, ownerUID int64, id string) (*domain.OwnPost, error) {
|
||
p, err := s.Repo.GetOwnPost(ctx, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if p.OwnerUID != ownerUID {
|
||
return nil, domain.ErrForbidden
|
||
}
|
||
return p, nil
|
||
}
|
||
|
||
func (s *Service) getMentionOwned(ctx context.Context, ownerUID int64, id string) (*domain.Mention, error) {
|
||
m, err := s.Repo.GetMention(ctx, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if m.OwnerUID != ownerUID {
|
||
return nil, domain.ErrForbidden
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (s *Service) ensureUsableAccounts(ctx context.Context, ownerUID int64, play *domain.Play) error {
|
||
if s.Accounts == nil {
|
||
// test mode without accounts — allow if lead set
|
||
if play.LeadAccountID == "" && !underPost(play) {
|
||
return domain.ErrNoAccount
|
||
}
|
||
return nil
|
||
}
|
||
ids := map[string]struct{}{}
|
||
if play.LeadAccountID != "" {
|
||
ids[play.LeadAccountID] = struct{}{}
|
||
}
|
||
for _, c := range play.CastAccountIDs {
|
||
ids[c] = struct{}{}
|
||
}
|
||
for _, st := range play.Steps {
|
||
ids[st.AccountID] = struct{}{}
|
||
}
|
||
for id := range ids {
|
||
if id == "" {
|
||
continue
|
||
}
|
||
acc, err := s.Accounts.Get(ctx, id)
|
||
if err != nil || acc == nil || acc.OwnerUID != ownerUID || !acc.IsUsable {
|
||
return domain.ErrNoAccount
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func underPost(p *domain.Play) bool {
|
||
return p.TargetOwnPostID != "" || (p.TargetExternal != nil && p.TargetExternal.URL != "")
|
||
}
|
||
|
||
func validatePlay(p *domain.Play) error {
|
||
if underPost(p) {
|
||
if len(p.Steps) == 0 {
|
||
return fmt.Errorf("%w: need replies", domain.ErrValidation)
|
||
}
|
||
speakers := speakerSet(p)
|
||
for _, st := range p.Steps {
|
||
if st.AccountID == "" || !speakers[st.AccountID] {
|
||
return fmt.Errorf("%w: reply account not in speakers", domain.ErrValidation)
|
||
}
|
||
if strings.TrimSpace(st.Text) == "" {
|
||
return fmt.Errorf("%w: empty step", domain.ErrValidation)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
if p.LeadAccountID == "" {
|
||
return fmt.Errorf("%w: need lead", domain.ErrValidation)
|
||
}
|
||
if len(p.Steps) == 0 {
|
||
return fmt.Errorf("%w: need root", domain.ErrValidation)
|
||
}
|
||
root := p.Steps[0]
|
||
for _, st := range p.Steps {
|
||
if st.Kind == domain.StepRoot {
|
||
root = st
|
||
break
|
||
}
|
||
}
|
||
if root.Kind != domain.StepRoot {
|
||
return fmt.Errorf("%w: first must root", domain.ErrValidation)
|
||
}
|
||
if root.AccountID != p.LeadAccountID {
|
||
return fmt.Errorf("%w: root must lead", domain.ErrValidation)
|
||
}
|
||
speakers := speakerSet(p)
|
||
for _, st := range p.Steps {
|
||
if st.Kind == domain.StepReply {
|
||
if !speakers[st.AccountID] {
|
||
return fmt.Errorf("%w: reply account not in speakers", domain.ErrValidation)
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func speakerSet(p *domain.Play) map[string]bool {
|
||
m := map[string]bool{}
|
||
if p.LeadAccountID != "" {
|
||
m[p.LeadAccountID] = true
|
||
}
|
||
for _, c := range p.CastAccountIDs {
|
||
if c != "" {
|
||
m[c] = true
|
||
}
|
||
}
|
||
return m
|
||
}
|
||
|
||
func playToOutbox(ownerUID int64, play *domain.Play) *domain.OutboxBundle {
|
||
now := domain.NowNano()
|
||
// 第一則:送出 Outbox 後立刻可發(忽略過期/過遠的 schedule_start_at 造成「排程中卡住」)。
|
||
// 若明確設了「未來開始時間」且 > now+30s,才把第一則排到那個時間(延後開跑)。
|
||
cursor := now
|
||
if play.ScheduleStartAt > now+int64(30*time.Second) {
|
||
cursor = play.ScheduleStartAt
|
||
}
|
||
steps := make([]domain.OutboxStep, 0, len(play.Steps))
|
||
replyTo := ""
|
||
if play.TargetExternal != nil {
|
||
replyTo = play.TargetExternal.MediaID
|
||
}
|
||
for i, st := range play.Steps {
|
||
if i > 0 {
|
||
// 後續:基礎間隔 + 隨機抖動;0 = 與上一則同一排程點(worker 同 tick 依序發)
|
||
base := st.DelayFromPreviousSec
|
||
if base < 0 {
|
||
base = 0
|
||
}
|
||
if base > 0 {
|
||
cursor += int64(intervalSecWithJitter(base)) * int64(time.Second)
|
||
}
|
||
}
|
||
kind := st.Kind
|
||
if underPost(play) {
|
||
kind = domain.StepReply
|
||
}
|
||
steps = append(steps, domain.OutboxStep{
|
||
ID: "obstep_" + uuid.NewString()[:10], StepID: st.ID, SortOrder: st.SortOrder,
|
||
Kind: kind, AccountID: st.AccountID, Text: st.Text,
|
||
ImageURLs: append([]string(nil), st.ImageURLs...),
|
||
Status: domain.StepScheduled, ScheduledAt: cursor, ReplyTo: replyTo,
|
||
})
|
||
}
|
||
title := play.Title
|
||
if title == "" {
|
||
title = play.Topic
|
||
}
|
||
return &domain.OutboxBundle{
|
||
ID: "outbox_" + uuid.NewString()[:12], OwnerUID: ownerUID, PlayID: play.ID,
|
||
Title: title, Status: domain.OBScheduling, Steps: steps,
|
||
ReplyToMediaID: replyTo, CreatedAt: now, UpdatedAt: now,
|
||
}
|
||
}
|
||
|
||
// intervalSecWithJitter — 在 base 秒上加 ±20% 抖動(至少 ±20s、最多 ±3min),且不低於 15s。
|
||
// 讓互回/串場節奏像真人,不要剛好每 300 秒一則。
|
||
func intervalSecWithJitter(baseSec int) int {
|
||
if baseSec < 0 {
|
||
baseSec = 0
|
||
}
|
||
span := baseSec / 5 // 20%
|
||
if span < 20 {
|
||
span = 20
|
||
}
|
||
if span > 180 {
|
||
span = 180
|
||
}
|
||
// [base-span, base+span]
|
||
delta := 0
|
||
if span > 0 {
|
||
delta = cryptoRandIntn(2*span+1) - span
|
||
}
|
||
out := baseSec + delta
|
||
if out < 15 {
|
||
out = 15
|
||
}
|
||
return out
|
||
}
|
||
|
||
// cryptoRandIntn — [0, n) 均勻;失敗時退回 0(不抖動)
|
||
func cryptoRandIntn(n int) int {
|
||
if n <= 1 {
|
||
return 0
|
||
}
|
||
// 用 crypto/rand 避免 math/rand 可預測
|
||
var b [8]byte
|
||
if _, err := rand.Read(b[:]); err != nil {
|
||
return 0
|
||
}
|
||
// big-endian uint64
|
||
v := uint64(b[0])<<56 | uint64(b[1])<<48 | uint64(b[2])<<40 | uint64(b[3])<<32 |
|
||
uint64(b[4])<<24 | uint64(b[5])<<16 | uint64(b[6])<<8 | uint64(b[7])
|
||
return int(v % uint64(n))
|
||
}
|
||
|
||
func findRoot(b *domain.OutboxBundle) *domain.OutboxStep {
|
||
for i := range b.Steps {
|
||
if b.Steps[i].Kind == domain.StepRoot {
|
||
return &b.Steps[i]
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func recomputeBundle(steps []domain.OutboxStep) string {
|
||
if len(steps) == 0 {
|
||
return domain.OBCancelled
|
||
}
|
||
allPub := true
|
||
anyFail := false
|
||
anyActive := false
|
||
for _, s := range steps {
|
||
switch s.Status {
|
||
case domain.StepPublished:
|
||
case domain.StepFailed:
|
||
anyFail = true
|
||
allPub = false
|
||
case domain.StepPublishing:
|
||
anyActive = true
|
||
allPub = false
|
||
case domain.StepBlocked, domain.StepCancelled:
|
||
allPub = false
|
||
default:
|
||
allPub = false
|
||
if s.Status == domain.StepScheduled {
|
||
// still scheduling
|
||
}
|
||
}
|
||
}
|
||
if allPub {
|
||
return domain.OBCompleted
|
||
}
|
||
if anyFail {
|
||
return domain.OBPartial
|
||
}
|
||
if anyActive {
|
||
return domain.OBActive
|
||
}
|
||
return domain.OBScheduling
|
||
}
|
||
|
||
// cleanupEphemeralImages 刪除發文用暫存圖(temp/*;相容 other/* 舊路徑)。
|
||
// avatar/* 永不刪。best-effort,失敗只記 log。
|
||
func (s *Service) cleanupEphemeralImages(ctx context.Context, urls []string) {
|
||
if s.Storage == nil || !s.Storage.Enabled() || len(urls) == 0 {
|
||
return
|
||
}
|
||
for _, u := range urls {
|
||
key := s.objectKeyFromPublicURL(u)
|
||
if key == "" || !isEphemeralPublishObjectKey(key) {
|
||
continue
|
||
}
|
||
if err := s.Storage.Delete(ctx, key); err != nil {
|
||
logx.Errorf("studio cleanup image key=%s: %v", key, err)
|
||
} else {
|
||
logx.Infof("studio cleaned ephemeral image key=%s", key)
|
||
}
|
||
}
|
||
}
|
||
|
||
func isEphemeralPublishObjectKey(key string) bool {
|
||
key = strings.TrimPrefix(key, "/")
|
||
return strings.HasPrefix(key, "temp/") || strings.HasPrefix(key, "other/")
|
||
}
|
||
|
||
func (s *Service) objectKeyFromPublicURL(raw string) string {
|
||
u := strings.TrimSpace(raw)
|
||
if u == "" {
|
||
return ""
|
||
}
|
||
base := strings.TrimRight(strings.TrimSpace(s.StoragePublicBase), "/")
|
||
if base != "" {
|
||
prefix := base + "/"
|
||
if strings.HasPrefix(u, prefix) {
|
||
return strings.TrimPrefix(u, prefix)
|
||
}
|
||
}
|
||
// 後備:路徑含 /temp/ 或 /other/ 時取之後片段
|
||
for _, marker := range []string{"/temp/", "/other/"} {
|
||
if i := strings.Index(u, marker); i >= 0 {
|
||
return strings.TrimPrefix(u[i:], "/")
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func normalizeURL(raw string) string {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return ""
|
||
}
|
||
u, err := url.Parse(raw)
|
||
if err != nil {
|
||
return strings.TrimRight(raw, "/")
|
||
}
|
||
if u.Scheme == "" {
|
||
u.Scheme = "https"
|
||
}
|
||
u.Host = strings.ToLower(u.Host)
|
||
u.Fragment = ""
|
||
// strip query noise
|
||
u.RawQuery = ""
|
||
s := u.String()
|
||
return strings.TrimRight(s, "/")
|
||
}
|
||
|
||
func truncate(s string, n int) string {
|
||
r := []rune(s)
|
||
if len(r) <= n {
|
||
return s
|
||
}
|
||
return string(r[:n]) + "…"
|
||
}
|