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

862 lines
25 KiB
Go
Raw Normal View History

2026-07-23 05:56:42 +00:00
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
}