862 lines
25 KiB
Go
862 lines
25 KiB
Go
|
|
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
|
|||
|
|
}
|