package usecase import ( "context" "crypto/sha256" "encoding/hex" "fmt" "math" "net/url" "sort" "strings" "unicode" "apps/backend/internal/module/growth/domain" "github.com/zeromicro/go-zero/core/logx" ) // --- Playbooks --- func (s *Service) PublishPlaybook(ctx context.Context, ownerUID int64, kind, title, niche, body string, anonymous bool) (*domain.Playbook, error) { kind = strings.ToLower(strings.TrimSpace(kind)) title = strings.TrimSpace(title) body = strings.TrimSpace(body) if title == "" || body == "" { return nil, fmt.Errorf("%w: title and body required", domain.ErrValidation) } switch kind { case domain.PlaybookBrief, domain.PlaybookPersona, domain.PlaybookPlay: default: return nil, fmt.Errorf("%w: kind must be brief|persona|play", domain.ErrValidation) } now := domain.NowNano() p := &domain.Playbook{ ID: domain.NewID(), OwnerUID: ownerUID, Kind: kind, Title: title, Niche: strings.TrimSpace(niche), Body: body, Anonymous: anonymous, CreatedAt: now, UpdatedAt: now, } if err := s.Repo.SavePlaybook(ctx, p); err != nil { return nil, err } return p, nil } func (s *Service) ListPlaybooks(ctx context.Context, viewerUID int64, kind, niche string, mine bool, page, pageSize int) ([]*domain.Playbook, int64, error) { ownerOnly := int64(0) if mine { ownerOnly = viewerUID } return s.Repo.ListPlaybooks(ctx, kind, niche, ownerOnly, page, pageSize) } func (s *Service) GetPlaybook(ctx context.Context, id string) (*domain.Playbook, error) { return s.Repo.GetPlaybook(ctx, id) } func (s *Service) ImportPlaybook(ctx context.Context, viewerUID int64, id string) (*domain.Playbook, error) { p, err := s.Repo.GetPlaybook(ctx, id) if err != nil { return nil, err } _ = s.Repo.IncPlaybookImport(ctx, id) // return a personal copy metadata (caller may store locally) now := domain.NowNano() copyPB := &domain.Playbook{ ID: domain.NewID(), OwnerUID: viewerUID, Kind: p.Kind, Title: p.Title + "(引用)", Niche: p.Niche, Body: p.Body, Anonymous: false, CreatedAt: now, UpdatedAt: now, } if err := s.Repo.SavePlaybook(ctx, copyPB); err != nil { return nil, err } return copyPB, nil } func (s *Service) RemovePlaybook(ctx context.Context, ownerUID int64, id string) error { p, err := s.Repo.GetPlaybook(ctx, id) if err != nil { return err } if p.OwnerUID != ownerUID { return domain.ErrForbidden } return s.Repo.DeletePlaybook(ctx, id) } func PlaybookAuthorLabel(p *domain.Playbook, viewerUID int64) string { if p == nil { return "" } if p.OwnerUID == viewerUID { return "我" } if p.Anonymous { return "匿名島民" } return fmt.Sprintf("島民#%d", p.OwnerUID%10000) } // --- Free tools --- func StyleQuiz(samples string) (tone, rhythm, hooks, avoid, summary string, tags []string) { text := strings.TrimSpace(samples) if text == "" { return "平實", "中等", "問題開頭", "避免空泛口號", "請貼上幾則你的貼文以分析風格。", []string{"待補充"} } runes := []rune(text) excl := strings.Count(text, "!") + strings.Count(text, "!") q := strings.Count(text, "?") + strings.Count(text, "?") avgLen := 0 lines := strings.FieldsFunc(text, func(r rune) bool { return r == '\n' || r == '。' }) if len(lines) > 0 { total := 0 for _, ln := range lines { total += len([]rune(ln)) } avgLen = total / len(lines) } tone = "溫和敘事" if excl > 3 { tone = "熱情有力" } else if q > 3 { tone = "對話邀請" } rhythm = "中短句" if avgLen > 40 { rhythm = "長句鋪陳" } else if avgLen < 15 { rhythm = "短句節奏" } hooks = "經驗分享" if q > 0 { hooks = "提問開場" } avoid = "避免罐頭 AI 腔與過度標點" if excl > 5 { avoid = "注意驚嘆號密度,避免像廣告" } tags = []string{tone, rhythm, hooks} summary = fmt.Sprintf("從約 %d 字樣本看:語氣偏「%s」,節奏「%s」,常用「%s」。", len(runes), tone, rhythm, hooks) return } func PainKeywords(productBrief, audience string) (keywords, pains, scan []string, summary string) { brief := strings.TrimSpace(productBrief) if brief == "" { return []string{"請描述產品"}, []string{"痛點待填"}, []string{"關鍵字"}, "請填產品簡述。" } // rule-based extraction: split CJK/words tokens := tokenizeTC(brief) for _, t := range tokens { if len([]rune(t)) < 2 { continue } keywords = append(keywords, t) if len(keywords) >= 12 { break } } pains = []string{ "不知道怎麼選", "用了沒感覺", "怕踩雷", "預算有限", } if audience != "" { pains = append([]string{audience + "常遇到的困擾"}, pains...) } scan = append([]string{}, keywords...) for _, p := range pains { scan = append(scan, p) } if len(scan) > 16 { scan = scan[:16] } summary = fmt.Sprintf("依產品描述抽出 %d 個掃描詞、%d 個痛點方向,可直接貼進海巡 brief。", len(keywords), len(pains)) return } func tokenizeTC(s string) []string { var cur strings.Builder var out []string flush := func() { t := strings.TrimSpace(cur.String()) cur.Reset() if t != "" { out = append(out, t) } } for _, r := range s { if unicode.Is(unicode.Han, r) { cur.WriteRune(r) if len([]rune(cur.String())) >= 4 { flush() } continue } if unicode.IsLetter(r) || unicode.IsDigit(r) { cur.WriteRune(r) continue } flush() } flush() // also bigrams from han han := make([]rune, 0) for _, r := range s { if unicode.Is(unicode.Han, r) { han = append(han, r) } } for i := 0; i+1 < len(han); i++ { out = append(out, string(han[i:i+2])) } // dedupe preserve order seen := map[string]bool{} var uniq []string for _, t := range out { if seen[t] { continue } seen[t] = true uniq = append(uniq, t) } return uniq } // --- UTM --- // validateRedirectTarget keeps /u/{code} from becoming an open redirect that can point anywhere. // A HasPrefix("http") check is not enough: it lets through "httpx://", scheme-relative "//evil", // and anything unparseable, all of which the browser resolves differently than we assume. func validateRedirectTarget(dest string) (string, error) { dest = strings.TrimSpace(dest) u, err := url.Parse(dest) if err != nil { return "", fmt.Errorf("%w: destination_url is not a valid URL", domain.ErrValidation) } if u.Scheme != "http" && u.Scheme != "https" { return "", fmt.Errorf("%w: destination_url must be http or https", domain.ErrValidation) } if u.Host == "" { return "", fmt.Errorf("%w: destination_url must include a host", domain.ErrValidation) } return u.String(), nil } func (s *Service) CreateUtmLink(ctx context.Context, ownerUID int64, dest, outcomeID, label string) (*domain.UtmLink, error) { dest, err := validateRedirectTarget(dest) if err != nil { return nil, err } code := domain.NewID()[:10] u := &domain.UtmLink{ ID: domain.NewID(), Code: code, OwnerUID: ownerUID, DestinationURL: dest, OutcomeID: strings.TrimSpace(outcomeID), Label: strings.TrimSpace(label), CreatedAt: domain.NowNano(), } if err := s.Repo.SaveUtmLink(ctx, u); err != nil { return nil, err } return u, nil } func (s *Service) ListUtmLinks(ctx context.Context, ownerUID int64) ([]*domain.UtmLink, error) { return s.Repo.ListUtmLinks(ctx, ownerUID) } func (s *Service) TrackUtmClick(ctx context.Context, code string) (dest string, err error) { u, err := s.Repo.IncUtmClick(ctx, code) if err != nil { return "", err } if u.OutcomeID != "" { if e, gerr := s.Repo.GetOutcome(ctx, u.OutcomeID); gerr == nil && e != nil { e.ClickCount++ if e.Kind == domain.KindReach || e.Kind == "" { e.Kind = domain.KindClick e.Confidence = domain.ConfidenceConfirmed e.Status = domain.StatusConfirmed } e.UpdatedAt = domain.NowNano() if serr := s.Repo.SaveOutcome(ctx, e); serr != nil { // 點擊已經發生,redirect 照走;但歸因掉了要看得見,否則成效數字會默默偏低。 logx.WithContext(ctx).Errorf("utm click attribution not saved: outcome=%s err=%v", e.ID, serr) } } } // 舊資料是在只檢查 "http" 前綴時寫入的,出門前再驗一次。 return validateRedirectTarget(u.DestinationURL) } // --- Benchmark --- // NormalizeNiche 讓利基桶有機會對得上:大小寫與前後空白不該切出不同的桶。 func NormalizeNiche(niche string) string { niche = strings.ToLower(strings.TrimSpace(niche)) if niche == "" { return "general" } return niche } func (s *Service) ContributeBenchmark(ctx context.Context, ownerUID int64, niche string, engRate, avgViews float64) error { niche = NormalizeNiche(niche) h := sha256.Sum256([]byte(fmt.Sprintf("%d|%s", ownerUID, niche))) return s.Repo.UpsertBenchmarkSample(ctx, &domain.BenchmarkSample{ Niche: niche, EngRate: engRate, AvgViews: avgViews, OwnerHash: hex.EncodeToString(h[:8]), UpdatedAt: domain.NowNano(), }) } func (s *Service) GetBenchmark(ctx context.Context, niche string, yourEng, yourViews float64) (sampleSize int, medianEng, medianViews float64, available bool, hint string) { niche = NormalizeNiche(niche) list, err := s.Repo.ListBenchmarkSamples(ctx, niche, 500) if err != nil || len(list) < 5 { return len(list), 0, 0, false, "樣本不足 5,暫不顯示中位數(多貢獻幾次成效後再看)" } engs := make([]float64, 0, len(list)) views := make([]float64, 0, len(list)) for _, s0 := range list { engs = append(engs, s0.EngRate) views = append(views, s0.AvgViews) } medianEng = medianFloat(engs) medianViews = medianFloat(views) hint = "約在中位附近" if yourEng > 0 { if yourEng >= medianEng*1.2 { hint = "你高於同利基中位數" } else if yourEng <= medianEng*0.8 { hint = "你低於同利基中位數,可調整題材/開頭" } } return len(list), medianEng, medianViews, true, hint } func medianFloat(xs []float64) float64 { if len(xs) == 0 { return 0 } sort.Float64s(xs) mid := len(xs) / 2 if len(xs)%2 == 0 { return (xs[mid-1] + xs[mid]) / 2 } return xs[mid] } // --- Workspace members --- func (s *Service) AddWorkspaceMember(ctx context.Context, actorUID, memberUID int64, workspaceID, role string) (*domain.WorkspaceMember, error) { w, err := s.Repo.GetWorkspace(ctx, workspaceID) if err != nil { return nil, err } if w.OwnerUID != actorUID { ok, r, _ := s.Repo.IsWorkspaceMember(ctx, workspaceID, actorUID) if !ok || r != domain.WSRoleOwner { return nil, domain.ErrForbidden } } if memberUID <= 0 { return nil, fmt.Errorf("%w: member_uid required", domain.ErrValidation) } role = strings.ToLower(strings.TrimSpace(role)) if role == "" { role = domain.WSRoleReviewer } switch role { case domain.WSRoleEditor, domain.WSRoleReviewer, domain.WSRoleOwner: default: return nil, fmt.Errorf("%w: invalid role", domain.ErrValidation) } m := &domain.WorkspaceMember{ ID: domain.NewID(), WorkspaceID: workspaceID, UID: memberUID, Role: role, JoinedAt: domain.NowNano(), } if err := s.Repo.SaveWorkspaceMember(ctx, m); err != nil { return nil, err } return m, nil } func (s *Service) ListWorkspaceMembers(ctx context.Context, actorUID int64, workspaceID string) ([]*domain.WorkspaceMember, error) { ok, _, err := s.Repo.IsWorkspaceMember(ctx, workspaceID, actorUID) if err != nil { return nil, err } w, werr := s.Repo.GetWorkspace(ctx, workspaceID) if werr != nil { return nil, werr } if !ok && w.OwnerUID != actorUID { return nil, domain.ErrForbidden } list, err := s.Repo.ListWorkspaceMembers(ctx, workspaceID) if err != nil { return nil, err } // ensure owner present hasOwner := false for _, m := range list { if m.UID == w.OwnerUID { hasOwner = true break } } if !hasOwner { list = append([]*domain.WorkspaceMember{{ ID: "owner", WorkspaceID: workspaceID, UID: w.OwnerUID, Role: domain.WSRoleOwner, JoinedAt: w.CreatedAt, }}, list...) } return list, nil } func (s *Service) RemoveWorkspaceMember(ctx context.Context, actorUID, targetUID int64, workspaceID string) error { w, err := s.Repo.GetWorkspace(ctx, workspaceID) if err != nil { return err } if w.OwnerUID != actorUID { return domain.ErrForbidden } if targetUID == w.OwnerUID { return fmt.Errorf("%w: cannot remove owner", domain.ErrValidation) } return s.Repo.RemoveWorkspaceMember(ctx, workspaceID, targetUID) } func (s *Service) UpdateWorkspaceBranding(ctx context.Context, ownerUID int64, id, footer, brandName string, hidePowered *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 footer != "" { w.FooterText = footer } if brandName != "" { w.BrandName = brandName } if hidePowered != nil { w.HidePoweredBy = *hidePowered } w.UpdatedAt = domain.NowNano() if err := s.Repo.SaveWorkspace(ctx, w); err != nil { return nil, err } return w, nil } // EnsureReviewAllowsAutoSendP2 — owner or any reviewer/editor/member may have approved func (s *Service) CanReviewInWorkspace(ctx context.Context, uid int64, workspaceID string) bool { w, err := s.Repo.GetWorkspace(ctx, workspaceID) if err != nil { return false } if w.OwnerUID == uid { return true } ok, role, _ := s.Repo.IsWorkspaceMember(ctx, workspaceID, uid) return ok && (role == domain.WSRoleOwner || role == domain.WSRoleReviewer || role == domain.WSRoleEditor) } // ExportReportPDF returns minimal valid PDF bytes func ExportReportPDF(title, body string) []byte { // Escape PDF strings esc := func(s string) string { s = strings.ReplaceAll(s, "\\", "\\\\") s = strings.ReplaceAll(s, "(", "\\(") s = strings.ReplaceAll(s, ")", "\\)") return s } // simple multi-line text lines := strings.Split(body, "\n") if len(lines) > 40 { lines = lines[:40] } var content strings.Builder content.WriteString("BT /F1 14 Tf 50 780 Td (" + esc(title) + ") Tj\n") content.WriteString("/F1 10 Tf 0 -24 Td\n") for i, ln := range lines { if i > 0 { content.WriteString("0 -14 Td\n") } // PDF standard fonts poor for CJK — keep ASCII fallback note safe := toPDFSafe(ln) content.WriteString("(" + esc(safe) + ") Tj\n") } content.WriteString("ET") stream := content.String() objs := []string{ "1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj\n", "2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj\n", "3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources<< /Font<< /F1 5 0 R >> >> >>endobj\n", fmt.Sprintf("4 0 obj<< /Length %d >>stream\n%s\nendstream endobj\n", len(stream), stream), "5 0 obj<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>endobj\n", } var pdf strings.Builder pdf.WriteString("%PDF-1.4\n") offsets := []int{0} for _, o := range objs { offsets = append(offsets, pdf.Len()) pdf.WriteString(o) } xref := pdf.Len() pdf.WriteString(fmt.Sprintf("xref\n0 %d\n", len(offsets))) pdf.WriteString("0000000000 65535 f \n") for i := 1; i < len(offsets); i++ { pdf.WriteString(fmt.Sprintf("%010d 00000 n \n", offsets[i])) } pdf.WriteString(fmt.Sprintf("trailer<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets), xref)) return []byte(pdf.String()) } func toPDFSafe(s string) string { var b strings.Builder for _, r := range s { if r < 128 && r >= 32 { b.WriteRune(r) } else if r == '\t' { b.WriteByte(' ') } else { // replace non-latin with ? b.WriteByte('?') } } out := b.String() if strings.Trim(out, "? ") == "" && s != "" { return "[Chinese content — open markdown export for full text]" } return out } func Round2(v float64) float64 { return math.Round(v*100) / 100 } // RoundRate 用在互動率這種比率(0.0234)。比率不能走 Round2 —— 那會輾成 0.02, // 前端 *100 後所有人都變成整數百分比。保留 4 位小數 = 百分比的 2 位。 func RoundRate(v float64) float64 { return math.Round(v*10000) / 10000 }