thread-master/apps/backend/internal/module/growth/usecase/service.go

862 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

package usecase
import (
"context"
"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
}