package usecase import ( "context" "encoding/base64" "fmt" "strings" "time" "apps/backend/internal/module/growth/domain" ) // PlanQuota returns monthly platform credits by plan id (for invite cap). type PlanQuota func(planID string) int // BonusCrediter adds invite bonus points to inviter platform pool. type BonusCrediter interface { CreditInviteBonus(ctx context.Context, uid int64, points int, ref string) error } // Notifier optional station notification. type Notifier interface { Notify(ctx context.Context, ownerUID int64, title, body, kind, refType, refID string) error } // HealthSignals optional inputs for recompute. type HealthSignals struct { Sends24h int IntervalViolations int FailRate7d float64 // 0..1 TokenBad bool } type Service struct { Repo domain.Repository PlanQuota PlanQuota Bonus BonusCrediter Notifier Notifier SignalProbe string // documentation: "degraded" | "partial" | "full" } func New(repo domain.Repository) *Service { return &Service{ Repo: repo, PlanQuota: defaultPlanQuota, SignalProbe: "degraded", // Threads scope not fully available; OC-08 path active } } func defaultPlanQuota(planID string) int { switch strings.ToLower(planID) { case "starter": return 600 case "pro": return 2000 default: return 120 } } // --- Outcomes --- func (s *Service) RecordPublished(ctx context.Context, ownerUID int64, sourceType, sourceID, accountID string, sentAt int64) (*domain.OutcomeEvent, error) { if ownerUID <= 0 || sourceType == "" || sourceID == "" { return nil, fmt.Errorf("%w: missing source", domain.ErrValidation) } if sentAt <= 0 { sentAt = domain.NowNano() } now := domain.NowNano() e := &domain.OutcomeEvent{ OwnerUID: ownerUID, SourceType: sourceType, SourceID: sourceID, ThreadsAccountID: accountID, Kind: domain.KindReach, Confidence: domain.ConfidenceConfirmed, Status: domain.StatusObserving, WindowEndsAt: sentAt + int64(domain.ObserveWindow), SentAt: sentAt, CreatedAt: now, UpdatedAt: now, } if err := s.Repo.UpsertOutcomeBySource(ctx, e); err != nil { return nil, err } return s.Repo.GetOutcome(ctx, e.ID) } func (s *Service) GetOutcome(ctx context.Context, ownerUID int64, id string) (*domain.OutcomeEvent, error) { e, err := s.Repo.GetOutcome(ctx, id) if err != nil { return nil, err } if e.OwnerUID != ownerUID { return nil, domain.ErrForbidden } return e, nil } func (s *Service) ListOutcomes(ctx context.Context, ownerUID int64, f domain.OutcomeListFilter) ([]*domain.OutcomeEvent, int64, error) { if f.Page < 1 { f.Page = 1 } if f.PageSize < 1 || f.PageSize > 50 { f.PageSize = 20 } return s.Repo.ListOutcomes(ctx, ownerUID, f) } func (s *Service) Summary(ctx context.Context, ownerUID int64, from, to int64) (*domain.OutcomeSummary, error) { if to <= 0 { to = domain.NowNano() } if from <= 0 { from = to - int64(7*24*time.Hour) } list, _, err := s.Repo.ListOutcomes(ctx, ownerUID, domain.OutcomeListFilter{From: from, To: to, Page: 1, PageSize: 1000}) if err != nil { return nil, err } sum := &domain.OutcomeSummary{From: from, To: to} for _, e := range list { sum.Reach++ if e.Kind == domain.KindReply || e.ReplyCountDelta > 0 { sum.Conversations++ } if e.FollowersDelta > 0 || e.Kind == domain.KindFollow { follows := e.FollowersDelta if follows < 1 { follows = 1 } if e.Confidence == domain.ConfidenceConfirmed { sum.FollowsConfirmed += follows } else { sum.FollowsPossible += follows } } if e.HasConversion || e.Kind == domain.KindConversion { sum.Conversions++ sum.ConversionAmount += e.ConversionAmount } } return sum, nil } func (s *Service) ReportConversion(ctx context.Context, ownerUID int64, id string, amount float64, note, currency string) (*domain.OutcomeEvent, error) { e, err := s.GetOutcome(ctx, ownerUID, id) if err != nil { return nil, err } if currency == "" { currency = "TWD" } now := domain.NowNano() e.HasConversion = true e.ConversionAmount = amount e.ConversionNote = note e.ConversionCurrency = currency e.Kind = domain.KindConversion e.Confidence = domain.ConfidenceConfirmed e.Status = domain.StatusConfirmed e.UpdatedAt = now if err := s.Repo.SaveOutcome(ctx, e); err != nil { return nil, err } return e, nil } func (s *Service) UpdateConversion(ctx context.Context, ownerUID int64, id string, amount float64, note, currency string) (*domain.OutcomeEvent, error) { return s.ReportConversion(ctx, ownerUID, id, amount, note, currency) } func (s *Service) DeleteConversion(ctx context.Context, ownerUID int64, id string) error { e, err := s.GetOutcome(ctx, ownerUID, id) if err != nil { return err } e.HasConversion = false e.ConversionAmount = 0 e.ConversionNote = "" e.ConversionCurrency = "" if e.ReplyCountDelta > 0 { e.Kind = domain.KindReply e.Status = domain.StatusConfirmed } else if e.FollowersDelta > 0 { e.Kind = domain.KindFollow e.Status = domain.StatusPossible e.Confidence = domain.ConfidencePossible } else { e.Kind = domain.KindReach e.Status = domain.StatusObserving e.Confidence = domain.ConfidenceConfirmed } e.UpdatedAt = domain.NowNano() return s.Repo.SaveOutcome(ctx, e) } // ApplyObserveSignals updates observing events. Missing signals are skipped (OC-08). func (s *Service) ApplyObserveSignals(ctx context.Context, id string, replyDelta, likeDelta, followersDelta int) (*domain.OutcomeEvent, error) { e, err := s.Repo.GetOutcome(ctx, id) if err != nil { return nil, err } now := domain.NowNano() if e.Status != domain.StatusObserving && e.Status != domain.StatusPossible { if now > e.WindowEndsAt && e.Status == domain.StatusObserving { e.Status = domain.StatusExpired e.UpdatedAt = now _ = s.Repo.SaveOutcome(ctx, e) } return e, nil } if replyDelta > 0 { e.ReplyCountDelta += replyDelta e.Kind = domain.KindReply e.Confidence = domain.ConfidenceConfirmed e.Status = domain.StatusConfirmed } if likeDelta > 0 { e.LikeCountDelta += likeDelta if e.Kind == domain.KindReach { e.Kind = domain.KindInteraction e.Confidence = domain.ConfidencePossible e.Status = domain.StatusPossible } } if followersDelta > 0 { e.FollowersDelta += followersDelta if e.Kind != domain.KindReply && e.Kind != domain.KindConversion { e.Kind = domain.KindFollow e.Confidence = domain.ConfidencePossible e.Status = domain.StatusPossible } } if now > e.WindowEndsAt && e.Status == domain.StatusObserving { e.Status = domain.StatusExpired } e.UpdatedAt = now if err := s.Repo.SaveOutcome(ctx, e); err != nil { return nil, err } return e, nil } func (s *Service) ObserveTick(ctx context.Context, now int64) (int, error) { if now <= 0 { now = domain.NowNano() } list, err := s.Repo.ListObservingDue(ctx, now, 100) if err != nil { return 0, err } n := 0 for _, e := range list { if now > e.WindowEndsAt { e.Status = domain.StatusExpired e.UpdatedAt = now _ = s.Repo.SaveOutcome(ctx, e) n++ } // Signal fetch left degraded: no external Threads probe in default path. } return n, nil } // --- Checkups (no usage debit — caller must not meter) --- func (s *Service) GenerateCheckup(ctx context.Context, ownerUID int64, timezone string, force bool) (*domain.WeeklyCheckup, error) { now := domain.NowNano() if force { since := now - int64(7*24*time.Hour) cnt, err := s.Repo.CountCheckupGenerations(ctx, ownerUID, since) if err != nil { return nil, err } if cnt >= 2 { return nil, domain.ErrRateLimited } } weekKey := weekKeyFor(timezone, time.Now().UTC()) // Build report from outcome summary (no external AI required for MVP reliability). sum, err := s.Summary(ctx, ownerUID, 0, 0) if err != nil { return nil, err } findings := []domain.CheckupFinding{} if sum.Reach > 0 { findings = append(findings, domain.CheckupFinding{ Claim: fmt.Sprintf("近 7 日送出/標記完成 %d 則,可作為本週觸達基線", sum.Reach), Evidence: []string{fmt.Sprintf("outcome_reach=%d", sum.Reach)}, }) } if sum.Conversations > 0 { findings = append(findings, domain.CheckupFinding{ Claim: fmt.Sprintf("有 %d 則進入對話訊號,優先回覆這些串", sum.Conversations), Evidence: []string{fmt.Sprintf("outcome_conversations=%d", sum.Conversations)}, }) } if sum.Conversions > 0 { findings = append(findings, domain.CheckupFinding{ Claim: fmt.Sprintf("已回報 %d 筆成交,持續追蹤同類痛點", sum.Conversions), Evidence: []string{fmt.Sprintf("outcome_conversions=%d amount=%.0f", sum.Conversions, sum.ConversionAmount)}, }) } if len(findings) == 0 { findings = append(findings, domain.CheckupFinding{ Claim: "本週尚無足夠送出紀錄;建議先完成海巡外展或標記完成以累積訊號", Evidence: []string{"outcome_reach=0"}, }) } // keep only findings with evidence var okFindings []domain.CheckupFinding for _, f := range findings { if len(f.Evidence) > 0 && strings.TrimSpace(f.Claim) != "" { okFindings = append(okFindings, f) } } actions := []domain.CheckupAction{ {Title: "處理海巡待回", Reason: "把待處理命中變成可歸因送出", Deeplink: "scout"}, {Title: "寫一則主貼", Reason: "維持帳號節奏,累積可分析貼文", Deeplink: "studio_compose"}, {Title: "回顧今日成果", Reason: "用成本+成果判斷本週重點", Deeplink: "today"}, } c := &domain.WeeklyCheckup{ ID: domain.NewID(), OwnerUID: ownerUID, WeekKey: weekKey, Status: domain.CheckupReady, Summary: fmt.Sprintf("本週觸達 %d、對話 %d、成交 %d。", sum.Reach, sum.Conversations, sum.Conversions), Findings: okFindings, Actions: actions, CreatedAt: now, UpdatedAt: now, } if err := s.Repo.SaveCheckup(ctx, c); err != nil { return nil, err } if s.Notifier != nil { _ = s.Notifier.Notify(ctx, ownerUID, "每週帳號健檢已完成", c.Summary, "system", "checkup", c.ID) } return c, nil } func weekKeyFor(tz string, now time.Time) string { loc := time.UTC if tz != "" { if l, err := time.LoadLocation(tz); err == nil { loc = l } } local := now.In(loc) // ISO week y, w := local.ISOWeek() return fmt.Sprintf("%d-W%02d", y, w) } func (s *Service) LatestCheckup(ctx context.Context, ownerUID int64) (*domain.WeeklyCheckup, error) { return s.Repo.LatestCheckup(ctx, ownerUID) } func (s *Service) GetCheckup(ctx context.Context, ownerUID int64, id string) (*domain.WeeklyCheckup, error) { c, err := s.Repo.GetCheckup(ctx, id) if err != nil { return nil, err } if c.OwnerUID != ownerUID { return nil, domain.ErrForbidden } return c, nil } func (s *Service) ListCheckups(ctx context.Context, ownerUID int64, page, pageSize int) ([]*domain.WeeklyCheckup, int64, error) { return s.Repo.ListCheckups(ctx, ownerUID, page, pageSize) } // --- Account health --- func (s *Service) RecomputeHealth(ctx context.Context, ownerUID int64, accountID string, sig HealthSignals) (*domain.AccountHealth, error) { score := 100 var factors []string if sig.Sends24h > 20 { score -= 25 factors = append(factors, fmt.Sprintf("24h 發送 %d 次偏高", sig.Sends24h)) } else if sig.Sends24h > 10 { score -= 10 factors = append(factors, fmt.Sprintf("24h 發送 %d 次", sig.Sends24h)) } if sig.IntervalViolations > 0 { d := sig.IntervalViolations * 8 if d > 30 { d = 30 } score -= d factors = append(factors, fmt.Sprintf("間隔違規 %d 次", sig.IntervalViolations)) } if sig.FailRate7d >= 0.5 { score -= 25 factors = append(factors, fmt.Sprintf("7 日失敗率 %.0f%%", sig.FailRate7d*100)) } else if sig.FailRate7d >= 0.25 { score -= 12 factors = append(factors, fmt.Sprintf("7 日失敗率 %.0f%%", sig.FailRate7d*100)) } if sig.TokenBad { score -= 40 factors = append(factors, "token 失效或錯誤") } if score < 0 { score = 0 } level := domain.HealthNormal advice := "操作節奏正常,可持續。" if score < 40 { level = domain.HealthThrottle advice = "帳號風險偏高:已阻擋自動送出,請改用複製→標記完成,並降低密度。" } else if score < 70 { level = domain.HealthWarn advice = "建議加倍發送間隔,並優先處理失敗步驟。" } if len(factors) == 0 { factors = []string{"無異常訊號"} } h := &domain.AccountHealth{ ThreadsAccountID: accountID, OwnerUID: ownerUID, Score: score, Level: level, Factors: factors, Advice: advice, ComputedAt: domain.NowNano(), } if err := s.Repo.SaveHealth(ctx, h); err != nil { return nil, err } return h, nil } func (s *Service) GetHealth(ctx context.Context, ownerUID int64, accountID string) (*domain.AccountHealth, error) { h, err := s.Repo.GetHealth(ctx, accountID) if err != nil { if err == domain.ErrNotFound { // default healthy until computed return &domain.AccountHealth{ ThreadsAccountID: accountID, OwnerUID: ownerUID, Score: 85, Level: domain.HealthNormal, Factors: []string{"尚未重算,預設正常"}, Advice: "尚無足夠操作數據。", ComputedAt: domain.NowNano(), }, nil } return nil, err } if h.OwnerUID != ownerUID { return nil, domain.ErrForbidden } return h, nil } func (s *Service) ListHealth(ctx context.Context, ownerUID int64) ([]*domain.AccountHealth, error) { return s.Repo.ListHealth(ctx, ownerUID) } // EnsureAutoSendAllowed returns health_warning message or ErrThrottle. func (s *Service) EnsureAutoSendAllowed(ctx context.Context, ownerUID int64, accountID string) (warning string, err error) { if accountID == "" { return "", nil } h, err := s.GetHealth(ctx, ownerUID, accountID) if err != nil { return "", err } switch h.Level { case domain.HealthThrottle: return "", fmt.Errorf("%w: score=%d %s", domain.ErrThrottle, h.Score, h.Advice) case domain.HealthWarn: return fmt.Sprintf("健康分 %d(警告):%s", h.Score, h.Advice), nil default: return "", nil } } // --- Invite rewards --- func (s *Service) OnFirstPaidPlan(ctx context.Context, inviteeUID, parentUID int64, planID string) (*domain.InviteReward, error) { planID = strings.ToLower(strings.TrimSpace(planID)) if parentUID <= 0 || inviteeUID <= 0 { return &domain.InviteReward{Status: domain.RewardSkipped}, nil } if planID != "starter" && planID != "pro" { return &domain.InviteReward{Status: domain.RewardSkipped}, nil } has, err := s.Repo.HasInviteRewardForInvitee(ctx, inviteeUID) if err != nil { return nil, err } if has { return &domain.InviteReward{Status: domain.RewardSkipped, InviteeUID: inviteeUID}, nil } points := 100 if planID == "pro" { points = 300 } quota := 120 if s.PlanQuota != nil { // cap based on inviter plan — unknown → free quota quota = s.PlanQuota("free") } // Prefer looking up inviter plan via PlanQuota with "inviter" if set externally; // default month cap = free 50% unless Bonus path sets better. monthCap := quota / 2 if monthCap < 0 { monthCap = 0 } // Try inviter starter/pro quotas: use max of free half as floor; service callers // should set PlanQuota to resolve inviter plan. if s.PlanQuota != nil { // convention: PlanQuota("__inviter__") not used; pass inviter plan via // PlanQuota already closed over. Month cap = PlanQuota("current")/2. // Here PlanQuota receives plan id; for month cap we call with "starter" max? // Spec: 邀請人當月方案額度 50%. Caller should set: // s.PlanQuota = func(id string) int { if id=="@inviter") return inviterQuota } if q := s.PlanQuota("@inviter"); q > 0 { monthCap = q / 2 } } monthStart := time.Date(time.Now().UTC().Year(), time.Now().UTC().Month(), 1, 0, 0, 0, 0, time.UTC).UnixNano() used, err := s.Repo.SumInviteRewardPointsMonth(ctx, parentUID, monthStart) if err != nil { return nil, err } r := &domain.InviteReward{ ID: domain.NewID(), InviterUID: parentUID, InviteeUID: inviteeUID, PlanID: planID, Points: points, CreatedAt: domain.NowNano(), } if used+points > monthCap && monthCap >= 0 { r.Status = domain.RewardCapped r.Points = 0 _ = s.Repo.SaveInviteReward(ctx, r) return r, nil } r.Status = domain.RewardCredited if err := s.Repo.SaveInviteReward(ctx, r); err != nil { return nil, err } if s.Bonus != nil && points > 0 { _ = s.Bonus.CreditInviteBonus(ctx, parentUID, points, r.ID) } return r, nil } func (s *Service) InviteRewardsSummary(ctx context.Context, inviterUID int64, inviterPlanID string) (total, month, cap int, recent []*domain.InviteReward, err error) { quota := defaultPlanQuota(inviterPlanID) if s.PlanQuota != nil { if q := s.PlanQuota(inviterPlanID); q > 0 { quota = q } } cap = quota / 2 monthStart := time.Date(time.Now().UTC().Year(), time.Now().UTC().Month(), 1, 0, 0, 0, 0, time.UTC).UnixNano() total, err = s.Repo.SumInviteRewardPointsTotal(ctx, inviterUID) if err != nil { return } month, err = s.Repo.SumInviteRewardPointsMonth(ctx, inviterUID, monthStart) if err != nil { return } recent, err = s.Repo.ListInviteRewards(ctx, inviterUID, 20) return } // --- Workspaces --- func (s *Service) EnsureDefaultWorkspace(ctx context.Context, ownerUID int64) (*domain.Workspace, error) { list, err := s.Repo.ListWorkspaces(ctx, ownerUID, true) if err != nil { return nil, err } for _, w := range list { if w.IsDefault && !w.Archived { return w, nil } } for _, w := range list { if !w.Archived { return w, nil } } now := domain.NowNano() w := &domain.Workspace{ ID: domain.NewID(), OwnerUID: ownerUID, Name: "預設工作區", IsDefault: true, CreatedAt: now, UpdatedAt: now, } if err := s.Repo.SaveWorkspace(ctx, w); err != nil { return nil, err } _ = s.Repo.SaveMemberWSState(ctx, &domain.MemberWorkspaceState{ OwnerUID: ownerUID, CurrentWorkspaceID: w.ID, UpdatedAt: now, }) return w, nil } func (s *Service) ListWorkspaces(ctx context.Context, ownerUID int64) ([]*domain.Workspace, string, error) { _, _ = s.EnsureDefaultWorkspace(ctx, ownerUID) list, err := s.Repo.ListWorkspaces(ctx, ownerUID, false) if err != nil { return nil, "", err } cur := "" st, err := s.Repo.GetMemberWSState(ctx, ownerUID) if err == nil && st != nil { cur = st.CurrentWorkspaceID } if cur == "" && len(list) > 0 { cur = list[0].ID } return list, cur, nil } func (s *Service) CreateWorkspace(ctx context.Context, ownerUID int64, name string) (*domain.Workspace, error) { name = strings.TrimSpace(name) if name == "" { return nil, fmt.Errorf("%w: name required", domain.ErrValidation) } _, _ = s.EnsureDefaultWorkspace(ctx, ownerUID) now := domain.NowNano() w := &domain.Workspace{ ID: domain.NewID(), OwnerUID: ownerUID, Name: name, CreatedAt: now, UpdatedAt: now, } if err := s.Repo.SaveWorkspace(ctx, w); err != nil { return nil, err } return w, nil } func (s *Service) UpdateWorkspace(ctx context.Context, ownerUID int64, id, name string, review *bool) (*domain.Workspace, error) { w, err := s.Repo.GetWorkspace(ctx, id) if err != nil { return nil, err } if w.OwnerUID != ownerUID { return nil, domain.ErrForbidden } if name = strings.TrimSpace(name); name != "" { w.Name = name } if review != nil { w.ReviewRequired = *review } w.UpdatedAt = domain.NowNano() if err := s.Repo.SaveWorkspace(ctx, w); err != nil { return nil, err } return w, nil } func (s *Service) ArchiveWorkspace(ctx context.Context, ownerUID int64, id string) error { w, err := s.Repo.GetWorkspace(ctx, id) if err != nil { return err } if w.OwnerUID != ownerUID { return domain.ErrForbidden } n, err := s.Repo.CountActiveWorkspaces(ctx, ownerUID) if err != nil { return err } if n <= 1 { return fmt.Errorf("%w: must keep at least one workspace", domain.ErrValidation) } w.Archived = true w.UpdatedAt = domain.NowNano() return s.Repo.SaveWorkspace(ctx, w) } func (s *Service) SwitchWorkspace(ctx context.Context, ownerUID int64, id string) error { w, err := s.Repo.GetWorkspace(ctx, id) if err != nil { return err } if w.OwnerUID != ownerUID || w.Archived { return domain.ErrForbidden } return s.Repo.SaveMemberWSState(ctx, &domain.MemberWorkspaceState{ OwnerUID: ownerUID, CurrentWorkspaceID: id, UpdatedAt: domain.NowNano(), }) } func (s *Service) CurrentWorkspaceID(ctx context.Context, ownerUID int64) (string, error) { list, cur, err := s.ListWorkspaces(ctx, ownerUID) if err != nil { return "", err } if cur != "" { return cur, nil } if len(list) > 0 { return list[0].ID, nil } return "", domain.ErrNotFound } // --- Draft review --- func (s *Service) SubmitReview(ctx context.Context, ownerUID int64, refType, refID, workspaceID string) (*domain.DraftReview, error) { if refType == "" || refID == "" { return nil, fmt.Errorf("%w: ref required", domain.ErrValidation) } if workspaceID == "" { var err error workspaceID, err = s.CurrentWorkspaceID(ctx, ownerUID) if err != nil { return nil, err } } now := domain.NowNano() r := &domain.DraftReview{ ID: domain.NewID(), OwnerUID: ownerUID, WorkspaceID: workspaceID, RefType: refType, RefID: refID, Status: domain.ReviewPending, CreatedAt: now, UpdatedAt: now, } if err := s.Repo.SaveReview(ctx, r); err != nil { return nil, err } return r, nil } func (s *Service) DecideReview(ctx context.Context, actorUID int64, id, status, reason string) (*domain.DraftReview, error) { r, err := s.Repo.GetReview(ctx, id) if err != nil { return nil, err } // owner of draft OR workspace collaborator may decide if r.OwnerUID != actorUID && !s.CanReviewInWorkspace(ctx, actorUID, r.WorkspaceID) { return nil, domain.ErrForbidden } r.Status = status r.Reason = reason r.UpdatedAt = domain.NowNano() if err := s.Repo.SaveReview(ctx, r); err != nil { return nil, err } return r, nil } func (s *Service) ListReviews(ctx context.Context, ownerUID int64, status string, page, pageSize int) ([]*domain.DraftReview, int64, error) { return s.Repo.ListReviews(ctx, ownerUID, status, page, pageSize) } // EnsureReviewAllowsAutoSend checks workspace review gate. func (s *Service) EnsureReviewAllowsAutoSend(ctx context.Context, ownerUID int64, workspaceID, refType, refID string) error { if workspaceID == "" { var err error workspaceID, err = s.CurrentWorkspaceID(ctx, ownerUID) if err != nil { return nil // no workspace → no gate } } w, err := s.Repo.GetWorkspace(ctx, workspaceID) if err != nil { if err == domain.ErrNotFound { return nil } return err } if !w.ReviewRequired { return nil } r, err := s.Repo.FindReviewByRef(ctx, ownerUID, refType, refID) if err != nil { return domain.ErrReviewPending } if r.Status != domain.ReviewApproved { return domain.ErrReviewPending } return nil } // ExportReportMarkdown returns a simple text report (PDF engine optional; content is the contract). func (s *Service) ExportReportMarkdown(ctx context.Context, ownerUID int64, workspaceID, yearMonth string) (filename, body string, err error) { if yearMonth == "" { yearMonth = time.Now().UTC().Format("2006-01") } w, err := s.Repo.GetWorkspace(ctx, workspaceID) if err != nil { return "", "", err } if w.OwnerUID != ownerUID { ok, _, _ := s.Repo.IsWorkspaceMember(ctx, workspaceID, ownerUID) if !ok { return "", "", domain.ErrForbidden } } sum, err := s.Summary(ctx, ownerUID, 0, 0) if err != nil { return "", "", err } title := w.Name if w.BrandName != "" { title = w.BrandName } filename = fmt.Sprintf("report-%s-%s.md", sanitize(title), yearMonth) body = fmt.Sprintf("# 月報 %s · %s\n\n- 觸達: %d\n- 對話: %d\n- 成交: %d (%.0f)\n- 追蹤(可能/確定): %d / %d\n", title, yearMonth, sum.Reach, sum.Conversations, sum.Conversions, sum.ConversionAmount, sum.FollowsPossible, sum.FollowsConfirmed) if w.FooterText != "" { body += "\n---\n" + w.FooterText + "\n" } if !w.HidePoweredBy { body += "\n_Powered by 巡樓_\n" } return filename, body, nil } // ExportReport returns md or pdf data URL. func (s *Service) ExportReport(ctx context.Context, ownerUID int64, workspaceID, yearMonth, format string) (filename, contentType, dataURL string, err error) { fn, body, err := s.ExportReportMarkdown(ctx, ownerUID, workspaceID, yearMonth) if err != nil { return "", "", "", err } format = strings.ToLower(strings.TrimSpace(format)) if format == "pdf" { title := strings.TrimSuffix(fn, ".md") pdf := ExportReportPDF(title, body) b64 := encodeB64(pdf) return strings.TrimSuffix(fn, ".md") + ".pdf", "application/pdf", "data:application/pdf;base64," + b64, nil } b64 := encodeB64([]byte(body)) return fn, "text/markdown", "data:text/markdown;base64," + b64, nil } func encodeB64(b []byte) string { return base64.StdEncoding.EncodeToString(b) } func sanitize(s string) string { s = strings.TrimSpace(s) s = strings.ReplaceAll(s, " ", "-") if s == "" { return "workspace" } return s }