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

546 lines
16 KiB
Go
Raw Permalink Normal View History

2026-07-23 05:56:42 +00:00
package usecase
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"math"
2026-07-28 06:33:40 +00:00
"net/url"
2026-07-23 05:56:42 +00:00
"sort"
"strings"
"unicode"
"apps/backend/internal/module/growth/domain"
2026-07-28 06:33:40 +00:00
"github.com/zeromicro/go-zero/core/logx"
2026-07-23 05:56:42 +00:00
)
// --- 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 ---
2026-07-28 06:33:40 +00:00
// 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) {
2026-07-23 05:56:42 +00:00
dest = strings.TrimSpace(dest)
2026-07-28 06:33:40 +00:00
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
2026-07-23 05:56:42 +00:00
}
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()
2026-07-28 06:33:40 +00:00
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)
}
2026-07-23 05:56:42 +00:00
}
}
2026-07-28 06:33:40 +00:00
// 舊資料是在只檢查 "http" 前綴時寫入的,出門前再驗一次。
return validateRedirectTarget(u.DestinationURL)
2026-07-23 05:56:42 +00:00
}
// --- Benchmark ---
2026-07-30 01:25:34 +00:00
// NormalizeNiche 讓利基桶有機會對得上:大小寫與前後空白不該切出不同的桶。
func NormalizeNiche(niche string) string {
niche = strings.ToLower(strings.TrimSpace(niche))
2026-07-23 05:56:42 +00:00
if niche == "" {
2026-07-30 01:25:34 +00:00
return "general"
2026-07-23 05:56:42 +00:00
}
2026-07-30 01:25:34 +00:00
return niche
}
func (s *Service) ContributeBenchmark(ctx context.Context, ownerUID int64, niche string, engRate, avgViews float64) error {
niche = NormalizeNiche(niche)
2026-07-23 05:56:42 +00:00
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) {
2026-07-30 01:25:34 +00:00
niche = NormalizeNiche(niche)
2026-07-23 05:56:42 +00:00
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
}
2026-07-30 01:25:34 +00:00
// RoundRate 用在互動率這種比率0.0234)。比率不能走 Round2 —— 那會輾成 0.02
// 前端 *100 後所有人都變成整數百分比。保留 4 位小數 = 百分比的 2 位。
func RoundRate(v float64) float64 {
return math.Round(v*10000) / 10000
}