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

537 lines
15 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"
"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 ---
func (s *Service) ContributeBenchmark(ctx context.Context, ownerUID int64, niche string, engRate, avgViews float64) error {
niche = strings.TrimSpace(niche)
if niche == "" {
niche = "general"
}
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 = strings.TrimSpace(niche)
if niche == "" {
niche = "general"
}
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
}