diff --git a/apps/backend/cmd/worker/main.go b/apps/backend/cmd/worker/main.go
index ac1f6b8..cfbb707 100644
--- a/apps/backend/cmd/worker/main.go
+++ b/apps/backend/cmd/worker/main.go
@@ -28,6 +28,8 @@ import (
memberRepo "apps/backend/internal/module/member/repository"
scoutDomain "apps/backend/internal/module/scout/domain"
scoutRepo "apps/backend/internal/module/scout/repository"
+ growthRepo "apps/backend/internal/module/growth/repository"
+ growthUC "apps/backend/internal/module/growth/usecase"
scoutUC "apps/backend/internal/module/scout/usecase"
studioPublish "apps/backend/internal/module/studio/publish"
studioRepo "apps/backend/internal/module/studio/repository"
@@ -143,7 +145,16 @@ func main() {
// worker 執行 job 本體,不經 API 再入列
studio.Jobs = nil
- logx.Infof("haixun worker started id=%s interval=%s (demo + token_renew + persona_analyze + compose_mimic + outbox)", workerID, interval)
+ growthSvc := growthUC.New(growthRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database))
+ growthSvc.Bonus = usageSvc
+ scoutSvc.OnOutreachPublished = func(ctx context.Context, ownerUID int64, postID, accountID string) {
+ _, _ = growthSvc.RecordPublished(ctx, ownerUID, "scout_outreach", postID, accountID, 0)
+ }
+ studio.OnStepPublished = func(ctx context.Context, ownerUID int64, bundleID, stepID, accountID string) {
+ _, _ = growthSvc.RecordPublished(ctx, ownerUID, "outbox_step", bundleID+":"+stepID, accountID, 0)
+ }
+
+ logx.Infof("haixun worker started id=%s interval=%s (demo + token_renew + persona_analyze + compose_mimic + outbox + growth)", workerID, interval)
fmt.Printf("worker running id=%s interval=%s (Ctrl+C to stop)\n", workerID, interval)
sig := make(chan os.Signal, 1)
@@ -220,7 +231,13 @@ func main() {
}
// 2) One worker owns an outbox tick and renews its lease while claims run.
processOutbox(ctx, studio, outboxLock, workerID, outboxLockTTL)
- // 3) 終態任務超過 2 天自動清除
+ // 3) growth-loop: expire observing outcomes + light health refresh
+ if n, err := growthSvc.ObserveTick(ctx, 0); err != nil {
+ logx.Errorf("worker %s outcome observe: %v", workerID, err)
+ } else if n > 0 {
+ logx.Infof("worker %s outcome observe updated %d", workerID, n)
+ }
+ // 4) 終態任務超過 2 天自動清除
if purged, err := jobs.PurgeExpiredTerminal(ctx); err != nil {
logx.Errorf("worker %s purge jobs: %v", workerID, err)
} else if purged > 0 {
diff --git a/apps/backend/etc/gateway.example.yaml b/apps/backend/etc/gateway.example.yaml
index 153eef0..02ac515 100644
--- a/apps/backend/etc/gateway.example.yaml
+++ b/apps/backend/etc/gateway.example.yaml
@@ -124,12 +124,12 @@ Mail:
DevExposeCode: false
# 前端 origin:重設信連結 + 預設 logo URL(/brand-mark.jpg)
-# 遠端 dev:https://threads-tool-dev.30cm.net
-PublicWebBase: "https://threads-tool-dev.30cm.net"
+# 正式遠端:https://threads-tool.30cm.net
+PublicWebBase: "https://threads-tool.30cm.net"
# 瀏覽器直連 gateway 時的 allowlist;同源 proxy 不需要列在此處。
# CORS_ALLOWED_ORIGINS 可用逗號附加額外的明確 origin,不能使用 *。
CORSAllowedOrigins:
- - "https://threads-tool-dev.30cm.net"
+ - "https://threads-tool.30cm.net"
- "http://127.0.0.1:5173"
- "http://localhost:5173"
@@ -158,5 +158,5 @@ ObjectStorage:
SecretKey: ""
UsePathStyle: true
# env HAIXUN_STORAGE_S3_PUBLIC_BASE_URL 會覆寫;勿用 /api/v1/public/assets(新後端無此 route)
- PublicBaseURL: "https://threads-tool-dev.30cm.net/haixun-assets"
+ PublicBaseURL: "https://threads-tool.30cm.net/haixun-assets"
diff --git a/apps/backend/generate/api/gateway.api b/apps/backend/generate/api/gateway.api
index 6df0038..1c0bdc1 100644
--- a/apps/backend/generate/api/gateway.api
+++ b/apps/backend/generate/api/gateway.api
@@ -23,3 +23,5 @@ import "extension.api"
import "studio.api"
import "m5.api"
import "invite.api"
+import "growth.api"
+import "growth_p2.api"
diff --git a/apps/backend/generate/api/growth.api b/apps/backend/generate/api/growth.api
new file mode 100644
index 0000000..dbaa764
--- /dev/null
+++ b/apps/backend/generate/api/growth.api
@@ -0,0 +1,315 @@
+syntax = "v1"
+
+// growth-loop: outcomes / checkups / account health / workspaces / invite rewards
+
+type (
+ OutcomeEventPublic {
+ Id string `json:"id"`
+ SourceType string `json:"source_type"`
+ SourceId string `json:"source_id"`
+ ThreadsAccountId string `json:"threads_account_id,optional"`
+ Kind string `json:"kind"`
+ Confidence string `json:"confidence"`
+ Status string `json:"status"`
+ ReplyCountDelta int `json:"reply_count_delta,optional"`
+ LikeCountDelta int `json:"like_count_delta,optional"`
+ FollowersDelta int `json:"followers_delta,optional"`
+ ConversionAmount float64 `json:"conversion_amount,optional"`
+ ConversionNote string `json:"conversion_note,optional"`
+ ConversionCurrency string `json:"conversion_currency,optional"`
+ WindowEndsAt int64 `json:"window_ends_at"`
+ SentAt int64 `json:"sent_at"`
+ CreatedAt int64 `json:"created_at"`
+ UpdatedAt int64 `json:"updated_at"`
+ }
+
+ OutcomeSummaryData {
+ From int64 `json:"from"`
+ To int64 `json:"to"`
+ Reach int `json:"reach"`
+ Conversations int `json:"conversations"`
+ FollowsPossible int `json:"follows_possible"`
+ FollowsConfirmed int `json:"follows_confirmed"`
+ Conversions int `json:"conversions"`
+ ConversionAmount float64 `json:"conversion_amount"`
+ }
+
+ ListOutcomesReq {
+ Page int `form:"page,default=1"`
+ PageSize int `form:"pageSize,default=20"`
+ Kind string `form:"kind,optional"`
+ Confidence string `form:"confidence,optional"`
+ SourceType string `form:"source_type,optional"`
+ From int64 `form:"from,optional"`
+ To int64 `form:"to,optional"`
+ }
+
+ OutcomeListData {
+ List []OutcomeEventPublic `json:"list"`
+ Pagination Pagination `json:"pagination"`
+ }
+
+ OutcomeSummaryReq {
+ Range string `form:"range,default=week"` // week | month
+ From int64 `form:"from,optional"`
+ To int64 `form:"to,optional"`
+ }
+
+ GetOutcomeReq {
+ Id string `path:"id"`
+ }
+
+ ReportConversionReq {
+ Id string `path:"id"`
+ Amount float64 `json:"amount,optional"`
+ Note string `json:"note,optional"`
+ Currency string `json:"currency,optional"`
+ }
+
+ UpdateConversionReq {
+ Id string `path:"id"`
+ Amount float64 `json:"amount,optional"`
+ Note string `json:"note,optional"`
+ Currency string `json:"currency,optional"`
+ }
+
+ DeleteConversionReq {
+ Id string `path:"id"`
+ }
+
+ CheckupFindingPublic {
+ Claim string `json:"claim"`
+ Evidence []string `json:"evidence"`
+ }
+
+ CheckupActionPublic {
+ Title string `json:"title"`
+ Reason string `json:"reason"`
+ Deeplink string `json:"deeplink"`
+ }
+
+ WeeklyCheckupPublic {
+ Id string `json:"id"`
+ WeekKey string `json:"week_key"`
+ Status string `json:"status"`
+ Summary string `json:"summary"`
+ Findings []CheckupFindingPublic `json:"findings"`
+ Actions []CheckupActionPublic `json:"actions"`
+ Error string `json:"error,optional"`
+ CreatedAt int64 `json:"created_at"`
+ UpdatedAt int64 `json:"updated_at"`
+ }
+
+ ListCheckupsReq {
+ Page int `form:"page,default=1"`
+ PageSize int `form:"pageSize,default=20"`
+ }
+
+ CheckupListData {
+ List []WeeklyCheckupPublic `json:"list"`
+ Pagination Pagination `json:"pagination"`
+ }
+
+ GetCheckupReq {
+ Id string `path:"id"`
+ }
+
+ AccountHealthPublic {
+ ThreadsAccountId string `json:"threads_account_id"`
+ Score int `json:"score"`
+ Level string `json:"level"`
+ Factors []string `json:"factors"`
+ Advice string `json:"advice"`
+ ComputedAt int64 `json:"computed_at"`
+ }
+
+ AccountHealthListData {
+ List []AccountHealthPublic `json:"list"`
+ }
+
+ GetAccountHealthReq {
+ AccountId string `path:"accountId"`
+ }
+
+ WorkspacePublic {
+ Id string `json:"id"`
+ Name string `json:"name"`
+ ReviewRequired bool `json:"review_required"`
+ IsDefault bool `json:"is_default"`
+ Archived bool `json:"archived"`
+ FooterText string `json:"footer_text,optional"`
+ HidePoweredBy bool `json:"hide_powered_by,optional"`
+ BrandName string `json:"brand_name,optional"`
+ CreatedAt int64 `json:"created_at"`
+ UpdatedAt int64 `json:"updated_at"`
+ }
+
+ WorkspaceListData {
+ List []WorkspacePublic `json:"list"`
+ CurrentWorkspaceId string `json:"current_workspace_id"`
+ }
+
+ CreateWorkspaceReq {
+ Name string `json:"name"`
+ }
+
+ UpdateWorkspaceReq {
+ Id string `path:"id"`
+ Name string `json:"name,optional"`
+ ReviewRequired *bool `json:"review_required,optional"`
+ }
+
+ WorkspaceIdReq {
+ Id string `path:"id"`
+ }
+
+ SwitchWorkspaceReq {
+ Id string `path:"id"`
+ }
+
+ DraftReviewPublic {
+ Id string `json:"id"`
+ RefType string `json:"ref_type"`
+ RefId string `json:"ref_id"`
+ WorkspaceId string `json:"workspace_id"`
+ Status string `json:"status"`
+ Reason string `json:"reason,optional"`
+ CreatedAt int64 `json:"created_at"`
+ UpdatedAt int64 `json:"updated_at"`
+ }
+
+ SubmitDraftReviewReq {
+ RefType string `json:"ref_type"`
+ RefId string `json:"ref_id"`
+ WorkspaceId string `json:"workspace_id,optional"`
+ }
+
+ ReviewDecisionReq {
+ Id string `path:"id"`
+ Reason string `json:"reason,optional"`
+ }
+
+ ListDraftReviewsReq {
+ Page int `form:"page,default=1"`
+ PageSize int `form:"pageSize,default=20"`
+ Status string `form:"status,optional"`
+ }
+
+ DraftReviewListData {
+ List []DraftReviewPublic `json:"list"`
+ Pagination Pagination `json:"pagination"`
+ }
+
+ ExportReportReq {
+ WorkspaceId string `form:"workspace_id"`
+ YearMonth string `form:"year_month"` // YYYY-MM
+ Format string `form:"format,optional"` // md | pdf
+ }
+
+ ExportReportData {
+ Url string `json:"url"`
+ ExpiresAt int64 `json:"expires_at,optional"`
+ Filename string `json:"filename"`
+ // ContentType: text/markdown or application/pdf
+ ContentType string `json:"content_type,optional"`
+ // Format: md | pdf
+ Format string `json:"format,optional"`
+ }
+
+ // branding fields on workspace (P2 white-label report)
+ // extended via UpdateWorkspaceBranding in growth_p2.api
+)
+
+@server (
+ group: outcomes
+ prefix: /api/v1/outcomes
+ middleware: AuthJWT
+)
+service gateway {
+ @handler GetOutcomeSummary
+ get /summary (OutcomeSummaryReq) returns (OutcomeSummaryData)
+
+ @handler ListOutcomes
+ get / (ListOutcomesReq) returns (OutcomeListData)
+
+ @handler GetOutcome
+ get /:id (GetOutcomeReq) returns (OutcomeEventPublic)
+
+ @handler ReportConversion
+ post /:id/conversion (ReportConversionReq) returns (OutcomeEventPublic)
+
+ @handler UpdateConversion
+ put /:id/conversion (UpdateConversionReq) returns (OutcomeEventPublic)
+
+ @handler DeleteConversion
+ delete /:id/conversion (DeleteConversionReq) returns (OkData)
+}
+
+@server (
+ group: checkups
+ prefix: /api/v1/checkups
+ middleware: AuthJWT
+)
+service gateway {
+ @handler GetLatestCheckup
+ get /latest returns (WeeklyCheckupPublic)
+
+ @handler ListCheckups
+ get / (ListCheckupsReq) returns (CheckupListData)
+
+ @handler GetCheckup
+ get /:id (GetCheckupReq) returns (WeeklyCheckupPublic)
+
+ @handler GenerateCheckupNow
+ post /generate returns (WeeklyCheckupPublic)
+}
+
+@server (
+ group: health
+ prefix: /api/v1/account-health
+ middleware: AuthJWT
+)
+service gateway {
+ @handler ListAccountHealth
+ get / returns (AccountHealthListData)
+
+ @handler GetAccountHealth
+ get /:accountId (GetAccountHealthReq) returns (AccountHealthPublic)
+}
+
+@server (
+ group: workspaces
+ prefix: /api/v1/workspaces
+ middleware: AuthJWT
+)
+service gateway {
+ @handler ListWorkspaces
+ get / returns (WorkspaceListData)
+
+ @handler CreateWorkspace
+ post / (CreateWorkspaceReq) returns (WorkspacePublic)
+
+ @handler UpdateWorkspace
+ put /:id (UpdateWorkspaceReq) returns (WorkspacePublic)
+
+ @handler ArchiveWorkspace
+ post /:id/archive (WorkspaceIdReq) returns (OkData)
+
+ @handler SwitchWorkspace
+ post /:id/switch (SwitchWorkspaceReq) returns (WorkspaceListData)
+
+ @handler SubmitDraftReview
+ post /reviews (SubmitDraftReviewReq) returns (DraftReviewPublic)
+
+ @handler ApproveDraftReview
+ post /reviews/:id/approve (ReviewDecisionReq) returns (DraftReviewPublic)
+
+ @handler RejectDraftReview
+ post /reviews/:id/reject (ReviewDecisionReq) returns (DraftReviewPublic)
+
+ @handler ListDraftReviews
+ get /reviews (ListDraftReviewsReq) returns (DraftReviewListData)
+
+ @handler ExportWorkspaceReport
+ get /report/export (ExportReportReq) returns (ExportReportData)
+}
diff --git a/apps/backend/generate/api/growth_p2.api b/apps/backend/generate/api/growth_p2.api
new file mode 100644
index 0000000..5a774fb
--- /dev/null
+++ b/apps/backend/generate/api/growth_p2.api
@@ -0,0 +1,285 @@
+syntax = "v1"
+
+// growth-loop P2: playbook / free tools / benchmark / utm / insights / workspace members
+
+type (
+ PlaybookPublic {
+ Id string `json:"id"`
+ Kind string `json:"kind"` // brief | persona | play
+ Title string `json:"title"`
+ Niche string `json:"niche"`
+ Body string `json:"body"`
+ Anonymous bool `json:"anonymous"`
+ AuthorLabel string `json:"author_label"`
+ ImportCount int `json:"import_count"`
+ CreatedAt int64 `json:"created_at"`
+ Mine bool `json:"mine,optional"`
+ }
+
+ PlaybookListData {
+ List []PlaybookPublic `json:"list"`
+ Pagination Pagination `json:"pagination"`
+ }
+
+ ListPlaybooksReq {
+ Page int `form:"page,default=1"`
+ PageSize int `form:"pageSize,default=20"`
+ Kind string `form:"kind,optional"`
+ Niche string `form:"niche,optional"`
+ Mine bool `form:"mine,optional"`
+ }
+
+ PublishPlaybookReq {
+ Kind string `json:"kind"`
+ Title string `json:"title"`
+ Niche string `json:"niche,optional"`
+ Body string `json:"body"`
+ Anonymous bool `json:"anonymous,optional"`
+ }
+
+ PlaybookIdPath {
+ Id string `path:"id"`
+ }
+
+ ImportPlaybookData {
+ Id string `json:"id"`
+ Copied bool `json:"copied"`
+ Message string `json:"message"`
+ }
+
+ StyleQuizReq {
+ Samples string `json:"samples"`
+ }
+
+ StyleQuizData {
+ Tone string `json:"tone"`
+ Rhythm string `json:"rhythm"`
+ Hooks string `json:"hooks"`
+ Avoid string `json:"avoid"`
+ Summary string `json:"summary"`
+ Tags []string `json:"tags"`
+ SignupHint string `json:"signup_hint"`
+ }
+
+ PainKeywordsReq {
+ ProductBrief string `json:"product_brief"`
+ Audience string `json:"audience,optional"`
+ }
+
+ PainKeywordsData {
+ Keywords []string `json:"keywords"`
+ Pains []string `json:"pains"`
+ ScanTerms []string `json:"scan_terms"`
+ Summary string `json:"summary"`
+ SignupHint string `json:"signup_hint"`
+ }
+
+ BenchmarkReq {
+ Niche string `form:"niche,optional"`
+ AccountId string `form:"account_id,optional"`
+ }
+
+ BenchmarkData {
+ Available bool `json:"available"`
+ SampleSize int `json:"sample_size"`
+ Niche string `json:"niche"`
+ MedianEngRate float64 `json:"median_eng_rate"`
+ MedianViews float64 `json:"median_views"`
+ YourEngRate float64 `json:"your_eng_rate,optional"`
+ YourViews float64 `json:"your_views,optional"`
+ PercentileHint string `json:"percentile_hint,optional"`
+ Message string `json:"message,optional"`
+ }
+
+ CreateUtmLinkReq {
+ DestinationUrl string `json:"destination_url"`
+ OutcomeId string `json:"outcome_id,optional"`
+ Label string `json:"label,optional"`
+ }
+
+ UtmLinkPublic {
+ Id string `json:"id"`
+ Code string `json:"code"`
+ TrackPath string `json:"track_path"`
+ DestinationUrl string `json:"destination_url"`
+ OutcomeId string `json:"outcome_id,optional"`
+ Label string `json:"label,optional"`
+ Clicks int `json:"clicks"`
+ CreatedAt int64 `json:"created_at"`
+ }
+
+ UtmLinkListData {
+ List []UtmLinkPublic `json:"list"`
+ }
+
+ UtmRedirectReq {
+ Code string `path:"code"`
+ }
+
+ InsightsSummaryReq {
+ AccountId string `form:"account_id,optional"`
+ Months int `form:"months,default=3"`
+ }
+
+ InsightsPostPublic {
+ Id string `json:"id"`
+ TextPreview string `json:"text_preview"`
+ PublishedAt int64 `json:"published_at"`
+ ViewCount int `json:"view_count"`
+ LikeCount int `json:"like_count"`
+ ReplyCount int `json:"reply_count"`
+ EngRate float64 `json:"eng_rate"`
+ }
+
+ InsightsSummaryData {
+ AccountId string `json:"account_id,optional"`
+ PostCount int `json:"post_count"`
+ TotalViews int `json:"total_views"`
+ TotalLikes int `json:"total_likes"`
+ TotalReplies int `json:"total_replies"`
+ AvgEngRate float64 `json:"avg_eng_rate"`
+ TopPosts []InsightsPostPublic `json:"top_posts"`
+ MonthBuckets []InsightsMonthBucket `json:"month_buckets"`
+ Conclusion string `json:"conclusion"`
+ Suggestions []string `json:"suggestions"`
+ }
+
+ InsightsMonthBucket {
+ MonthKey string `json:"month_key"`
+ Posts int `json:"posts"`
+ Views int `json:"views"`
+ Likes int `json:"likes"`
+ Replies int `json:"replies"`
+ AvgEngRate float64 `json:"avg_eng_rate"`
+ }
+
+ AddWorkspaceMemberReq {
+ WorkspaceId string `path:"id"`
+ MemberUid int64 `json:"member_uid,optional"`
+ Email string `json:"email,optional"`
+ Role string `json:"role,optional"` // editor | reviewer | owner
+ }
+
+ WorkspaceMemberPublic {
+ Uid int64 `json:"uid"`
+ Email string `json:"email"`
+ DisplayName string `json:"display_name"`
+ Role string `json:"role"`
+ JoinedAt int64 `json:"joined_at"`
+ }
+
+ WorkspaceMemberListData {
+ List []WorkspaceMemberPublic `json:"list"`
+ }
+
+ ListWorkspaceMembersReq {
+ Id string `path:"id"`
+ }
+
+ RemoveWorkspaceMemberReq {
+ Id string `path:"id"`
+ Uid int64 `path:"uid"`
+ }
+
+ WorkspaceBrandingReq {
+ Id string `path:"id"`
+ FooterText string `json:"footer_text,optional"`
+ HidePoweredBy bool `json:"hide_powered_by,optional"`
+ BrandName string `json:"brand_name,optional"`
+ }
+)
+
+@server (
+ group: playbooks
+ prefix: /api/v1/playbooks
+ middleware: AuthJWT
+)
+service gateway {
+ @handler ListPlaybooks
+ get / (ListPlaybooksReq) returns (PlaybookListData)
+
+ @handler PublishPlaybook
+ post / (PublishPlaybookReq) returns (PlaybookPublic)
+
+ @handler GetPlaybook
+ get /:id (PlaybookIdPath) returns (PlaybookPublic)
+
+ @handler ImportPlaybook
+ post /:id/import (PlaybookIdPath) returns (ImportPlaybookData)
+
+ @handler RemovePlaybook
+ delete /:id (PlaybookIdPath) returns (OkData)
+}
+
+@server (
+ group: publictools
+ prefix: /api/v1/public/tools
+)
+service gateway {
+ @handler PublicStyleQuiz
+ post /style-quiz (StyleQuizReq) returns (StyleQuizData)
+
+ @handler PublicPainKeywords
+ post /pain-keywords (PainKeywordsReq) returns (PainKeywordsData)
+}
+
+@server (
+ group: benchmark
+ prefix: /api/v1/benchmark
+ middleware: AuthJWT
+)
+service gateway {
+ @handler GetBenchmark
+ get / (BenchmarkReq) returns (BenchmarkData)
+}
+
+@server (
+ group: utm
+ prefix: /api/v1/utm
+ middleware: AuthJWT
+)
+service gateway {
+ @handler CreateUtmLink
+ post /links (CreateUtmLinkReq) returns (UtmLinkPublic)
+
+ @handler ListUtmLinks
+ get /links returns (UtmLinkListData)
+}
+
+@server (
+ group: publicutm
+ prefix: /api/v1/public/u
+)
+service gateway {
+ @handler UtmRedirect
+ get /:code (UtmRedirectReq) returns (OkData)
+}
+
+@server (
+ group: insightsapi
+ prefix: /api/v1/insights
+ middleware: AuthJWT
+)
+service gateway {
+ @handler GetInsightsSummary
+ get /summary (InsightsSummaryReq) returns (InsightsSummaryData)
+}
+
+@server (
+ group: workspaces
+ prefix: /api/v1/workspaces
+ middleware: AuthJWT
+)
+service gateway {
+ @handler ListWorkspaceMembers
+ get /:id/members (ListWorkspaceMembersReq) returns (WorkspaceMemberListData)
+
+ @handler AddWorkspaceMember
+ post /:id/members (AddWorkspaceMemberReq) returns (WorkspaceMemberPublic)
+
+ @handler RemoveWorkspaceMember
+ delete /:id/members/:uid (RemoveWorkspaceMemberReq) returns (OkData)
+
+ @handler UpdateWorkspaceBranding
+ put /:id/branding (WorkspaceBrandingReq) returns (WorkspacePublic)
+}
diff --git a/apps/backend/generate/api/invite.api b/apps/backend/generate/api/invite.api
index 96b1431..789b7a7 100644
--- a/apps/backend/generate/api/invite.api
+++ b/apps/backend/generate/api/invite.api
@@ -34,11 +34,28 @@ type (
Children []InviteTreeNode `json:"children"`
}
+ InviteRewardPublic {
+ Id string `json:"id"`
+ InviteeUid int64 `json:"invitee_uid"`
+ PlanId string `json:"plan_id"`
+ Points int `json:"points"`
+ Status string `json:"status"`
+ CreatedAt int64 `json:"created_at"`
+ }
+
+ InviteRewardsSummary {
+ TotalPoints int `json:"total_points"`
+ MonthPoints int `json:"month_points"`
+ MonthCap int `json:"month_cap"`
+ Recent []InviteRewardPublic `json:"recent"`
+ }
+
MyInviteNetworkData {
Me InviteMemberBrief `json:"me"`
Upline *InviteMemberBrief `json:"upline,optional"`
Downlines []InviteMemberBrief `json:"downlines"`
TotalDownlineCount int `json:"total_downline_count"`
+ Rewards *InviteRewardsSummary `json:"rewards,optional"`
}
ApplyInviteCodeReq {
diff --git a/apps/backend/generate/api/m5.api b/apps/backend/generate/api/m5.api
index 86b0696..36cc1e1 100644
--- a/apps/backend/generate/api/m5.api
+++ b/apps/backend/generate/api/m5.api
@@ -152,11 +152,15 @@ type (
// --- Scout ---
BrandPublic {
- Id string `json:"id"`
- DisplayName string `json:"display_name"`
- Brief string `json:"brief"`
- TargetAudience string `json:"target_audience,optional"`
- Goals string `json:"goals,optional"`
+ Id string `json:"id"`
+ DisplayName string `json:"display_name"`
+ Brief string `json:"brief"`
+ TargetAudience string `json:"target_audience,optional"`
+ Goals string `json:"goals,optional"`
+ LearningSummary string `json:"learning_summary,optional"`
+ LearningVersion int `json:"learning_version,optional"`
+ LearnedFromPostsCount int `json:"learned_from_posts_count,optional"`
+ LastLearnedAt int64 `json:"last_learned_at,optional"`
}
BrandListData {
List []BrandPublic `json:"list"`
diff --git a/apps/backend/generate/api/studio.api b/apps/backend/generate/api/studio.api
index 43e2c9a..b51708c 100644
--- a/apps/backend/generate/api/studio.api
+++ b/apps/backend/generate/api/studio.api
@@ -43,16 +43,20 @@ type (
}
PersonaPublic {
- Id string `json:"id"`
- Name string `json:"name"`
- Brief string `json:"brief"`
- Status string `json:"status"`
- Style PersonaStylePublic `json:"style"`
- Guard PersonaGuardPublic `json:"guard"`
- Voice string `json:"voice,optional"`
- Notes string `json:"notes,optional"`
- CreatedAt int64 `json:"created_at"`
- UpdatedAt int64 `json:"updated_at"`
+ Id string `json:"id"`
+ Name string `json:"name"`
+ Brief string `json:"brief"`
+ Status string `json:"status"`
+ Style PersonaStylePublic `json:"style"`
+ Guard PersonaGuardPublic `json:"guard"`
+ Voice string `json:"voice,optional"`
+ Notes string `json:"notes,optional"`
+ LearningSummary string `json:"learning_summary,optional"`
+ LearningVersion int `json:"learning_version,optional"`
+ LearnedFromPostsCount int `json:"learned_from_posts_count,optional"`
+ LastLearnedAt int64 `json:"last_learned_at,optional"`
+ CreatedAt int64 `json:"created_at"`
+ UpdatedAt int64 `json:"updated_at"`
}
PersonaListData {
diff --git a/apps/backend/generate/database/mongo/000013_growth_loop_indexes.down.json b/apps/backend/generate/database/mongo/000013_growth_loop_indexes.down.json
new file mode 100644
index 0000000..a6fa11d
--- /dev/null
+++ b/apps/backend/generate/database/mongo/000013_growth_loop_indexes.down.json
@@ -0,0 +1,12 @@
+[
+ { "dropIndexes": "growth_outcomes", "index": "owner_outcomes_recent" },
+ { "dropIndexes": "growth_outcomes", "index": "owner_outcome_source" },
+ { "dropIndexes": "growth_outcomes", "index": "outcome_observe" },
+ { "dropIndexes": "growth_checkups", "index": "owner_checkups_recent" },
+ { "dropIndexes": "growth_checkups", "index": "owner_checkup_week" },
+ { "dropIndexes": "growth_account_health", "index": "owner_health" },
+ { "dropIndexes": "growth_workspaces", "index": "owner_workspaces" },
+ { "dropIndexes": "growth_draft_reviews", "index": "owner_review_ref" },
+ { "dropIndexes": "growth_invite_rewards", "index": "inviter_rewards" },
+ { "dropIndexes": "growth_invite_rewards", "index": "invitee_reward_once" }
+]
diff --git a/apps/backend/generate/database/mongo/000013_growth_loop_indexes.up.json b/apps/backend/generate/database/mongo/000013_growth_loop_indexes.up.json
new file mode 100644
index 0000000..b9db948
--- /dev/null
+++ b/apps/backend/generate/database/mongo/000013_growth_loop_indexes.up.json
@@ -0,0 +1,42 @@
+[
+ {
+ "createIndexes": "growth_outcomes",
+ "indexes": [
+ { "key": { "owner_uid": 1, "sent_at": -1 }, "name": "owner_outcomes_recent" },
+ { "key": { "owner_uid": 1, "source_type": 1, "source_id": 1 }, "name": "owner_outcome_source", "unique": true },
+ { "key": { "status": 1, "window_ends_at": 1 }, "name": "outcome_observe" }
+ ]
+ },
+ {
+ "createIndexes": "growth_checkups",
+ "indexes": [
+ { "key": { "owner_uid": 1, "created_at": -1 }, "name": "owner_checkups_recent" },
+ { "key": { "owner_uid": 1, "week_key": 1 }, "name": "owner_checkup_week" }
+ ]
+ },
+ {
+ "createIndexes": "growth_account_health",
+ "indexes": [
+ { "key": { "owner_uid": 1 }, "name": "owner_health" }
+ ]
+ },
+ {
+ "createIndexes": "growth_workspaces",
+ "indexes": [
+ { "key": { "owner_uid": 1, "archived": 1 }, "name": "owner_workspaces" }
+ ]
+ },
+ {
+ "createIndexes": "growth_draft_reviews",
+ "indexes": [
+ { "key": { "owner_uid": 1, "ref_type": 1, "ref_id": 1, "updated_at": -1 }, "name": "owner_review_ref" }
+ ]
+ },
+ {
+ "createIndexes": "growth_invite_rewards",
+ "indexes": [
+ { "key": { "inviter_uid": 1, "created_at": -1 }, "name": "inviter_rewards" },
+ { "key": { "invitee_uid": 1 }, "name": "invitee_reward_once" }
+ ]
+ }
+]
diff --git a/apps/backend/internal/config/config.go b/apps/backend/internal/config/config.go
index 0edd4d7..5949aec 100644
--- a/apps/backend/internal/config/config.go
+++ b/apps/backend/internal/config/config.go
@@ -101,11 +101,11 @@ type Config struct {
DevExposeCode bool `json:",default=true"`
} `json:",optional"`
// PublicWebBase — 前端 origin,用於重設密碼信內連結/logo(勿尾斜線)
- // 例:http://127.0.0.1:5173 或 https://threads-tool-dev.30cm.net
+ // 例:http://127.0.0.1:5173 或 https://threads-tool.30cm.net
PublicWebBase string `json:",optional,default=http://127.0.0.1:5173"`
// PublicAPIBase — 對外 API origin(Threads OAuth redirect_uri 必須 https 公開網址)
// 空則:若 PublicWebBase 為 https 則同 host;否則回落 http://127.0.0.1:Port
- // 例:https://threads-tool-dev.30cm.net
+ // 例:https://threads-tool.30cm.net
PublicAPIBase string `json:",optional"`
// Brand — 郵件 chrome(hermes);LogoURL 空則用 PublicWebBase + /brand-mark.svg
Brand struct {
diff --git a/apps/backend/internal/handler/benchmark/get_benchmark_handler.go b/apps/backend/internal/handler/benchmark/get_benchmark_handler.go
new file mode 100644
index 0000000..da0f1fc
--- /dev/null
+++ b/apps/backend/internal/handler/benchmark/get_benchmark_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package benchmark
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/benchmark"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func GetBenchmarkHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.BenchmarkReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := benchmark.NewGetBenchmarkLogic(r.Context(), svcCtx)
+ data, err := l.GetBenchmark(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/checkups/generate_checkup_now_handler.go b/apps/backend/internal/handler/checkups/generate_checkup_now_handler.go
new file mode 100644
index 0000000..65deb00
--- /dev/null
+++ b/apps/backend/internal/handler/checkups/generate_checkup_now_handler.go
@@ -0,0 +1,20 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package checkups
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/checkups"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+)
+
+func GenerateCheckupNowHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ l := checkups.NewGenerateCheckupNowLogic(r.Context(), svcCtx)
+ data, err := l.GenerateCheckupNow()
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/checkups/get_checkup_handler.go b/apps/backend/internal/handler/checkups/get_checkup_handler.go
new file mode 100644
index 0000000..46d012d
--- /dev/null
+++ b/apps/backend/internal/handler/checkups/get_checkup_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package checkups
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/checkups"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func GetCheckupHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.GetCheckupReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := checkups.NewGetCheckupLogic(r.Context(), svcCtx)
+ data, err := l.GetCheckup(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/checkups/get_latest_checkup_handler.go b/apps/backend/internal/handler/checkups/get_latest_checkup_handler.go
new file mode 100644
index 0000000..f8e03a0
--- /dev/null
+++ b/apps/backend/internal/handler/checkups/get_latest_checkup_handler.go
@@ -0,0 +1,20 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package checkups
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/checkups"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+)
+
+func GetLatestCheckupHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ l := checkups.NewGetLatestCheckupLogic(r.Context(), svcCtx)
+ data, err := l.GetLatestCheckup()
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/checkups/list_checkups_handler.go b/apps/backend/internal/handler/checkups/list_checkups_handler.go
new file mode 100644
index 0000000..497a0d9
--- /dev/null
+++ b/apps/backend/internal/handler/checkups/list_checkups_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package checkups
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/checkups"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func ListCheckupsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ListCheckupsReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := checkups.NewListCheckupsLogic(r.Context(), svcCtx)
+ data, err := l.ListCheckups(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/health/get_account_health_handler.go b/apps/backend/internal/handler/health/get_account_health_handler.go
new file mode 100644
index 0000000..3c27862
--- /dev/null
+++ b/apps/backend/internal/handler/health/get_account_health_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package health
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/health"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func GetAccountHealthHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.GetAccountHealthReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := health.NewGetAccountHealthLogic(r.Context(), svcCtx)
+ data, err := l.GetAccountHealth(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/health/list_account_health_handler.go b/apps/backend/internal/handler/health/list_account_health_handler.go
new file mode 100644
index 0000000..3f982ed
--- /dev/null
+++ b/apps/backend/internal/handler/health/list_account_health_handler.go
@@ -0,0 +1,20 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package health
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/health"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+)
+
+func ListAccountHealthHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ l := health.NewListAccountHealthLogic(r.Context(), svcCtx)
+ data, err := l.ListAccountHealth()
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/insightsapi/get_insights_summary_handler.go b/apps/backend/internal/handler/insightsapi/get_insights_summary_handler.go
new file mode 100644
index 0000000..285d199
--- /dev/null
+++ b/apps/backend/internal/handler/insightsapi/get_insights_summary_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package insightsapi
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/insightsapi"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func GetInsightsSummaryHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.InsightsSummaryReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := insightsapi.NewGetInsightsSummaryLogic(r.Context(), svcCtx)
+ data, err := l.GetInsightsSummary(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/outcomes/delete_conversion_handler.go b/apps/backend/internal/handler/outcomes/delete_conversion_handler.go
new file mode 100644
index 0000000..1bf4d55
--- /dev/null
+++ b/apps/backend/internal/handler/outcomes/delete_conversion_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package outcomes
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/outcomes"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func DeleteConversionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.DeleteConversionReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := outcomes.NewDeleteConversionLogic(r.Context(), svcCtx)
+ data, err := l.DeleteConversion(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/outcomes/get_outcome_handler.go b/apps/backend/internal/handler/outcomes/get_outcome_handler.go
new file mode 100644
index 0000000..a35e737
--- /dev/null
+++ b/apps/backend/internal/handler/outcomes/get_outcome_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package outcomes
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/outcomes"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func GetOutcomeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.GetOutcomeReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := outcomes.NewGetOutcomeLogic(r.Context(), svcCtx)
+ data, err := l.GetOutcome(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/outcomes/get_outcome_summary_handler.go b/apps/backend/internal/handler/outcomes/get_outcome_summary_handler.go
new file mode 100644
index 0000000..99f5dc9
--- /dev/null
+++ b/apps/backend/internal/handler/outcomes/get_outcome_summary_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package outcomes
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/outcomes"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func GetOutcomeSummaryHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.OutcomeSummaryReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := outcomes.NewGetOutcomeSummaryLogic(r.Context(), svcCtx)
+ data, err := l.GetOutcomeSummary(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/outcomes/list_outcomes_handler.go b/apps/backend/internal/handler/outcomes/list_outcomes_handler.go
new file mode 100644
index 0000000..51d7ee6
--- /dev/null
+++ b/apps/backend/internal/handler/outcomes/list_outcomes_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package outcomes
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/outcomes"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func ListOutcomesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ListOutcomesReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := outcomes.NewListOutcomesLogic(r.Context(), svcCtx)
+ data, err := l.ListOutcomes(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/outcomes/report_conversion_handler.go b/apps/backend/internal/handler/outcomes/report_conversion_handler.go
new file mode 100644
index 0000000..fe47af7
--- /dev/null
+++ b/apps/backend/internal/handler/outcomes/report_conversion_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package outcomes
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/outcomes"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func ReportConversionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ReportConversionReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := outcomes.NewReportConversionLogic(r.Context(), svcCtx)
+ data, err := l.ReportConversion(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/outcomes/update_conversion_handler.go b/apps/backend/internal/handler/outcomes/update_conversion_handler.go
new file mode 100644
index 0000000..5621a3e
--- /dev/null
+++ b/apps/backend/internal/handler/outcomes/update_conversion_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package outcomes
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/outcomes"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func UpdateConversionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.UpdateConversionReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := outcomes.NewUpdateConversionLogic(r.Context(), svcCtx)
+ data, err := l.UpdateConversion(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/playbooks/get_playbook_handler.go b/apps/backend/internal/handler/playbooks/get_playbook_handler.go
new file mode 100644
index 0000000..53b7e1a
--- /dev/null
+++ b/apps/backend/internal/handler/playbooks/get_playbook_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package playbooks
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/playbooks"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func GetPlaybookHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.PlaybookIdPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := playbooks.NewGetPlaybookLogic(r.Context(), svcCtx)
+ data, err := l.GetPlaybook(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/playbooks/import_playbook_handler.go b/apps/backend/internal/handler/playbooks/import_playbook_handler.go
new file mode 100644
index 0000000..a71048c
--- /dev/null
+++ b/apps/backend/internal/handler/playbooks/import_playbook_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package playbooks
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/playbooks"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func ImportPlaybookHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.PlaybookIdPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := playbooks.NewImportPlaybookLogic(r.Context(), svcCtx)
+ data, err := l.ImportPlaybook(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/playbooks/list_playbooks_handler.go b/apps/backend/internal/handler/playbooks/list_playbooks_handler.go
new file mode 100644
index 0000000..25932b8
--- /dev/null
+++ b/apps/backend/internal/handler/playbooks/list_playbooks_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package playbooks
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/playbooks"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func ListPlaybooksHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ListPlaybooksReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := playbooks.NewListPlaybooksLogic(r.Context(), svcCtx)
+ data, err := l.ListPlaybooks(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/playbooks/publish_playbook_handler.go b/apps/backend/internal/handler/playbooks/publish_playbook_handler.go
new file mode 100644
index 0000000..c88ad2d
--- /dev/null
+++ b/apps/backend/internal/handler/playbooks/publish_playbook_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package playbooks
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/playbooks"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func PublishPlaybookHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.PublishPlaybookReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := playbooks.NewPublishPlaybookLogic(r.Context(), svcCtx)
+ data, err := l.PublishPlaybook(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/playbooks/remove_playbook_handler.go b/apps/backend/internal/handler/playbooks/remove_playbook_handler.go
new file mode 100644
index 0000000..e786010
--- /dev/null
+++ b/apps/backend/internal/handler/playbooks/remove_playbook_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package playbooks
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/playbooks"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func RemovePlaybookHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.PlaybookIdPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := playbooks.NewRemovePlaybookLogic(r.Context(), svcCtx)
+ data, err := l.RemovePlaybook(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/publictools/public_pain_keywords_handler.go b/apps/backend/internal/handler/publictools/public_pain_keywords_handler.go
new file mode 100644
index 0000000..d0c2bdd
--- /dev/null
+++ b/apps/backend/internal/handler/publictools/public_pain_keywords_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package publictools
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/publictools"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func PublicPainKeywordsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.PainKeywordsReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := publictools.NewPublicPainKeywordsLogic(r.Context(), svcCtx)
+ data, err := l.PublicPainKeywords(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/publictools/public_style_quiz_handler.go b/apps/backend/internal/handler/publictools/public_style_quiz_handler.go
new file mode 100644
index 0000000..3a9d855
--- /dev/null
+++ b/apps/backend/internal/handler/publictools/public_style_quiz_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package publictools
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/publictools"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func PublicStyleQuizHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.StyleQuizReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := publictools.NewPublicStyleQuizLogic(r.Context(), svcCtx)
+ data, err := l.PublicStyleQuiz(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/publicutm/utm_redirect_handler.go b/apps/backend/internal/handler/publicutm/utm_redirect_handler.go
new file mode 100644
index 0000000..71af0f4
--- /dev/null
+++ b/apps/backend/internal/handler/publicutm/utm_redirect_handler.go
@@ -0,0 +1,33 @@
+// Code generated by goctl. DO NOT EDIT.
+// customized: HTTP 302 redirect for UTM tracking
+
+package publicutm
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/publicutm"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func UtmRedirectHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.UtmRedirectReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := publicutm.NewUtmRedirectLogic(r.Context(), svcCtx)
+ dest, err := svcCtx.Growth.TrackUtmClick(r.Context(), req.Code)
+ if err != nil {
+ response.Write(r.Context(), w, nil, err)
+ return
+ }
+ _ = l
+ http.Redirect(w, r, dest, http.StatusFound)
+ }
+}
diff --git a/apps/backend/internal/handler/routes.go b/apps/backend/internal/handler/routes.go
index 69a3688..176971b 100644
--- a/apps/backend/internal/handler/routes.go
+++ b/apps/backend/internal/handler/routes.go
@@ -8,9 +8,13 @@ import (
admin "apps/backend/internal/handler/admin"
auth "apps/backend/internal/handler/auth"
+ benchmark "apps/backend/internal/handler/benchmark"
billing "apps/backend/internal/handler/billing"
+ checkups "apps/backend/internal/handler/checkups"
compose "apps/backend/internal/handler/compose"
extension "apps/backend/internal/handler/extension"
+ health "apps/backend/internal/handler/health"
+ insightsapi "apps/backend/internal/handler/insightsapi"
inspire "apps/backend/internal/handler/inspire"
invite "apps/backend/internal/handler/invite"
jobs "apps/backend/internal/handler/jobs"
@@ -18,16 +22,22 @@ import (
mentions "apps/backend/internal/handler/mentions"
notifications "apps/backend/internal/handler/notifications"
outbox "apps/backend/internal/handler/outbox"
+ outcomes "apps/backend/internal/handler/outcomes"
ownposts "apps/backend/internal/handler/ownposts"
personas "apps/backend/internal/handler/personas"
ping "apps/backend/internal/handler/ping"
+ playbooks "apps/backend/internal/handler/playbooks"
plays "apps/backend/internal/handler/plays"
proxy "apps/backend/internal/handler/proxy"
+ publictools "apps/backend/internal/handler/publictools"
+ publicutm "apps/backend/internal/handler/publicutm"
research "apps/backend/internal/handler/research"
scout "apps/backend/internal/handler/scout"
settings "apps/backend/internal/handler/settings"
threads "apps/backend/internal/handler/threads"
usage "apps/backend/internal/handler/usage"
+ utm "apps/backend/internal/handler/utm"
+ workspaces "apps/backend/internal/handler/workspaces"
"apps/backend/internal/svc"
"github.com/zeromicro/go-zero/rest"
@@ -188,6 +198,20 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1/auth"),
)
+ server.AddRoutes(
+ rest.WithMiddlewares(
+ []rest.Middleware{serverCtx.AuthJWT},
+ []rest.Route{
+ {
+ Method: http.MethodGet,
+ Path: "/",
+ Handler: benchmark.GetBenchmarkHandler(serverCtx),
+ },
+ }...,
+ ),
+ rest.WithPrefix("/api/v1/benchmark"),
+ )
+
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
@@ -232,6 +256,35 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1/billing"),
)
+ server.AddRoutes(
+ rest.WithMiddlewares(
+ []rest.Middleware{serverCtx.AuthJWT},
+ []rest.Route{
+ {
+ Method: http.MethodGet,
+ Path: "/",
+ Handler: checkups.ListCheckupsHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id",
+ Handler: checkups.GetCheckupHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/generate",
+ Handler: checkups.GenerateCheckupNowHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/latest",
+ Handler: checkups.GetLatestCheckupHandler(serverCtx),
+ },
+ }...,
+ ),
+ rest.WithPrefix("/api/v1/checkups"),
+ )
+
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
@@ -277,6 +330,39 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1/extension"),
)
+ server.AddRoutes(
+ rest.WithMiddlewares(
+ []rest.Middleware{serverCtx.AuthJWT},
+ []rest.Route{
+ {
+ Method: http.MethodGet,
+ Path: "/",
+ Handler: health.ListAccountHealthHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:accountId",
+ Handler: health.GetAccountHealthHandler(serverCtx),
+ },
+ }...,
+ ),
+ rest.WithPrefix("/api/v1/account-health"),
+ )
+
+ server.AddRoutes(
+ rest.WithMiddlewares(
+ []rest.Middleware{serverCtx.AuthJWT},
+ []rest.Route{
+ {
+ Method: http.MethodGet,
+ Path: "/summary",
+ Handler: insightsapi.GetInsightsSummaryHandler(serverCtx),
+ },
+ }...,
+ ),
+ rest.WithPrefix("/api/v1/insights"),
+ )
+
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
@@ -446,11 +532,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
[]rest.Route{
{
Method: http.MethodPost,
- Path: "/upload",
- Handler: media.UploadHandler(serverCtx),
+ Path: "/generate-image",
+ Handler: media.GenerateImageHandler(serverCtx),
},
}...,
),
+ rest.WithJwt(serverCtx.Config.Auth.AccessSecret),
rest.WithPrefix("/api/v1/media"),
)
@@ -460,12 +547,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
[]rest.Route{
{
Method: http.MethodPost,
- Path: "/generate-image",
- Handler: media.GenerateImageHandler(serverCtx),
+ Path: "/upload",
+ Handler: media.UploadHandler(serverCtx),
},
}...,
),
- rest.WithJwt(serverCtx.Config.Auth.AccessSecret),
rest.WithPrefix("/api/v1/media"),
)
@@ -564,6 +650,45 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1/outbox"),
)
+ server.AddRoutes(
+ rest.WithMiddlewares(
+ []rest.Middleware{serverCtx.AuthJWT},
+ []rest.Route{
+ {
+ Method: http.MethodGet,
+ Path: "/",
+ Handler: outcomes.ListOutcomesHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id",
+ Handler: outcomes.GetOutcomeHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/conversion",
+ Handler: outcomes.ReportConversionHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPut,
+ Path: "/:id/conversion",
+ Handler: outcomes.UpdateConversionHandler(serverCtx),
+ },
+ {
+ Method: http.MethodDelete,
+ Path: "/:id/conversion",
+ Handler: outcomes.DeleteConversionHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/summary",
+ Handler: outcomes.GetOutcomeSummaryHandler(serverCtx),
+ },
+ }...,
+ ),
+ rest.WithPrefix("/api/v1/outcomes"),
+ )
+
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
@@ -675,6 +800,40 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1"),
)
+ server.AddRoutes(
+ rest.WithMiddlewares(
+ []rest.Middleware{serverCtx.AuthJWT},
+ []rest.Route{
+ {
+ Method: http.MethodGet,
+ Path: "/",
+ Handler: playbooks.ListPlaybooksHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/",
+ Handler: playbooks.PublishPlaybookHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id",
+ Handler: playbooks.GetPlaybookHandler(serverCtx),
+ },
+ {
+ Method: http.MethodDelete,
+ Path: "/:id",
+ Handler: playbooks.RemovePlaybookHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/import",
+ Handler: playbooks.ImportPlaybookHandler(serverCtx),
+ },
+ }...,
+ ),
+ rest.WithPrefix("/api/v1/playbooks"),
+ )
+
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
@@ -755,6 +914,33 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1/proxy"),
)
+ server.AddRoutes(
+ []rest.Route{
+ {
+ Method: http.MethodPost,
+ Path: "/pain-keywords",
+ Handler: publictools.PublicPainKeywordsHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/style-quiz",
+ Handler: publictools.PublicStyleQuizHandler(serverCtx),
+ },
+ },
+ rest.WithPrefix("/api/v1/public/tools"),
+ )
+
+ server.AddRoutes(
+ []rest.Route{
+ {
+ Method: http.MethodGet,
+ Path: "/:code",
+ Handler: publicutm.UtmRedirectHandler(serverCtx),
+ },
+ },
+ rest.WithPrefix("/api/v1/public/u"),
+ )
+
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
@@ -1059,4 +1245,111 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithJwt(serverCtx.Config.Auth.AccessSecret),
rest.WithPrefix("/api/v1/usage"),
)
+
+ server.AddRoutes(
+ rest.WithMiddlewares(
+ []rest.Middleware{serverCtx.AuthJWT},
+ []rest.Route{
+ {
+ Method: http.MethodPost,
+ Path: "/links",
+ Handler: utm.CreateUtmLinkHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/links",
+ Handler: utm.ListUtmLinksHandler(serverCtx),
+ },
+ }...,
+ ),
+ rest.WithPrefix("/api/v1/utm"),
+ )
+
+ server.AddRoutes(
+ rest.WithMiddlewares(
+ []rest.Middleware{serverCtx.AuthJWT},
+ []rest.Route{
+ {
+ Method: http.MethodPut,
+ Path: "/:id/branding",
+ Handler: workspaces.UpdateWorkspaceBrandingHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id/members",
+ Handler: workspaces.ListWorkspaceMembersHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/members",
+ Handler: workspaces.AddWorkspaceMemberHandler(serverCtx),
+ },
+ {
+ Method: http.MethodDelete,
+ Path: "/:id/members/:uid",
+ Handler: workspaces.RemoveWorkspaceMemberHandler(serverCtx),
+ },
+ }...,
+ ),
+ rest.WithPrefix("/api/v1/workspaces"),
+ )
+
+ server.AddRoutes(
+ rest.WithMiddlewares(
+ []rest.Middleware{serverCtx.AuthJWT},
+ []rest.Route{
+ {
+ Method: http.MethodGet,
+ Path: "/",
+ Handler: workspaces.ListWorkspacesHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/",
+ Handler: workspaces.CreateWorkspaceHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPut,
+ Path: "/:id",
+ Handler: workspaces.UpdateWorkspaceHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/archive",
+ Handler: workspaces.ArchiveWorkspaceHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/switch",
+ Handler: workspaces.SwitchWorkspaceHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/report/export",
+ Handler: workspaces.ExportWorkspaceReportHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/reviews",
+ Handler: workspaces.SubmitDraftReviewHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/reviews",
+ Handler: workspaces.ListDraftReviewsHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/reviews/:id/approve",
+ Handler: workspaces.ApproveDraftReviewHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/reviews/:id/reject",
+ Handler: workspaces.RejectDraftReviewHandler(serverCtx),
+ },
+ }...,
+ ),
+ rest.WithPrefix("/api/v1/workspaces"),
+ )
}
diff --git a/apps/backend/internal/handler/utm/create_utm_link_handler.go b/apps/backend/internal/handler/utm/create_utm_link_handler.go
new file mode 100644
index 0000000..c0163dd
--- /dev/null
+++ b/apps/backend/internal/handler/utm/create_utm_link_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package utm
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/utm"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func CreateUtmLinkHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.CreateUtmLinkReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := utm.NewCreateUtmLinkLogic(r.Context(), svcCtx)
+ data, err := l.CreateUtmLink(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/utm/list_utm_links_handler.go b/apps/backend/internal/handler/utm/list_utm_links_handler.go
new file mode 100644
index 0000000..48c3c0b
--- /dev/null
+++ b/apps/backend/internal/handler/utm/list_utm_links_handler.go
@@ -0,0 +1,20 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package utm
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/utm"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+)
+
+func ListUtmLinksHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ l := utm.NewListUtmLinksLogic(r.Context(), svcCtx)
+ data, err := l.ListUtmLinks()
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/workspaces/add_workspace_member_handler.go b/apps/backend/internal/handler/workspaces/add_workspace_member_handler.go
new file mode 100644
index 0000000..7424bef
--- /dev/null
+++ b/apps/backend/internal/handler/workspaces/add_workspace_member_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package workspaces
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/workspaces"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func AddWorkspaceMemberHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.AddWorkspaceMemberReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := workspaces.NewAddWorkspaceMemberLogic(r.Context(), svcCtx)
+ data, err := l.AddWorkspaceMember(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/workspaces/approve_draft_review_handler.go b/apps/backend/internal/handler/workspaces/approve_draft_review_handler.go
new file mode 100644
index 0000000..78e5cf7
--- /dev/null
+++ b/apps/backend/internal/handler/workspaces/approve_draft_review_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package workspaces
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/workspaces"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func ApproveDraftReviewHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ReviewDecisionReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := workspaces.NewApproveDraftReviewLogic(r.Context(), svcCtx)
+ data, err := l.ApproveDraftReview(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/workspaces/archive_workspace_handler.go b/apps/backend/internal/handler/workspaces/archive_workspace_handler.go
new file mode 100644
index 0000000..472d494
--- /dev/null
+++ b/apps/backend/internal/handler/workspaces/archive_workspace_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package workspaces
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/workspaces"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func ArchiveWorkspaceHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.WorkspaceIdReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := workspaces.NewArchiveWorkspaceLogic(r.Context(), svcCtx)
+ data, err := l.ArchiveWorkspace(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/workspaces/create_workspace_handler.go b/apps/backend/internal/handler/workspaces/create_workspace_handler.go
new file mode 100644
index 0000000..6348712
--- /dev/null
+++ b/apps/backend/internal/handler/workspaces/create_workspace_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package workspaces
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/workspaces"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func CreateWorkspaceHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.CreateWorkspaceReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := workspaces.NewCreateWorkspaceLogic(r.Context(), svcCtx)
+ data, err := l.CreateWorkspace(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/workspaces/export_workspace_report_handler.go b/apps/backend/internal/handler/workspaces/export_workspace_report_handler.go
new file mode 100644
index 0000000..9cebc30
--- /dev/null
+++ b/apps/backend/internal/handler/workspaces/export_workspace_report_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package workspaces
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/workspaces"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func ExportWorkspaceReportHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ExportReportReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := workspaces.NewExportWorkspaceReportLogic(r.Context(), svcCtx)
+ data, err := l.ExportWorkspaceReport(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/workspaces/list_draft_reviews_handler.go b/apps/backend/internal/handler/workspaces/list_draft_reviews_handler.go
new file mode 100644
index 0000000..3e60cad
--- /dev/null
+++ b/apps/backend/internal/handler/workspaces/list_draft_reviews_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package workspaces
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/workspaces"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func ListDraftReviewsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ListDraftReviewsReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := workspaces.NewListDraftReviewsLogic(r.Context(), svcCtx)
+ data, err := l.ListDraftReviews(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/workspaces/list_workspace_members_handler.go b/apps/backend/internal/handler/workspaces/list_workspace_members_handler.go
new file mode 100644
index 0000000..c490738
--- /dev/null
+++ b/apps/backend/internal/handler/workspaces/list_workspace_members_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package workspaces
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/workspaces"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func ListWorkspaceMembersHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ListWorkspaceMembersReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := workspaces.NewListWorkspaceMembersLogic(r.Context(), svcCtx)
+ data, err := l.ListWorkspaceMembers(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/workspaces/list_workspaces_handler.go b/apps/backend/internal/handler/workspaces/list_workspaces_handler.go
new file mode 100644
index 0000000..d71d704
--- /dev/null
+++ b/apps/backend/internal/handler/workspaces/list_workspaces_handler.go
@@ -0,0 +1,20 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package workspaces
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/workspaces"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+)
+
+func ListWorkspacesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ l := workspaces.NewListWorkspacesLogic(r.Context(), svcCtx)
+ data, err := l.ListWorkspaces()
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/workspaces/reject_draft_review_handler.go b/apps/backend/internal/handler/workspaces/reject_draft_review_handler.go
new file mode 100644
index 0000000..4b12881
--- /dev/null
+++ b/apps/backend/internal/handler/workspaces/reject_draft_review_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package workspaces
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/workspaces"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func RejectDraftReviewHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ReviewDecisionReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := workspaces.NewRejectDraftReviewLogic(r.Context(), svcCtx)
+ data, err := l.RejectDraftReview(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/workspaces/remove_workspace_member_handler.go b/apps/backend/internal/handler/workspaces/remove_workspace_member_handler.go
new file mode 100644
index 0000000..f8c2572
--- /dev/null
+++ b/apps/backend/internal/handler/workspaces/remove_workspace_member_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package workspaces
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/workspaces"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func RemoveWorkspaceMemberHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.RemoveWorkspaceMemberReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := workspaces.NewRemoveWorkspaceMemberLogic(r.Context(), svcCtx)
+ data, err := l.RemoveWorkspaceMember(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/workspaces/submit_draft_review_handler.go b/apps/backend/internal/handler/workspaces/submit_draft_review_handler.go
new file mode 100644
index 0000000..5f874b5
--- /dev/null
+++ b/apps/backend/internal/handler/workspaces/submit_draft_review_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package workspaces
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/workspaces"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func SubmitDraftReviewHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.SubmitDraftReviewReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := workspaces.NewSubmitDraftReviewLogic(r.Context(), svcCtx)
+ data, err := l.SubmitDraftReview(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/workspaces/switch_workspace_handler.go b/apps/backend/internal/handler/workspaces/switch_workspace_handler.go
new file mode 100644
index 0000000..5f3a25a
--- /dev/null
+++ b/apps/backend/internal/handler/workspaces/switch_workspace_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package workspaces
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/workspaces"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func SwitchWorkspaceHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.SwitchWorkspaceReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := workspaces.NewSwitchWorkspaceLogic(r.Context(), svcCtx)
+ data, err := l.SwitchWorkspace(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/workspaces/update_workspace_branding_handler.go b/apps/backend/internal/handler/workspaces/update_workspace_branding_handler.go
new file mode 100644
index 0000000..3e9ded0
--- /dev/null
+++ b/apps/backend/internal/handler/workspaces/update_workspace_branding_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package workspaces
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/workspaces"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func UpdateWorkspaceBrandingHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.WorkspaceBrandingReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := workspaces.NewUpdateWorkspaceBrandingLogic(r.Context(), svcCtx)
+ data, err := l.UpdateWorkspaceBranding(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/workspaces/update_workspace_handler.go b/apps/backend/internal/handler/workspaces/update_workspace_handler.go
new file mode 100644
index 0000000..d66c664
--- /dev/null
+++ b/apps/backend/internal/handler/workspaces/update_workspace_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package workspaces
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/workspaces"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func UpdateWorkspaceHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.UpdateWorkspaceReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := workspaces.NewUpdateWorkspaceLogic(r.Context(), svcCtx)
+ data, err := l.UpdateWorkspace(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/logic/benchmark/get_benchmark_logic.go b/apps/backend/internal/logic/benchmark/get_benchmark_logic.go
new file mode 100644
index 0000000..9c7dd18
--- /dev/null
+++ b/apps/backend/internal/logic/benchmark/get_benchmark_logic.go
@@ -0,0 +1,66 @@
+package benchmark
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/module/growth/usecase"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type GetBenchmarkLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewGetBenchmarkLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetBenchmarkLogic {
+ return &GetBenchmarkLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *GetBenchmarkLogic) GetBenchmark(req *types.BenchmarkReq) (*types.BenchmarkData, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ niche := req.Niche
+ if niche == "" {
+ niche = "general"
+ }
+ var yourEng, yourViews float64
+ // contribute + read from own posts if studio available
+ if l.svcCtx.Studio != nil {
+ posts, err := l.svcCtx.Studio.ListOwnPosts(l.ctx, uid, req.AccountId)
+ if err == nil && len(posts) > 0 {
+ var views, likes, replies int
+ n := 0
+ for _, p := range posts {
+ if p == nil {
+ continue
+ }
+ n++
+ views += p.ViewCount
+ likes += p.LikeCount
+ replies += p.ReplyCount
+ }
+ if n > 0 {
+ yourViews = float64(views) / float64(n)
+ if views > 0 {
+ yourEng = float64(likes+replies) / float64(views)
+ }
+ _ = l.svcCtx.Growth.ContributeBenchmark(l.ctx, uid, niche, yourEng, yourViews)
+ }
+ }
+ }
+ sample, medEng, medViews, avail, hint := l.svcCtx.Growth.GetBenchmark(l.ctx, niche, yourEng, yourViews)
+ return &types.BenchmarkData{
+ Available: avail, SampleSize: sample, Niche: niche,
+ MedianEngRate: usecase.Round2(medEng), MedianViews: usecase.Round2(medViews),
+ YourEngRate: usecase.Round2(yourEng), YourViews: usecase.Round2(yourViews),
+ PercentileHint: hint, Message: hint,
+ }, nil
+}
diff --git a/apps/backend/internal/logic/checkups/generate_checkup_now_logic.go b/apps/backend/internal/logic/checkups/generate_checkup_now_logic.go
new file mode 100644
index 0000000..a3a0e98
--- /dev/null
+++ b/apps/backend/internal/logic/checkups/generate_checkup_now_logic.go
@@ -0,0 +1,39 @@
+package checkups
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/logic/growthmap"
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type GenerateCheckupNowLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewGenerateCheckupNowLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GenerateCheckupNowLogic {
+ return &GenerateCheckupNowLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *GenerateCheckupNowLogic) GenerateCheckupNow() (*types.WeeklyCheckupPublic, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ tz := ""
+ if m, err := l.svcCtx.Members.FindByUID(l.ctx, uid); err == nil && m != nil {
+ tz = m.Timezone
+ }
+ c, err := l.svcCtx.Growth.GenerateCheckup(l.ctx, uid, tz, true)
+ if err != nil {
+ return nil, err
+ }
+ return growthmap.Checkup(c), nil
+}
diff --git a/apps/backend/internal/logic/checkups/get_checkup_logic.go b/apps/backend/internal/logic/checkups/get_checkup_logic.go
new file mode 100644
index 0000000..1030ae7
--- /dev/null
+++ b/apps/backend/internal/logic/checkups/get_checkup_logic.go
@@ -0,0 +1,35 @@
+package checkups
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/logic/growthmap"
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type GetCheckupLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewGetCheckupLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetCheckupLogic {
+ return &GetCheckupLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *GetCheckupLogic) GetCheckup(req *types.GetCheckupReq) (*types.WeeklyCheckupPublic, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ c, err := l.svcCtx.Growth.GetCheckup(l.ctx, uid, req.Id)
+ if err != nil {
+ return nil, err
+ }
+ return growthmap.Checkup(c), nil
+}
diff --git a/apps/backend/internal/logic/checkups/get_latest_checkup_logic.go b/apps/backend/internal/logic/checkups/get_latest_checkup_logic.go
new file mode 100644
index 0000000..d346888
--- /dev/null
+++ b/apps/backend/internal/logic/checkups/get_latest_checkup_logic.go
@@ -0,0 +1,45 @@
+package checkups
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "apps/backend/internal/logic/growthmap"
+ "apps/backend/internal/middleware"
+ growthDomain "apps/backend/internal/module/growth/domain"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type GetLatestCheckupLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewGetLatestCheckupLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetLatestCheckupLogic {
+ return &GetLatestCheckupLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *GetLatestCheckupLogic) GetLatestCheckup() (*types.WeeklyCheckupPublic, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ c, err := l.svcCtx.Growth.LatestCheckup(l.ctx, uid)
+ if err != nil {
+ if errors.Is(err, growthDomain.ErrNotFound) {
+ // auto-generate first
+ c, err = l.svcCtx.Growth.GenerateCheckup(l.ctx, uid, "", false)
+ if err != nil {
+ return nil, err
+ }
+ return growthmap.Checkup(c), nil
+ }
+ return nil, err
+ }
+ return growthmap.Checkup(c), nil
+}
diff --git a/apps/backend/internal/logic/checkups/list_checkups_logic.go b/apps/backend/internal/logic/checkups/list_checkups_logic.go
new file mode 100644
index 0000000..50809c2
--- /dev/null
+++ b/apps/backend/internal/logic/checkups/list_checkups_logic.go
@@ -0,0 +1,38 @@
+package checkups
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/logic/growthmap"
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ListCheckupsLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewListCheckupsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListCheckupsLogic {
+ return &ListCheckupsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *ListCheckupsLogic) ListCheckups(req *types.ListCheckupsReq) (*types.CheckupListData, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ list, total, err := l.svcCtx.Growth.ListCheckups(l.ctx, uid, req.Page, req.PageSize)
+ if err != nil {
+ return nil, err
+ }
+ return &types.CheckupListData{
+ List: growthmap.CheckupList(list),
+ Pagination: growthmap.Pagination(req.Page, req.PageSize, total),
+ }, nil
+}
diff --git a/apps/backend/internal/logic/growthmap/map.go b/apps/backend/internal/logic/growthmap/map.go
new file mode 100644
index 0000000..e492fe4
--- /dev/null
+++ b/apps/backend/internal/logic/growthmap/map.go
@@ -0,0 +1,166 @@
+package growthmap
+
+import (
+ "apps/backend/internal/module/growth/domain"
+ "apps/backend/internal/types"
+)
+
+func Outcome(e *domain.OutcomeEvent) *types.OutcomeEventPublic {
+ if e == nil {
+ return nil
+ }
+ return &types.OutcomeEventPublic{
+ Id: e.ID,
+ SourceType: e.SourceType,
+ SourceId: e.SourceID,
+ ThreadsAccountId: e.ThreadsAccountID,
+ Kind: e.Kind,
+ Confidence: e.Confidence,
+ Status: e.Status,
+ ReplyCountDelta: e.ReplyCountDelta,
+ LikeCountDelta: e.LikeCountDelta,
+ FollowersDelta: e.FollowersDelta,
+ ConversionAmount: e.ConversionAmount,
+ ConversionNote: e.ConversionNote,
+ ConversionCurrency: e.ConversionCurrency,
+ WindowEndsAt: e.WindowEndsAt,
+ SentAt: e.SentAt,
+ CreatedAt: e.CreatedAt,
+ UpdatedAt: e.UpdatedAt,
+ }
+}
+
+func OutcomeList(list []*domain.OutcomeEvent) []types.OutcomeEventPublic {
+ out := make([]types.OutcomeEventPublic, 0, len(list))
+ for _, e := range list {
+ if p := Outcome(e); p != nil {
+ out = append(out, *p)
+ }
+ }
+ return out
+}
+
+func Summary(s *domain.OutcomeSummary) *types.OutcomeSummaryData {
+ if s == nil {
+ return nil
+ }
+ return &types.OutcomeSummaryData{
+ From: s.From,
+ To: s.To,
+ Reach: s.Reach,
+ Conversations: s.Conversations,
+ FollowsPossible: s.FollowsPossible,
+ FollowsConfirmed: s.FollowsConfirmed,
+ Conversions: s.Conversions,
+ ConversionAmount: s.ConversionAmount,
+ }
+}
+
+func Checkup(c *domain.WeeklyCheckup) *types.WeeklyCheckupPublic {
+ if c == nil {
+ return nil
+ }
+ findings := make([]types.CheckupFindingPublic, 0, len(c.Findings))
+ for _, f := range c.Findings {
+ findings = append(findings, types.CheckupFindingPublic{Claim: f.Claim, Evidence: f.Evidence})
+ }
+ actions := make([]types.CheckupActionPublic, 0, len(c.Actions))
+ for _, a := range c.Actions {
+ actions = append(actions, types.CheckupActionPublic{Title: a.Title, Reason: a.Reason, Deeplink: a.Deeplink})
+ }
+ return &types.WeeklyCheckupPublic{
+ Id: c.ID, WeekKey: c.WeekKey, Status: c.Status, Summary: c.Summary,
+ Findings: findings, Actions: actions, Error: c.Error,
+ CreatedAt: c.CreatedAt, UpdatedAt: c.UpdatedAt,
+ }
+}
+
+func CheckupList(list []*domain.WeeklyCheckup) []types.WeeklyCheckupPublic {
+ out := make([]types.WeeklyCheckupPublic, 0, len(list))
+ for _, c := range list {
+ if p := Checkup(c); p != nil {
+ out = append(out, *p)
+ }
+ }
+ return out
+}
+
+func Health(h *domain.AccountHealth) *types.AccountHealthPublic {
+ if h == nil {
+ return nil
+ }
+ return &types.AccountHealthPublic{
+ ThreadsAccountId: h.ThreadsAccountID,
+ Score: h.Score,
+ Level: h.Level,
+ Factors: h.Factors,
+ Advice: h.Advice,
+ ComputedAt: h.ComputedAt,
+ }
+}
+
+func HealthList(list []*domain.AccountHealth) []types.AccountHealthPublic {
+ out := make([]types.AccountHealthPublic, 0, len(list))
+ for _, h := range list {
+ if p := Health(h); p != nil {
+ out = append(out, *p)
+ }
+ }
+ return out
+}
+
+func Workspace(w *domain.Workspace) *types.WorkspacePublic {
+ if w == nil {
+ return nil
+ }
+ return &types.WorkspacePublic{
+ Id: w.ID, Name: w.Name, ReviewRequired: w.ReviewRequired,
+ IsDefault: w.IsDefault, Archived: w.Archived,
+ FooterText: w.FooterText, HidePoweredBy: w.HidePoweredBy, BrandName: w.BrandName,
+ CreatedAt: w.CreatedAt, UpdatedAt: w.UpdatedAt,
+ }
+}
+
+func WorkspaceList(list []*domain.Workspace) []types.WorkspacePublic {
+ out := make([]types.WorkspacePublic, 0, len(list))
+ for _, w := range list {
+ if p := Workspace(w); p != nil {
+ out = append(out, *p)
+ }
+ }
+ return out
+}
+
+func Review(r *domain.DraftReview) *types.DraftReviewPublic {
+ if r == nil {
+ return nil
+ }
+ return &types.DraftReviewPublic{
+ Id: r.ID, RefType: r.RefType, RefId: r.RefID, WorkspaceId: r.WorkspaceID,
+ Status: r.Status, Reason: r.Reason, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
+ }
+}
+
+func ReviewList(list []*domain.DraftReview) []types.DraftReviewPublic {
+ out := make([]types.DraftReviewPublic, 0, len(list))
+ for _, r := range list {
+ if p := Review(r); p != nil {
+ out = append(out, *p)
+ }
+ }
+ return out
+}
+
+func Pagination(page, pageSize int, total int64) types.Pagination {
+ if page < 1 {
+ page = 1
+ }
+ if pageSize < 1 {
+ pageSize = 20
+ }
+ tp := int(total) / pageSize
+ if int(total)%pageSize != 0 {
+ tp++
+ }
+ return types.Pagination{Page: page, PageSize: pageSize, Total: total, TotalPages: tp}
+}
diff --git a/apps/backend/internal/logic/health/get_account_health_logic.go b/apps/backend/internal/logic/health/get_account_health_logic.go
new file mode 100644
index 0000000..a1b6e3d
--- /dev/null
+++ b/apps/backend/internal/logic/health/get_account_health_logic.go
@@ -0,0 +1,35 @@
+package health
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/logic/growthmap"
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type GetAccountHealthLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewGetAccountHealthLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetAccountHealthLogic {
+ return &GetAccountHealthLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *GetAccountHealthLogic) GetAccountHealth(req *types.GetAccountHealthReq) (*types.AccountHealthPublic, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ h, err := l.svcCtx.Growth.GetHealth(l.ctx, uid, req.AccountId)
+ if err != nil {
+ return nil, err
+ }
+ return growthmap.Health(h), nil
+}
diff --git a/apps/backend/internal/logic/health/list_account_health_logic.go b/apps/backend/internal/logic/health/list_account_health_logic.go
new file mode 100644
index 0000000..54a03a5
--- /dev/null
+++ b/apps/backend/internal/logic/health/list_account_health_logic.go
@@ -0,0 +1,115 @@
+package health
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "apps/backend/internal/logic/growthmap"
+ "apps/backend/internal/middleware"
+ growthUC "apps/backend/internal/module/growth/usecase"
+ studioDomain "apps/backend/internal/module/studio/domain"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ListAccountHealthLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewListAccountHealthLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListAccountHealthLogic {
+ return &ListAccountHealthLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *ListAccountHealthLogic) ListAccountHealth() (*types.AccountHealthListData, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ accs, err := l.svcCtx.Threads.List(l.ctx, uid)
+ if err != nil {
+ return nil, err
+ }
+ // gather outbox stats per account
+ type stats struct {
+ sends24h, fails7d, total7d, intervalViol int
+ }
+ byAcc := map[string]*stats{}
+ now := time.Now().UTC()
+ dayAgo := now.Add(-24 * time.Hour).UnixNano()
+ weekAgo := now.Add(-7 * 24 * time.Hour).UnixNano()
+ if l.svcCtx.Studio != nil {
+ if boxes, oerr := l.svcCtx.Studio.ListOutbox(l.ctx, uid); oerr == nil {
+ for _, b := range boxes {
+ if b == nil {
+ continue
+ }
+ var prevAt int64
+ for _, st := range b.Steps {
+ s := byAcc[st.AccountID]
+ if s == nil {
+ s = &stats{}
+ byAcc[st.AccountID] = s
+ }
+ ts := st.PublishedAt
+ if ts == 0 {
+ ts = st.ScheduledAt
+ }
+ if st.Status == studioDomain.StepPublished || st.Status == studioDomain.StepFailed || st.Status == studioDomain.StepPublishing {
+ if ts >= dayAgo {
+ s.sends24h++
+ }
+ if ts >= weekAgo {
+ s.total7d++
+ if st.Status == studioDomain.StepFailed {
+ s.fails7d++
+ }
+ }
+ }
+ if prevAt > 0 && st.ScheduledAt > 0 {
+ gap := st.ScheduledAt - prevAt
+ // less than 2 minutes between steps → interval violation
+ if gap > 0 && gap < int64(2*time.Minute) {
+ s.intervalViol++
+ }
+ }
+ if st.ScheduledAt > 0 {
+ prevAt = st.ScheduledAt
+ }
+ }
+ }
+ }
+ }
+ var list []*types.AccountHealthPublic
+ for _, a := range accs {
+ if a == nil {
+ continue
+ }
+ sig := growthUC.HealthSignals{}
+ if s := byAcc[a.ID]; s != nil {
+ sig.Sends24h = s.sends24h
+ sig.IntervalViolations = s.intervalViol
+ if s.total7d > 0 {
+ sig.FailRate7d = float64(s.fails7d) / float64(s.total7d)
+ }
+ }
+ sig.TokenBad = !a.IsUsable || a.Connection == "error" || a.Connection == "disconnected"
+ h, herr := l.svcCtx.Growth.RecomputeHealth(l.ctx, uid, a.ID, sig)
+ if herr != nil {
+ continue
+ }
+ list = append(list, growthmap.Health(h))
+ }
+ // flatten for response type
+ out := make([]types.AccountHealthPublic, 0, len(list))
+ for _, h := range list {
+ if h != nil {
+ out = append(out, *h)
+ }
+ }
+ return &types.AccountHealthListData{List: out}, nil
+}
diff --git a/apps/backend/internal/logic/insightsapi/get_insights_summary_logic.go b/apps/backend/internal/logic/insightsapi/get_insights_summary_logic.go
new file mode 100644
index 0000000..5c295a3
--- /dev/null
+++ b/apps/backend/internal/logic/insightsapi/get_insights_summary_logic.go
@@ -0,0 +1,123 @@
+package insightsapi
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "time"
+
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/module/growth/usecase"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type GetInsightsSummaryLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewGetInsightsSummaryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetInsightsSummaryLogic {
+ return &GetInsightsSummaryLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *GetInsightsSummaryLogic) GetInsightsSummary(req *types.InsightsSummaryReq) (*types.InsightsSummaryData, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ if l.svcCtx.Studio == nil {
+ return &types.InsightsSummaryData{Conclusion: "尚無創作資料", Suggestions: []string{"先同步已發貼文"}}, nil
+ }
+ posts, err := l.svcCtx.Studio.ListOwnPosts(l.ctx, uid, req.AccountId)
+ if err != nil {
+ return nil, err
+ }
+ months := req.Months
+ if months <= 0 {
+ months = 3
+ }
+ cutoff := time.Now().UTC().AddDate(0, -months, 0).UnixNano()
+ type agg struct{ posts, views, likes, replies int }
+ byMonth := map[string]*agg{}
+ var totalV, totalL, totalR, n int
+ var top []types.InsightsPostPublic
+ for _, p := range posts {
+ if p == nil {
+ continue
+ }
+ ts := p.PublishedAt
+ if ts > 0 && ts < cutoff {
+ continue
+ }
+ n++
+ totalV += p.ViewCount
+ totalL += p.LikeCount
+ totalR += p.ReplyCount
+ mk := time.Unix(0, ts).UTC().Format("2006-01")
+ if ts == 0 {
+ mk = time.Now().UTC().Format("2006-01")
+ }
+ a := byMonth[mk]
+ if a == nil {
+ a = &agg{}
+ byMonth[mk] = a
+ }
+ a.posts++
+ a.views += p.ViewCount
+ a.likes += p.LikeCount
+ a.replies += p.ReplyCount
+ eng := 0.0
+ if p.ViewCount > 0 {
+ eng = float64(p.LikeCount+p.ReplyCount) / float64(p.ViewCount)
+ }
+ prev := p.Text
+ if len([]rune(prev)) > 80 {
+ prev = string([]rune(prev)[:80]) + "…"
+ }
+ top = append(top, types.InsightsPostPublic{
+ Id: p.ID, TextPreview: prev, PublishedAt: ts,
+ ViewCount: p.ViewCount, LikeCount: p.LikeCount, ReplyCount: p.ReplyCount, EngRate: usecase.Round2(eng),
+ })
+ }
+ sort.Slice(top, func(i, j int) bool { return top[i].EngRate > top[j].EngRate })
+ if len(top) > 5 {
+ top = top[:5]
+ }
+ avgEng := 0.0
+ if totalV > 0 {
+ avgEng = float64(totalL+totalR) / float64(totalV)
+ }
+ var buckets []types.InsightsMonthBucket
+ for mk, a := range byMonth {
+ e := 0.0
+ if a.views > 0 {
+ e = float64(a.likes+a.replies) / float64(a.views)
+ }
+ buckets = append(buckets, types.InsightsMonthBucket{
+ MonthKey: mk, Posts: a.posts, Views: a.views, Likes: a.likes, Replies: a.replies, AvgEngRate: usecase.Round2(e),
+ })
+ }
+ sort.Slice(buckets, func(i, j int) bool { return buckets[i].MonthKey < buckets[j].MonthKey })
+ conclusion := fmt.Sprintf("近 %d 個月同步貼文 %d 則,平均互動率 %.2f%%。", months, n, avgEng*100)
+ sugs := []string{"維持高互動題材節奏", "把表現佳貼文結構拿去仿寫"}
+ if n == 0 {
+ conclusion = "尚無足夠已同步貼文"
+ sugs = []string{"到創作 → 我的貼文按同步", "連好 Threads 帳號後再看成效"}
+ } else if avgEng < 0.02 {
+ sugs = append([]string{"互動率偏低:可加強開頭鉤子與提問 CTA"}, sugs...)
+ }
+ // also feed benchmark
+ if n > 0 {
+ _ = l.svcCtx.Growth.ContributeBenchmark(l.ctx, uid, "general", avgEng, float64(totalV)/float64(n))
+ }
+ return &types.InsightsSummaryData{
+ AccountId: req.AccountId, PostCount: n, TotalViews: totalV, TotalLikes: totalL, TotalReplies: totalR,
+ AvgEngRate: usecase.Round2(avgEng), TopPosts: top, MonthBuckets: buckets,
+ Conclusion: conclusion, Suggestions: sugs,
+ }, nil
+}
+
diff --git a/apps/backend/internal/logic/invite/get_my_invite_network_logic.go b/apps/backend/internal/logic/invite/get_my_invite_network_logic.go
index 13be63d..abc9ea6 100644
--- a/apps/backend/internal/logic/invite/get_my_invite_network_logic.go
+++ b/apps/backend/internal/logic/invite/get_my_invite_network_logic.go
@@ -30,5 +30,33 @@ func (l *GetMyInviteNetworkLogic) GetMyInviteNetwork() (resp *types.MyInviteNetw
if err != nil {
return nil, err
}
- return types.MyInviteNetworkFromUC(net), nil
+ out := types.MyInviteNetworkFromUC(net)
+ if l.svcCtx.Growth != nil && out != nil {
+ planID := "free"
+ if l.svcCtx.Usage != nil {
+ if prefs, perr := l.svcCtx.Usage.GetPrefs(l.ctx, uid); perr == nil && prefs != nil {
+ planID = prefs.PlanID
+ }
+ }
+ total, month, cap, recent, rerr := l.svcCtx.Growth.InviteRewardsSummary(l.ctx, uid, planID)
+ if rerr == nil {
+ sum := &types.InviteRewardsSummary{
+ TotalPoints: total,
+ MonthPoints: month,
+ MonthCap: cap,
+ Recent: make([]types.InviteRewardPublic, 0, len(recent)),
+ }
+ for _, r := range recent {
+ if r == nil {
+ continue
+ }
+ sum.Recent = append(sum.Recent, types.InviteRewardPublic{
+ Id: r.ID, InviteeUid: r.InviteeUID, PlanId: r.PlanID,
+ Points: r.Points, Status: r.Status, CreatedAt: r.CreatedAt,
+ })
+ }
+ out.Rewards = sum
+ }
+ }
+ return out, nil
}
diff --git a/apps/backend/internal/logic/outcomes/delete_conversion_logic.go b/apps/backend/internal/logic/outcomes/delete_conversion_logic.go
new file mode 100644
index 0000000..d0ac20f
--- /dev/null
+++ b/apps/backend/internal/logic/outcomes/delete_conversion_logic.go
@@ -0,0 +1,33 @@
+package outcomes
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type DeleteConversionLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewDeleteConversionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteConversionLogic {
+ return &DeleteConversionLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *DeleteConversionLogic) DeleteConversion(req *types.DeleteConversionReq) (*types.OkData, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ if err := l.svcCtx.Growth.DeleteConversion(l.ctx, uid, req.Id); err != nil {
+ return nil, err
+ }
+ return &types.OkData{Ok: true}, nil
+}
diff --git a/apps/backend/internal/logic/outcomes/get_outcome_logic.go b/apps/backend/internal/logic/outcomes/get_outcome_logic.go
new file mode 100644
index 0000000..43704e8
--- /dev/null
+++ b/apps/backend/internal/logic/outcomes/get_outcome_logic.go
@@ -0,0 +1,35 @@
+package outcomes
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/logic/growthmap"
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type GetOutcomeLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewGetOutcomeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetOutcomeLogic {
+ return &GetOutcomeLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *GetOutcomeLogic) GetOutcome(req *types.GetOutcomeReq) (*types.OutcomeEventPublic, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ e, err := l.svcCtx.Growth.GetOutcome(l.ctx, uid, req.Id)
+ if err != nil {
+ return nil, err
+ }
+ return growthmap.Outcome(e), nil
+}
diff --git a/apps/backend/internal/logic/outcomes/get_outcome_summary_logic.go b/apps/backend/internal/logic/outcomes/get_outcome_summary_logic.go
new file mode 100644
index 0000000..218a8a6
--- /dev/null
+++ b/apps/backend/internal/logic/outcomes/get_outcome_summary_logic.go
@@ -0,0 +1,45 @@
+package outcomes
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "apps/backend/internal/logic/growthmap"
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type GetOutcomeSummaryLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewGetOutcomeSummaryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetOutcomeSummaryLogic {
+ return &GetOutcomeSummaryLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *GetOutcomeSummaryLogic) GetOutcomeSummary(req *types.OutcomeSummaryReq) (*types.OutcomeSummaryData, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ from, to := req.From, req.To
+ if from == 0 && to == 0 {
+ to = time.Now().UTC().UnixNano()
+ if req.Range == "month" {
+ from = to - int64(30*24*time.Hour)
+ } else {
+ from = to - int64(7*24*time.Hour)
+ }
+ }
+ sum, err := l.svcCtx.Growth.Summary(l.ctx, uid, from, to)
+ if err != nil {
+ return nil, err
+ }
+ return growthmap.Summary(sum), nil
+}
diff --git a/apps/backend/internal/logic/outcomes/list_outcomes_logic.go b/apps/backend/internal/logic/outcomes/list_outcomes_logic.go
new file mode 100644
index 0000000..edfe2ba
--- /dev/null
+++ b/apps/backend/internal/logic/outcomes/list_outcomes_logic.go
@@ -0,0 +1,42 @@
+package outcomes
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/logic/growthmap"
+ "apps/backend/internal/middleware"
+ growthDomain "apps/backend/internal/module/growth/domain"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ListOutcomesLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewListOutcomesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListOutcomesLogic {
+ return &ListOutcomesLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *ListOutcomesLogic) ListOutcomes(req *types.ListOutcomesReq) (*types.OutcomeListData, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ list, total, err := l.svcCtx.Growth.ListOutcomes(l.ctx, uid, growthDomain.OutcomeListFilter{
+ Kind: req.Kind, Confidence: req.Confidence, SourceType: req.SourceType,
+ From: req.From, To: req.To, Page: req.Page, PageSize: req.PageSize,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &types.OutcomeListData{
+ List: growthmap.OutcomeList(list),
+ Pagination: growthmap.Pagination(req.Page, req.PageSize, total),
+ }, nil
+}
diff --git a/apps/backend/internal/logic/outcomes/report_conversion_logic.go b/apps/backend/internal/logic/outcomes/report_conversion_logic.go
new file mode 100644
index 0000000..5366523
--- /dev/null
+++ b/apps/backend/internal/logic/outcomes/report_conversion_logic.go
@@ -0,0 +1,35 @@
+package outcomes
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/logic/growthmap"
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ReportConversionLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewReportConversionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ReportConversionLogic {
+ return &ReportConversionLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *ReportConversionLogic) ReportConversion(req *types.ReportConversionReq) (*types.OutcomeEventPublic, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ e, err := l.svcCtx.Growth.ReportConversion(l.ctx, uid, req.Id, req.Amount, req.Note, req.Currency)
+ if err != nil {
+ return nil, err
+ }
+ return growthmap.Outcome(e), nil
+}
diff --git a/apps/backend/internal/logic/outcomes/update_conversion_logic.go b/apps/backend/internal/logic/outcomes/update_conversion_logic.go
new file mode 100644
index 0000000..d3031b0
--- /dev/null
+++ b/apps/backend/internal/logic/outcomes/update_conversion_logic.go
@@ -0,0 +1,35 @@
+package outcomes
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/logic/growthmap"
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type UpdateConversionLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewUpdateConversionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateConversionLogic {
+ return &UpdateConversionLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *UpdateConversionLogic) UpdateConversion(req *types.UpdateConversionReq) (*types.OutcomeEventPublic, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ e, err := l.svcCtx.Growth.UpdateConversion(l.ctx, uid, req.Id, req.Amount, req.Note, req.Currency)
+ if err != nil {
+ return nil, err
+ }
+ return growthmap.Outcome(e), nil
+}
diff --git a/apps/backend/internal/logic/playbooks/get_playbook_logic.go b/apps/backend/internal/logic/playbooks/get_playbook_logic.go
new file mode 100644
index 0000000..c5754e7
--- /dev/null
+++ b/apps/backend/internal/logic/playbooks/get_playbook_logic.go
@@ -0,0 +1,35 @@
+package playbooks
+
+import (
+ "context"
+
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/module/growth/usecase"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type GetPlaybookLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewGetPlaybookLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPlaybookLogic {
+ return &GetPlaybookLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *GetPlaybookLogic) GetPlaybook(req *types.PlaybookIdPath) (*types.PlaybookPublic, error) {
+ uid, _ := middleware.UIDFrom(l.ctx)
+ p, err := l.svcCtx.Growth.GetPlaybook(l.ctx, req.Id)
+ if err != nil {
+ return nil, err
+ }
+ return &types.PlaybookPublic{
+ Id: p.ID, Kind: p.Kind, Title: p.Title, Niche: p.Niche, Body: p.Body,
+ Anonymous: p.Anonymous, AuthorLabel: usecase.PlaybookAuthorLabel(p, uid),
+ ImportCount: p.ImportCount, CreatedAt: p.CreatedAt, Mine: p.OwnerUID == uid,
+ }, nil
+}
diff --git a/apps/backend/internal/logic/playbooks/import_playbook_logic.go b/apps/backend/internal/logic/playbooks/import_playbook_logic.go
new file mode 100644
index 0000000..1795912
--- /dev/null
+++ b/apps/backend/internal/logic/playbooks/import_playbook_logic.go
@@ -0,0 +1,34 @@
+package playbooks
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ImportPlaybookLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewImportPlaybookLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ImportPlaybookLogic {
+ return &ImportPlaybookLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *ImportPlaybookLogic) ImportPlaybook(req *types.PlaybookIdPath) (*types.ImportPlaybookData, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ p, err := l.svcCtx.Growth.ImportPlaybook(l.ctx, uid, req.Id)
+ if err != nil {
+ return nil, err
+ }
+ return &types.ImportPlaybookData{Id: p.ID, Copied: true, Message: "已複製到我的 playbook"}, nil
+}
diff --git a/apps/backend/internal/logic/playbooks/list_playbooks_logic.go b/apps/backend/internal/logic/playbooks/list_playbooks_logic.go
new file mode 100644
index 0000000..eff690d
--- /dev/null
+++ b/apps/backend/internal/logic/playbooks/list_playbooks_logic.go
@@ -0,0 +1,44 @@
+package playbooks
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/logic/growthmap"
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/module/growth/usecase"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ListPlaybooksLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewListPlaybooksLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPlaybooksLogic {
+ return &ListPlaybooksLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *ListPlaybooksLogic) ListPlaybooks(req *types.ListPlaybooksReq) (*types.PlaybookListData, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ list, total, err := l.svcCtx.Growth.ListPlaybooks(l.ctx, uid, req.Kind, req.Niche, req.Mine, req.Page, req.PageSize)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]types.PlaybookPublic, 0, len(list))
+ for _, p := range list {
+ out = append(out, types.PlaybookPublic{
+ Id: p.ID, Kind: p.Kind, Title: p.Title, Niche: p.Niche, Body: p.Body,
+ Anonymous: p.Anonymous, AuthorLabel: usecase.PlaybookAuthorLabel(p, uid),
+ ImportCount: p.ImportCount, CreatedAt: p.CreatedAt, Mine: p.OwnerUID == uid,
+ })
+ }
+ return &types.PlaybookListData{List: out, Pagination: growthmap.Pagination(req.Page, req.PageSize, total)}, nil
+}
diff --git a/apps/backend/internal/logic/playbooks/publish_playbook_logic.go b/apps/backend/internal/logic/playbooks/publish_playbook_logic.go
new file mode 100644
index 0000000..dfa64bb
--- /dev/null
+++ b/apps/backend/internal/logic/playbooks/publish_playbook_logic.go
@@ -0,0 +1,39 @@
+package playbooks
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/module/growth/usecase"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type PublishPlaybookLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewPublishPlaybookLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PublishPlaybookLogic {
+ return &PublishPlaybookLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *PublishPlaybookLogic) PublishPlaybook(req *types.PublishPlaybookReq) (*types.PlaybookPublic, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ p, err := l.svcCtx.Growth.PublishPlaybook(l.ctx, uid, req.Kind, req.Title, req.Niche, req.Body, req.Anonymous)
+ if err != nil {
+ return nil, err
+ }
+ return &types.PlaybookPublic{
+ Id: p.ID, Kind: p.Kind, Title: p.Title, Niche: p.Niche, Body: p.Body,
+ Anonymous: p.Anonymous, AuthorLabel: usecase.PlaybookAuthorLabel(p, uid),
+ ImportCount: p.ImportCount, CreatedAt: p.CreatedAt, Mine: true,
+ }, nil
+}
diff --git a/apps/backend/internal/logic/playbooks/remove_playbook_logic.go b/apps/backend/internal/logic/playbooks/remove_playbook_logic.go
new file mode 100644
index 0000000..a52cd98
--- /dev/null
+++ b/apps/backend/internal/logic/playbooks/remove_playbook_logic.go
@@ -0,0 +1,33 @@
+package playbooks
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type RemovePlaybookLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewRemovePlaybookLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RemovePlaybookLogic {
+ return &RemovePlaybookLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *RemovePlaybookLogic) RemovePlaybook(req *types.PlaybookIdPath) (*types.OkData, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ if err := l.svcCtx.Growth.RemovePlaybook(l.ctx, uid, req.Id); err != nil {
+ return nil, err
+ }
+ return &types.OkData{Ok: true}, nil
+}
diff --git a/apps/backend/internal/logic/plays/submit_play_logic.go b/apps/backend/internal/logic/plays/submit_play_logic.go
index 390a430..bf7d745 100644
--- a/apps/backend/internal/logic/plays/submit_play_logic.go
+++ b/apps/backend/internal/logic/plays/submit_play_logic.go
@@ -29,6 +29,22 @@ func (l *SubmitPlayLogic) SubmitPlay(req *types.PlayIdPath) (*types.OutboxPublic
if !ok {
return nil, response.Biz(401, 401001, "missing authorization")
}
+ if l.svcCtx.Growth != nil {
+ if play, perr := l.svcCtx.Studio.GetPlay(l.ctx, uid, req.Id); perr == nil && play != nil {
+ accID := play.LeadAccountID
+ if accID == "" && len(play.Steps) > 0 {
+ accID = play.Steps[0].AccountID
+ }
+ if accID != "" {
+ if _, herr := l.svcCtx.Growth.EnsureAutoSendAllowed(l.ctx, uid, accID); herr != nil {
+ return nil, herr
+ }
+ }
+ if err := l.svcCtx.Growth.EnsureReviewAllowsAutoSend(l.ctx, uid, "", "play", req.Id); err != nil {
+ return nil, err
+ }
+ }
+ }
b, err := l.svcCtx.Studio.SubmitPlay(l.ctx, uid, req.Id)
if err != nil {
return nil, err
diff --git a/apps/backend/internal/logic/publictools/public_pain_keywords_logic.go b/apps/backend/internal/logic/publictools/public_pain_keywords_logic.go
new file mode 100644
index 0000000..d4bd5c0
--- /dev/null
+++ b/apps/backend/internal/logic/publictools/public_pain_keywords_logic.go
@@ -0,0 +1,29 @@
+package publictools
+
+import (
+ "context"
+
+ "apps/backend/internal/module/growth/usecase"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type PublicPainKeywordsLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewPublicPainKeywordsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PublicPainKeywordsLogic {
+ return &PublicPainKeywordsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *PublicPainKeywordsLogic) PublicPainKeywords(req *types.PainKeywordsReq) (*types.PainKeywordsData, error) {
+ kw, pains, scan, summary := usecase.PainKeywords(req.ProductBrief, req.Audience)
+ return &types.PainKeywordsData{
+ Keywords: kw, Pains: pains, ScanTerms: scan, Summary: summary,
+ SignupHint: "把掃描詞丟進海巡 brief,登入後一鍵找痛點貼文。",
+ }, nil
+}
diff --git a/apps/backend/internal/logic/publictools/public_style_quiz_logic.go b/apps/backend/internal/logic/publictools/public_style_quiz_logic.go
new file mode 100644
index 0000000..d8b88f3
--- /dev/null
+++ b/apps/backend/internal/logic/publictools/public_style_quiz_logic.go
@@ -0,0 +1,29 @@
+package publictools
+
+import (
+ "context"
+
+ "apps/backend/internal/module/growth/usecase"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type PublicStyleQuizLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewPublicStyleQuizLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PublicStyleQuizLogic {
+ return &PublicStyleQuizLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *PublicStyleQuizLogic) PublicStyleQuiz(req *types.StyleQuizReq) (*types.StyleQuizData, error) {
+ tone, rhythm, hooks, avoid, summary, tags := usecase.StyleQuiz(req.Samples)
+ return &types.StyleQuizData{
+ Tone: tone, Rhythm: rhythm, Hooks: hooks, Avoid: avoid, Summary: summary, Tags: tags,
+ SignupHint: "想用人設語紋自動產文?登入巡樓試試。",
+ }, nil
+}
diff --git a/apps/backend/internal/logic/publicutm/utm_redirect_logic.go b/apps/backend/internal/logic/publicutm/utm_redirect_logic.go
new file mode 100644
index 0000000..cae5bc9
--- /dev/null
+++ b/apps/backend/internal/logic/publicutm/utm_redirect_logic.go
@@ -0,0 +1,41 @@
+package publicutm
+
+import (
+ "context"
+ "net/http"
+
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type UtmRedirectLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+ w http.ResponseWriter
+ r *http.Request
+}
+
+func NewUtmRedirectLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UtmRedirectLogic {
+ return &UtmRedirectLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+// SetHTTP allows handler to pass ResponseWriter for 302
+func (l *UtmRedirectLogic) SetHTTP(w http.ResponseWriter, r *http.Request) {
+ l.w, l.r = w, r
+}
+
+func (l *UtmRedirectLogic) UtmRedirect(req *types.UtmRedirectReq) (*types.OkData, error) {
+ dest, err := l.svcCtx.Growth.TrackUtmClick(l.ctx, req.Code)
+ if err != nil {
+ return nil, err
+ }
+ if l.w != nil {
+ http.Redirect(l.w, l.r, dest, http.StatusFound)
+ return nil, response.Biz(302, 302000, "redirect")
+ }
+ return &types.OkData{Ok: true, Message: dest}, nil
+}
diff --git a/apps/backend/internal/logic/scout/send_outreach_logic.go b/apps/backend/internal/logic/scout/send_outreach_logic.go
index 9d8402c..09a396f 100644
--- a/apps/backend/internal/logic/scout/send_outreach_logic.go
+++ b/apps/backend/internal/logic/scout/send_outreach_logic.go
@@ -30,6 +30,14 @@ func (l *SendOutreachLogic) SendOutreach(req *types.ScoutSendPathReq) (*types.Sc
return nil, response.Biz(401, 401001, "missing authorization")
}
+ if l.svcCtx.Growth != nil && req.AccountId != "" {
+ if _, herr := l.svcCtx.Growth.EnsureAutoSendAllowed(l.ctx, uid, req.AccountId); herr != nil {
+ return nil, herr
+ }
+ if err := l.svcCtx.Growth.EnsureReviewAllowsAutoSend(l.ctx, uid, "", "scout_post", req.Id); err != nil {
+ return nil, err
+ }
+ }
p, err := l.svcCtx.Scout.SendOutreach(l.ctx, uid, req.Id, req.Text, req.AccountId)
if err != nil {
return nil, err
diff --git a/apps/backend/internal/logic/usage/purchase_plan_logic.go b/apps/backend/internal/logic/usage/purchase_plan_logic.go
index 510cfc1..7525b95 100644
--- a/apps/backend/internal/logic/usage/purchase_plan_logic.go
+++ b/apps/backend/internal/logic/usage/purchase_plan_logic.go
@@ -33,5 +33,36 @@ func (l *PurchasePlanLogic) PurchasePlan(req *types.UsagePurchaseReq) (*types.Us
if err != nil {
return nil, err
}
+ // growth-loop: first paid plan → invite reward (single-level)
+ if l.svcCtx.Growth != nil {
+ if m, merr := l.svcCtx.Members.FindByUID(l.ctx, uid); merr == nil && m != nil && m.ParentUID != nil && *m.ParentUID > 0 {
+ parentUID := *m.ParentUID
+ inviterPlan := "free"
+ if prefs, perr := l.svcCtx.Usage.GetPrefs(l.ctx, parentUID); perr == nil && prefs != nil {
+ inviterPlan = prefs.PlanID
+ }
+ l.svcCtx.Growth.PlanQuota = func(id string) int {
+ if id == "@inviter" {
+ switch inviterPlan {
+ case "starter":
+ return 600
+ case "pro":
+ return 2000
+ default:
+ return 120
+ }
+ }
+ switch id {
+ case "starter":
+ return 600
+ case "pro":
+ return 2000
+ default:
+ return 120
+ }
+ }
+ _, _ = l.svcCtx.Growth.OnFirstPaidPlan(l.ctx, uid, parentUID, p.PlanID)
+ }
+ }
return &types.UsagePurchasePublic{Id: p.ID, PlanId: p.PlanID, MockRef: p.MockRef, CreatedAt: p.CreatedAt}, nil
}
diff --git a/apps/backend/internal/logic/utm/create_utm_link_logic.go b/apps/backend/internal/logic/utm/create_utm_link_logic.go
new file mode 100644
index 0000000..0fe27f5
--- /dev/null
+++ b/apps/backend/internal/logic/utm/create_utm_link_logic.go
@@ -0,0 +1,38 @@
+package utm
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type CreateUtmLinkLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewCreateUtmLinkLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateUtmLinkLogic {
+ return &CreateUtmLinkLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *CreateUtmLinkLogic) CreateUtmLink(req *types.CreateUtmLinkReq) (*types.UtmLinkPublic, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ u, err := l.svcCtx.Growth.CreateUtmLink(l.ctx, uid, req.DestinationUrl, req.OutcomeId, req.Label)
+ if err != nil {
+ return nil, err
+ }
+ return &types.UtmLinkPublic{
+ Id: u.ID, Code: u.Code, TrackPath: "/api/v1/public/u/" + u.Code,
+ DestinationUrl: u.DestinationURL, OutcomeId: u.OutcomeID, Label: u.Label,
+ Clicks: u.Clicks, CreatedAt: u.CreatedAt,
+ }, nil
+}
diff --git a/apps/backend/internal/logic/utm/list_utm_links_logic.go b/apps/backend/internal/logic/utm/list_utm_links_logic.go
new file mode 100644
index 0000000..9f07b4e
--- /dev/null
+++ b/apps/backend/internal/logic/utm/list_utm_links_logic.go
@@ -0,0 +1,42 @@
+package utm
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ListUtmLinksLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewListUtmLinksLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListUtmLinksLogic {
+ return &ListUtmLinksLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *ListUtmLinksLogic) ListUtmLinks() (*types.UtmLinkListData, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ list, err := l.svcCtx.Growth.ListUtmLinks(l.ctx, uid)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]types.UtmLinkPublic, 0, len(list))
+ for _, u := range list {
+ out = append(out, types.UtmLinkPublic{
+ Id: u.ID, Code: u.Code, TrackPath: "/api/v1/public/u/" + u.Code,
+ DestinationUrl: u.DestinationURL, OutcomeId: u.OutcomeID, Label: u.Label,
+ Clicks: u.Clicks, CreatedAt: u.CreatedAt,
+ })
+ }
+ return &types.UtmLinkListData{List: out}, nil
+}
diff --git a/apps/backend/internal/logic/workspaces/add_workspace_member_logic.go b/apps/backend/internal/logic/workspaces/add_workspace_member_logic.go
new file mode 100644
index 0000000..6986a8d
--- /dev/null
+++ b/apps/backend/internal/logic/workspaces/add_workspace_member_logic.go
@@ -0,0 +1,56 @@
+package workspaces
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type AddWorkspaceMemberLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewAddWorkspaceMemberLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AddWorkspaceMemberLogic {
+ return &AddWorkspaceMemberLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *AddWorkspaceMemberLogic) AddWorkspaceMember(req *types.AddWorkspaceMemberReq) (*types.WorkspaceMemberPublic, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ memberUID := req.MemberUid
+ email, name := "", ""
+ if memberUID <= 0 && strings.TrimSpace(req.Email) != "" {
+ mem, err := l.svcCtx.Members.FindByEmail(l.ctx, strings.TrimSpace(req.Email))
+ if err != nil || mem == nil {
+ return nil, fmt.Errorf("member not found by email")
+ }
+ memberUID = mem.UID
+ email = mem.Email
+ name = mem.DisplayName
+ } else if memberUID > 0 {
+ if mem, err := l.svcCtx.Members.FindByUID(l.ctx, memberUID); err == nil && mem != nil {
+ email = mem.Email
+ name = mem.DisplayName
+ }
+ }
+ m, err := l.svcCtx.Growth.AddWorkspaceMember(l.ctx, uid, memberUID, req.WorkspaceId, req.Role)
+ if err != nil {
+ return nil, err
+ }
+ if name == "" {
+ name = fmt.Sprintf("uid %d", m.UID)
+ }
+ return &types.WorkspaceMemberPublic{
+ Uid: m.UID, Email: email, DisplayName: name, Role: m.Role, JoinedAt: m.JoinedAt,
+ }, nil
+}
diff --git a/apps/backend/internal/logic/workspaces/approve_draft_review_logic.go b/apps/backend/internal/logic/workspaces/approve_draft_review_logic.go
new file mode 100644
index 0000000..42cc6e7
--- /dev/null
+++ b/apps/backend/internal/logic/workspaces/approve_draft_review_logic.go
@@ -0,0 +1,36 @@
+package workspaces
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/logic/growthmap"
+ "apps/backend/internal/middleware"
+ growthDomain "apps/backend/internal/module/growth/domain"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ApproveDraftReviewLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewApproveDraftReviewLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ApproveDraftReviewLogic {
+ return &ApproveDraftReviewLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *ApproveDraftReviewLogic) ApproveDraftReview(req *types.ReviewDecisionReq) (*types.DraftReviewPublic, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ r, err := l.svcCtx.Growth.DecideReview(l.ctx, uid, req.Id, growthDomain.ReviewApproved, req.Reason)
+ if err != nil {
+ return nil, err
+ }
+ return growthmap.Review(r), nil
+}
diff --git a/apps/backend/internal/logic/workspaces/archive_workspace_logic.go b/apps/backend/internal/logic/workspaces/archive_workspace_logic.go
new file mode 100644
index 0000000..260d447
--- /dev/null
+++ b/apps/backend/internal/logic/workspaces/archive_workspace_logic.go
@@ -0,0 +1,33 @@
+package workspaces
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ArchiveWorkspaceLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewArchiveWorkspaceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ArchiveWorkspaceLogic {
+ return &ArchiveWorkspaceLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *ArchiveWorkspaceLogic) ArchiveWorkspace(req *types.WorkspaceIdReq) (*types.OkData, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ if err := l.svcCtx.Growth.ArchiveWorkspace(l.ctx, uid, req.Id); err != nil {
+ return nil, err
+ }
+ return &types.OkData{Ok: true}, nil
+}
diff --git a/apps/backend/internal/logic/workspaces/create_workspace_logic.go b/apps/backend/internal/logic/workspaces/create_workspace_logic.go
new file mode 100644
index 0000000..400a993
--- /dev/null
+++ b/apps/backend/internal/logic/workspaces/create_workspace_logic.go
@@ -0,0 +1,35 @@
+package workspaces
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/logic/growthmap"
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type CreateWorkspaceLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewCreateWorkspaceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateWorkspaceLogic {
+ return &CreateWorkspaceLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *CreateWorkspaceLogic) CreateWorkspace(req *types.CreateWorkspaceReq) (*types.WorkspacePublic, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ w, err := l.svcCtx.Growth.CreateWorkspace(l.ctx, uid, req.Name)
+ if err != nil {
+ return nil, err
+ }
+ return growthmap.Workspace(w), nil
+}
diff --git a/apps/backend/internal/logic/workspaces/export_workspace_report_logic.go b/apps/backend/internal/logic/workspaces/export_workspace_report_logic.go
new file mode 100644
index 0000000..7735ffa
--- /dev/null
+++ b/apps/backend/internal/logic/workspaces/export_workspace_report_logic.go
@@ -0,0 +1,38 @@
+package workspaces
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ExportWorkspaceReportLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewExportWorkspaceReportLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ExportWorkspaceReportLogic {
+ return &ExportWorkspaceReportLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *ExportWorkspaceReportLogic) ExportWorkspaceReport(req *types.ExportReportReq) (*types.ExportReportData, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ format := req.Format
+ if format == "" {
+ format = "pdf"
+ }
+ filename, ct, url, err := l.svcCtx.Growth.ExportReport(l.ctx, uid, req.WorkspaceId, req.YearMonth, format)
+ if err != nil {
+ return nil, err
+ }
+ return &types.ExportReportData{Url: url, Filename: filename, ContentType: ct, Format: format}, nil
+}
diff --git a/apps/backend/internal/logic/workspaces/list_draft_reviews_logic.go b/apps/backend/internal/logic/workspaces/list_draft_reviews_logic.go
new file mode 100644
index 0000000..611e8af
--- /dev/null
+++ b/apps/backend/internal/logic/workspaces/list_draft_reviews_logic.go
@@ -0,0 +1,38 @@
+package workspaces
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/logic/growthmap"
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ListDraftReviewsLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewListDraftReviewsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListDraftReviewsLogic {
+ return &ListDraftReviewsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *ListDraftReviewsLogic) ListDraftReviews(req *types.ListDraftReviewsReq) (*types.DraftReviewListData, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ list, total, err := l.svcCtx.Growth.ListReviews(l.ctx, uid, req.Status, req.Page, req.PageSize)
+ if err != nil {
+ return nil, err
+ }
+ return &types.DraftReviewListData{
+ List: growthmap.ReviewList(list),
+ Pagination: growthmap.Pagination(req.Page, req.PageSize, total),
+ }, nil
+}
diff --git a/apps/backend/internal/logic/workspaces/list_workspace_members_logic.go b/apps/backend/internal/logic/workspaces/list_workspace_members_logic.go
new file mode 100644
index 0000000..5512683
--- /dev/null
+++ b/apps/backend/internal/logic/workspaces/list_workspace_members_logic.go
@@ -0,0 +1,47 @@
+package workspaces
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ListWorkspaceMembersLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewListWorkspaceMembersLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListWorkspaceMembersLogic {
+ return &ListWorkspaceMembersLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *ListWorkspaceMembersLogic) ListWorkspaceMembers(req *types.ListWorkspaceMembersReq) (*types.WorkspaceMemberListData, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ list, err := l.svcCtx.Growth.ListWorkspaceMembers(l.ctx, uid, req.Id)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]types.WorkspaceMemberPublic, 0, len(list))
+ for _, m := range list {
+ email, name := "", fmt.Sprintf("uid %d", m.UID)
+ if mem, err := l.svcCtx.Members.FindByUID(l.ctx, m.UID); err == nil && mem != nil {
+ email = mem.Email
+ if mem.DisplayName != "" {
+ name = mem.DisplayName
+ }
+ }
+ out = append(out, types.WorkspaceMemberPublic{
+ Uid: m.UID, Email: email, DisplayName: name, Role: m.Role, JoinedAt: m.JoinedAt,
+ })
+ }
+ return &types.WorkspaceMemberListData{List: out}, nil
+}
diff --git a/apps/backend/internal/logic/workspaces/list_workspaces_logic.go b/apps/backend/internal/logic/workspaces/list_workspaces_logic.go
new file mode 100644
index 0000000..f6ed390
--- /dev/null
+++ b/apps/backend/internal/logic/workspaces/list_workspaces_logic.go
@@ -0,0 +1,35 @@
+package workspaces
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/logic/growthmap"
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ListWorkspacesLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewListWorkspacesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListWorkspacesLogic {
+ return &ListWorkspacesLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *ListWorkspacesLogic) ListWorkspaces() (*types.WorkspaceListData, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ list, cur, err := l.svcCtx.Growth.ListWorkspaces(l.ctx, uid)
+ if err != nil {
+ return nil, err
+ }
+ return &types.WorkspaceListData{List: growthmap.WorkspaceList(list), CurrentWorkspaceId: cur}, nil
+}
diff --git a/apps/backend/internal/logic/workspaces/reject_draft_review_logic.go b/apps/backend/internal/logic/workspaces/reject_draft_review_logic.go
new file mode 100644
index 0000000..7a8e937
--- /dev/null
+++ b/apps/backend/internal/logic/workspaces/reject_draft_review_logic.go
@@ -0,0 +1,36 @@
+package workspaces
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/logic/growthmap"
+ "apps/backend/internal/middleware"
+ growthDomain "apps/backend/internal/module/growth/domain"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type RejectDraftReviewLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewRejectDraftReviewLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RejectDraftReviewLogic {
+ return &RejectDraftReviewLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *RejectDraftReviewLogic) RejectDraftReview(req *types.ReviewDecisionReq) (*types.DraftReviewPublic, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ r, err := l.svcCtx.Growth.DecideReview(l.ctx, uid, req.Id, growthDomain.ReviewRejected, req.Reason)
+ if err != nil {
+ return nil, err
+ }
+ return growthmap.Review(r), nil
+}
diff --git a/apps/backend/internal/logic/workspaces/remove_workspace_member_logic.go b/apps/backend/internal/logic/workspaces/remove_workspace_member_logic.go
new file mode 100644
index 0000000..76983b8
--- /dev/null
+++ b/apps/backend/internal/logic/workspaces/remove_workspace_member_logic.go
@@ -0,0 +1,33 @@
+package workspaces
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type RemoveWorkspaceMemberLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewRemoveWorkspaceMemberLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RemoveWorkspaceMemberLogic {
+ return &RemoveWorkspaceMemberLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *RemoveWorkspaceMemberLogic) RemoveWorkspaceMember(req *types.RemoveWorkspaceMemberReq) (*types.OkData, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ if err := l.svcCtx.Growth.RemoveWorkspaceMember(l.ctx, uid, req.Uid, req.Id); err != nil {
+ return nil, err
+ }
+ return &types.OkData{Ok: true}, nil
+}
diff --git a/apps/backend/internal/logic/workspaces/submit_draft_review_logic.go b/apps/backend/internal/logic/workspaces/submit_draft_review_logic.go
new file mode 100644
index 0000000..56112e0
--- /dev/null
+++ b/apps/backend/internal/logic/workspaces/submit_draft_review_logic.go
@@ -0,0 +1,35 @@
+package workspaces
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/logic/growthmap"
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type SubmitDraftReviewLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewSubmitDraftReviewLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SubmitDraftReviewLogic {
+ return &SubmitDraftReviewLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *SubmitDraftReviewLogic) SubmitDraftReview(req *types.SubmitDraftReviewReq) (*types.DraftReviewPublic, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ r, err := l.svcCtx.Growth.SubmitReview(l.ctx, uid, req.RefType, req.RefId, req.WorkspaceId)
+ if err != nil {
+ return nil, err
+ }
+ return growthmap.Review(r), nil
+}
diff --git a/apps/backend/internal/logic/workspaces/switch_workspace_logic.go b/apps/backend/internal/logic/workspaces/switch_workspace_logic.go
new file mode 100644
index 0000000..2043592
--- /dev/null
+++ b/apps/backend/internal/logic/workspaces/switch_workspace_logic.go
@@ -0,0 +1,38 @@
+package workspaces
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/logic/growthmap"
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type SwitchWorkspaceLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewSwitchWorkspaceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SwitchWorkspaceLogic {
+ return &SwitchWorkspaceLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *SwitchWorkspaceLogic) SwitchWorkspace(req *types.SwitchWorkspaceReq) (*types.WorkspaceListData, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ if err := l.svcCtx.Growth.SwitchWorkspace(l.ctx, uid, req.Id); err != nil {
+ return nil, err
+ }
+ list, cur, err := l.svcCtx.Growth.ListWorkspaces(l.ctx, uid)
+ if err != nil {
+ return nil, err
+ }
+ return &types.WorkspaceListData{List: growthmap.WorkspaceList(list), CurrentWorkspaceId: cur}, nil
+}
diff --git a/apps/backend/internal/logic/workspaces/update_workspace_branding_logic.go b/apps/backend/internal/logic/workspaces/update_workspace_branding_logic.go
new file mode 100644
index 0000000..29d2926
--- /dev/null
+++ b/apps/backend/internal/logic/workspaces/update_workspace_branding_logic.go
@@ -0,0 +1,36 @@
+package workspaces
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/logic/growthmap"
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type UpdateWorkspaceBrandingLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewUpdateWorkspaceBrandingLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateWorkspaceBrandingLogic {
+ return &UpdateWorkspaceBrandingLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *UpdateWorkspaceBrandingLogic) UpdateWorkspaceBranding(req *types.WorkspaceBrandingReq) (*types.WorkspacePublic, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ hide := req.HidePoweredBy
+ w, err := l.svcCtx.Growth.UpdateWorkspaceBranding(l.ctx, uid, req.Id, req.FooterText, req.BrandName, &hide)
+ if err != nil {
+ return nil, err
+ }
+ return growthmap.Workspace(w), nil
+}
diff --git a/apps/backend/internal/logic/workspaces/update_workspace_logic.go b/apps/backend/internal/logic/workspaces/update_workspace_logic.go
new file mode 100644
index 0000000..861c73a
--- /dev/null
+++ b/apps/backend/internal/logic/workspaces/update_workspace_logic.go
@@ -0,0 +1,35 @@
+package workspaces
+
+import (
+ "context"
+ "fmt"
+
+ "apps/backend/internal/logic/growthmap"
+ "apps/backend/internal/middleware"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type UpdateWorkspaceLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewUpdateWorkspaceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateWorkspaceLogic {
+ return &UpdateWorkspaceLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *UpdateWorkspaceLogic) UpdateWorkspace(req *types.UpdateWorkspaceReq) (*types.WorkspacePublic, error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok || uid <= 0 {
+ return nil, fmt.Errorf("unauthorized")
+ }
+ w, err := l.svcCtx.Growth.UpdateWorkspace(l.ctx, uid, req.Id, req.Name, req.ReviewRequired)
+ if err != nil {
+ return nil, err
+ }
+ return growthmap.Workspace(w), nil
+}
diff --git a/apps/backend/internal/module/growth/domain/domain.go b/apps/backend/internal/module/growth/domain/domain.go
new file mode 100644
index 0000000..43e245a
--- /dev/null
+++ b/apps/backend/internal/module/growth/domain/domain.go
@@ -0,0 +1,300 @@
+package domain
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+var (
+ ErrNotFound = errors.New("growth not found")
+ ErrForbidden = errors.New("growth access denied")
+ ErrValidation = errors.New("growth validation")
+ ErrThrottle = errors.New("account health throttle")
+ ErrReviewPending = errors.New("draft review required")
+ ErrRateLimited = errors.New("checkup regenerate limit")
+)
+
+const (
+ SourceScoutOutreach = "scout_outreach"
+ SourceOutboxStep = "outbox_step"
+
+ KindReach = "reach"
+ KindReply = "reply"
+ KindFollow = "follow"
+ KindConversion = "conversion"
+ KindInteraction = "interaction"
+
+ ConfidenceConfirmed = "confirmed"
+ ConfidencePossible = "possible"
+
+ StatusObserving = "observing"
+ StatusConfirmed = "confirmed"
+ StatusPossible = "possible"
+ StatusExpired = "expired"
+ StatusAmended = "amended"
+
+ CheckupGenerating = "generating"
+ CheckupReady = "ready"
+ CheckupFailed = "failed"
+
+ HealthNormal = "normal"
+ HealthWarn = "warn"
+ HealthThrottle = "throttle"
+
+ ReviewNone = "none"
+ ReviewPending = "pending"
+ ReviewApproved = "approved"
+ ReviewRejected = "rejected"
+
+ RewardCredited = "credited"
+ RewardCapped = "capped"
+ RewardSkipped = "skipped"
+
+ ObserveWindow = 72 * time.Hour
+)
+
+func NowNano() int64 { return time.Now().UTC().UnixNano() }
+
+func NewID() string { return uuid.NewString() }
+
+type OutcomeEvent struct {
+ ID string `bson:"_id" json:"id"`
+ OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
+ SourceType string `bson:"source_type" json:"source_type"`
+ SourceID string `bson:"source_id" json:"source_id"`
+ ThreadsAccountID string `bson:"threads_account_id,omitempty" json:"threads_account_id,omitempty"`
+ Kind string `bson:"kind" json:"kind"`
+ Confidence string `bson:"confidence" json:"confidence"`
+ Status string `bson:"status" json:"status"`
+ ReplyCountDelta int `bson:"reply_count_delta,omitempty" json:"reply_count_delta,omitempty"`
+ LikeCountDelta int `bson:"like_count_delta,omitempty" json:"like_count_delta,omitempty"`
+ FollowersDelta int `bson:"followers_delta,omitempty" json:"followers_delta,omitempty"`
+ ConversionAmount float64 `bson:"conversion_amount,omitempty" json:"conversion_amount,omitempty"`
+ ConversionNote string `bson:"conversion_note,omitempty" json:"conversion_note,omitempty"`
+ ConversionCurrency string `bson:"conversion_currency,omitempty" json:"conversion_currency,omitempty"`
+ HasConversion bool `bson:"has_conversion" json:"has_conversion"`
+ ClickCount int `bson:"click_count,omitempty" json:"click_count,omitempty"`
+ WindowEndsAt int64 `bson:"window_ends_at" json:"window_ends_at"`
+ SentAt int64 `bson:"sent_at" json:"sent_at"`
+ CreatedAt int64 `bson:"created_at" json:"created_at"`
+ UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
+}
+
+const (
+ KindClick = "click"
+
+ PlaybookBrief = "brief"
+ PlaybookPersona = "persona"
+ PlaybookPlay = "play"
+
+ WSRoleOwner = "owner"
+ WSRoleEditor = "editor"
+ WSRoleReviewer = "reviewer"
+)
+
+type Playbook struct {
+ ID string `bson:"_id" json:"id"`
+ OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
+ Kind string `bson:"kind" json:"kind"`
+ Title string `bson:"title" json:"title"`
+ Niche string `bson:"niche" json:"niche"`
+ Body string `bson:"body" json:"body"`
+ Anonymous bool `bson:"anonymous" json:"anonymous"`
+ ImportCount int `bson:"import_count" json:"import_count"`
+ CreatedAt int64 `bson:"created_at" json:"created_at"`
+ UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
+}
+
+type UtmLink struct {
+ ID string `bson:"_id" json:"id"`
+ Code string `bson:"code" json:"code"`
+ OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
+ DestinationURL string `bson:"destination_url" json:"destination_url"`
+ OutcomeID string `bson:"outcome_id,omitempty" json:"outcome_id,omitempty"`
+ Label string `bson:"label,omitempty" json:"label,omitempty"`
+ Clicks int `bson:"clicks" json:"clicks"`
+ CreatedAt int64 `bson:"created_at" json:"created_at"`
+}
+
+type WorkspaceMember struct {
+ ID string `bson:"_id" json:"id"`
+ WorkspaceID string `bson:"workspace_id" json:"workspace_id"`
+ UID int64 `bson:"uid" json:"uid"`
+ Role string `bson:"role" json:"role"`
+ JoinedAt int64 `bson:"joined_at" json:"joined_at"`
+}
+
+// BenchmarkSample — anonymized contribution for niche medians
+type BenchmarkSample struct {
+ ID string `bson:"_id" json:"id"`
+ Niche string `bson:"niche" json:"niche"`
+ EngRate float64 `bson:"eng_rate" json:"eng_rate"`
+ AvgViews float64 `bson:"avg_views" json:"avg_views"`
+ OwnerHash string `bson:"owner_hash" json:"owner_hash"` // one sample per owner per niche
+ UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
+}
+
+type OutcomeSummary struct {
+ From int64
+ To int64
+ Reach int
+ Conversations int
+ FollowsPossible int
+ FollowsConfirmed int
+ Conversions int
+ ConversionAmount float64
+}
+
+type CheckupFinding struct {
+ Claim string `bson:"claim" json:"claim"`
+ Evidence []string `bson:"evidence" json:"evidence"`
+}
+
+type CheckupAction struct {
+ Title string `bson:"title" json:"title"`
+ Reason string `bson:"reason" json:"reason"`
+ Deeplink string `bson:"deeplink" json:"deeplink"`
+}
+
+type WeeklyCheckup struct {
+ ID string `bson:"_id" json:"id"`
+ OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
+ WeekKey string `bson:"week_key" json:"week_key"`
+ Status string `bson:"status" json:"status"`
+ Summary string `bson:"summary" json:"summary"`
+ Findings []CheckupFinding `bson:"findings" json:"findings"`
+ Actions []CheckupAction `bson:"actions" json:"actions"`
+ Error string `bson:"error,omitempty" json:"error,omitempty"`
+ CreatedAt int64 `bson:"created_at" json:"created_at"`
+ UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
+}
+
+type AccountHealth struct {
+ ThreadsAccountID string `bson:"_id" json:"threads_account_id"`
+ OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
+ Score int `bson:"score" json:"score"`
+ Level string `bson:"level" json:"level"`
+ Factors []string `bson:"factors" json:"factors"`
+ Advice string `bson:"advice" json:"advice"`
+ ComputedAt int64 `bson:"computed_at" json:"computed_at"`
+}
+
+type Workspace struct {
+ ID string `bson:"_id" json:"id"`
+ OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
+ Name string `bson:"name" json:"name"`
+ ReviewRequired bool `bson:"review_required" json:"review_required"`
+ IsDefault bool `bson:"is_default" json:"is_default"`
+ Archived bool `bson:"archived" json:"archived"`
+ FooterText string `bson:"footer_text,omitempty" json:"footer_text,omitempty"`
+ HidePoweredBy bool `bson:"hide_powered_by,omitempty" json:"hide_powered_by,omitempty"`
+ BrandName string `bson:"brand_name,omitempty" json:"brand_name,omitempty"`
+ CreatedAt int64 `bson:"created_at" json:"created_at"`
+ UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
+}
+
+type MemberWorkspaceState struct {
+ OwnerUID int64 `bson:"_id" json:"owner_uid"`
+ CurrentWorkspaceID string `bson:"current_workspace_id" json:"current_workspace_id"`
+ UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
+}
+
+type DraftReview struct {
+ ID string `bson:"_id" json:"id"`
+ OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
+ WorkspaceID string `bson:"workspace_id" json:"workspace_id"`
+ RefType string `bson:"ref_type" json:"ref_type"`
+ RefID string `bson:"ref_id" json:"ref_id"`
+ Status string `bson:"status" json:"status"`
+ Reason string `bson:"reason,omitempty" json:"reason,omitempty"`
+ CreatedAt int64 `bson:"created_at" json:"created_at"`
+ UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
+}
+
+type InviteReward struct {
+ ID string `bson:"_id" json:"id"`
+ InviterUID int64 `bson:"inviter_uid" json:"inviter_uid"`
+ InviteeUID int64 `bson:"invitee_uid" json:"invitee_uid"`
+ PlanID string `bson:"plan_id" json:"plan_id"`
+ Points int `bson:"points" json:"points"`
+ Status string `bson:"status" json:"status"`
+ CreatedAt int64 `bson:"created_at" json:"created_at"`
+}
+
+type OutcomeListFilter struct {
+ Kind string
+ Confidence string
+ SourceType string
+ From int64
+ To int64
+ Page int
+ PageSize int
+}
+
+type Repository interface {
+ // Outcomes
+ UpsertOutcomeBySource(ctx context.Context, e *OutcomeEvent) error
+ GetOutcome(ctx context.Context, id string) (*OutcomeEvent, error)
+ ListOutcomes(ctx context.Context, ownerUID int64, f OutcomeListFilter) ([]*OutcomeEvent, int64, error)
+ SaveOutcome(ctx context.Context, e *OutcomeEvent) error
+ ListObservingDue(ctx context.Context, now int64, limit int) ([]*OutcomeEvent, error)
+
+ // Checkups
+ SaveCheckup(ctx context.Context, c *WeeklyCheckup) error
+ GetCheckup(ctx context.Context, id string) (*WeeklyCheckup, error)
+ LatestCheckup(ctx context.Context, ownerUID int64) (*WeeklyCheckup, error)
+ ListCheckups(ctx context.Context, ownerUID int64, page, pageSize int) ([]*WeeklyCheckup, int64, error)
+ CountCheckupGenerations(ctx context.Context, ownerUID int64, sinceNano int64) (int64, error)
+
+ // Health
+ SaveHealth(ctx context.Context, h *AccountHealth) error
+ GetHealth(ctx context.Context, accountID string) (*AccountHealth, error)
+ ListHealth(ctx context.Context, ownerUID int64) ([]*AccountHealth, error)
+
+ // Workspace
+ SaveWorkspace(ctx context.Context, w *Workspace) error
+ GetWorkspace(ctx context.Context, id string) (*Workspace, error)
+ ListWorkspaces(ctx context.Context, ownerUID int64, includeArchived bool) ([]*Workspace, error)
+ CountActiveWorkspaces(ctx context.Context, ownerUID int64) (int64, error)
+ GetMemberWSState(ctx context.Context, ownerUID int64) (*MemberWorkspaceState, error)
+ SaveMemberWSState(ctx context.Context, s *MemberWorkspaceState) error
+
+ // Reviews
+ SaveReview(ctx context.Context, r *DraftReview) error
+ GetReview(ctx context.Context, id string) (*DraftReview, error)
+ FindReviewByRef(ctx context.Context, ownerUID int64, refType, refID string) (*DraftReview, error)
+ ListReviews(ctx context.Context, ownerUID int64, status string, page, pageSize int) ([]*DraftReview, int64, error)
+
+ // Invite rewards
+ SaveInviteReward(ctx context.Context, r *InviteReward) error
+ ListInviteRewards(ctx context.Context, inviterUID int64, limit int) ([]*InviteReward, error)
+ SumInviteRewardPointsMonth(ctx context.Context, inviterUID int64, monthStartNano int64) (int, error)
+ SumInviteRewardPointsTotal(ctx context.Context, inviterUID int64) (int, error)
+ HasInviteRewardForInvitee(ctx context.Context, inviteeUID int64) (bool, error)
+
+ // P2 Playbooks
+ SavePlaybook(ctx context.Context, p *Playbook) error
+ GetPlaybook(ctx context.Context, id string) (*Playbook, error)
+ ListPlaybooks(ctx context.Context, kind, niche string, ownerOnlyUID int64, page, pageSize int) ([]*Playbook, int64, error)
+ DeletePlaybook(ctx context.Context, id string) error
+ IncPlaybookImport(ctx context.Context, id string) error
+
+ // P2 UTM
+ SaveUtmLink(ctx context.Context, u *UtmLink) error
+ GetUtmByCode(ctx context.Context, code string) (*UtmLink, error)
+ ListUtmLinks(ctx context.Context, ownerUID int64) ([]*UtmLink, error)
+ IncUtmClick(ctx context.Context, code string) (*UtmLink, error)
+
+ // P2 Workspace members
+ SaveWorkspaceMember(ctx context.Context, m *WorkspaceMember) error
+ ListWorkspaceMembers(ctx context.Context, workspaceID string) ([]*WorkspaceMember, error)
+ RemoveWorkspaceMember(ctx context.Context, workspaceID string, uid int64) error
+ IsWorkspaceMember(ctx context.Context, workspaceID string, uid int64) (bool, string, error)
+
+ // P2 Benchmark
+ UpsertBenchmarkSample(ctx context.Context, s *BenchmarkSample) error
+ ListBenchmarkSamples(ctx context.Context, niche string, limit int) ([]*BenchmarkSample, error)
+}
diff --git a/apps/backend/internal/module/growth/repository/memory.go b/apps/backend/internal/module/growth/repository/memory.go
new file mode 100644
index 0000000..4cd3910
--- /dev/null
+++ b/apps/backend/internal/module/growth/repository/memory.go
@@ -0,0 +1,674 @@
+package repository
+
+import (
+ "context"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "apps/backend/internal/module/growth/domain"
+)
+
+type Memory struct {
+ mu sync.Mutex
+ outcomes map[string]*domain.OutcomeEvent
+ bySource map[string]string
+ checkups map[string]*domain.WeeklyCheckup
+ health map[string]*domain.AccountHealth
+ workspaces map[string]*domain.Workspace
+ wsState map[int64]*domain.MemberWorkspaceState
+ reviews map[string]*domain.DraftReview
+ rewards map[string]*domain.InviteReward
+ playbooks map[string]*domain.Playbook
+ utm map[string]*domain.UtmLink
+ wsMembers map[string]*domain.WorkspaceMember
+ bench map[string]*domain.BenchmarkSample
+}
+
+func NewMemory() *Memory {
+ return &Memory{
+ outcomes: map[string]*domain.OutcomeEvent{},
+ bySource: map[string]string{},
+ checkups: map[string]*domain.WeeklyCheckup{},
+ health: map[string]*domain.AccountHealth{},
+ workspaces: map[string]*domain.Workspace{},
+ wsState: map[int64]*domain.MemberWorkspaceState{},
+ reviews: map[string]*domain.DraftReview{},
+ rewards: map[string]*domain.InviteReward{},
+ playbooks: map[string]*domain.Playbook{},
+ utm: map[string]*domain.UtmLink{},
+ wsMembers: map[string]*domain.WorkspaceMember{},
+ bench: map[string]*domain.BenchmarkSample{},
+ }
+}
+
+func sourceKey(t, id string) string { return t + ":" + id }
+
+func (m *Memory) UpsertOutcomeBySource(_ context.Context, e *domain.OutcomeEvent) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ sk := sourceKey(e.SourceType, e.SourceID)
+ if id, ok := m.bySource[sk]; ok {
+ // 發佈 hook 可能重送;已存在就保持原 sent_at 與所有觀測/成交訊號。
+ *e = *m.outcomes[id]
+ return nil
+ } else if e.ID == "" {
+ e.ID = domain.NewID()
+ }
+ cp := *e
+ m.outcomes[e.ID] = &cp
+ m.bySource[sk] = e.ID
+ return nil
+}
+
+func (m *Memory) GetOutcome(_ context.Context, id string) (*domain.OutcomeEvent, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ e, ok := m.outcomes[id]
+ if !ok {
+ return nil, domain.ErrNotFound
+ }
+ cp := *e
+ return &cp, nil
+}
+
+func (m *Memory) ListOutcomes(_ context.Context, ownerUID int64, f domain.OutcomeListFilter) ([]*domain.OutcomeEvent, int64, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ var all []*domain.OutcomeEvent
+ for _, e := range m.outcomes {
+ if e.OwnerUID != ownerUID {
+ continue
+ }
+ if f.Kind != "" && e.Kind != f.Kind {
+ continue
+ }
+ if f.Confidence != "" && e.Confidence != f.Confidence {
+ continue
+ }
+ if f.SourceType != "" && e.SourceType != f.SourceType {
+ continue
+ }
+ if f.From > 0 && e.SentAt < f.From {
+ continue
+ }
+ if f.To > 0 && e.SentAt > f.To {
+ continue
+ }
+ cp := *e
+ all = append(all, &cp)
+ }
+ sort.Slice(all, func(i, j int) bool { return all[i].SentAt > all[j].SentAt })
+ total := int64(len(all))
+ page, ps := f.Page, f.PageSize
+ if page < 1 {
+ page = 1
+ }
+ if ps < 1 {
+ ps = 20
+ }
+ start := (page - 1) * ps
+ if start >= len(all) {
+ return nil, total, nil
+ }
+ end := start + ps
+ if end > len(all) {
+ end = len(all)
+ }
+ return all[start:end], total, nil
+}
+
+func (m *Memory) SaveOutcome(_ context.Context, e *domain.OutcomeEvent) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ cp := *e
+ m.outcomes[e.ID] = &cp
+ m.bySource[sourceKey(e.SourceType, e.SourceID)] = e.ID
+ return nil
+}
+
+func (m *Memory) ListObservingDue(_ context.Context, now int64, limit int) ([]*domain.OutcomeEvent, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ var out []*domain.OutcomeEvent
+ for _, e := range m.outcomes {
+ if e.Status != domain.StatusObserving {
+ continue
+ }
+ cp := *e
+ out = append(out, &cp)
+ if limit > 0 && len(out) >= limit {
+ break
+ }
+ }
+ return out, nil
+}
+
+func (m *Memory) SaveCheckup(_ context.Context, c *domain.WeeklyCheckup) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ cp := *c
+ m.checkups[c.ID] = &cp
+ return nil
+}
+
+func (m *Memory) GetCheckup(_ context.Context, id string) (*domain.WeeklyCheckup, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ c, ok := m.checkups[id]
+ if !ok {
+ return nil, domain.ErrNotFound
+ }
+ cp := *c
+ return &cp, nil
+}
+
+func (m *Memory) LatestCheckup(_ context.Context, ownerUID int64) (*domain.WeeklyCheckup, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ var best *domain.WeeklyCheckup
+ for _, c := range m.checkups {
+ if c.OwnerUID != ownerUID {
+ continue
+ }
+ if best == nil || c.CreatedAt > best.CreatedAt {
+ cp := *c
+ best = &cp
+ }
+ }
+ if best == nil {
+ return nil, domain.ErrNotFound
+ }
+ return best, nil
+}
+
+func (m *Memory) ListCheckups(_ context.Context, ownerUID int64, page, pageSize int) ([]*domain.WeeklyCheckup, int64, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ var all []*domain.WeeklyCheckup
+ for _, c := range m.checkups {
+ if c.OwnerUID == ownerUID {
+ cp := *c
+ all = append(all, &cp)
+ }
+ }
+ sort.Slice(all, func(i, j int) bool { return all[i].CreatedAt > all[j].CreatedAt })
+ total := int64(len(all))
+ if page < 1 {
+ page = 1
+ }
+ if pageSize < 1 {
+ pageSize = 20
+ }
+ start := (page - 1) * pageSize
+ if start >= len(all) {
+ return nil, total, nil
+ }
+ end := start + pageSize
+ if end > len(all) {
+ end = len(all)
+ }
+ return all[start:end], total, nil
+}
+
+func (m *Memory) CountCheckupGenerations(_ context.Context, ownerUID int64, sinceNano int64) (int64, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ var n int64
+ for _, c := range m.checkups {
+ if c.OwnerUID == ownerUID && c.CreatedAt >= sinceNano {
+ n++
+ }
+ }
+ return n, nil
+}
+
+func (m *Memory) SaveHealth(_ context.Context, h *domain.AccountHealth) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ cp := *h
+ m.health[h.ThreadsAccountID] = &cp
+ return nil
+}
+
+func (m *Memory) GetHealth(_ context.Context, accountID string) (*domain.AccountHealth, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ h, ok := m.health[accountID]
+ if !ok {
+ return nil, domain.ErrNotFound
+ }
+ cp := *h
+ return &cp, nil
+}
+
+func (m *Memory) ListHealth(_ context.Context, ownerUID int64) ([]*domain.AccountHealth, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ var out []*domain.AccountHealth
+ for _, h := range m.health {
+ if h.OwnerUID == ownerUID {
+ cp := *h
+ out = append(out, &cp)
+ }
+ }
+ return out, nil
+}
+
+func (m *Memory) SaveWorkspace(_ context.Context, w *domain.Workspace) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ cp := *w
+ m.workspaces[w.ID] = &cp
+ return nil
+}
+
+func (m *Memory) GetWorkspace(_ context.Context, id string) (*domain.Workspace, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ w, ok := m.workspaces[id]
+ if !ok {
+ return nil, domain.ErrNotFound
+ }
+ cp := *w
+ return &cp, nil
+}
+
+func (m *Memory) ListWorkspaces(_ context.Context, ownerUID int64, includeArchived bool) ([]*domain.Workspace, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ var out []*domain.Workspace
+ for _, w := range m.workspaces {
+ if w.OwnerUID != ownerUID {
+ continue
+ }
+ if w.Archived && !includeArchived {
+ continue
+ }
+ cp := *w
+ out = append(out, &cp)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt < out[j].CreatedAt })
+ return out, nil
+}
+
+func (m *Memory) CountActiveWorkspaces(_ context.Context, ownerUID int64) (int64, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ var n int64
+ for _, w := range m.workspaces {
+ if w.OwnerUID == ownerUID && !w.Archived {
+ n++
+ }
+ }
+ return n, nil
+}
+
+func (m *Memory) GetMemberWSState(_ context.Context, ownerUID int64) (*domain.MemberWorkspaceState, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ s, ok := m.wsState[ownerUID]
+ if !ok {
+ return nil, domain.ErrNotFound
+ }
+ cp := *s
+ return &cp, nil
+}
+
+func (m *Memory) SaveMemberWSState(_ context.Context, s *domain.MemberWorkspaceState) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ cp := *s
+ m.wsState[s.OwnerUID] = &cp
+ return nil
+}
+
+func (m *Memory) SaveReview(_ context.Context, r *domain.DraftReview) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ cp := *r
+ m.reviews[r.ID] = &cp
+ return nil
+}
+
+func (m *Memory) GetReview(_ context.Context, id string) (*domain.DraftReview, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ r, ok := m.reviews[id]
+ if !ok {
+ return nil, domain.ErrNotFound
+ }
+ cp := *r
+ return &cp, nil
+}
+
+func (m *Memory) FindReviewByRef(_ context.Context, ownerUID int64, refType, refID string) (*domain.DraftReview, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ var best *domain.DraftReview
+ for _, r := range m.reviews {
+ if r.OwnerUID == ownerUID && r.RefType == refType && r.RefID == refID {
+ if best == nil || r.UpdatedAt > best.UpdatedAt {
+ cp := *r
+ best = &cp
+ }
+ }
+ }
+ if best == nil {
+ return nil, domain.ErrNotFound
+ }
+ return best, nil
+}
+
+func (m *Memory) ListReviews(_ context.Context, ownerUID int64, status string, page, pageSize int) ([]*domain.DraftReview, int64, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ var all []*domain.DraftReview
+ for _, r := range m.reviews {
+ if r.OwnerUID != ownerUID {
+ continue
+ }
+ if status != "" && r.Status != status {
+ continue
+ }
+ cp := *r
+ all = append(all, &cp)
+ }
+ total := int64(len(all))
+ if page < 1 {
+ page = 1
+ }
+ if pageSize < 1 {
+ pageSize = 20
+ }
+ start := (page - 1) * pageSize
+ if start >= len(all) {
+ return nil, total, nil
+ }
+ end := start + pageSize
+ if end > len(all) {
+ end = len(all)
+ }
+ return all[start:end], total, nil
+}
+
+func (m *Memory) SaveInviteReward(_ context.Context, r *domain.InviteReward) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ cp := *r
+ m.rewards[r.ID] = &cp
+ return nil
+}
+
+func (m *Memory) ListInviteRewards(_ context.Context, inviterUID int64, limit int) ([]*domain.InviteReward, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ var all []*domain.InviteReward
+ for _, r := range m.rewards {
+ if r.InviterUID == inviterUID {
+ cp := *r
+ all = append(all, &cp)
+ }
+ }
+ sort.Slice(all, func(i, j int) bool { return all[i].CreatedAt > all[j].CreatedAt })
+ if limit > 0 && len(all) > limit {
+ all = all[:limit]
+ }
+ return all, nil
+}
+
+func (m *Memory) SumInviteRewardPointsMonth(_ context.Context, inviterUID int64, monthStartNano int64) (int, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ sum := 0
+ for _, r := range m.rewards {
+ if r.InviterUID == inviterUID && r.Status == domain.RewardCredited && r.CreatedAt >= monthStartNano {
+ sum += r.Points
+ }
+ }
+ return sum, nil
+}
+
+func (m *Memory) SumInviteRewardPointsTotal(_ context.Context, inviterUID int64) (int, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ sum := 0
+ for _, r := range m.rewards {
+ if r.InviterUID == inviterUID && r.Status == domain.RewardCredited {
+ sum += r.Points
+ }
+ }
+ return sum, nil
+}
+
+func (m *Memory) HasInviteRewardForInvitee(_ context.Context, inviteeUID int64) (bool, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ for _, r := range m.rewards {
+ if r.InviteeUID == inviteeUID && (r.Status == domain.RewardCredited || r.Status == domain.RewardCapped) {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+// Ensure compile-time interface
+var _ domain.Repository = (*Memory)(nil)
+
+// MonthStartNano helper for tests
+func MonthStartNano(t time.Time) int64 {
+ u := t.UTC()
+ return time.Date(u.Year(), u.Month(), 1, 0, 0, 0, 0, time.UTC).UnixNano()
+}
+
+// --- P2 ---
+
+func (m *Memory) SavePlaybook(_ context.Context, p *domain.Playbook) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.playbooks == nil {
+ m.playbooks = map[string]*domain.Playbook{}
+ }
+ cp := *p
+ m.playbooks[p.ID] = &cp
+ return nil
+}
+
+func (m *Memory) GetPlaybook(_ context.Context, id string) (*domain.Playbook, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ p, ok := m.playbooks[id]
+ if !ok {
+ return nil, domain.ErrNotFound
+ }
+ cp := *p
+ return &cp, nil
+}
+
+func (m *Memory) ListPlaybooks(_ context.Context, kind, niche string, ownerOnlyUID int64, page, pageSize int) ([]*domain.Playbook, int64, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ var all []*domain.Playbook
+ for _, p := range m.playbooks {
+ if kind != "" && p.Kind != kind {
+ continue
+ }
+ if niche != "" && !stringsContainsFold(p.Niche, niche) {
+ continue
+ }
+ if ownerOnlyUID > 0 && p.OwnerUID != ownerOnlyUID {
+ continue
+ }
+ cp := *p
+ all = append(all, &cp)
+ }
+ sort.Slice(all, func(i, j int) bool { return all[i].CreatedAt > all[j].CreatedAt })
+ total := int64(len(all))
+ if page < 1 {
+ page = 1
+ }
+ if pageSize < 1 {
+ pageSize = 20
+ }
+ start := (page - 1) * pageSize
+ if start >= len(all) {
+ return nil, total, nil
+ }
+ end := start + pageSize
+ if end > len(all) {
+ end = len(all)
+ }
+ return all[start:end], total, nil
+}
+
+func stringsContainsFold(s, sub string) bool {
+ return strings.Contains(strings.ToLower(s), strings.ToLower(sub))
+}
+
+func (m *Memory) DeletePlaybook(_ context.Context, id string) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if _, ok := m.playbooks[id]; !ok {
+ return domain.ErrNotFound
+ }
+ delete(m.playbooks, id)
+ return nil
+}
+
+func (m *Memory) IncPlaybookImport(_ context.Context, id string) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ p, ok := m.playbooks[id]
+ if !ok {
+ return domain.ErrNotFound
+ }
+ p.ImportCount++
+ return nil
+}
+
+func (m *Memory) SaveUtmLink(_ context.Context, u *domain.UtmLink) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.utm == nil {
+ m.utm = map[string]*domain.UtmLink{}
+ }
+ cp := *u
+ m.utm[u.Code] = &cp
+ return nil
+}
+
+func (m *Memory) GetUtmByCode(_ context.Context, code string) (*domain.UtmLink, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ u, ok := m.utm[code]
+ if !ok {
+ return nil, domain.ErrNotFound
+ }
+ cp := *u
+ return &cp, nil
+}
+
+func (m *Memory) ListUtmLinks(_ context.Context, ownerUID int64) ([]*domain.UtmLink, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ var out []*domain.UtmLink
+ for _, u := range m.utm {
+ if u.OwnerUID == ownerUID {
+ cp := *u
+ out = append(out, &cp)
+ }
+ }
+ return out, nil
+}
+
+func (m *Memory) IncUtmClick(_ context.Context, code string) (*domain.UtmLink, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ u, ok := m.utm[code]
+ if !ok {
+ return nil, domain.ErrNotFound
+ }
+ u.Clicks++
+ cp := *u
+ return &cp, nil
+}
+
+func (m *Memory) SaveWorkspaceMember(_ context.Context, mem *domain.WorkspaceMember) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.wsMembers == nil {
+ m.wsMembers = map[string]*domain.WorkspaceMember{}
+ }
+ cp := *mem
+ m.wsMembers[mem.ID] = &cp
+ return nil
+}
+
+func (m *Memory) ListWorkspaceMembers(_ context.Context, workspaceID string) ([]*domain.WorkspaceMember, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ var out []*domain.WorkspaceMember
+ for _, mem := range m.wsMembers {
+ if mem.WorkspaceID == workspaceID {
+ cp := *mem
+ out = append(out, &cp)
+ }
+ }
+ return out, nil
+}
+
+func (m *Memory) RemoveWorkspaceMember(_ context.Context, workspaceID string, uid int64) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ for id, mem := range m.wsMembers {
+ if mem.WorkspaceID == workspaceID && mem.UID == uid {
+ delete(m.wsMembers, id)
+ return nil
+ }
+ }
+ return domain.ErrNotFound
+}
+
+func (m *Memory) IsWorkspaceMember(_ context.Context, workspaceID string, uid int64) (bool, string, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if w, ok := m.workspaces[workspaceID]; ok && w.OwnerUID == uid {
+ return true, domain.WSRoleOwner, nil
+ }
+ for _, mem := range m.wsMembers {
+ if mem.WorkspaceID == workspaceID && mem.UID == uid {
+ return true, mem.Role, nil
+ }
+ }
+ return false, "", nil
+}
+
+func (m *Memory) UpsertBenchmarkSample(_ context.Context, s *domain.BenchmarkSample) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.bench == nil {
+ m.bench = map[string]*domain.BenchmarkSample{}
+ }
+ key := s.Niche + ":" + s.OwnerHash
+ s.ID = key
+ cp := *s
+ m.bench[key] = &cp
+ return nil
+}
+
+func (m *Memory) ListBenchmarkSamples(_ context.Context, niche string, limit int) ([]*domain.BenchmarkSample, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ var out []*domain.BenchmarkSample
+ for _, s := range m.bench {
+ if niche == "" || s.Niche == niche {
+ cp := *s
+ out = append(out, &cp)
+ }
+ }
+ if limit > 0 && len(out) > limit {
+ out = out[:limit]
+ }
+ return out, nil
+}
diff --git a/apps/backend/internal/module/growth/repository/mongo.go b/apps/backend/internal/module/growth/repository/mongo.go
new file mode 100644
index 0000000..ab1a374
--- /dev/null
+++ b/apps/backend/internal/module/growth/repository/mongo.go
@@ -0,0 +1,473 @@
+package repository
+
+import (
+ "context"
+
+ libmongo "apps/backend/internal/lib/mongo"
+ "apps/backend/internal/module/growth/domain"
+
+ "github.com/zeromicro/go-zero/core/stores/mon"
+ "go.mongodb.org/mongo-driver/bson"
+ "go.mongodb.org/mongo-driver/mongo/options"
+)
+
+type MonStore struct {
+ outcomes *mon.Model
+ checkups *mon.Model
+ health *mon.Model
+ workspaces *mon.Model
+ wsState *mon.Model
+ reviews *mon.Model
+ rewards *mon.Model
+ playbooks *mon.Model
+ utm *mon.Model
+ wsMembers *mon.Model
+ bench *mon.Model
+}
+
+func NewMonStore(uri, database string) *MonStore {
+ uri = libmongo.MustMongoURI(uri)
+ return &MonStore{
+ outcomes: mon.MustNewModel(uri, database, "growth_outcomes"),
+ checkups: mon.MustNewModel(uri, database, "growth_checkups"),
+ health: mon.MustNewModel(uri, database, "growth_account_health"),
+ workspaces: mon.MustNewModel(uri, database, "growth_workspaces"),
+ wsState: mon.MustNewModel(uri, database, "growth_ws_state"),
+ reviews: mon.MustNewModel(uri, database, "growth_draft_reviews"),
+ rewards: mon.MustNewModel(uri, database, "growth_invite_rewards"),
+ playbooks: mon.MustNewModel(uri, database, "growth_playbooks"),
+ utm: mon.MustNewModel(uri, database, "growth_utm_links"),
+ wsMembers: mon.MustNewModel(uri, database, "growth_ws_members"),
+ bench: mon.MustNewModel(uri, database, "growth_benchmark"),
+ }
+}
+
+func (s *MonStore) UpsertOutcomeBySource(ctx context.Context, e *domain.OutcomeEvent) error {
+ filter := bson.M{"owner_uid": e.OwnerUID, "source_type": e.SourceType, "source_id": e.SourceID}
+ var existing domain.OutcomeEvent
+ err := s.outcomes.FindOne(ctx, &existing, filter)
+ if err == nil {
+ // 發佈 hook 可能重送;已存在就保持原 sent_at 與所有觀測/成交訊號。
+ *e = existing
+ return nil
+ }
+ if err != mon.ErrNotFound {
+ return err
+ }
+ if e.ID == "" {
+ e.ID = domain.NewID()
+ }
+ _, err = s.outcomes.InsertOne(ctx, e)
+ return err
+}
+
+func (s *MonStore) GetOutcome(ctx context.Context, id string) (*domain.OutcomeEvent, error) {
+ var e domain.OutcomeEvent
+ err := s.outcomes.FindOne(ctx, &e, bson.M{"_id": id})
+ if err == mon.ErrNotFound {
+ return nil, domain.ErrNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &e, nil
+}
+
+func (s *MonStore) ListOutcomes(ctx context.Context, ownerUID int64, f domain.OutcomeListFilter) ([]*domain.OutcomeEvent, int64, error) {
+ q := bson.M{"owner_uid": ownerUID}
+ if f.Kind != "" {
+ q["kind"] = f.Kind
+ }
+ if f.Confidence != "" {
+ q["confidence"] = f.Confidence
+ }
+ if f.SourceType != "" {
+ q["source_type"] = f.SourceType
+ }
+ if f.From > 0 || f.To > 0 {
+ rangeQ := bson.M{}
+ if f.From > 0 {
+ rangeQ["$gte"] = f.From
+ }
+ if f.To > 0 {
+ rangeQ["$lte"] = f.To
+ }
+ q["sent_at"] = rangeQ
+ }
+ total, err := s.outcomes.CountDocuments(ctx, q)
+ if err != nil {
+ return nil, 0, err
+ }
+ page, ps := f.Page, f.PageSize
+ if page < 1 {
+ page = 1
+ }
+ if ps < 1 {
+ ps = 20
+ }
+ var list []*domain.OutcomeEvent
+ err = s.outcomes.Find(ctx, &list, q, options.Find().
+ SetSort(bson.D{{Key: "sent_at", Value: -1}}).
+ SetSkip(int64((page-1)*ps)).
+ SetLimit(int64(ps)))
+ return list, total, err
+}
+
+func (s *MonStore) SaveOutcome(ctx context.Context, e *domain.OutcomeEvent) error {
+ _, err := s.outcomes.UpdateOne(ctx, bson.M{"_id": e.ID}, bson.M{"$set": e}, options.Update().SetUpsert(true))
+ return err
+}
+
+func (s *MonStore) ListObservingDue(ctx context.Context, now int64, limit int) ([]*domain.OutcomeEvent, error) {
+ var list []*domain.OutcomeEvent
+ opts := options.Find().SetSort(bson.D{{Key: "sent_at", Value: 1}})
+ if limit > 0 {
+ opts.SetLimit(int64(limit))
+ }
+ err := s.outcomes.Find(ctx, &list, bson.M{"status": domain.StatusObserving}, opts)
+ return list, err
+}
+
+func (s *MonStore) SaveCheckup(ctx context.Context, c *domain.WeeklyCheckup) error {
+ _, err := s.checkups.UpdateOne(ctx, bson.M{"_id": c.ID}, bson.M{"$set": c}, options.Update().SetUpsert(true))
+ return err
+}
+
+func (s *MonStore) GetCheckup(ctx context.Context, id string) (*domain.WeeklyCheckup, error) {
+ var c domain.WeeklyCheckup
+ err := s.checkups.FindOne(ctx, &c, bson.M{"_id": id})
+ if err == mon.ErrNotFound {
+ return nil, domain.ErrNotFound
+ }
+ return &c, err
+}
+
+func (s *MonStore) LatestCheckup(ctx context.Context, ownerUID int64) (*domain.WeeklyCheckup, error) {
+ var list []*domain.WeeklyCheckup
+ err := s.checkups.Find(ctx, &list, bson.M{"owner_uid": ownerUID},
+ options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}).SetLimit(1))
+ if err != nil {
+ return nil, err
+ }
+ if len(list) == 0 {
+ return nil, domain.ErrNotFound
+ }
+ return list[0], nil
+}
+
+func (s *MonStore) ListCheckups(ctx context.Context, ownerUID int64, page, pageSize int) ([]*domain.WeeklyCheckup, int64, error) {
+ q := bson.M{"owner_uid": ownerUID}
+ total, err := s.checkups.CountDocuments(ctx, q)
+ if err != nil {
+ return nil, 0, err
+ }
+ if page < 1 {
+ page = 1
+ }
+ if pageSize < 1 {
+ pageSize = 20
+ }
+ var list []*domain.WeeklyCheckup
+ err = s.checkups.Find(ctx, &list, q, options.Find().
+ SetSort(bson.D{{Key: "created_at", Value: -1}}).
+ SetSkip(int64((page-1)*pageSize)).SetLimit(int64(pageSize)))
+ return list, total, err
+}
+
+func (s *MonStore) CountCheckupGenerations(ctx context.Context, ownerUID int64, sinceNano int64) (int64, error) {
+ return s.checkups.CountDocuments(ctx, bson.M{"owner_uid": ownerUID, "created_at": bson.M{"$gte": sinceNano}})
+}
+
+func (s *MonStore) SaveHealth(ctx context.Context, h *domain.AccountHealth) error {
+ _, err := s.health.UpdateOne(ctx, bson.M{"_id": h.ThreadsAccountID}, bson.M{"$set": h}, options.Update().SetUpsert(true))
+ return err
+}
+
+func (s *MonStore) GetHealth(ctx context.Context, accountID string) (*domain.AccountHealth, error) {
+ var h domain.AccountHealth
+ err := s.health.FindOne(ctx, &h, bson.M{"_id": accountID})
+ if err == mon.ErrNotFound {
+ return nil, domain.ErrNotFound
+ }
+ return &h, err
+}
+
+func (s *MonStore) ListHealth(ctx context.Context, ownerUID int64) ([]*domain.AccountHealth, error) {
+ var list []*domain.AccountHealth
+ err := s.health.Find(ctx, &list, bson.M{"owner_uid": ownerUID})
+ return list, err
+}
+
+func (s *MonStore) SaveWorkspace(ctx context.Context, w *domain.Workspace) error {
+ _, err := s.workspaces.UpdateOne(ctx, bson.M{"_id": w.ID}, bson.M{"$set": w}, options.Update().SetUpsert(true))
+ return err
+}
+
+func (s *MonStore) GetWorkspace(ctx context.Context, id string) (*domain.Workspace, error) {
+ var w domain.Workspace
+ err := s.workspaces.FindOne(ctx, &w, bson.M{"_id": id})
+ if err == mon.ErrNotFound {
+ return nil, domain.ErrNotFound
+ }
+ return &w, err
+}
+
+func (s *MonStore) ListWorkspaces(ctx context.Context, ownerUID int64, includeArchived bool) ([]*domain.Workspace, error) {
+ q := bson.M{"owner_uid": ownerUID}
+ if !includeArchived {
+ q["archived"] = bson.M{"$ne": true}
+ }
+ var list []*domain.Workspace
+ err := s.workspaces.Find(ctx, &list, q, options.Find().SetSort(bson.D{{Key: "created_at", Value: 1}}))
+ return list, err
+}
+
+func (s *MonStore) CountActiveWorkspaces(ctx context.Context, ownerUID int64) (int64, error) {
+ return s.workspaces.CountDocuments(ctx, bson.M{"owner_uid": ownerUID, "archived": bson.M{"$ne": true}})
+}
+
+func (s *MonStore) GetMemberWSState(ctx context.Context, ownerUID int64) (*domain.MemberWorkspaceState, error) {
+ var st domain.MemberWorkspaceState
+ err := s.wsState.FindOne(ctx, &st, bson.M{"_id": ownerUID})
+ if err == mon.ErrNotFound {
+ return nil, domain.ErrNotFound
+ }
+ return &st, err
+}
+
+func (s *MonStore) SaveMemberWSState(ctx context.Context, st *domain.MemberWorkspaceState) error {
+ _, err := s.wsState.UpdateOne(ctx, bson.M{"_id": st.OwnerUID}, bson.M{"$set": st}, options.Update().SetUpsert(true))
+ return err
+}
+
+func (s *MonStore) SaveReview(ctx context.Context, r *domain.DraftReview) error {
+ _, err := s.reviews.UpdateOne(ctx, bson.M{"_id": r.ID}, bson.M{"$set": r}, options.Update().SetUpsert(true))
+ return err
+}
+
+func (s *MonStore) GetReview(ctx context.Context, id string) (*domain.DraftReview, error) {
+ var r domain.DraftReview
+ err := s.reviews.FindOne(ctx, &r, bson.M{"_id": id})
+ if err == mon.ErrNotFound {
+ return nil, domain.ErrNotFound
+ }
+ return &r, err
+}
+
+func (s *MonStore) FindReviewByRef(ctx context.Context, ownerUID int64, refType, refID string) (*domain.DraftReview, error) {
+ var list []*domain.DraftReview
+ err := s.reviews.Find(ctx, &list, bson.M{"owner_uid": ownerUID, "ref_type": refType, "ref_id": refID},
+ options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}}).SetLimit(1))
+ if err != nil {
+ return nil, err
+ }
+ if len(list) == 0 {
+ return nil, domain.ErrNotFound
+ }
+ return list[0], nil
+}
+
+func (s *MonStore) ListReviews(ctx context.Context, ownerUID int64, status string, page, pageSize int) ([]*domain.DraftReview, int64, error) {
+ q := bson.M{"owner_uid": ownerUID}
+ if status != "" {
+ q["status"] = status
+ }
+ total, err := s.reviews.CountDocuments(ctx, q)
+ if err != nil {
+ return nil, 0, err
+ }
+ if page < 1 {
+ page = 1
+ }
+ if pageSize < 1 {
+ pageSize = 20
+ }
+ var list []*domain.DraftReview
+ err = s.reviews.Find(ctx, &list, q, options.Find().SetSkip(int64((page-1)*pageSize)).SetLimit(int64(pageSize)))
+ return list, total, err
+}
+
+func (s *MonStore) SaveInviteReward(ctx context.Context, r *domain.InviteReward) error {
+ _, err := s.rewards.InsertOne(ctx, r)
+ return err
+}
+
+func (s *MonStore) ListInviteRewards(ctx context.Context, inviterUID int64, limit int) ([]*domain.InviteReward, error) {
+ opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})
+ if limit > 0 {
+ opts.SetLimit(int64(limit))
+ }
+ var list []*domain.InviteReward
+ err := s.rewards.Find(ctx, &list, bson.M{"inviter_uid": inviterUID}, opts)
+ return list, err
+}
+
+func (s *MonStore) SumInviteRewardPointsMonth(ctx context.Context, inviterUID int64, monthStartNano int64) (int, error) {
+ var list []*domain.InviteReward
+ err := s.rewards.Find(ctx, &list, bson.M{
+ "inviter_uid": inviterUID,
+ "status": domain.RewardCredited,
+ "created_at": bson.M{"$gte": monthStartNano},
+ })
+ if err != nil {
+ return 0, err
+ }
+ sum := 0
+ for _, r := range list {
+ sum += r.Points
+ }
+ return sum, nil
+}
+
+func (s *MonStore) SumInviteRewardPointsTotal(ctx context.Context, inviterUID int64) (int, error) {
+ var list []*domain.InviteReward
+ err := s.rewards.Find(ctx, &list, bson.M{"inviter_uid": inviterUID, "status": domain.RewardCredited})
+ if err != nil {
+ return 0, err
+ }
+ sum := 0
+ for _, r := range list {
+ sum += r.Points
+ }
+ return sum, nil
+}
+
+func (s *MonStore) HasInviteRewardForInvitee(ctx context.Context, inviteeUID int64) (bool, error) {
+ n, err := s.rewards.CountDocuments(ctx, bson.M{
+ "invitee_uid": inviteeUID,
+ "status": bson.M{"$in": []string{domain.RewardCredited, domain.RewardCapped}},
+ })
+ return n > 0, err
+}
+
+var _ domain.Repository = (*MonStore)(nil)
+
+func (s *MonStore) SavePlaybook(ctx context.Context, p *domain.Playbook) error {
+ _, err := s.playbooks.UpdateOne(ctx, bson.M{"_id": p.ID}, bson.M{"$set": p}, options.Update().SetUpsert(true))
+ return err
+}
+func (s *MonStore) GetPlaybook(ctx context.Context, id string) (*domain.Playbook, error) {
+ var p domain.Playbook
+ err := s.playbooks.FindOne(ctx, &p, bson.M{"_id": id})
+ if err == mon.ErrNotFound {
+ return nil, domain.ErrNotFound
+ }
+ return &p, err
+}
+func (s *MonStore) ListPlaybooks(ctx context.Context, kind, niche string, ownerOnlyUID int64, page, pageSize int) ([]*domain.Playbook, int64, error) {
+ q := bson.M{}
+ if kind != "" {
+ q["kind"] = kind
+ }
+ if niche != "" {
+ q["niche"] = bson.M{"$regex": niche, "$options": "i"}
+ }
+ if ownerOnlyUID > 0 {
+ q["owner_uid"] = ownerOnlyUID
+ }
+ total, err := s.playbooks.CountDocuments(ctx, q)
+ if err != nil {
+ return nil, 0, err
+ }
+ if page < 1 {
+ page = 1
+ }
+ if pageSize < 1 {
+ pageSize = 20
+ }
+ var list []*domain.Playbook
+ err = s.playbooks.Find(ctx, &list, q, options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}).SetSkip(int64((page-1)*pageSize)).SetLimit(int64(pageSize)))
+ return list, total, err
+}
+func (s *MonStore) DeletePlaybook(ctx context.Context, id string) error {
+ res, err := s.playbooks.DeleteOne(ctx, bson.M{"_id": id})
+ if err != nil {
+ return err
+ }
+ if res == 0 {
+ return domain.ErrNotFound
+ }
+ return nil
+}
+func (s *MonStore) IncPlaybookImport(ctx context.Context, id string) error {
+ _, err := s.playbooks.UpdateOne(ctx, bson.M{"_id": id}, bson.M{"$inc": bson.M{"import_count": 1}})
+ return err
+}
+func (s *MonStore) SaveUtmLink(ctx context.Context, u *domain.UtmLink) error {
+ _, err := s.utm.UpdateOne(ctx, bson.M{"_id": u.ID}, bson.M{"$set": u}, options.Update().SetUpsert(true))
+ return err
+}
+func (s *MonStore) GetUtmByCode(ctx context.Context, code string) (*domain.UtmLink, error) {
+ var u domain.UtmLink
+ err := s.utm.FindOne(ctx, &u, bson.M{"code": code})
+ if err == mon.ErrNotFound {
+ return nil, domain.ErrNotFound
+ }
+ return &u, err
+}
+func (s *MonStore) ListUtmLinks(ctx context.Context, ownerUID int64) ([]*domain.UtmLink, error) {
+ var list []*domain.UtmLink
+ err := s.utm.Find(ctx, &list, bson.M{"owner_uid": ownerUID}, options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}))
+ return list, err
+}
+func (s *MonStore) IncUtmClick(ctx context.Context, code string) (*domain.UtmLink, error) {
+ _, err := s.utm.UpdateOne(ctx, bson.M{"code": code}, bson.M{"$inc": bson.M{"clicks": 1}})
+ if err != nil {
+ return nil, err
+ }
+ return s.GetUtmByCode(ctx, code)
+}
+func (s *MonStore) SaveWorkspaceMember(ctx context.Context, m *domain.WorkspaceMember) error {
+ _, err := s.wsMembers.UpdateOne(ctx, bson.M{"_id": m.ID}, bson.M{"$set": m}, options.Update().SetUpsert(true))
+ return err
+}
+func (s *MonStore) ListWorkspaceMembers(ctx context.Context, workspaceID string) ([]*domain.WorkspaceMember, error) {
+ var list []*domain.WorkspaceMember
+ err := s.wsMembers.Find(ctx, &list, bson.M{"workspace_id": workspaceID})
+ return list, err
+}
+func (s *MonStore) RemoveWorkspaceMember(ctx context.Context, workspaceID string, uid int64) error {
+ res, err := s.wsMembers.DeleteOne(ctx, bson.M{"workspace_id": workspaceID, "uid": uid})
+ if err != nil {
+ return err
+ }
+ if res == 0 {
+ return domain.ErrNotFound
+ }
+ return nil
+}
+func (s *MonStore) IsWorkspaceMember(ctx context.Context, workspaceID string, uid int64) (bool, string, error) {
+ w, err := s.GetWorkspace(ctx, workspaceID)
+ if err == nil && w.OwnerUID == uid {
+ return true, domain.WSRoleOwner, nil
+ }
+ var list []*domain.WorkspaceMember
+ err = s.wsMembers.Find(ctx, &list, bson.M{"workspace_id": workspaceID, "uid": uid}, options.Find().SetLimit(1))
+ if err != nil {
+ return false, "", err
+ }
+ if len(list) == 0 {
+ return false, "", nil
+ }
+ return true, list[0].Role, nil
+}
+func (s *MonStore) UpsertBenchmarkSample(ctx context.Context, sample *domain.BenchmarkSample) error {
+ if sample.ID == "" {
+ sample.ID = sample.Niche + ":" + sample.OwnerHash
+ }
+ _, err := s.bench.UpdateOne(ctx, bson.M{"_id": sample.ID}, bson.M{"$set": sample}, options.Update().SetUpsert(true))
+ return err
+}
+func (s *MonStore) ListBenchmarkSamples(ctx context.Context, niche string, limit int) ([]*domain.BenchmarkSample, error) {
+ q := bson.M{}
+ if niche != "" {
+ q["niche"] = niche
+ }
+ opts := options.Find()
+ if limit > 0 {
+ opts.SetLimit(int64(limit))
+ }
+ var list []*domain.BenchmarkSample
+ err := s.bench.Find(ctx, &list, q, opts)
+ return list, err
+}
diff --git a/apps/backend/internal/module/growth/usecase/p2.go b/apps/backend/internal/module/growth/usecase/p2.go
new file mode 100644
index 0000000..f9663db
--- /dev/null
+++ b/apps/backend/internal/module/growth/usecase/p2.go
@@ -0,0 +1,511 @@
+package usecase
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "math"
+ "sort"
+ "strings"
+ "unicode"
+
+ "apps/backend/internal/module/growth/domain"
+)
+
+// --- 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 ---
+
+func (s *Service) CreateUtmLink(ctx context.Context, ownerUID int64, dest, outcomeID, label string) (*domain.UtmLink, error) {
+ dest = strings.TrimSpace(dest)
+ if dest == "" || !strings.HasPrefix(dest, "http") {
+ return nil, fmt.Errorf("%w: destination_url must be http(s)", domain.ErrValidation)
+ }
+ 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()
+ _ = s.Repo.SaveOutcome(ctx, e)
+ }
+ }
+ return u.DestinationURL, nil
+}
+
+// --- 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
+}
diff --git a/apps/backend/internal/module/growth/usecase/service.go b/apps/backend/internal/module/growth/usecase/service.go
new file mode 100644
index 0000000..4592329
--- /dev/null
+++ b/apps/backend/internal/module/growth/usecase/service.go
@@ -0,0 +1,861 @@
+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
+}
diff --git a/apps/backend/internal/module/growth/usecase/service_test.go b/apps/backend/internal/module/growth/usecase/service_test.go
new file mode 100644
index 0000000..c85658f
--- /dev/null
+++ b/apps/backend/internal/module/growth/usecase/service_test.go
@@ -0,0 +1,170 @@
+package usecase
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "apps/backend/internal/module/growth/domain"
+ "apps/backend/internal/module/growth/repository"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestOC_01_CreateOnPublished(t *testing.T) {
+ svc := New(repository.NewMemory())
+ const originalSentAt int64 = 1_700_000_000_000_000_000
+ e, err := svc.RecordPublished(context.Background(), 1001, domain.SourceScoutOutreach, "post1", "acc1", originalSentAt)
+ require.NoError(t, err)
+ require.Equal(t, domain.StatusObserving, e.Status)
+ require.Equal(t, domain.KindReach, e.Kind)
+ // idempotent
+ e2, err := svc.RecordPublished(context.Background(), 1001, domain.SourceScoutOutreach, "post1", "acc1", originalSentAt+1_000_000)
+ require.NoError(t, err)
+ require.Equal(t, e.ID, e2.ID)
+ require.Equal(t, originalSentAt, e2.SentAt)
+}
+
+func TestOC_02_03_ApplySignals(t *testing.T) {
+ svc := New(repository.NewMemory())
+ e, _ := svc.RecordPublished(context.Background(), 1, domain.SourceScoutOutreach, "p", "a", 0)
+ e, err := svc.ApplyObserveSignals(context.Background(), e.ID, 1, 0, 0)
+ require.NoError(t, err)
+ require.Equal(t, domain.ConfidenceConfirmed, e.Confidence)
+ require.Equal(t, domain.KindReply, e.Kind)
+
+ e3, _ := svc.RecordPublished(context.Background(), 1, domain.SourceScoutOutreach, "p2", "a", 0)
+ e3, err = svc.ApplyObserveSignals(context.Background(), e3.ID, 0, 0, 2)
+ require.NoError(t, err)
+ require.Equal(t, domain.ConfidencePossible, e3.Confidence)
+ sum, err := svc.Summary(context.Background(), 1, 0, 0)
+ require.NoError(t, err)
+ require.Equal(t, 2, sum.FollowsPossible)
+}
+
+func TestOC_04_05_Conversion(t *testing.T) {
+ svc := New(repository.NewMemory())
+ e, _ := svc.RecordPublished(context.Background(), 1, domain.SourceScoutOutreach, "p", "a", 0)
+ e, err := svc.ReportConversion(context.Background(), 1, e.ID, 1000, "成交", "TWD")
+ require.NoError(t, err)
+ require.True(t, e.HasConversion)
+ require.Equal(t, domain.KindConversion, e.Kind)
+ sum, err := svc.Summary(context.Background(), 1, 0, 0)
+ require.NoError(t, err)
+ require.Equal(t, 1, sum.Conversions)
+ require.Equal(t, 1000.0, sum.ConversionAmount)
+ require.NoError(t, svc.DeleteConversion(context.Background(), 1, e.ID))
+ sum, _ = svc.Summary(context.Background(), 1, 0, 0)
+ require.Equal(t, 0, sum.Conversions)
+}
+
+func TestOC_08_DegradedStillWorks(t *testing.T) {
+ svc := New(repository.NewMemory())
+ require.Equal(t, "degraded", svc.SignalProbe)
+ e, err := svc.RecordPublished(context.Background(), 1, domain.SourceScoutOutreach, "p", "", 0)
+ require.NoError(t, err)
+ _, err = svc.ReportConversion(context.Background(), 1, e.ID, 100, "", "")
+ require.NoError(t, err)
+ sum, err := svc.Summary(context.Background(), 1, 0, 0)
+ require.NoError(t, err)
+ require.Equal(t, 1, sum.Reach)
+}
+
+func TestCU_01_02_03_GenerateCheckup(t *testing.T) {
+ svc := New(repository.NewMemory())
+ _, _ = svc.RecordPublished(context.Background(), 9, domain.SourceScoutOutreach, "x", "a", 0)
+ c, err := svc.GenerateCheckup(context.Background(), 9, "Asia/Taipei", false)
+ require.NoError(t, err)
+ require.Equal(t, domain.CheckupReady, c.Status)
+ require.Len(t, c.Actions, 3)
+ for _, f := range c.Findings {
+ require.NotEmpty(t, f.Evidence)
+ }
+ // first force ok (total gens=2); third gen in 7d window blocked
+ _, err = svc.GenerateCheckup(context.Background(), 9, "Asia/Taipei", true)
+ require.NoError(t, err)
+ _, err = svc.GenerateCheckup(context.Background(), 9, "Asia/Taipei", true)
+ require.ErrorIs(t, err, domain.ErrRateLimited)
+}
+
+func TestAH_01_03_HealthGate(t *testing.T) {
+ svc := New(repository.NewMemory())
+ h, err := svc.RecomputeHealth(context.Background(), 1, "acc", HealthSignals{
+ Sends24h: 30, IntervalViolations: 5, FailRate7d: 0.6, TokenBad: true,
+ })
+ require.NoError(t, err)
+ require.Equal(t, domain.HealthThrottle, h.Level)
+ _, err = svc.EnsureAutoSendAllowed(context.Background(), 1, "acc")
+ require.ErrorIs(t, err, domain.ErrThrottle)
+
+ h2, err := svc.RecomputeHealth(context.Background(), 1, "acc2", HealthSignals{
+ Sends24h: 15, IntervalViolations: 2, FailRate7d: 0.3,
+ })
+ require.NoError(t, err)
+ require.Equal(t, domain.HealthWarn, h2.Level)
+ warn, err := svc.EnsureAutoSendAllowed(context.Background(), 1, "acc2")
+ require.NoError(t, err)
+ require.NotEmpty(t, warn)
+}
+
+func TestIR_01_03_InviteReward(t *testing.T) {
+ svc := New(repository.NewMemory())
+ svc.PlanQuota = func(id string) int {
+ if id == "@inviter" {
+ return 2000 // pro inviter → month cap 1000
+ }
+ return 120
+ }
+ r, err := svc.OnFirstPaidPlan(context.Background(), 2, 1, "starter")
+ require.NoError(t, err)
+ require.Equal(t, domain.RewardCredited, r.Status)
+ require.Equal(t, 100, r.Points)
+ // second time skipped
+ r2, err := svc.OnFirstPaidPlan(context.Background(), 2, 1, "pro")
+ require.NoError(t, err)
+ require.Equal(t, domain.RewardSkipped, r2.Status)
+ // pro first
+ r3, err := svc.OnFirstPaidPlan(context.Background(), 3, 1, "pro")
+ require.NoError(t, err)
+ require.Equal(t, 300, r3.Points)
+}
+
+func TestWS_01_02_Workspace(t *testing.T) {
+ svc := New(repository.NewMemory())
+ list, cur, err := svc.ListWorkspaces(context.Background(), 5)
+ require.NoError(t, err)
+ require.Len(t, list, 1)
+ require.NotEmpty(t, cur)
+ w2, err := svc.CreateWorkspace(context.Background(), 5, "客戶 A")
+ require.NoError(t, err)
+ require.NoError(t, svc.SwitchWorkspace(context.Background(), 5, w2.ID))
+ list, cur, err = svc.ListWorkspaces(context.Background(), 5)
+ require.NoError(t, err)
+ require.Equal(t, w2.ID, cur)
+ require.Len(t, list, 2)
+}
+
+func TestWS_03_04_ReviewGate(t *testing.T) {
+ svc := New(repository.NewMemory())
+ list, _, _ := svc.ListWorkspaces(context.Background(), 7)
+ ws := list[0]
+ on := true
+ _, err := svc.UpdateWorkspace(context.Background(), 7, ws.ID, "", &on)
+ require.NoError(t, err)
+ err = svc.EnsureReviewAllowsAutoSend(context.Background(), 7, ws.ID, "play", "p1")
+ require.True(t, errors.Is(err, domain.ErrReviewPending))
+ r, err := svc.SubmitReview(context.Background(), 7, "play", "p1", ws.ID)
+ require.NoError(t, err)
+ _, err = svc.DecideReview(context.Background(), 7, r.ID, domain.ReviewApproved, "")
+ require.NoError(t, err)
+ require.NoError(t, svc.EnsureReviewAllowsAutoSend(context.Background(), 7, ws.ID, "play", "p1"))
+}
+
+func TestWS_05_ExportReport(t *testing.T) {
+ svc := New(repository.NewMemory())
+ list, _, _ := svc.ListWorkspaces(context.Background(), 8)
+ fn, body, err := svc.ExportReportMarkdown(context.Background(), 8, list[0].ID, "2026-07")
+ require.NoError(t, err)
+ require.Contains(t, fn, "2026-07")
+ require.Contains(t, body, "月報")
+}
diff --git a/apps/backend/internal/module/inspire/usecase/m5_inspire_test.go b/apps/backend/internal/module/inspire/usecase/m5_inspire_test.go
index 9e2161d..feeb739 100644
--- a/apps/backend/internal/module/inspire/usecase/m5_inspire_test.go
+++ b/apps/backend/internal/module/inspire/usecase/m5_inspire_test.go
@@ -38,6 +38,15 @@ func (fakePersonaSource) ResolvePersona(context.Context, int64, string) (*usecas
}, nil
}
+type topicSearchStub struct{}
+
+func (topicSearchStub) Search(context.Context, string, string, int) ([]search.Hit, error) {
+ return []search.Hit{{
+ Title: "Threads 熱議:夏日防曬",
+ Snippet: "#夏日防曬 最近討論增加",
+ }}, nil
+}
+
func setupInspireUID(svc *usecase.Service, uid int64) {
_ = svc.Usage.Repo.SavePrefs(context.Background(), &usageDomain.MemberPrefs{
UID: uid, PlanID: usageDomain.PlanPro, Unlimited: true, UpdatedAt: domain.NowNano(),
@@ -93,10 +102,41 @@ func TestIN_02_RefreshTrends(t *testing.T) {
svc := newInspire()
uid := int64(5_001_002)
setupInspireUID(svc, uid)
+ svc.Search = topicSearchStub{}
+ svc.ResolveKey = func(context.Context, int64, string) (string, string, error) {
+ return usageDomain.KeyModePlatform, "test-search-key", nil
+ }
_, _ = svc.ListTrends(context.Background(), uid)
list, err := svc.RefreshTrends(context.Background(), uid)
require.NoError(t, err)
require.NotEmpty(t, list)
+ require.Equal(t, "web", list[0].SourceLabel)
+
+ // 相同搜尋結果不能再次冒充更新成功。
+ _, err = svc.RefreshTrends(context.Background(), uid)
+ require.Error(t, err)
+}
+
+func TestIN_02_RefreshFailureKeepsCacheAndDoesNotCharge(t *testing.T) {
+ svc := newInspire()
+ uid := int64(5_001_012)
+ setupInspireUID(svc, uid)
+ svc.Search = &search.FakeClient{Fail: true}
+ svc.ResolveKey = func(context.Context, int64, string) (string, string, error) {
+ return usageDomain.KeyModePlatform, "test-search-key", nil
+ }
+ before, err := svc.ListTrends(context.Background(), uid)
+ require.NoError(t, err)
+
+ _, err = svc.RefreshTrends(context.Background(), uid)
+ require.Error(t, err)
+
+ after, err := svc.ListTrends(context.Background(), uid)
+ require.NoError(t, err)
+ require.Equal(t, before[0].ID, after[0].ID)
+ counter, err := svc.Usage.Repo.GetMonthlyCounter(context.Background(), uid, usageDomain.CurrentMonthKey())
+ require.ErrorIs(t, err, usageDomain.ErrNotFound)
+ require.Nil(t, counter)
}
func TestIN_03_ElementCRUD(t *testing.T) {
diff --git a/apps/backend/internal/module/inspire/usecase/service.go b/apps/backend/internal/module/inspire/usecase/service.go
index fa0dfbe..22565ab 100644
--- a/apps/backend/internal/module/inspire/usecase/service.go
+++ b/apps/backend/internal/module/inspire/usecase/service.go
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"fmt"
"strings"
+ "time"
"unicode/utf8"
"apps/backend/internal/module/ai"
@@ -51,22 +52,15 @@ func (s *Service) ListTrends(ctx context.Context, ownerUID int64) ([]*domain.Tre
return list, nil
}
-// RefreshTrends — 手動「找靈感」:先扣 web_search 點數,再網搜抽話題。
-// 扣點失敗直接回錯(不 soft ignore);搜尋失敗可回種子但不退點(已消耗配額嘗試)。
+// RefreshTrends — 手動「找靈感」:取得真實結果後才扣點並覆蓋快取。
+// 搜尋失敗時保留舊內容;不可用示意種子冒充本次刷新成功。
func (s *Service) RefreshTrends(ctx context.Context, ownerUID int64) ([]*domain.TrendItem, error) {
- if err := s.bill(ctx, ownerUID, usageDomain.MeterWebSearch, "找靈感話題", "inspire.refreshTrends"); err != nil {
- return nil, err
- }
list := s.tryLiveTrends(ctx, ownerUID)
if len(list) == 0 {
- list = defaultTrends(ownerUID)
- for i := range list {
- list[i].Heat = i + 1
- list[i].ObservedAt = domain.NowNano()
- list[i].ID = "tr_" + uuid.NewString()[:10]
- list[i].SourceLabel = "seed"
- list[i].Summary = "網搜未取到可用話題,以下為示意"
- }
+ return nil, fmt.Errorf("找不到新的可用話題,已保留原本內容")
+ }
+ if err := s.bill(ctx, ownerUID, usageDomain.MeterWebSearch, "找靈感話題", "inspire.refreshTrends"); err != nil {
+ return nil, err
}
if err := s.Repo.SaveTrends(ctx, ownerUID, list); err != nil {
return nil, err
@@ -91,10 +85,30 @@ func (s *Service) tryLiveTrends(ctx context.Context, ownerUID int64) []*domain.T
}
// 計費在 RefreshTrends 已做;此處只搜尋
+ previous := map[string]struct{}{}
+ var excluded []string
+ if cached, err := s.Repo.ListTrends(ctx, ownerUID); err == nil {
+ for _, item := range cached {
+ if item == nil {
+ continue
+ }
+ label := strings.TrimSpace(item.Label)
+ if label == "" {
+ continue
+ }
+ previous[strings.ToLower(label)] = struct{}{}
+ if len(excluded) < 6 {
+ excluded = append(excluded, label)
+ }
+ }
+ }
+ nowTW := time.Now().In(time.FixedZone("Asia/Taipei", 8*60*60))
+ freshness := nowTW.Format("2006-01-02 15:04")
+ excludeHint := strings.Join(excluded, "、")
queries := []string{
- "本週 Threads 紅什麼 台灣",
- "Threads 熱議話題 列表 台灣 本週",
- "Threads 夯 話題 二砂糖 OR 泡麵 OR 颱風 台灣",
+ fmt.Sprintf("%s 台灣 Threads 最近正在討論的新話題,不要重複:%s", freshness, excludeHint),
+ fmt.Sprintf("%s Threads 台灣最新貼文熱門關鍵字與生活討論,不要:%s", freshness, excludeHint),
+ fmt.Sprintf("%s 台灣社群近 24 小時新話題 Threads 趨勢,不要重複:%s", freshness, excludeHint),
}
seen := map[string]struct{}{}
now := domain.NowNano()
@@ -113,6 +127,9 @@ func (s *Service) tryLiveTrends(ctx context.Context, ownerUID int64) []*domain.T
if keyNorm == "" {
continue
}
+ if _, old := previous[keyNorm]; old {
+ continue
+ }
if _, ok := seen[keyNorm]; ok {
continue
}
@@ -131,7 +148,7 @@ func (s *Service) tryLiveTrends(ctx context.Context, ownerUID int64) []*domain.T
Kind: "threads_tag",
Label: label,
Summary: truncate(summary, 120),
- Heat: rank, // 列表序位,前端當 #rank 用
+ Heat: 101 - rank, // 越前面的搜尋結果熱度越高,符合前端倒序排列
Keywords: []string{label},
Samples: []string{truncate(summary, 80)},
SourceLabel: "web",
@@ -1505,11 +1522,11 @@ func defaultTrends(ownerUID int64) []*domain.TrendItem {
now := domain.NowNano()
// 僅 live 失敗時的後備;SourceLabel=seed,UI 應標示「示意」而非 Threads 即時榜
return []*domain.TrendItem{
- {ID: "tr_seed_1", OwnerUID: ownerUID, Kind: "threads_tag", Label: "敏感肌換季", Summary: "示意話題(網搜未就緒)", Heat: 1,
+ {ID: "tr_seed_1", OwnerUID: ownerUID, Kind: "threads_tag", Label: "敏感肌換季", Summary: "示意話題(網搜未就緒)", Heat: 100,
Keywords: []string{"敏感肌"}, Samples: []string{"換季又紅了…"}, SourceLabel: "seed", ObservedAt: now},
- {ID: "tr_seed_2", OwnerUID: ownerUID, Kind: "web_keyword", Label: "遠端上班", Summary: "示意話題(網搜未就緒)", Heat: 2,
+ {ID: "tr_seed_2", OwnerUID: ownerUID, Kind: "web_keyword", Label: "遠端上班", Summary: "示意話題(網搜未就緒)", Heat: 90,
Keywords: []string{"遠端"}, Samples: []string{"居家辦公第三年"}, SourceLabel: "seed", ObservedAt: now},
- {ID: "tr_seed_3", OwnerUID: ownerUID, Kind: "news", Label: "無香洗髮", Summary: "示意話題(網搜未就緒)", Heat: 3,
+ {ID: "tr_seed_3", OwnerUID: ownerUID, Kind: "news", Label: "無香洗髮", Summary: "示意話題(網搜未就緒)", Heat: 80,
Keywords: []string{"無香"}, Samples: []string{"終於找到不刺鼻的"}, SourceLabel: "seed", ObservedAt: now},
}
}
diff --git a/apps/backend/internal/module/member/usecase/m1_t119_test.go b/apps/backend/internal/module/member/usecase/m1_t119_test.go
index d2872a1..7666868 100644
--- a/apps/backend/internal/module/member/usecase/m1_t119_test.go
+++ b/apps/backend/internal/module/member/usecase/m1_t119_test.go
@@ -309,7 +309,7 @@ func TestAP_07_ChangePasswordSuccess(t *testing.T) {
func TestAP_08_AvatarURLSetAndClear(t *testing.T) {
store, svc := newM1Svc()
m, _ := seedMember(t, store, svc, "ap08@test.local")
- url := "https://threads-tool-dev.30cm.net/haixun-assets/avatar/1.png"
+ url := "https://threads-tool.30cm.net/haixun-assets/avatar/1.png"
out, err := svc.UpdateUserInfo(context.Background(), m.UID, &domain.UpdateUserInfoPatch{AvatarURL: &url})
require.NoError(t, err)
require.Equal(t, url, out.AvatarURL)
diff --git a/apps/backend/internal/module/scout/domain/domain.go b/apps/backend/internal/module/scout/domain/domain.go
index 14db36e..ac280c1 100644
--- a/apps/backend/internal/module/scout/domain/domain.go
+++ b/apps/backend/internal/module/scout/domain/domain.go
@@ -46,8 +46,13 @@ type Brand struct {
Brief string `bson:"brief" json:"brief"`
TargetAudience string `bson:"target_audience,omitempty" json:"target_audience,omitempty"`
Goals string `bson:"goals,omitempty" json:"goals,omitempty"`
- CreatedAt int64 `bson:"created_at" json:"created_at"`
- UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
+ // Learning — growth-loop 資產複利
+ LearningSummary string `bson:"learning_summary,omitempty" json:"learning_summary,omitempty"`
+ LearningVersion int `bson:"learning_version,omitempty" json:"learning_version,omitempty"`
+ LearnedFromPostsCount int `bson:"learned_from_posts_count,omitempty" json:"learned_from_posts_count,omitempty"`
+ LastLearnedAt int64 `bson:"last_learned_at,omitempty" json:"last_learned_at,omitempty"`
+ CreatedAt int64 `bson:"created_at" json:"created_at"`
+ UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
}
type Product struct {
diff --git a/apps/backend/internal/module/scout/usecase/service.go b/apps/backend/internal/module/scout/usecase/service.go
index eb3e732..d6bd45e 100644
--- a/apps/backend/internal/module/scout/usecase/service.go
+++ b/apps/backend/internal/module/scout/usecase/service.go
@@ -25,6 +25,9 @@ type ReplyQueue interface {
QueueExternalReply(ctx context.Context, ownerUID int64, accountID, replyToMediaID, text, title string) (outboxID string, err error)
}
+// OutreachPublishedHook fires after mark-published / successful outreach send path.
+type OutreachPublishedHook func(ctx context.Context, ownerUID int64, postID, accountID string)
+
type Service struct {
Repo domain.Repository
Settings SettingsReader
@@ -35,6 +38,8 @@ type Service struct {
Provider ThreadSearchProvider
Crawler ChromeCrawlerProvider
SessionSecret string
+ // OnOutreachPublished optional growth-loop outcome hook.
+ OnOutreachPublished OutreachPublishedHook
}
func New(repo domain.Repository) *Service {
@@ -394,6 +399,20 @@ func (s *Service) MarkPublished(ctx context.Context, ownerUID int64, postID stri
if err := s.Repo.SavePost(ctx, p); err != nil {
return nil, err
}
+ // growth-loop: brand learning bump when brand known
+ if p.BrandID != "" {
+ if b, berr := s.Repo.GetBrand(ctx, p.BrandID); berr == nil && b != nil && b.OwnerUID == ownerUID {
+ b.LearningVersion++
+ b.LearnedFromPostsCount++
+ b.LastLearnedAt = domain.NowNano()
+ b.LearningSummary = fmt.Sprintf("海巡外展累積 %d 則 · 知識 v%d", b.LearnedFromPostsCount, b.LearningVersion)
+ b.UpdatedAt = domain.NowNano()
+ _ = s.Repo.SaveBrand(ctx, b)
+ }
+ }
+ if s.OnOutreachPublished != nil {
+ s.OnOutreachPublished(ctx, ownerUID, postID, "")
+ }
return p, nil
}
diff --git a/apps/backend/internal/module/studio/domain/domain.go b/apps/backend/internal/module/studio/domain/domain.go
index 25a6fe1..75fe7f2 100644
--- a/apps/backend/internal/module/studio/domain/domain.go
+++ b/apps/backend/internal/module/studio/domain/domain.go
@@ -66,8 +66,13 @@ type Persona struct {
Guard PersonaGuard `bson:"guard" json:"guard"`
Voice string `bson:"voice,omitempty" json:"voice,omitempty"`
Notes string `bson:"notes,omitempty" json:"notes,omitempty"`
- CreatedAt int64 `bson:"created_at" json:"created_at"`
- UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
+ // Learning — 資產複利可見(growth-loop)
+ LearningSummary string `bson:"learning_summary,omitempty" json:"learning_summary,omitempty"`
+ LearningVersion int `bson:"learning_version,omitempty" json:"learning_version,omitempty"`
+ LearnedFromPostsCount int `bson:"learned_from_posts_count,omitempty" json:"learned_from_posts_count,omitempty"`
+ LastLearnedAt int64 `bson:"last_learned_at,omitempty" json:"last_learned_at,omitempty"`
+ CreatedAt int64 `bson:"created_at" json:"created_at"`
+ UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
}
type PersonaStyle struct {
diff --git a/apps/backend/internal/module/studio/usecase/service.go b/apps/backend/internal/module/studio/usecase/service.go
index 4e4bb5d..82144b6 100644
--- a/apps/backend/internal/module/studio/usecase/service.go
+++ b/apps/backend/internal/module/studio/usecase/service.go
@@ -112,6 +112,8 @@ type Service struct {
OutboxWorkerID string
// OutboxLeaseDuration is configurable for focused lease-renewal tests.
OutboxLeaseDuration time.Duration
+ // OnStepPublished optional growth-loop outcome hook after real publish.
+ OnStepPublished func(ctx context.Context, ownerUID int64, bundleID, stepID, accountID string)
}
func New(repo domain.Repository, transport publish.Transport) *Service {
@@ -556,6 +558,15 @@ func (s *Service) finishPersonaStyleAnalysis(
return nil
}
p.Notes = fmt.Sprintf("LLM 分析 · %s / %s · 樣本 %d 則", provider, model, len(samples))
+ // growth-loop: 資產複利可見
+ p.LearningVersion++
+ p.LearnedFromPostsCount = len(samples)
+ p.LastLearnedAt = domain.NowNano()
+ if p.LearningSummary == "" {
+ p.LearningSummary = fmt.Sprintf("已從 %d 則樣本學到語氣/結構指紋(v%d)", len(samples), p.LearningVersion)
+ } else {
+ p.LearningSummary = fmt.Sprintf("更新語言指紋 v%d · 樣本 %d 則", p.LearningVersion, len(samples))
+ }
p.UpdatedAt = domain.NowNano()
return nil
}
@@ -1311,6 +1322,9 @@ func (s *Service) processBundle(ctx context.Context, b *domain.OutboxBundle, now
return count, err
}
s.cleanupEphemeralImages(ctx, images)
+ if s.OnStepPublished != nil {
+ s.OnStepPublished(ctx, b.OwnerUID, b.ID, result.ID, result.AccountID)
+ }
count++
}
fresh, getErr := s.Repo.GetOutbox(ctx, b.ID)
diff --git a/apps/backend/internal/module/usage/domain/usage.go b/apps/backend/internal/module/usage/domain/usage.go
index 96dd35e..8710a66 100644
--- a/apps/backend/internal/module/usage/domain/usage.go
+++ b/apps/backend/internal/module/usage/domain/usage.go
@@ -69,9 +69,14 @@ type MemberPrefs struct {
CancelAtPeriodEnd bool `bson:"cancel_at_period_end" json:"cancel_at_period_end"`
BillingEventID string `bson:"billing_event_id,omitempty" json:"-"`
BillingEventCreatedAt int64 `bson:"billing_event_created_at,omitempty" json:"-"`
- UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
+ // InviteBonusCredits — 邀請贈點(入平台額度池,與消耗分開顯示)
+ InviteBonusCredits int `bson:"invite_bonus_credits,omitempty" json:"invite_bonus_credits,omitempty"`
+ UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
}
+const MeterInviteBonus = "invite_bonus"
+const KeyModeBonus = "bonus"
+
type Purchase struct {
ID string `bson:"_id" json:"id"`
UID int64 `bson:"uid" json:"uid"`
diff --git a/apps/backend/internal/module/usage/usecase/service.go b/apps/backend/internal/module/usage/usecase/service.go
index c41600f..985bc39 100644
--- a/apps/backend/internal/module/usage/usecase/service.go
+++ b/apps/backend/internal/module/usage/usecase/service.go
@@ -107,7 +107,10 @@ func (s *Service) reservePlatform(ctx context.Context, uid int64, meter string)
return err
}
plan := domain.ResolvePlan(prefs.PlanID)
- totalLimit, meterLimit := plan.MonthlyCredits, -1
+ totalLimit, meterLimit := plan.MonthlyCredits+prefs.InviteBonusCredits, -1
+ if totalLimit < plan.MonthlyCredits {
+ totalLimit = plan.MonthlyCredits
+ }
if capN, ok := plan.SoftCaps[meter]; ok && capN > 0 {
meterLimit = capN
}
@@ -187,7 +190,12 @@ func (s *Service) GetSummary(ctx context.Context, uid int64, monthKey string) (*
} else if counterErr != domain.ErrNotFound {
return nil, counterErr
}
- remaining := plan.MonthlyCredits - platUsed
+ bonus := prefs.InviteBonusCredits
+ if bonus < 0 {
+ bonus = 0
+ }
+ totalPool := plan.MonthlyCredits + bonus
+ remaining := totalPool - platUsed
if remaining < 0 {
remaining = 0
}
@@ -195,15 +203,38 @@ func (s *Service) GetSummary(ctx context.Context, uid int64, monthKey string) (*
return &domain.MonthSummary{
MonthKey: monthKey, UID: uid, PlanID: plan.ID, Unlimited: prefs.Unlimited,
Platform: domain.PlatformBlock{
- CreditsUsed: platUsed, CreditsRemaining: remaining, CreditsTotal: plan.MonthlyCredits,
+ CreditsUsed: platUsed, CreditsRemaining: remaining, CreditsTotal: totalPool,
CallCount: platCalls, ByMeter: platBy,
},
Byok: domain.ByokBlock{CallCount: byokCalls, ByMeter: byokBy},
- // TotalCredits = 方案配給(legacy 欄位名);已用請看 platform.credits_used
- TotalCredits: plan.MonthlyCredits, RemainingCredits: remaining,
+ // TotalCredits = 方案配給 + 邀請贈點;已用請看 platform.credits_used
+ TotalCredits: totalPool, RemainingCredits: remaining,
}, nil
}
+// CreditInviteBonus — 邀請贈點入平台額度池;另記 bonus event(不進 platform 消耗計數)
+func (s *Service) CreditInviteBonus(ctx context.Context, uid int64, points int, ref string) error {
+ if uid <= 0 || points <= 0 {
+ return nil
+ }
+ prefs, err := s.ensurePrefs(ctx, uid)
+ if err != nil {
+ return err
+ }
+ prefs.InviteBonusCredits += points
+ prefs.UpdatedAt = domain.NowNano()
+ if err := s.Repo.SavePrefs(ctx, prefs); err != nil {
+ return err
+ }
+ now := domain.NowNano()
+ _ = s.Repo.InsertEvent(ctx, &domain.Event{
+ ID: uuid.NewString(), UID: uid, Meter: domain.MeterInviteBonus, Credits: 0,
+ KeyMode: domain.KeyModeBonus, Label: fmt.Sprintf("邀請贈點 +%d", points),
+ Source: "invite_reward:" + ref, MonthKey: domain.MonthKey(now), CreatedAt: now,
+ })
+ return nil
+}
+
func (s *Service) ListEvents(ctx context.Context, uid int64, monthKey, keyMode string, limit int) ([]*domain.Event, error) {
if limit <= 0 {
limit = 100
diff --git a/apps/backend/internal/module/usage/usecase/service_test.go b/apps/backend/internal/module/usage/usecase/service_test.go
index f8832f1..41b0e5b 100644
--- a/apps/backend/internal/module/usage/usecase/service_test.go
+++ b/apps/backend/internal/module/usage/usecase/service_test.go
@@ -294,3 +294,13 @@ func TestUSG_14_MemberCannotSetOthersPrefs(t *testing.T) {
_, err := svc.SetPrefs(context.Background(), 1_000_001, 1_000_002, false, domain.PlanPro, nil)
require.ErrorIs(t, err, domain.ErrForbidden)
}
+
+func TestCreditInviteBonus_AddsToPool(t *testing.T) {
+ svc := newUsage(42, domain.KeyModePlatform)
+ require.NoError(t, svc.CreditInviteBonus(context.Background(), 42, 100, "r1"))
+ sum, err := svc.GetSummary(context.Background(), 42, "")
+ require.NoError(t, err)
+ // free 120 + 100 bonus
+ require.Equal(t, 220, sum.TotalCredits)
+ require.Equal(t, 220, sum.RemainingCredits)
+}
diff --git a/apps/backend/internal/response/response.go b/apps/backend/internal/response/response.go
index 26ff844..2faa9f5 100644
--- a/apps/backend/internal/response/response.go
+++ b/apps/backend/internal/response/response.go
@@ -9,6 +9,7 @@ import (
"apps/backend/internal/domain"
appnotifDomain "apps/backend/internal/module/appnotif/domain"
billingDomain "apps/backend/internal/module/billing"
+ growthDomain "apps/backend/internal/module/growth/domain"
inspireDomain "apps/backend/internal/module/inspire/domain"
jobDomain "apps/backend/internal/module/job/domain"
memberDomain "apps/backend/internal/module/member/domain"
@@ -195,6 +196,18 @@ func mapError(err error) (int, Envelope) {
return http.StatusBadRequest, Envelope{Code: 400061, Message: "crawler session required when dev_mode enabled"}
case errors.Is(err, scoutDomain.ErrHasProducts):
return http.StatusConflict, Envelope{Code: 409030, Message: "brand has products; remove products first"}
+ case errors.Is(err, growthDomain.ErrNotFound):
+ return http.StatusNotFound, Envelope{Code: 404001, Message: "not found"}
+ case errors.Is(err, growthDomain.ErrForbidden):
+ return http.StatusForbidden, Envelope{Code: 403003, Message: "forbidden"}
+ case errors.Is(err, growthDomain.ErrValidation):
+ return http.StatusBadRequest, Envelope{Code: 400090, Message: cleanBizMessage(err.Error())}
+ case errors.Is(err, growthDomain.ErrThrottle):
+ return http.StatusConflict, Envelope{Code: 409040, Message: err.Error()}
+ case errors.Is(err, growthDomain.ErrReviewPending):
+ return http.StatusConflict, Envelope{Code: 409041, Message: "draft review required before auto send"}
+ case errors.Is(err, growthDomain.ErrRateLimited):
+ return http.StatusTooManyRequests, Envelope{Code: 429020, Message: "checkup regenerate limit (max 2 per 7 days)"}
default:
return http.StatusInternalServerError, Envelope{Code: 500000, Message: "internal server error"}
}
diff --git a/apps/backend/internal/svc/service_context.go b/apps/backend/internal/svc/service_context.go
index e45a5b2..81c55b5 100644
--- a/apps/backend/internal/svc/service_context.go
+++ b/apps/backend/internal/svc/service_context.go
@@ -18,6 +18,8 @@ import (
fsDomain "apps/backend/internal/module/filestorage/domain"
"apps/backend/internal/module/filestorage/noop"
"apps/backend/internal/module/filestorage/s3store"
+ growthRepo "apps/backend/internal/module/growth/repository"
+ growthUC "apps/backend/internal/module/growth/usecase"
inspireRepo "apps/backend/internal/module/inspire/repository"
inspireUC "apps/backend/internal/module/inspire/usecase"
jobRepo "apps/backend/internal/module/job/repository"
@@ -77,6 +79,7 @@ type ServiceContext struct {
PublishTransport studioPublish.Transport
Inspire *inspireUC.Service
Scout *scoutUC.Service
+ Growth *growthUC.Service
ExtensionZipPath string
}
@@ -193,6 +196,18 @@ func NewServiceContext(c config.Config) *ServiceContext {
inspireSvc.Personas = &inspirePersonaBridge{Studio: studioSvc}
inspireSvc.Brands = &inspireBrandBridge{Scout: scoutSvc}
+ growthSvc := growthUC.New(growthRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database))
+ growthSvc.Notifier = &growthNotifBridge{App: appN}
+ growthSvc.Bonus = usageSvc
+ // Scout mark-published → outcome hook
+ scoutSvc.OnOutreachPublished = func(ctx context.Context, ownerUID int64, postID, accountID string) {
+ _, _ = growthSvc.RecordPublished(ctx, ownerUID, "scout_outreach", postID, accountID, 0)
+ }
+ // Outbox step published → outcome
+ studioSvc.OnStepPublished = func(ctx context.Context, ownerUID int64, bundleID, stepID, accountID string) {
+ _, _ = growthSvc.RecordPublished(ctx, ownerUID, "outbox_step", bundleID+":"+stepID, accountID, 0)
+ }
+
zipPath := findExtensionZip()
return &ServiceContext{
@@ -216,6 +231,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
PublishTransport: pub,
Inspire: inspireSvc,
Scout: scoutSvc,
+ Growth: growthSvc,
ExtensionZipPath: zipPath,
AuthJWT: middleware.NewAuthJWTMiddleware(issuer, repo).Handle,
AdminAuth: middleware.NewAdminAuthMiddleware().Handle,
@@ -645,3 +661,15 @@ func (b *inspireBrandBridge) ListCatalog(ctx context.Context, ownerUID int64) ([
}
return out, nil
}
+
+type growthNotifBridge struct {
+ App *appnotifUC.Service
+}
+
+func (b *growthNotifBridge) Notify(ctx context.Context, ownerUID int64, title, body, kind, refType, refID string) error {
+ if b == nil || b.App == nil {
+ return nil
+ }
+ // Reuse job-state channel for system notes via NotifyJobState with synthetic id.
+ return b.App.NotifyJobState(ctx, ownerUID, "growth:"+refType+":"+refID, "growth", "succeeded", title+": "+body, 100)
+}
diff --git a/apps/backend/internal/types/m4_convert.go b/apps/backend/internal/types/m4_convert.go
index f349d18..9f44dfe 100644
--- a/apps/backend/internal/types/m4_convert.go
+++ b/apps/backend/internal/types/m4_convert.go
@@ -28,7 +28,10 @@ func PersonaFromDomain(p *domain.Persona) PersonaPublic {
Guard: PersonaGuardPublic{
Avoid: p.Guard.Avoid, MaxChars: p.Guard.MaxChars, BanAiTone: p.Guard.BanAiTone,
},
- Voice: p.Voice, Notes: p.Notes, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt,
+ Voice: p.Voice, Notes: p.Notes,
+ LearningSummary: p.LearningSummary, LearningVersion: p.LearningVersion,
+ LearnedFromPostsCount: p.LearnedFromPostsCount, LastLearnedAt: p.LastLearnedAt,
+ CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt,
}
}
diff --git a/apps/backend/internal/types/m5_convert.go b/apps/backend/internal/types/m5_convert.go
index c7cc695..84e439e 100644
--- a/apps/backend/internal/types/m5_convert.go
+++ b/apps/backend/internal/types/m5_convert.go
@@ -67,6 +67,8 @@ func BrandFromDomain(b *scoutDomain.Brand) BrandPublic {
return BrandPublic{
Id: b.ID, DisplayName: b.DisplayName, Brief: b.Brief,
TargetAudience: b.TargetAudience, Goals: b.Goals,
+ LearningSummary: b.LearningSummary, LearningVersion: b.LearningVersion,
+ LearnedFromPostsCount: b.LearnedFromPostsCount, LastLearnedAt: b.LastLearnedAt,
}
}
diff --git a/apps/backend/internal/types/types.go b/apps/backend/internal/types/types.go
index a017742..b84afd3 100644
--- a/apps/backend/internal/types/types.go
+++ b/apps/backend/internal/types/types.go
@@ -15,6 +15,26 @@ type AICompleteReq struct {
Meter string `json:"meter,optional"` // default ai_copy
}
+type AccountHealthListData struct {
+ List []AccountHealthPublic `json:"list"`
+}
+
+type AccountHealthPublic struct {
+ ThreadsAccountId string `json:"threads_account_id"`
+ Score int `json:"score"`
+ Level string `json:"level"`
+ Factors []string `json:"factors"`
+ Advice string `json:"advice"`
+ ComputedAt int64 `json:"computed_at"`
+}
+
+type AddWorkspaceMemberReq struct {
+ WorkspaceId string `path:"id"`
+ MemberUid int64 `json:"member_uid,optional"`
+ Email string `json:"email,optional"`
+ Role string `json:"role,optional"` // editor | reviewer | owner
+}
+
type AdminCreateMemberData struct {
User MemberPublic `json:"user"`
TemporaryPassword string `json:"temporary_password"`
@@ -216,6 +236,23 @@ type AuthVerifyEmailReq struct {
Code string `json:"code"`
}
+type BenchmarkData struct {
+ Available bool `json:"available"`
+ SampleSize int `json:"sample_size"`
+ Niche string `json:"niche"`
+ MedianEngRate float64 `json:"median_eng_rate"`
+ MedianViews float64 `json:"median_views"`
+ YourEngRate float64 `json:"your_eng_rate,optional"`
+ YourViews float64 `json:"your_views,optional"`
+ PercentileHint string `json:"percentile_hint,optional"`
+ Message string `json:"message,optional"`
+}
+
+type BenchmarkReq struct {
+ Niche string `form:"niche,optional"`
+ AccountId string `form:"account_id,optional"`
+}
+
type BillingCheckoutCreateReq struct {
PlanId string `json:"plan_id"`
RequestId string `json:"request_id"`
@@ -265,11 +302,15 @@ type BrandListData struct {
}
type BrandPublic struct {
- Id string `json:"id"`
- DisplayName string `json:"display_name"`
- Brief string `json:"brief"`
- TargetAudience string `json:"target_audience,optional"`
- Goals string `json:"goals,optional"`
+ Id string `json:"id"`
+ DisplayName string `json:"display_name"`
+ Brief string `json:"brief"`
+ TargetAudience string `json:"target_audience,optional"`
+ Goals string `json:"goals,optional"`
+ LearningSummary string `json:"learning_summary,optional"`
+ LearningVersion int `json:"learning_version,optional"`
+ LearnedFromPostsCount int `json:"learned_from_posts_count,optional"`
+ LastLearnedAt int64 `json:"last_learned_at,optional"`
}
type BrandSaveReq struct {
@@ -284,6 +325,22 @@ type BrandSetActiveReq struct {
Id string `json:"id"`
}
+type CheckupActionPublic struct {
+ Title string `json:"title"`
+ Reason string `json:"reason"`
+ Deeplink string `json:"deeplink"`
+}
+
+type CheckupFindingPublic struct {
+ Claim string `json:"claim"`
+ Evidence []string `json:"evidence"`
+}
+
+type CheckupListData struct {
+ List []WeeklyCheckupPublic `json:"list"`
+ Pagination Pagination `json:"pagination"`
+}
+
type ComposeMimicData struct {
Text string `json:"text,optional"`
JobId string `json:"job_id,optional"`
@@ -324,9 +381,53 @@ type ComposeViralReq struct {
Text string `json:"text"`
}
+type CreateUtmLinkReq struct {
+ DestinationUrl string `json:"destination_url"`
+ OutcomeId string `json:"outcome_id,optional"`
+ Label string `json:"label,optional"`
+}
+
+type CreateWorkspaceReq struct {
+ Name string `json:"name"`
+}
+
+type DeleteConversionReq struct {
+ Id string `path:"id"`
+}
+
+type DraftReviewListData struct {
+ List []DraftReviewPublic `json:"list"`
+ Pagination Pagination `json:"pagination"`
+}
+
+type DraftReviewPublic struct {
+ Id string `json:"id"`
+ RefType string `json:"ref_type"`
+ RefId string `json:"ref_id"`
+ WorkspaceId string `json:"workspace_id"`
+ Status string `json:"status"`
+ Reason string `json:"reason,optional"`
+ CreatedAt int64 `json:"created_at"`
+ UpdatedAt int64 `json:"updated_at"`
+}
+
type Empty struct {
}
+type ExportReportData struct {
+ Url string `json:"url"`
+ ExpiresAt int64 `json:"expires_at,optional"`
+ Filename string `json:"filename"`
+ ContentType string `json:"content_type,optional"`
+ Format string `json:"format,optional"`
+}
+
+type ExportReportReq struct {
+ WorkspaceId string `form:"workspace_id"`
+ YearMonth string `form:"year_month"` // YYYY-MM
+ Format string `form:"format,optional"` // md | pdf
+}
+
type ExternalTargetPublic struct {
Url string `json:"url"`
RawUrl string `json:"raw_url,optional"`
@@ -347,10 +448,22 @@ type GenerateImageReq struct {
Prompt string `json:"prompt"`
}
+type GetAccountHealthReq struct {
+ AccountId string `path:"accountId"`
+}
+
type GetAiReq struct {
Provider string `form:"provider,optional"`
}
+type GetCheckupReq struct {
+ Id string `path:"id"`
+}
+
+type GetOutcomeReq struct {
+ Id string `path:"id"`
+}
+
type HealthData struct {
Status string `json:"status"`
Service string `json:"service"`
@@ -363,6 +476,12 @@ type IdentityPublic struct {
CreatedAt int64 `json:"created_at,optional"`
}
+type ImportPlaybookData struct {
+ Id string `json:"id"`
+ Copied bool `json:"copied"`
+ Message string `json:"message"`
+}
+
type ImportProductData struct {
Label string `json:"label"`
ProductContext string `json:"product_context"`
@@ -391,6 +510,43 @@ type ImportThreadsAccountSessionReq struct {
StorageState string `json:"storageState"`
}
+type InsightsMonthBucket struct {
+ MonthKey string `json:"month_key"`
+ Posts int `json:"posts"`
+ Views int `json:"views"`
+ Likes int `json:"likes"`
+ Replies int `json:"replies"`
+ AvgEngRate float64 `json:"avg_eng_rate"`
+}
+
+type InsightsPostPublic struct {
+ Id string `json:"id"`
+ TextPreview string `json:"text_preview"`
+ PublishedAt int64 `json:"published_at"`
+ ViewCount int `json:"view_count"`
+ LikeCount int `json:"like_count"`
+ ReplyCount int `json:"reply_count"`
+ EngRate float64 `json:"eng_rate"`
+}
+
+type InsightsSummaryData struct {
+ AccountId string `json:"account_id,optional"`
+ PostCount int `json:"post_count"`
+ TotalViews int `json:"total_views"`
+ TotalLikes int `json:"total_likes"`
+ TotalReplies int `json:"total_replies"`
+ AvgEngRate float64 `json:"avg_eng_rate"`
+ TopPosts []InsightsPostPublic `json:"top_posts"`
+ MonthBuckets []InsightsMonthBucket `json:"month_buckets"`
+ Conclusion string `json:"conclusion"`
+ Suggestions []string `json:"suggestions"`
+}
+
+type InsightsSummaryReq struct {
+ AccountId string `form:"account_id,optional"`
+ Months int `form:"months,default=3"`
+}
+
type InspireChatData struct {
Session InspireSessionPublic `json:"session"`
Messages []InspireMessagePublic `json:"messages"`
@@ -530,6 +686,22 @@ type InviteParentCandidatesData struct {
List []InviteMemberBrief `json:"list"`
}
+type InviteRewardPublic struct {
+ Id string `json:"id"`
+ InviteeUid int64 `json:"invitee_uid"`
+ PlanId string `json:"plan_id"`
+ Points int `json:"points"`
+ Status string `json:"status"`
+ CreatedAt int64 `json:"created_at"`
+}
+
+type InviteRewardsSummary struct {
+ TotalPoints int `json:"total_points"`
+ MonthPoints int `json:"month_points"`
+ MonthCap int `json:"month_cap"`
+ Recent []InviteRewardPublic `json:"recent"`
+}
+
type InviteTreeData struct {
List []InviteTreeNode `json:"list"`
}
@@ -580,6 +752,17 @@ type JobPublic struct {
CompletedAt int64 `json:"completed_at,optional"`
}
+type ListCheckupsReq struct {
+ Page int `form:"page,default=1"`
+ PageSize int `form:"pageSize,default=20"`
+}
+
+type ListDraftReviewsReq struct {
+ Page int `form:"page,default=1"`
+ PageSize int `form:"pageSize,default=20"`
+ Status string `form:"status,optional"`
+}
+
type ListModelsData struct {
List []string `json:"list"`
Provider string `json:"provider"`
@@ -592,10 +775,32 @@ type ListModelsReq struct {
Provider string `form:"provider,optional"`
}
+type ListOutcomesReq struct {
+ Page int `form:"page,default=1"`
+ PageSize int `form:"pageSize,default=20"`
+ Kind string `form:"kind,optional"`
+ Confidence string `form:"confidence,optional"`
+ SourceType string `form:"source_type,optional"`
+ From int64 `form:"from,optional"`
+ To int64 `form:"to,optional"`
+}
+
type ListParentCandidatesReq struct {
ExcludeSubtreeRootUid int64 `form:"exclude_subtree_root_uid,optional"`
}
+type ListPlaybooksReq struct {
+ Page int `form:"page,default=1"`
+ PageSize int `form:"pageSize,default=20"`
+ Kind string `form:"kind,optional"`
+ Niche string `form:"niche,optional"`
+ Mine bool `form:"mine,optional"`
+}
+
+type ListWorkspaceMembersReq struct {
+ Id string `path:"id"`
+}
+
type MediaUploadData struct {
Url string `json:"url"`
Key string `json:"key,optional"`
@@ -685,10 +890,11 @@ type MoveInviteMemberReq struct {
}
type MyInviteNetworkData struct {
- Me InviteMemberBrief `json:"me"`
- Upline *InviteMemberBrief `json:"upline,optional"`
- Downlines []InviteMemberBrief `json:"downlines"`
- TotalDownlineCount int `json:"total_downline_count"`
+ Me InviteMemberBrief `json:"me"`
+ Upline *InviteMemberBrief `json:"upline,optional"`
+ Downlines []InviteMemberBrief `json:"downlines"`
+ TotalDownlineCount int `json:"total_downline_count"`
+ Rewards *InviteRewardsSummary `json:"rewards,optional"`
}
type NotificationIdPath struct {
@@ -751,6 +957,48 @@ type OutboxStepPublic struct {
PublishedAt int64 `json:"published_at,optional"`
}
+type OutcomeEventPublic struct {
+ Id string `json:"id"`
+ SourceType string `json:"source_type"`
+ SourceId string `json:"source_id"`
+ ThreadsAccountId string `json:"threads_account_id,optional"`
+ Kind string `json:"kind"`
+ Confidence string `json:"confidence"`
+ Status string `json:"status"`
+ ReplyCountDelta int `json:"reply_count_delta,optional"`
+ LikeCountDelta int `json:"like_count_delta,optional"`
+ FollowersDelta int `json:"followers_delta,optional"`
+ ConversionAmount float64 `json:"conversion_amount,optional"`
+ ConversionNote string `json:"conversion_note,optional"`
+ ConversionCurrency string `json:"conversion_currency,optional"`
+ WindowEndsAt int64 `json:"window_ends_at"`
+ SentAt int64 `json:"sent_at"`
+ CreatedAt int64 `json:"created_at"`
+ UpdatedAt int64 `json:"updated_at"`
+}
+
+type OutcomeListData struct {
+ List []OutcomeEventPublic `json:"list"`
+ Pagination Pagination `json:"pagination"`
+}
+
+type OutcomeSummaryData struct {
+ From int64 `json:"from"`
+ To int64 `json:"to"`
+ Reach int `json:"reach"`
+ Conversations int `json:"conversations"`
+ FollowsPossible int `json:"follows_possible"`
+ FollowsConfirmed int `json:"follows_confirmed"`
+ Conversions int `json:"conversions"`
+ ConversionAmount float64 `json:"conversion_amount"`
+}
+
+type OutcomeSummaryReq struct {
+ Range string `form:"range,default=week"` // week | month
+ From int64 `form:"from,optional"`
+ To int64 `form:"to,optional"`
+}
+
type OwnPostAnalyzeReq struct {
PostId string `json:"post_id"`
}
@@ -838,6 +1086,19 @@ type Pagination struct {
TotalPages int `json:"totalPages"`
}
+type PainKeywordsData struct {
+ Keywords []string `json:"keywords"`
+ Pains []string `json:"pains"`
+ ScanTerms []string `json:"scan_terms"`
+ Summary string `json:"summary"`
+ SignupHint string `json:"signup_hint"`
+}
+
+type PainKeywordsReq struct {
+ ProductBrief string `json:"product_brief"`
+ Audience string `json:"audience,optional"`
+}
+
type PersonaActiveData struct {
Id string `json:"id"`
}
@@ -883,16 +1144,20 @@ type PersonaListData struct {
}
type PersonaPublic struct {
- Id string `json:"id"`
- Name string `json:"name"`
- Brief string `json:"brief"`
- Status string `json:"status"`
- Style PersonaStylePublic `json:"style"`
- Guard PersonaGuardPublic `json:"guard"`
- Voice string `json:"voice,optional"`
- Notes string `json:"notes,optional"`
- CreatedAt int64 `json:"created_at"`
- UpdatedAt int64 `json:"updated_at"`
+ Id string `json:"id"`
+ Name string `json:"name"`
+ Brief string `json:"brief"`
+ Status string `json:"status"`
+ Style PersonaStylePublic `json:"style"`
+ Guard PersonaGuardPublic `json:"guard"`
+ Voice string `json:"voice,optional"`
+ Notes string `json:"notes,optional"`
+ LearningSummary string `json:"learning_summary,optional"`
+ LearningVersion int `json:"learning_version,optional"`
+ LearnedFromPostsCount int `json:"learned_from_posts_count,optional"`
+ LastLearnedAt int64 `json:"last_learned_at,optional"`
+ CreatedAt int64 `json:"created_at"`
+ UpdatedAt int64 `json:"updated_at"`
}
type PersonaSaveReq struct {
@@ -1011,6 +1276,28 @@ type PlayStepPublic struct {
ImageUrls []string `json:"image_urls,optional"`
}
+type PlaybookIdPath struct {
+ Id string `path:"id"`
+}
+
+type PlaybookListData struct {
+ List []PlaybookPublic `json:"list"`
+ Pagination Pagination `json:"pagination"`
+}
+
+type PlaybookPublic struct {
+ Id string `json:"id"`
+ Kind string `json:"kind"` // brief | persona | play
+ Title string `json:"title"`
+ Niche string `json:"niche"`
+ Body string `json:"body"`
+ Anonymous bool `json:"anonymous"`
+ AuthorLabel string `json:"author_label"`
+ ImportCount int `json:"import_count"`
+ CreatedAt int64 `json:"created_at"`
+ Mine bool `json:"mine,optional"`
+}
+
type ProductIdPath struct {
Id string `path:"id"`
}
@@ -1045,6 +1332,26 @@ type ProductSaveReq struct {
PlacementUrl string `json:"placement_url,optional"`
}
+type PublishPlaybookReq struct {
+ Kind string `json:"kind"`
+ Title string `json:"title"`
+ Niche string `json:"niche,optional"`
+ Body string `json:"body"`
+ Anonymous bool `json:"anonymous,optional"`
+}
+
+type RemoveWorkspaceMemberReq struct {
+ Id string `path:"id"`
+ Uid int64 `path:"uid"`
+}
+
+type ReportConversionReq struct {
+ Id string `path:"id"`
+ Amount float64 `json:"amount,optional"`
+ Note string `json:"note,optional"`
+ Currency string `json:"currency,optional"`
+}
+
type ResearchHitPublic struct {
Id string `json:"id"`
Title string `json:"title"`
@@ -1064,6 +1371,11 @@ type ResearchSearchReq struct {
Query string `json:"query"`
}
+type ReviewDecisionReq struct {
+ Id string `path:"id"`
+ Reason string `json:"reason,optional"`
+}
+
type SaveAiReq struct {
Provider string `json:"provider,optional"`
Model string `json:"model,optional"`
@@ -1209,6 +1521,30 @@ type StyleDimensionPublic struct {
Evidence []string `json:"evidence,optional"`
}
+type StyleQuizData struct {
+ Tone string `json:"tone"`
+ Rhythm string `json:"rhythm"`
+ Hooks string `json:"hooks"`
+ Avoid string `json:"avoid"`
+ Summary string `json:"summary"`
+ Tags []string `json:"tags"`
+ SignupHint string `json:"signup_hint"`
+}
+
+type StyleQuizReq struct {
+ Samples string `json:"samples"`
+}
+
+type SubmitDraftReviewReq struct {
+ RefType string `json:"ref_type"`
+ RefId string `json:"ref_id"`
+ WorkspaceId string `json:"workspace_id,optional"`
+}
+
+type SwitchWorkspaceReq struct {
+ Id string `path:"id"`
+}
+
type ThreadsAccountIdPath struct {
Id string `path:"id"`
}
@@ -1287,6 +1623,19 @@ type UnreadCountData struct {
Count int64 `json:"count"`
}
+type UpdateConversionReq struct {
+ Id string `path:"id"`
+ Amount float64 `json:"amount,optional"`
+ Note string `json:"note,optional"`
+ Currency string `json:"currency,optional"`
+}
+
+type UpdateWorkspaceReq struct {
+ Id string `path:"id"`
+ Name string `json:"name,optional"`
+ ReviewRequired *bool `json:"review_required,optional"`
+}
+
type UsageByokBlock struct {
CallCount int `json:"call_count"`
ByMeter map[string]UsageMeterCount `json:"by_meter,optional"`
@@ -1435,6 +1784,25 @@ type UsageTenantSummaryReq struct {
MonthKey string `form:"month_key,optional"`
}
+type UtmLinkListData struct {
+ List []UtmLinkPublic `json:"list"`
+}
+
+type UtmLinkPublic struct {
+ Id string `json:"id"`
+ Code string `json:"code"`
+ TrackPath string `json:"track_path"`
+ DestinationUrl string `json:"destination_url"`
+ OutcomeId string `json:"outcome_id,optional"`
+ Label string `json:"label,optional"`
+ Clicks int `json:"clicks"`
+ CreatedAt int64 `json:"created_at"`
+}
+
+type UtmRedirectReq struct {
+ Code string `path:"code"`
+}
+
type ViralAnalysisPublic struct {
Hooks string `json:"hooks"`
Structure string `json:"structure"`
@@ -1444,3 +1812,56 @@ type ViralAnalysisPublic struct {
Copyable string `json:"copyable,optional"`
Risks string `json:"risks,optional"`
}
+
+type WeeklyCheckupPublic struct {
+ Id string `json:"id"`
+ WeekKey string `json:"week_key"`
+ Status string `json:"status"`
+ Summary string `json:"summary"`
+ Findings []CheckupFindingPublic `json:"findings"`
+ Actions []CheckupActionPublic `json:"actions"`
+ Error string `json:"error,optional"`
+ CreatedAt int64 `json:"created_at"`
+ UpdatedAt int64 `json:"updated_at"`
+}
+
+type WorkspaceBrandingReq struct {
+ Id string `path:"id"`
+ FooterText string `json:"footer_text,optional"`
+ HidePoweredBy bool `json:"hide_powered_by,optional"`
+ BrandName string `json:"brand_name,optional"`
+}
+
+type WorkspaceIdReq struct {
+ Id string `path:"id"`
+}
+
+type WorkspaceListData struct {
+ List []WorkspacePublic `json:"list"`
+ CurrentWorkspaceId string `json:"current_workspace_id"`
+}
+
+type WorkspaceMemberListData struct {
+ List []WorkspaceMemberPublic `json:"list"`
+}
+
+type WorkspaceMemberPublic struct {
+ Uid int64 `json:"uid"`
+ Email string `json:"email"`
+ DisplayName string `json:"display_name"`
+ Role string `json:"role"`
+ JoinedAt int64 `json:"joined_at"`
+}
+
+type WorkspacePublic struct {
+ Id string `json:"id"`
+ Name string `json:"name"`
+ ReviewRequired bool `json:"review_required"`
+ IsDefault bool `json:"is_default"`
+ Archived bool `json:"archived"`
+ FooterText string `json:"footer_text,optional"`
+ HidePoweredBy bool `json:"hide_powered_by,optional"`
+ BrandName string `json:"brand_name,optional"`
+ CreatedAt int64 `json:"created_at"`
+ UpdatedAt int64 `json:"updated_at"`
+}
diff --git a/apps/extension/haixun-threads-sync/manifest.json b/apps/extension/haixun-threads-sync/manifest.json
index f3a90e4..8ae97c0 100644
--- a/apps/extension/haixun-threads-sync/manifest.json
+++ b/apps/extension/haixun-threads-sync/manifest.json
@@ -22,7 +22,7 @@
"http://127.0.0.1:8888/*",
"http://localhost:8890/*",
"http://127.0.0.1:8890/*",
- "https://threads-tool-dev.30cm.net/*",
+ "https://threads-tool.30cm.net/*",
"https://*.30cm.net/*"
],
"optional_host_permissions": ["http://*/*", "https://*/*"],
@@ -47,7 +47,7 @@
"http://127.0.0.1:8888/*",
"http://localhost:8890/*",
"http://127.0.0.1:8890/*",
- "https://threads-tool-dev.30cm.net/*",
+ "https://threads-tool.30cm.net/*",
"https://*.30cm.net/*"
],
"js": ["content-haixun.js"],
diff --git a/apps/extension/haixun-threads-sync/options.html b/apps/extension/haixun-threads-sync/options.html
index 79b4072..fdf8c0f 100644
--- a/apps/extension/haixun-threads-sync/options.html
+++ b/apps/extension/haixun-threads-sync/options.html
@@ -64,11 +64,11 @@
127.0.0.1 不可混用)。
- 遠端例:https://threads-tool-dev.30cm.net
+ 遠端例:https://threads-tool.30cm.net
本機例:http://localhost:5173
-
+
diff --git a/apps/extension/haixun-threads-sync/options.js b/apps/extension/haixun-threads-sync/options.js
index 2dd2e88..10aaaba 100644
--- a/apps/extension/haixun-threads-sync/options.js
+++ b/apps/extension/haixun-threads-sync/options.js
@@ -2,7 +2,7 @@ const input = document.getElementById("serverUrl");
const saveBtn = document.getElementById("save");
const savedEl = document.getElementById("saved");
-const DEFAULT_URL = "https://threads-tool-dev.30cm.net";
+const DEFAULT_URL = "https://threads-tool.30cm.net";
chrome.storage.sync.get(["serverUrl"], ({ serverUrl }) => {
input.value = serverUrl ?? DEFAULT_URL;
diff --git a/apps/extension/haixun-threads-sync/service-worker.js b/apps/extension/haixun-threads-sync/service-worker.js
index 8102f06..877d712 100644
--- a/apps/extension/haixun-threads-sync/service-worker.js
+++ b/apps/extension/haixun-threads-sync/service-worker.js
@@ -347,11 +347,11 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
async function bootstrapBridgeFromStorage() {
try {
const { serverUrl } = await chrome.storage.sync.get(["serverUrl"]);
- // 預設同時覆蓋本機與遠端 dev
+ // 正式站優先,並保留本機開發網址。
const defaults = [
+ "https://threads-tool.30cm.net",
"http://localhost:5173",
"http://127.0.0.1:5173",
- "https://threads-tool-dev.30cm.net",
];
const primary = serverUrl || defaults[0];
if (!serverUrl) {
diff --git a/apps/web/.env.example b/apps/web/.env.example
index 5b2f882..a4b8254 100644
--- a/apps/web/.env.example
+++ b/apps/web/.env.example
@@ -1,7 +1,7 @@
# ── 前後端串接 ──────────────────────────────────────────
# 預設建議:不設 VITE_API_BASE,讓請求走 same-origin
# 瀏覽器 → Vite(/api) → proxy → gateway:8888
-# 遠端開 FE(threads-tool-dev.30cm.net)時必須這樣,
+# 遠端開 FE(threads-tool.30cm.net)時必須這樣,
# 不要寫死 http://127.0.0.1:8888(那會打到使用者自己的電腦)。
#
# 本機若要直連 gateway(跳過 proxy):須將這個前端 origin 加入
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index dbac713..c33b3fe 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -29,6 +29,15 @@ const StudioPage = lazy(() => import("./pages/StudioPage").then((m) => ({ defaul
const VerifyEmailPage = lazy(() => import("./pages/VerifyEmailPage").then((m) => ({ default: m.VerifyEmailPage })));
const WizardPage = lazy(() => import("./pages/studio/WizardPage").then((m) => ({ default: m.WizardPage })));
const TodayPage = lazy(() => import("./pages/TodayPage").then((m) => ({ default: m.TodayPage })));
+const PlaybooksPage = lazy(() => import("./pages/PlaybooksPage").then((m) => ({ default: m.PlaybooksPage })));
+const BenchmarkPage = lazy(() => import("./pages/BenchmarkPage").then((m) => ({ default: m.BenchmarkPage })));
+const UtmLinksPage = lazy(() => import("./pages/UtmLinksPage").then((m) => ({ default: m.UtmLinksPage })));
+const StyleQuizPage = lazy(() =>
+ import("./pages/tools/StyleQuizPage").then((m) => ({ default: m.StyleQuizPage })),
+);
+const PainKeywordsPage = lazy(() =>
+ import("./pages/tools/PainKeywordsPage").then((m) => ({ default: m.PainKeywordsPage })),
+);
export default function App() {
return (
@@ -43,6 +52,8 @@ export default function App() {
} />
} />
} />
+
} />
+
} />
} />
} />
} />
+
} />
+
} />
+
} />
} />
} />
diff --git a/apps/web/src/components/layout/MobileDock.tsx b/apps/web/src/components/layout/MobileDock.tsx
index 0633424..86a8e7c 100644
--- a/apps/web/src/components/layout/MobileDock.tsx
+++ b/apps/web/src/components/layout/MobileDock.tsx
@@ -6,6 +6,7 @@ import {
isNavActive,
mobileDockMoreKeys,
mobileDockPrimaryKeys,
+ navGroupedItemsByKeys,
navItemsByKeys,
} from "../../lib/nav";
import { AppIcon } from "../ui/AppIcons";
@@ -20,7 +21,7 @@ export function MobileDock() {
const titleId = useId();
const dockItems = navItemsByKeys(mobileDockPrimaryKeys);
- const moreItems = navItemsByKeys(mobileDockMoreKeys);
+ const moreGroups = navGroupedItemsByKeys(mobileDockMoreKeys);
const moreActive = isMoreNavActive(pathname);
useEffect(() => {
@@ -82,26 +83,31 @@ export function MobileDock() {
{t("nav.moreTitle")}
-
- {moreItems.map((item) => {
- const active = isNavActive(pathname, item);
- return (
- -
-
-
- );
- })}
-
+ {moreGroups.map(({ group, items }) => (
+
+
{t(group.labelKey)}
+
+ {items.map((item) => {
+ const active = isNavActive(pathname, item);
+ return (
+ -
+
+
+ );
+ })}
+
+
+ ))}
) : null}
diff --git a/apps/web/src/components/layout/SidebarNav.tsx b/apps/web/src/components/layout/SidebarNav.tsx
index d8c781e..fbd159c 100644
--- a/apps/web/src/components/layout/SidebarNav.tsx
+++ b/apps/web/src/components/layout/SidebarNav.tsx
@@ -1,6 +1,6 @@
import { NavLink, useLocation } from "react-router-dom";
import { useI18n } from "../../i18n/I18nContext";
-import { isNavActive, primaryNav } from "../../lib/nav";
+import { isNavActive, navGroups, navItemsByKeys } from "../../lib/nav";
import { AppIcon } from "../ui/AppIcons";
export function SidebarNav() {
@@ -10,23 +10,28 @@ export function SidebarNav() {
return (
);
}
diff --git a/apps/web/src/components/layout/WorkspaceSwitcher.tsx b/apps/web/src/components/layout/WorkspaceSwitcher.tsx
new file mode 100644
index 0000000..ab802fa
--- /dev/null
+++ b/apps/web/src/components/layout/WorkspaceSwitcher.tsx
@@ -0,0 +1,81 @@
+import { useCallback, useEffect, useState } from "react";
+import { useI18n } from "../../i18n/I18nContext";
+import { useRepos } from "../../data/DataContext";
+import type { Workspace } from "../../domain/types";
+import { AppIcon } from "../ui/AppIcons";
+
+/** 頂欄 Workspace 切換(growth-loop P1) */
+export function WorkspaceSwitcher() {
+ const { t } = useI18n();
+ const repos = useRepos();
+ const [list, setList] = useState([]);
+ const [currentId, setCurrentId] = useState("");
+ const [busy, setBusy] = useState(false);
+
+ const load = useCallback(async () => {
+ try {
+ const res = await repos.growth.listWorkspaces();
+ setList(res.list);
+ setCurrentId(res.current_workspace_id || res.list[0]?.id || "");
+ } catch {
+ /* ignore until auth ready */
+ }
+ }, [repos.growth]);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ if (list.length === 0) return null;
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/persona/PersonaWorkbench.tsx b/apps/web/src/components/persona/PersonaWorkbench.tsx
index f436389..b0d7f4f 100644
--- a/apps/web/src/components/persona/PersonaWorkbench.tsx
+++ b/apps/web/src/components/persona/PersonaWorkbench.tsx
@@ -369,6 +369,11 @@ export function PersonaWorkbench() {
{activeId === p.id ? {t("persona.default")} : null}
{toneOf(p) || t("common.dash")}
+ {p.learning_summary ? (
+
+ {p.learning_summary}
+
+ ) : null}
))}
diff --git a/apps/web/src/components/ui/AppIcons.tsx b/apps/web/src/components/ui/AppIcons.tsx
index 58b1804..accc00e 100644
--- a/apps/web/src/components/ui/AppIcons.tsx
+++ b/apps/web/src/components/ui/AppIcons.tsx
@@ -130,6 +130,33 @@ const glyphs: Record = {
>
),
+ playbooks: (
+ <>
+
+
+ >
+ ),
+ insights: (
+ <>
+
+
+
+ >
+ ),
+ benchmark: (
+ <>
+
+
+
+ >
+ ),
+ utm: (
+ <>
+
+
+
+ >
+ ),
more: (
<>
diff --git a/apps/web/src/data/live/growthRepo.ts b/apps/web/src/data/live/growthRepo.ts
new file mode 100644
index 0000000..393785a
--- /dev/null
+++ b/apps/web/src/data/live/growthRepo.ts
@@ -0,0 +1,179 @@
+import type {
+ AccountHealth,
+ OutcomeEvent,
+ OutcomeSummary,
+ WeeklyCheckup,
+ Workspace,
+} from "../../domain/types";
+import type { GrowthRepo } from "../repos";
+import { apiRequest } from "./http";
+
+function mapOutcome(raw: Record): OutcomeEvent {
+ return {
+ id: String(raw.id ?? ""),
+ source_type: String(raw.source_type ?? ""),
+ source_id: String(raw.source_id ?? ""),
+ threads_account_id: raw.threads_account_id ? String(raw.threads_account_id) : undefined,
+ kind: String(raw.kind ?? ""),
+ confidence: String(raw.confidence ?? ""),
+ status: String(raw.status ?? ""),
+ reply_count_delta: Number(raw.reply_count_delta ?? 0),
+ like_count_delta: Number(raw.like_count_delta ?? 0),
+ followers_delta: Number(raw.followers_delta ?? 0),
+ conversion_amount: Number(raw.conversion_amount ?? 0),
+ conversion_note: raw.conversion_note ? String(raw.conversion_note) : undefined,
+ conversion_currency: raw.conversion_currency ? String(raw.conversion_currency) : undefined,
+ window_ends_at: Number(raw.window_ends_at ?? 0),
+ sent_at: Number(raw.sent_at ?? 0),
+ created_at: Number(raw.created_at ?? 0),
+ updated_at: Number(raw.updated_at ?? 0),
+ };
+}
+
+function mapSummary(raw: Record): OutcomeSummary {
+ return {
+ from: Number(raw.from ?? 0),
+ to: Number(raw.to ?? 0),
+ reach: Number(raw.reach ?? 0),
+ conversations: Number(raw.conversations ?? 0),
+ follows_possible: Number(raw.follows_possible ?? 0),
+ follows_confirmed: Number(raw.follows_confirmed ?? 0),
+ conversions: Number(raw.conversions ?? 0),
+ conversion_amount: Number(raw.conversion_amount ?? 0),
+ };
+}
+
+function outcomeSummaryQuery(range: string): string {
+ const now = new Date();
+ let start: Date | null = null;
+ if (range === "week") {
+ start = new Date(now);
+ const daysSinceMonday = (now.getDay() + 6) % 7;
+ start.setDate(now.getDate() - daysSinceMonday);
+ start.setHours(0, 0, 0, 0);
+ } else if (range === "month") {
+ start = new Date(now.getFullYear(), now.getMonth(), 1);
+ }
+ if (!start) return `range=${encodeURIComponent(range)}`;
+ const from = Math.trunc(start.getTime()) * 1_000_000;
+ const to = Math.trunc(now.getTime()) * 1_000_000;
+ return `from=${from}&to=${to}`;
+}
+
+function mapCheckup(raw: Record): WeeklyCheckup {
+ const findingsRaw = Array.isArray(raw.findings) ? raw.findings : [];
+ const actionsRaw = Array.isArray(raw.actions) ? raw.actions : [];
+ return {
+ id: String(raw.id ?? ""),
+ week_key: String(raw.week_key ?? ""),
+ status: String(raw.status ?? ""),
+ summary: String(raw.summary ?? ""),
+ findings: findingsRaw.map((f) => {
+ const row = f as Record;
+ return {
+ claim: String(row.claim ?? ""),
+ evidence: Array.isArray(row.evidence) ? row.evidence.map(String) : [],
+ };
+ }),
+ actions: actionsRaw.map((a) => {
+ const row = a as Record;
+ return {
+ title: String(row.title ?? ""),
+ reason: String(row.reason ?? ""),
+ deeplink: String(row.deeplink ?? "today"),
+ };
+ }),
+ error: raw.error ? String(raw.error) : undefined,
+ created_at: Number(raw.created_at ?? 0),
+ updated_at: Number(raw.updated_at ?? 0),
+ };
+}
+
+export function createLiveGrowthRepo(): GrowthRepo {
+ return {
+ async getOutcomeSummary(range = "week") {
+ const data = await apiRequest>(
+ `/api/v1/outcomes/summary?${outcomeSummaryQuery(range)}`,
+ );
+ return mapSummary(data);
+ },
+ async listOutcomes(page = 1, pageSize = 20) {
+ const data = await apiRequest<{ list?: Record[] }>(
+ `/api/v1/outcomes?page=${page}&pageSize=${pageSize}`,
+ );
+ return (data.list ?? []).map(mapOutcome);
+ },
+ async reportConversion(id, amount, note, currency) {
+ const data = await apiRequest>(
+ `/api/v1/outcomes/${encodeURIComponent(id)}/conversion`,
+ {
+ method: "POST",
+ body: { amount: amount ?? 0, note: note ?? "", currency: currency ?? "TWD" },
+ },
+ );
+ return mapOutcome(data);
+ },
+ async getLatestCheckup() {
+ const data = await apiRequest>("/api/v1/checkups/latest");
+ return mapCheckup(data);
+ },
+ async generateCheckupNow() {
+ const data = await apiRequest>("/api/v1/checkups/generate", {
+ method: "POST",
+ body: {},
+ });
+ return mapCheckup(data);
+ },
+ async listAccountHealth() {
+ const data = await apiRequest<{ list?: Record[] }>("/api/v1/account-health");
+ return (data.list ?? []).map((raw) => ({
+ threads_account_id: String(raw.threads_account_id ?? ""),
+ score: Number(raw.score ?? 0),
+ level: String(raw.level ?? "normal"),
+ factors: Array.isArray(raw.factors) ? raw.factors.map(String) : [],
+ advice: String(raw.advice ?? ""),
+ computed_at: Number(raw.computed_at ?? 0),
+ })) as AccountHealth[];
+ },
+ async listWorkspaces() {
+ const data = await apiRequest<{
+ list?: Record[];
+ current_workspace_id?: string;
+ }>("/api/v1/workspaces");
+ const list = (data.list ?? []).map(mapWorkspace);
+ return { list, current_workspace_id: String(data.current_workspace_id ?? "") };
+ },
+ async switchWorkspace(id) {
+ const data = await apiRequest<{
+ list?: Record[];
+ current_workspace_id?: string;
+ }>(`/api/v1/workspaces/${encodeURIComponent(id)}/switch`, {
+ method: "POST",
+ body: {},
+ });
+ return {
+ list: (data.list ?? []).map(mapWorkspace),
+ current_workspace_id: String(data.current_workspace_id ?? id),
+ };
+ },
+ async createWorkspace(name) {
+ const raw = await apiRequest>("/api/v1/workspaces", {
+ method: "POST",
+ body: { name },
+ });
+ return mapWorkspace(raw);
+ },
+ };
+}
+
+function mapWorkspace(raw: Record): Workspace {
+ return {
+ id: String(raw.id ?? ""),
+ name: String(raw.name ?? ""),
+ review_required: Boolean(raw.review_required),
+ is_default: Boolean(raw.is_default),
+ archived: Boolean(raw.archived),
+ created_at: Number(raw.created_at ?? 0),
+ updated_at: Number(raw.updated_at ?? 0),
+ };
+}
diff --git a/apps/web/src/data/live/http.ts b/apps/web/src/data/live/http.ts
index ea49449..48dea9d 100644
--- a/apps/web/src/data/live/http.ts
+++ b/apps/web/src/data/live/http.ts
@@ -4,7 +4,7 @@
* API base 規則(前後端要串得起來):
* 1. VITE_API_BASE 有設 → 用它(例如本機直連 http://127.0.0.1:8888)
* 2. 未設 → 空字串 = same-origin,走 Vite proxy `/api` → gateway:8888
- * (遠端開 threads-tool-dev.30cm.net 時不能打瀏覽器本機 127.0.0.1)
+ * (遠端開 threads-tool.30cm.net 時不能打瀏覽器本機 127.0.0.1)
*/
import { messageForApiCode } from "../../lib/apiErrors";
diff --git a/apps/web/src/data/live/inviteRepo.ts b/apps/web/src/data/live/inviteRepo.ts
index 057525b..b4887fd 100644
--- a/apps/web/src/data/live/inviteRepo.ts
+++ b/apps/web/src/data/live/inviteRepo.ts
@@ -53,11 +53,33 @@ function mapNetwork(raw: Record): MyInviteNetwork {
const me = mapBrief((raw.me || {}) as Record);
const uplineRaw = raw.upline as Record | null | undefined;
const downRaw = Array.isArray(raw.downlines) ? raw.downlines : [];
+ const rewardsRaw = raw.rewards as Record | null | undefined;
+ let rewards: MyInviteNetwork["rewards"] = null;
+ if (rewardsRaw) {
+ const recentRaw = Array.isArray(rewardsRaw.recent) ? rewardsRaw.recent : [];
+ rewards = {
+ total_points: Number(rewardsRaw.total_points ?? 0),
+ month_points: Number(rewardsRaw.month_points ?? 0),
+ month_cap: Number(rewardsRaw.month_cap ?? 0),
+ recent: recentRaw.map((r) => {
+ const row = r as Record;
+ return {
+ id: String(row.id ?? ""),
+ invitee_uid: uidToString(row.invitee_uid),
+ plan_id: String(row.plan_id ?? ""),
+ points: Number(row.points ?? 0),
+ status: String(row.status ?? ""),
+ created_at: Number(row.created_at ?? 0),
+ };
+ }),
+ };
+ }
return {
me,
upline: uplineRaw ? mapBrief(uplineRaw) : null,
downlines: downRaw.map((d) => mapBrief(d as Record)),
total_downline_count: Number(raw.total_downline_count ?? me.total_downline_count ?? 0),
+ rewards,
};
}
diff --git a/apps/web/src/data/live/m5Repos.ts b/apps/web/src/data/live/m5Repos.ts
index 7e63bd1..bbd14a9 100644
--- a/apps/web/src/data/live/m5Repos.ts
+++ b/apps/web/src/data/live/m5Repos.ts
@@ -107,6 +107,11 @@ function mapBrand(raw: Record): Brand {
brief: String(raw.brief ?? ""),
target_audience: raw.target_audience != null ? String(raw.target_audience) : undefined,
goals: raw.goals != null ? String(raw.goals) : undefined,
+ learning_summary: raw.learning_summary != null ? String(raw.learning_summary) : undefined,
+ learning_version: raw.learning_version != null ? Number(raw.learning_version) : undefined,
+ learned_from_posts_count:
+ raw.learned_from_posts_count != null ? Number(raw.learned_from_posts_count) : undefined,
+ last_learned_at: raw.last_learned_at != null ? Number(raw.last_learned_at) : undefined,
};
}
diff --git a/apps/web/src/data/live/repos.ts b/apps/web/src/data/live/repos.ts
index de3a853..c36cf7c 100644
--- a/apps/web/src/data/live/repos.ts
+++ b/apps/web/src/data/live/repos.ts
@@ -60,6 +60,7 @@ import {
createLiveScout,
} from "./m5Repos";
import { createLiveInviteRepo } from "./inviteRepo";
+import { createLiveGrowthRepo } from "./growthRepo";
import { ApiError, apiRequest, loadTokens, saveTokens, type StoredTokens } from "./http";
function uidToString(uid: unknown): string {
@@ -1054,6 +1055,11 @@ function mapPersona(raw: Record): Persona {
},
voice: raw.voice != null ? String(raw.voice) : undefined,
notes: raw.notes != null ? String(raw.notes) : undefined,
+ learning_summary: raw.learning_summary != null ? String(raw.learning_summary) : undefined,
+ learning_version: raw.learning_version != null ? Number(raw.learning_version) : undefined,
+ learned_from_posts_count:
+ raw.learned_from_posts_count != null ? Number(raw.learned_from_posts_count) : undefined,
+ last_learned_at: raw.last_learned_at != null ? Number(raw.last_learned_at) : undefined,
};
}
@@ -1646,5 +1652,6 @@ export function createLiveRepos(): Repos {
research: createLiveResearch(),
media: createLiveMedia(),
scout: createLiveScout(),
+ growth: createLiveGrowthRepo(),
};
}
diff --git a/apps/web/src/data/repos.ts b/apps/web/src/data/repos.ts
index 8748228..b9b5ef2 100644
--- a/apps/web/src/data/repos.ts
+++ b/apps/web/src/data/repos.ts
@@ -621,6 +621,29 @@ export type BillingSubscription = {
export type BillingPortalSession = { url: string };
+export type GrowthRepo = {
+ getOutcomeSummary(range?: "week" | "month"): Promise;
+ listOutcomes(page?: number, pageSize?: number): Promise;
+ reportConversion(
+ id: string,
+ amount?: number,
+ note?: string,
+ currency?: string,
+ ): Promise;
+ getLatestCheckup(): Promise;
+ generateCheckupNow(): Promise;
+ listAccountHealth(): Promise;
+ listWorkspaces(): Promise<{
+ list: import("../domain/types").Workspace[];
+ current_workspace_id: string;
+ }>;
+ switchWorkspace(id: string): Promise<{
+ list: import("../domain/types").Workspace[];
+ current_workspace_id: string;
+ }>;
+ createWorkspace(name: string): Promise;
+};
+
export type Repos = {
dataSource: DataSource;
auth: AuthRepo;
@@ -641,4 +664,5 @@ export type Repos = {
usage: UsageRepo;
adminUsers: AdminUsersRepo;
invite: InviteRepo;
+ growth: GrowthRepo;
};
diff --git a/apps/web/src/domain/types.ts b/apps/web/src/domain/types.ts
index 912e888..4f43710 100644
--- a/apps/web/src/domain/types.ts
+++ b/apps/web/src/domain/types.ts
@@ -84,6 +84,23 @@ export type InviteTreeNode = InviteMemberBrief & {
children: InviteTreeNode[];
};
+/** 邀請獎勵(growth-loop) */
+export type InviteReward = {
+ id: string;
+ invitee_uid: string;
+ plan_id: string;
+ points: number;
+ status: "credited" | "capped" | "skipped" | string;
+ created_at: number;
+};
+
+export type InviteRewardsSummary = {
+ total_points: number;
+ month_points: number;
+ month_cap: number;
+ recent: InviteReward[];
+};
+
/** 一般會員:自己的邀請碼 + 上下線 */
export type MyInviteNetwork = {
me: InviteMemberBrief;
@@ -93,6 +110,71 @@ export type MyInviteNetwork = {
downlines: InviteMemberBrief[];
/** 全部子孫人數(含間接) */
total_downline_count: number;
+ /** 邀請點數回饋(可選) */
+ rewards?: InviteRewardsSummary | null;
+};
+
+/** 成果歸因(growth-loop) */
+export type OutcomeEvent = {
+ id: string;
+ source_type: string;
+ source_id: string;
+ threads_account_id?: string;
+ kind: string;
+ confidence: "confirmed" | "possible" | string;
+ status: string;
+ reply_count_delta?: number;
+ like_count_delta?: number;
+ followers_delta?: number;
+ conversion_amount?: number;
+ conversion_note?: string;
+ conversion_currency?: string;
+ window_ends_at: number;
+ sent_at: number;
+ created_at: number;
+ updated_at: number;
+};
+
+export type OutcomeSummary = {
+ from: number;
+ to: number;
+ reach: number;
+ conversations: number;
+ follows_possible: number;
+ follows_confirmed: number;
+ conversions: number;
+ conversion_amount: number;
+};
+
+export type WeeklyCheckup = {
+ id: string;
+ week_key: string;
+ status: string;
+ summary: string;
+ findings: { claim: string; evidence: string[] }[];
+ actions: { title: string; reason: string; deeplink: string }[];
+ error?: string;
+ created_at: number;
+ updated_at: number;
+};
+
+export type AccountHealth = {
+ threads_account_id: string;
+ score: number;
+ level: "normal" | "warn" | "throttle" | string;
+ factors: string[];
+ advice: string;
+ computed_at: number;
+};
+
+export type Workspace = {
+ id: string;
+ name: string;
+ review_required: boolean;
+ is_default: boolean;
+ archived: boolean;
+ created_at: number;
+ updated_at: number;
};
export type ConnectionStatus = "unknown" | "connected" | "error" | "disconnected";
@@ -334,6 +416,11 @@ export type Persona = {
voice?: string;
/** @deprecated 相容 */
notes?: string;
+ /** growth-loop 資產複利 */
+ learning_summary?: string;
+ learning_version?: number;
+ learned_from_posts_count?: number;
+ last_learned_at?: number;
};
/** 留言是否已被我們回過 */
@@ -615,6 +702,10 @@ export type Brand = {
target_audience?: string;
/** 這輪目標:軟性經驗分享、導連結… */
goals?: string;
+ learning_summary?: string;
+ learning_version?: number;
+ learned_from_posts_count?: number;
+ last_learned_at?: number;
};
/**
diff --git a/apps/web/src/lib/i18n/messages.ts b/apps/web/src/lib/i18n/messages.ts
index e0036e0..8c9ec04 100644
--- a/apps/web/src/lib/i18n/messages.ts
+++ b/apps/web/src/lib/i18n/messages.ts
@@ -16,6 +16,10 @@ export const zhTW: MessageDict = {
"nav.outbox": "發送",
"nav.jobs": "任務",
"nav.brands": "品牌",
+ "nav.playbooks": "市集",
+ "nav.insights": "成效",
+ "nav.benchmark": "基準",
+ "nav.utm": "追蹤",
"nav.more": "更多",
"nav.moreTitle": "更多功能",
"nav.users": "島民管理",
@@ -25,6 +29,14 @@ export const zhTW: MessageDict = {
"nav.settings": "系統設定",
"nav.logout": "登出",
"nav.navigate": "導覽",
+ "navGroup.workflow": "主流程",
+ "navGroup.accounts": "帳號品牌",
+ "navGroup.growth": "成長工具",
+
+ "workspace.label": "工作區",
+ "workspace.default": "預設",
+ "workspace.new": "+ 新增工作區",
+ "workspace.newPrompt": "新工作區名稱",
"common.save": "儲存",
"common.cancel": "取消",
@@ -275,6 +287,8 @@ export const zhTW: MessageDict = {
"invite.copied": "已複製",
"invite.copyFail": "複製失敗",
"invite.stats": "直邀 {direct} · 延伸 {total}",
+ "invite.rewards.summary": "邀請贈點累計 {total} · 本月 {month} /上限 {cap}",
+ "invite.rewards.title": "邀請回饋紀錄",
"invite.upline": "邀請人",
"invite.downlines": "直邀成員 · {n}",
"invite.noUpline": "無邀請人",
@@ -363,6 +377,7 @@ export const zhTW: MessageDict = {
"crew.health.needsReconnectHint": "token 無法使用,請按「連接帳號」重新授權(不是延長 token)",
"crew.health.disconnectedHint": "已解除綁定",
"crew.health.expiredHint": "請延長 token 或重新連帳",
+ "crew.opHealthScore": "操作 {n}",
"crew.msg.refreshed": "@{user} 已用現有授權延長 token(約 60 天)",
"crew.msg.refreshedAll": "已用現有授權延長 {n} 個帳號 token",
"crew.msg.refreshFail": "延長失敗(token 失效時請重新「連接帳號」)",
@@ -377,9 +392,20 @@ export const zhTW: MessageDict = {
"today.reload": "重新整理",
"today.loadFail": "無法載入今日資料",
"today.trendsFail": "無法刷新話題(可能額度不足或搜尋未設定)",
+ "today.trendsUpdated": "已更新 {n} 個話題",
"today.syncPosts": "同步已發文",
"today.syncPostsFail": "同步貼文失敗",
"today.needAccount": "請先連接 Threads 帳號",
+ "today.outcome.title": "本週成果",
+ "today.outcome.reach": "觸達",
+ "today.outcome.conversations": "對話",
+ "today.outcome.follows": "追蹤",
+ "today.outcome.followsHint": "可能相關,尚無強訊號",
+ "today.outcome.followsConfirmedHint": "/確定 {n}",
+ "today.outcome.conversions": "成交",
+ "today.outcome.emptyHint": "本週還沒有海巡外展成果,去海巡試試身手?",
+ "today.checkup.empty": "尚未產生本週健檢,將於下週一(依你的時區)自動產生。",
+ "today.checkup.prefix": "健檢:",
"today.pendingReplies": "待回覆",
"today.pendingRepliesN": "待回覆 · {n}",
"today.newThread": "寫一則",
@@ -1641,6 +1667,10 @@ export const zhTW: MessageDict = {
"usage.planMeta": "/月 · 每月 {n} 點額度",
"usage.changePlan": "變更方案",
"usage.upgradePlan": "升級方案",
+ "usage.outcome.title": "本月成果",
+ "usage.outcome.summary": "觸達 {reach} · 對話 {conversations} · 成交 {conversions}",
+ "usage.outcome.amount": "(約 ${amount})",
+ "usage.outcome.emptyHint": "本月還沒有可歸因的成果,去海巡或發文試試。",
"usage.warn.unlimitedOver": "本月已用 {used} 點(方案 {cap},不擋額度,仍可繼續)。",
"usage.warn.exhausted": "本月點數已用完。可升級方案或等待下月重置。",
"usage.warn.high": "本月已用 {pct}% 點數。",
@@ -1738,6 +1768,10 @@ export const en: MessageDict = {
"nav.outbox": "Outbox",
"nav.jobs": "Jobs",
"nav.brands": "Brands",
+ "nav.playbooks": "Playbooks",
+ "nav.insights": "Insights",
+ "nav.benchmark": "Benchmark",
+ "nav.utm": "UTM",
"nav.more": "More",
"nav.moreTitle": "More",
"nav.users": "Islanders",
@@ -1747,6 +1781,14 @@ export const en: MessageDict = {
"nav.settings": "Settings",
"nav.logout": "Log out",
"nav.navigate": "Navigate",
+ "navGroup.workflow": "Workflow",
+ "navGroup.accounts": "Accounts & brands",
+ "navGroup.growth": "Growth tools",
+
+ "workspace.label": "Workspace",
+ "workspace.default": "Default",
+ "workspace.new": "+ New workspace",
+ "workspace.newPrompt": "New workspace name",
"common.save": "Save",
"common.cancel": "Cancel",
@@ -1996,6 +2038,8 @@ export const en: MessageDict = {
"invite.copied": "Copied",
"invite.copyFail": "Copy failed",
"invite.stats": "{direct} direct · {total} in chain",
+ "invite.rewards.summary": "Reward points total {total} · this month {month} / cap {cap}",
+ "invite.rewards.title": "Reward history",
"invite.upline": "Invited by",
"invite.downlines": "Direct invites · {n}",
"invite.noUpline": "No inviter",
@@ -2084,6 +2128,7 @@ export const en: MessageDict = {
"crew.health.needsReconnectHint": "Token unusable — use Connect account (not Extend token)",
"crew.health.disconnectedHint": "Unlinked",
"crew.health.expiredHint": "Extend token or reconnect",
+ "crew.opHealthScore": "Health {n}",
"crew.msg.refreshed": "@{user} token extended with stored auth (~60 days)",
"crew.msg.refreshedAll": "Extended {n} account tokens with stored auth",
"crew.msg.refreshFail": "Extend failed (reconnect if token is invalid)",
@@ -2098,9 +2143,20 @@ export const en: MessageDict = {
"today.reload": "Reload",
"today.loadFail": "Could not load Today",
"today.trendsFail": "Could not refresh topics (quota or search not set)",
+ "today.trendsUpdated": "Updated {n} topics",
"today.syncPosts": "Sync own posts",
"today.syncPostsFail": "Could not sync posts",
"today.needAccount": "Connect a Threads account first",
+ "today.outcome.title": "This week's outcomes",
+ "today.outcome.reach": "Reach",
+ "today.outcome.conversations": "Conversations",
+ "today.outcome.follows": "Follows",
+ "today.outcome.followsHint": "Possibly related, no strong signal yet",
+ "today.outcome.followsConfirmedHint": " / confirmed {n}",
+ "today.outcome.conversions": "Conversions",
+ "today.outcome.emptyHint": "No patrol outreach outcomes this week yet — go give patrol a try?",
+ "today.checkup.empty": "This week's checkup hasn't been generated yet. It runs automatically next Monday in your timezone.",
+ "today.checkup.prefix": "Checkup: ",
"today.pendingReplies": "Pending replies",
"today.pendingRepliesN": "Pending · {n}",
"today.newThread": "Compose",
@@ -3362,6 +3418,10 @@ export const en: MessageDict = {
"usage.planMeta": "/ mo · {n} credits monthly",
"usage.changePlan": "Change plan",
"usage.upgradePlan": "Upgrade plan",
+ "usage.outcome.title": "This month's outcomes",
+ "usage.outcome.summary": "Reach {reach} · Conversations {conversations} · Conversions {conversions}",
+ "usage.outcome.amount": " (~${amount})",
+ "usage.outcome.emptyHint": "No attributable outcomes this month yet — try patrol or posting.",
"usage.warn.unlimitedOver": "Used {used} credits this month (plan {cap}; limits off — you can continue).",
"usage.warn.exhausted": "Monthly credits are used up. Upgrade or wait for next month.",
"usage.warn.high": "You've used {pct}% of monthly credits.",
diff --git a/apps/web/src/lib/nav.ts b/apps/web/src/lib/nav.ts
index 31555d1..6fec06c 100644
--- a/apps/web/src/lib/nav.ts
+++ b/apps/web/src/lib/nav.ts
@@ -1,4 +1,15 @@
-export type NavKey = "today" | "crew" | "studio" | "scout" | "outbox" | "jobs" | "brands";
+export type NavKey =
+ | "today"
+ | "crew"
+ | "studio"
+ | "scout"
+ | "outbox"
+ | "jobs"
+ | "brands"
+ | "playbooks"
+ | "insights"
+ | "benchmark"
+ | "utm";
export type NavItem = {
key: NavKey;
@@ -19,19 +30,79 @@ export const primaryNav: NavItem[] = [
{ key: "outbox", path: "/app/outbox", labelKey: "nav.outbox", label: "發送", en: "Outbox" },
{ key: "jobs", path: "/app/jobs", labelKey: "nav.jobs", label: "任務", en: "Jobs" },
{ key: "brands", path: "/app/brands", labelKey: "nav.brands", label: "品牌", en: "Brands" },
+ {
+ key: "playbooks",
+ path: "/app/playbooks",
+ labelKey: "nav.playbooks",
+ label: "市集",
+ en: "Playbooks",
+ },
+ {
+ key: "insights",
+ path: "/app/insights",
+ labelKey: "nav.insights",
+ label: "成效",
+ en: "Insights",
+ },
+ {
+ key: "benchmark",
+ path: "/app/benchmark",
+ labelKey: "nav.benchmark",
+ label: "基準",
+ en: "Benchmark",
+ },
+ { key: "utm", path: "/app/utm", labelKey: "nav.utm", label: "追蹤", en: "UTM" },
];
/** 手機底欄固定 4 格(主流程) */
export const mobileDockPrimaryKeys: NavKey[] = ["today", "studio", "scout", "outbox"];
/** 手機底欄「更多」內項目 */
-export const mobileDockMoreKeys: NavKey[] = ["crew", "jobs", "brands"];
+export const mobileDockMoreKeys: NavKey[] = [
+ "crew",
+ "jobs",
+ "brands",
+ "playbooks",
+ "insights",
+ "benchmark",
+ "utm",
+];
+
+export type NavGroupKey = "workflow" | "accounts" | "growth";
+
+export type NavGroup = {
+ key: NavGroupKey;
+ /** i18n key,例如 navGroup.workflow */
+ labelKey: string;
+ keys: NavKey[];
+};
+
+/** 側欄二級分類:主流程 → 帳號品牌 → 成長工具,避免 11 項全部散在同一層 */
+export const navGroups: NavGroup[] = [
+ { key: "workflow", labelKey: "navGroup.workflow", keys: ["today", "studio", "scout", "outbox", "jobs"] },
+ { key: "accounts", labelKey: "navGroup.accounts", keys: ["crew", "brands"] },
+ {
+ key: "growth",
+ labelKey: "navGroup.growth",
+ keys: ["insights", "benchmark", "playbooks", "utm"],
+ },
+];
export function navItemsByKeys(keys: NavKey[]): NavItem[] {
const map = new Map(primaryNav.map((n) => [n.key, n]));
return keys.map((k) => map.get(k)).filter((x): x is NavItem => Boolean(x));
}
+/** 依 navGroups 分類,只保留 keys 中真的存在的項目;空分類不回傳。 */
+export function navGroupedItemsByKeys(
+ keys: NavKey[],
+): { group: NavGroup; items: NavItem[] }[] {
+ const allow = new Set(keys);
+ return navGroups
+ .map((group) => ({ group, items: navItemsByKeys(group.keys.filter((k) => allow.has(k))) }))
+ .filter(({ items }) => items.length > 0);
+}
+
export function isNavActive(pathname: string, item: NavItem): boolean {
if (item.path === "/app/today") {
return pathname === "/app" || pathname === "/app/today";
diff --git a/apps/web/src/pages/BenchmarkPage.tsx b/apps/web/src/pages/BenchmarkPage.tsx
new file mode 100644
index 0000000..e9f20b1
--- /dev/null
+++ b/apps/web/src/pages/BenchmarkPage.tsx
@@ -0,0 +1,69 @@
+import { useCallback, useEffect, useState } from "react";
+import { PageHeader } from "../components/layout/PageHeader";
+import { Button, Card, Input } from "../components/ui";
+import { apiRequest } from "../data/live/http";
+
+type Bench = {
+ available: boolean;
+ sample_size: number;
+ niche: string;
+ median_eng_rate: number;
+ median_views: number;
+ your_eng_rate?: number;
+ your_views?: number;
+ percentile_hint?: string;
+ message?: string;
+};
+
+export function BenchmarkPage() {
+ const [niche, setNiche] = useState("general");
+ const [data, setData] = useState(null);
+ const [err, setErr] = useState("");
+
+ const load = useCallback(async () => {
+ setErr("");
+ try {
+ const q = new URLSearchParams({ niche });
+ setData(await apiRequest(`/api/v1/benchmark?${q}`));
+ } catch (e) {
+ setErr(e instanceof Error ? e.message : "fail");
+ }
+ }, [niche]);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ return (
+ <>
+
+
+ setNiche(e.target.value)} placeholder="niche" />
+
+
+ {err ? {err}
: null}
+ {data ? (
+
+ 利基:{data.niche} · 樣本 {data.sample_size}
+ {data.available ? (
+ <>
+
+ 中位互動率 {(data.median_eng_rate * 100).toFixed(2)}% · 中位瀏覽{" "}
+ {data.median_views.toFixed(0)}
+
+
+ 你的互動率 {((data.your_eng_rate ?? 0) * 100).toFixed(2)}% · 均覽{" "}
+ {(data.your_views ?? 0).toFixed(0)}
+
+ {data.percentile_hint}
+ >
+ ) : (
+ {data.message || "樣本不足"}
+ )}
+
+ ) : null}
+ >
+ );
+}
diff --git a/apps/web/src/pages/BrandsPage.tsx b/apps/web/src/pages/BrandsPage.tsx
index d4a9c2b..666b4dc 100644
--- a/apps/web/src/pages/BrandsPage.tsx
+++ b/apps/web/src/pages/BrandsPage.tsx
@@ -424,6 +424,9 @@ export function BrandsPage() {
{selected.display_name}
{t("brands.inUseHint")}
+ {selected.learning_summary ? (
+
{selected.learning_summary}
+ ) : null}
{t("brands.inUse")}
diff --git a/apps/web/src/pages/CrewPage.tsx b/apps/web/src/pages/CrewPage.tsx
index 32936da..96c075c 100644
--- a/apps/web/src/pages/CrewPage.tsx
+++ b/apps/web/src/pages/CrewPage.tsx
@@ -3,7 +3,7 @@ import { PageHeader } from "../components/layout/PageHeader";
import { PersonaWorkbench } from "../components/persona/PersonaWorkbench";
import { AccountAvatar, Badge, Button, EmptyState, Pager } from "../components/ui";
import { useData, useRepos } from "../data/DataContext";
-import type { ThreadsAccount } from "../domain/types";
+import type { AccountHealth, ThreadsAccount } from "../domain/types";
import { useI18n } from "../i18n/I18nContext";
import { pageSlice } from "../lib/pagination";
import { formatLocalDateTime, formatSessionExpiry } from "../lib/time";
@@ -85,6 +85,7 @@ export function CrewPage() {
const { t } = useI18n();
const [tab, setTab] = useState("accounts");
const [accounts, setAccounts] = useState([]);
+ const [opHealth, setOpHealth] = useState>({});
const [accPage, setAccPage] = useState(1);
const [busy, setBusy] = useState("");
const [message, setMessage] = useState("");
@@ -92,7 +93,11 @@ export function CrewPage() {
const load = useCallback(async () => {
setAccounts(await repos.accounts.list());
- }, [repos.accounts]);
+ const list = await repos.growth.listAccountHealth().catch(() => [] as AccountHealth[]);
+ const map: Record = {};
+ for (const h of list) map[h.threads_account_id] = h;
+ setOpHealth(map);
+ }, [repos.accounts, repos.growth]);
const pagedAccounts = useMemo(
() => pageSlice(accounts, accPage, ACCOUNTS_PAGE),
@@ -236,10 +241,26 @@ export function CrewPage() {
{health.label}
+ {opHealth[acc.id] ? (
+
+ {t("crew.opHealthScore", { n: opHealth[acc.id].score })}
+
+ ) : null}
{health.detail ? (
{health.detail}
) : null}
+ {opHealth[acc.id]?.advice ? (
+ {opHealth[acc.id].advice}
+ ) : null}
)}
+ {network?.rewards && network.rewards.recent.length > 0 ? (
+
+
+
+ ) : null}
+
{!network ? (
{t("common.loading")}
diff --git a/apps/web/src/pages/PlaybooksPage.tsx b/apps/web/src/pages/PlaybooksPage.tsx
new file mode 100644
index 0000000..3e77a1a
--- /dev/null
+++ b/apps/web/src/pages/PlaybooksPage.tsx
@@ -0,0 +1,160 @@
+import { useCallback, useEffect, useState } from "react";
+import { PageHeader } from "../components/layout/PageHeader";
+import { Badge, Button, Card, EmptyState, Input, Textarea } from "../components/ui";
+import { useRepos } from "../data/DataContext";
+import { apiRequest } from "../data/live/http";
+
+type Playbook = {
+ id: string;
+ kind: string;
+ title: string;
+ niche: string;
+ body: string;
+ anonymous: boolean;
+ author_label: string;
+ import_count: number;
+ created_at: number;
+ mine?: boolean;
+};
+
+export function PlaybooksPage() {
+ const repos = useRepos();
+ const [list, setList] = useState([]);
+ const [mine, setMine] = useState(false);
+ const [kind, setKind] = useState("");
+ const [niche, setNiche] = useState("");
+ const [busy, setBusy] = useState("");
+ const [msg, setMsg] = useState("");
+ const [showPub, setShowPub] = useState(false);
+ const [form, setForm] = useState({ kind: "brief", title: "", niche: "", body: "", anonymous: true });
+
+ const load = useCallback(async () => {
+ const q = new URLSearchParams({ page: "1", pageSize: "50" });
+ if (kind) q.set("kind", kind);
+ if (niche) q.set("niche", niche);
+ if (mine) q.set("mine", "true");
+ const data = await apiRequest<{ list?: Playbook[] }>(`/api/v1/playbooks?${q}`);
+ setList(data.list ?? []);
+ }, [kind, niche, mine]);
+
+ useEffect(() => {
+ void load().catch((e) => setMsg(e instanceof Error ? e.message : "load fail"));
+ }, [load]);
+
+ async function publish() {
+ setBusy("pub");
+ try {
+ await apiRequest("/api/v1/playbooks", { method: "POST", body: form });
+ setShowPub(false);
+ setForm({ kind: "brief", title: "", niche: "", body: "", anonymous: true });
+ await load();
+ setMsg("已發布");
+ } catch (e) {
+ setMsg(e instanceof Error ? e.message : "fail");
+ } finally {
+ setBusy("");
+ }
+ }
+
+ async function importPb(id: string) {
+ setBusy(id);
+ try {
+ await apiRequest(`/api/v1/playbooks/${id}/import`, { method: "POST", body: {} });
+ setMsg("已引用到我的 playbook");
+ await load();
+ } catch (e) {
+ setMsg(e instanceof Error ? e.message : "fail");
+ } finally {
+ setBusy("");
+ }
+ }
+
+ return (
+ <>
+
+ {msg ? {msg}
: null}
+
+ {list.length === 0 ? (
+
+ ) : (
+
+ )}
+ {/* silence unused */}
+ {repos ? null : null}
+ >
+ );
+}
diff --git a/apps/web/src/pages/ScoutPage.tsx b/apps/web/src/pages/ScoutPage.tsx
index e7039d2..90f176d 100644
--- a/apps/web/src/pages/ScoutPage.tsx
+++ b/apps/web/src/pages/ScoutPage.tsx
@@ -232,6 +232,8 @@ export function ScoutPage() {
const [busy, setBusy] = useState("");
const [message, setMessage] = useState("");
+ const [convAmount, setConvAmount] = useState("");
+ const [convNote, setConvNote] = useState("");
const [scanJob, setScanJob] = useState(null);
const [scanJobThemeKey, setScanJobThemeKey] = useState(null);
const [crawlerSessionRequired, setCrawlerSessionRequired] = useState(false);
@@ -618,6 +620,40 @@ export function ScoutPage() {
}
}
+ async function reportConversionForCurrent() {
+ if (!current || current.outreach_status !== "published") return;
+ const amountRaw = window.prompt("成交金額(可留空)", convAmount);
+ if (amountRaw === null) return;
+ const note = window.prompt("備註(可留空)", convNote) ?? "";
+ const amount = Number(amountRaw) || 0;
+ setBusy("conversion");
+ setMessage("");
+ try {
+ // find outcome by listing recent and matching source
+ const outcomes = await repos.growth.listOutcomes(1, 50);
+ const hit =
+ outcomes.find(
+ (o) => o.source_type === "scout_outreach" && o.source_id === current.id,
+ ) ?? null;
+ if (!hit) {
+ setMessage("尚無歸因紀錄(請稍後再試,或先完成標記)");
+ return;
+ }
+ await repos.growth.reportConversion(hit.id, amount, note, "TWD");
+ setConvAmount(String(amount || ""));
+ setConvNote(note);
+ setMessage(
+ amount > 0
+ ? `已回報成交 $${Math.round(amount)}`
+ : "已回報成交(無金額)",
+ );
+ } catch (e) {
+ setMessage(e instanceof Error ? e.message : "回報成交失敗");
+ } finally {
+ setBusy("");
+ }
+ }
+
async function removeCurrent() {
if (!current) return;
if (!window.confirm(t("scout.confirmDeletePost"))) return;
@@ -905,6 +941,16 @@ export function ScoutPage() {
>
{busy === "manual-done" ? "…" : t("scout.markManualDone")}
+ {current.outreach_status === "published" ? (
+
+ ) : null}
+
+
+ {t("usage.outcome.summary", {
+ reach: outcomeSummary?.reach ?? 0,
+ conversations: outcomeSummary?.conversations ?? 0,
+ conversions: outcomeSummary?.conversions ?? 0,
+ })}
+ {outcomeSummary?.conversion_amount
+ ? t("usage.outcome.amount", { amount: Math.round(outcomeSummary.conversion_amount) })
+ : ""}
+
+ {!outcomeSummary?.reach && !outcomeSummary?.conversations && !outcomeSummary?.conversions ? (
+
+ {t("usage.outcome.emptyHint")}
+
+ ) : null}
+
+
{/* 本月用量:進度為主 */}
diff --git a/apps/web/src/pages/UtmLinksPage.tsx b/apps/web/src/pages/UtmLinksPage.tsx
new file mode 100644
index 0000000..5eeea73
--- /dev/null
+++ b/apps/web/src/pages/UtmLinksPage.tsx
@@ -0,0 +1,85 @@
+import { useCallback, useEffect, useState } from "react";
+import { PageHeader } from "../components/layout/PageHeader";
+import { Button, Card, EmptyState, Input } from "../components/ui";
+import { apiRequest } from "../data/live/http";
+
+type Link = {
+ id: string;
+ code: string;
+ track_path: string;
+ destination_url: string;
+ outcome_id?: string;
+ label?: string;
+ clicks: number;
+ created_at: number;
+};
+
+export function UtmLinksPage() {
+ const [list, setList] = useState
([]);
+ const [url, setUrl] = useState("");
+ const [label, setLabel] = useState("");
+ const [outcomeId, setOutcomeId] = useState("");
+ const [msg, setMsg] = useState("");
+
+ const load = useCallback(async () => {
+ const data = await apiRequest<{ list?: Link[] }>("/api/v1/utm/links");
+ setList(data.list ?? []);
+ }, []);
+
+ useEffect(() => {
+ void load().catch((e) => setMsg(String(e)));
+ }, [load]);
+
+ async function create() {
+ try {
+ await apiRequest("/api/v1/utm/links", {
+ method: "POST",
+ body: { destination_url: url, label, outcome_id: outcomeId || undefined },
+ });
+ setUrl("");
+ setLabel("");
+ setOutcomeId("");
+ await load();
+ setMsg("已建立追蹤連結");
+ } catch (e) {
+ setMsg(e instanceof Error ? e.message : "fail");
+ }
+ }
+
+ return (
+ <>
+
+ {msg ?
{msg}
: null}
+
+
+ setUrl(e.target.value)} />
+ setLabel(e.target.value)} />
+ setOutcomeId(e.target.value)}
+ />
+ void create()}>
+ 建立
+
+
+
+ {list.length === 0 ? (
+
+ ) : (
+
+ )}
+ >
+ );
+}
diff --git a/apps/web/src/pages/tools/PainKeywordsPage.tsx b/apps/web/src/pages/tools/PainKeywordsPage.tsx
new file mode 100644
index 0000000..fe58706
--- /dev/null
+++ b/apps/web/src/pages/tools/PainKeywordsPage.tsx
@@ -0,0 +1,67 @@
+import { useState } from "react";
+import { Link } from "react-router-dom";
+import { Button, Card, Input, Textarea } from "../../components/ui";
+import { apiRequest } from "../../data/live/http";
+
+export function PainKeywordsPage() {
+ const [brief, setBrief] = useState("");
+ const [audience, setAudience] = useState("");
+ const [result, setResult] = useState<{
+ keywords: string[];
+ pains: string[];
+ scan_terms: string[];
+ summary: string;
+ signup_hint: string;
+ } | null>(null);
+ const [busy, setBusy] = useState(false);
+ const [err, setErr] = useState("");
+
+ async function run() {
+ setBusy(true);
+ setErr("");
+ try {
+ const data = await apiRequest
>("/api/v1/public/tools/pain-keywords", {
+ method: "POST",
+ body: { product_brief: brief, audience },
+ });
+ setResult(data);
+ } catch (e) {
+ setErr(e instanceof Error ? e.message : "fail");
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
痛點關鍵字產生器
+
免登入:描述產品,產出海巡可用的掃描詞與痛點。
+
+
+ {err ?
{err}
: null}
+ {result ? (
+
+ {result.summary}
+
+ 關鍵字:{result.keywords.join("、")}
+
+
+ 痛點:{result.pains.join("、")}
+
+
+ 掃描詞:{result.scan_terms.join("、")}
+
+ {result.signup_hint}
+
+ 登入巡樓海巡
+
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/src/pages/tools/StyleQuizPage.tsx b/apps/web/src/pages/tools/StyleQuizPage.tsx
new file mode 100644
index 0000000..d82422d
--- /dev/null
+++ b/apps/web/src/pages/tools/StyleQuizPage.tsx
@@ -0,0 +1,64 @@
+import { useState } from "react";
+import { Link } from "react-router-dom";
+import { Button, Card, Textarea } from "../../components/ui";
+import { apiRequest } from "../../data/live/http";
+
+export function StyleQuizPage() {
+ const [samples, setSamples] = useState("");
+ const [result, setResult] = useState<{
+ tone: string;
+ rhythm: string;
+ hooks: string;
+ avoid: string;
+ summary: string;
+ tags: string[];
+ signup_hint: string;
+ } | null>(null);
+ const [busy, setBusy] = useState(false);
+ const [err, setErr] = useState("");
+
+ async function run() {
+ setBusy(true);
+ setErr("");
+ try {
+ const data = await apiRequest>("/api/v1/public/tools/style-quiz", {
+ method: "POST",
+ body: { samples },
+ });
+ setResult(data);
+ } catch (e) {
+ setErr(e instanceof Error ? e.message : "fail");
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
風格指紋測驗
+
免登入:貼上幾則你的 Threads 貼文,立刻看語氣與節奏。
+
+
+ {err ?
{err}
: null}
+ {result ? (
+
+ {result.summary}
+
+ - 語氣:{result.tone}
+ - 節奏:{result.rhythm}
+ - 鉤子:{result.hooks}
+ - 注意:{result.avoid}
+
+ {result.signup_hint}
+
+ 登入巡樓
+
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/src/styles/layout.css b/apps/web/src/styles/layout.css
index 5b77b89..87bb522 100644
--- a/apps/web/src/styles/layout.css
+++ b/apps/web/src/styles/layout.css
@@ -220,9 +220,68 @@
.hb-topbar__actions {
display: flex;
align-items: center;
+ gap: 0.5rem;
flex-shrink: 0;
}
+/* Workspace 切換:獨立於用量/鈴鐺/帳號那顆膠囊之外的自成一格 chip */
+.hb-ws-switch {
+ --hb-top-ctrl: 2rem;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ height: var(--hb-top-ctrl);
+ padding: 0 0.65rem 0 0.55rem;
+ border: 1px solid color-mix(in srgb, var(--hb-line) 90%, transparent);
+ border-radius: 0.55rem;
+ background: color-mix(in srgb, var(--hb-surface) 92%, var(--hb-surface-muted));
+ color: var(--hb-ink-secondary);
+ font-size: 0.8rem;
+ cursor: pointer;
+ transition: border-color 0.14s ease, background 0.14s ease;
+}
+
+.hb-ws-switch:hover,
+.hb-ws-switch:focus-within {
+ border-color: color-mix(in srgb, var(--hb-brand) 45%, var(--hb-line));
+ background: color-mix(in srgb, var(--hb-brand-soft, var(--hb-surface-muted)) 55%, var(--hb-surface));
+}
+
+.hb-ws-switch__ico {
+ display: grid;
+ place-items: center;
+ flex-shrink: 0;
+ opacity: 0.75;
+}
+
+.hb-ws-switch__label {
+ font-weight: 650;
+ white-space: nowrap;
+}
+
+@media (max-width: 480px) {
+ /* 手機寬度有限:只留 icon + 下拉,文字標籤讓路給用量/鈴鐺/帳號 */
+ .hb-ws-switch__label {
+ display: none;
+ }
+ .hb-ws-switch {
+ padding: 0 0.5rem;
+ }
+}
+
+.hb-ws-switch__select {
+ max-width: 8.5rem;
+ height: 100%;
+ border: 0;
+ padding: 0;
+ background: transparent;
+ color: var(--hb-ink);
+ font: inherit;
+ font-weight: 600;
+ cursor: pointer;
+ outline: none;
+}
+
/* 用量 + 鈴鐺 + 帳號:同一工具列(略方圓角,非膠囊) */
.hb-topbar__cluster {
--hb-top-ctrl: 2rem;
@@ -657,6 +716,25 @@ a.hb-topbar__chip:hover {
color: var(--hb-subtle);
}
+.hb-nav__group {
+ display: flex;
+ flex-direction: column;
+}
+
+.hb-nav__group + .hb-nav__group {
+ margin-top: var(--hb-space-4);
+ padding-top: var(--hb-space-3);
+ border-top: 1px solid color-mix(in srgb, var(--hb-line) 80%, transparent);
+}
+
+.hb-nav__group-label {
+ margin: 0 0 var(--hb-space-1) var(--hb-space-2);
+ font-size: 0.68rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ color: var(--hb-subtle);
+}
+
.hb-nav {
display: flex;
flex-direction: column;
@@ -701,25 +779,13 @@ a.hb-topbar__chip:hover {
}
.hb-nav__item--active {
- background: linear-gradient(90deg, var(--hb-brand-soft), color-mix(in srgb, var(--hb-magic) 7%, transparent));
+ background: var(--hb-brand-soft);
color: var(--hb-brand-deep);
- box-shadow: inset 2px 0 0 var(--hb-brand), inset 0 0 0 1px color-mix(in srgb, var(--hb-brand) 20%, transparent);
-}
-
-.hb-nav__item--active::after {
- content: "";
- position: absolute;
- right: 0.7rem;
- width: 0.35rem;
- height: 0.35rem;
- border-radius: 50%;
- background: var(--hb-magic);
- box-shadow: 0 0 10px var(--hb-magic);
+ box-shadow: inset 3px 0 0 var(--hb-brand);
}
.hb-nav__item--active .hb-nav__ico {
opacity: 1;
- filter: drop-shadow(0 0 6px var(--hb-magic-glow));
}
.hb-nav__dot {
@@ -846,10 +912,6 @@ a.hb-topbar__chip:hover {
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--hb-brand) 22%, transparent);
}
-.hb-dock__item--active .hb-dock__icon {
- filter: drop-shadow(0 0 8px var(--hb-magic-glow));
-}
-
.hb-dock__icon {
display: grid;
place-items: center;
@@ -945,6 +1007,18 @@ a.hb-topbar__chip:hover {
padding: 0 0.25rem;
}
+.hb-dock-more__group + .hb-dock-more__group {
+ margin-top: 0.55rem;
+}
+
+.hb-dock-more__group-label {
+ margin: 0 0 0.3rem 0.25rem;
+ font-size: 0.68rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ color: var(--hb-subtle);
+}
+
.hb-dock-more__list {
list-style: none;
margin: 0;
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index d2a8680..b245d90 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -27,13 +27,13 @@ export default defineConfig({
host: "0.0.0.0",
port: 5173,
strictPort: false,
- allowedHosts: ["threads-tool-dev.30cm.net", ".30cm.net"],
+ allowedHosts: ["threads-tool.30cm.net", ".30cm.net"],
proxy,
},
preview: {
host: "0.0.0.0",
port: 4173,
- allowedHosts: ["threads-tool-dev.30cm.net", ".30cm.net"],
+ allowedHosts: ["threads-tool.30cm.net", ".30cm.net"],
proxy,
},
});
diff --git a/deploy/.env.example b/deploy/.env.example
index a9facec..7c59d05 100644
--- a/deploy/.env.example
+++ b/deploy/.env.example
@@ -3,10 +3,10 @@
# 腳本讀:deploy/.env.dev(優先)或 deploy/.env
# ---------- 公開網域(瀏覽器、PublicBaseURL、信件連結)----------
-PUBLIC_HOST=threads-tool-dev.30cm.net
-PUBLIC_WEB_BASE=https://threads-tool-dev.30cm.net
+PUBLIC_HOST=threads-tool.30cm.net
+PUBLIC_WEB_BASE=https://threads-tool.30cm.net
# 頭像等公開前綴 = 同源 /haixun-assets(Vite proxy 或 nginx → MinIO)
-HAIXUN_STORAGE_S3_PUBLIC_BASE_URL=https://threads-tool-dev.30cm.net/haixun-assets
+HAIXUN_STORAGE_S3_PUBLIC_BASE_URL=https://threads-tool.30cm.net/haixun-assets
# ---------- Infra 埠(只綁 127.0.0.1)----------
MONGO_PORT=27017
@@ -64,9 +64,9 @@ STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_STARTER_PRICE_ID=
STRIPE_PRO_PRICE_ID=
-STRIPE_SUCCESS_URL=https://threads-tool-dev.30cm.net/app/usage/checkout?result=success&checkout_id={CHECKOUT_ID}&plan={PLAN_ID}
-STRIPE_CANCEL_URL=https://threads-tool-dev.30cm.net/app/usage/checkout?result=cancel&plan={PLAN_ID}
-STRIPE_PORTAL_RETURN_URL=https://threads-tool-dev.30cm.net/app/usage/plans
+STRIPE_SUCCESS_URL=https://threads-tool.30cm.net/app/usage/checkout?result=success&checkout_id={CHECKOUT_ID}&plan={PLAN_ID}
+STRIPE_CANCEL_URL=https://threads-tool.30cm.net/app/usage/checkout?result=cancel&plan={PLAN_ID}
+STRIPE_PORTAL_RETURN_URL=https://threads-tool.30cm.net/app/usage/plans
# ---------- Mail(dev → Mailpit)----------
HAIXUN_SMTP_HOST=127.0.0.1
@@ -75,7 +75,7 @@ HAIXUN_SMTP_USERNAME=
HAIXUN_SMTP_PASSWORD=
HAIXUN_SMTP_FROM="Harbor Desk "
MAIL_SENDER="Harbor Desk "
-PUBLIC_WEB_BASE=https://threads-tool-dev.30cm.net
+PUBLIC_WEB_BASE=https://threads-tool.30cm.net
# ---------- App 埠 ----------
GATEWAY_PORT=8888
diff --git a/deploy/README.md b/deploy/README.md
index 73f335d..6e1e9a9 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -117,7 +117,7 @@ make -C apps/backend seeder
| API | http://127.0.0.1:8888/api/v1/ping |
| Mailpit | http://127.0.0.1:8025 |
| MinIO Console | http://127.0.0.1:9001(ROOT user/pass) |
-| 公開(CF/nginx) | https://threads-tool-dev.30cm.net |
+| 公開(CF/nginx) | https://threads-tool.30cm.net |
頭像公開路徑:`{HAIXUN_STORAGE_S3_PUBLIC_BASE_URL}/avatar/{uid}/{uuid}.jpg`
未裝 nginx 時,Vite 已 proxy `/haixun-assets` → MinIO。
diff --git a/deploy/nginx/haixun-dev.conf b/deploy/nginx/haixun-dev.conf
index dce4dbc..9735154 100644
--- a/deploy/nginx/haixun-dev.conf
+++ b/deploy/nginx/haixun-dev.conf
@@ -24,7 +24,7 @@ upstream haixun_minio {
server {
listen 80;
listen [::]:80;
- server_name threads-tool-dev.30cm.net threads-tool.30cm.net;
+ server_name threads-tool.30cm.net;
location /haixun-assets/ {
proxy_pass http://haixun_minio;
diff --git a/deploy/prod/README.md b/deploy/prod/README.md
index 604b4b7..652cb18 100644
--- a/deploy/prod/README.md
+++ b/deploy/prod/README.md
@@ -1,7 +1,7 @@
# Harbor Desk Production Deployment
This deployment targets Ubuntu amd64 at `daniel@10.0.0.33` and serves
-`threads-tool-dev.30cm.net`.
+`threads-tool.30cm.net`.
## Layout
@@ -108,7 +108,7 @@ sudoedit /etc/letsencrypt/cloudflare.ini
# dns_cloudflare_api_token = REPLACE_WITH_RESTRICTED_TOKEN
sudo certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
- -d threads-tool-dev.30cm.net
+ -d threads-tool.30cm.net
sudo /opt/harbor/deploy/remote/enable-tls.sh
```
diff --git a/deploy/prod/config/gateway.yaml b/deploy/prod/config/gateway.yaml
index 111d26f..207b9ac 100644
--- a/deploy/prod/config/gateway.yaml
+++ b/deploy/prod/config/gateway.yaml
@@ -79,10 +79,10 @@ Mail:
Password: ""
DevExposeCode: false
-PublicWebBase: "https://threads-tool-dev.30cm.net"
-PublicAPIBase: "https://threads-tool-dev.30cm.net"
+PublicWebBase: "https://threads-tool.30cm.net"
+PublicAPIBase: "https://threads-tool.30cm.net"
CORSAllowedOrigins:
- - "https://threads-tool-dev.30cm.net"
+ - "https://threads-tool.30cm.net"
Brand:
Name: "Harbor Desk"
@@ -96,4 +96,4 @@ ObjectStorage:
AccessKey: ""
SecretKey: ""
UsePathStyle: true
- PublicBaseURL: "https://threads-tool-dev.30cm.net/haixun-assets"
+ PublicBaseURL: "https://threads-tool.30cm.net/haixun-assets"
diff --git a/deploy/prod/harbor.env.example b/deploy/prod/harbor.env.example
index 47d0e53..89fb9d4 100644
--- a/deploy/prod/harbor.env.example
+++ b/deploy/prod/harbor.env.example
@@ -1,10 +1,10 @@
# Installed as /etc/harbor/harbor.env by remote/bootstrap.sh.
# Bootstrap generates all first-party secrets. Add external provider credentials
# directly on the server and keep this file free of real secrets.
-PUBLIC_HOST=threads-tool-dev.30cm.net
-PUBLIC_WEB_BASE=https://threads-tool-dev.30cm.net
-PUBLIC_API_BASE=https://threads-tool-dev.30cm.net
-CORS_ALLOWED_ORIGINS=https://threads-tool-dev.30cm.net
+PUBLIC_HOST=threads-tool.30cm.net
+PUBLIC_WEB_BASE=https://threads-tool.30cm.net
+PUBLIC_API_BASE=https://threads-tool.30cm.net
+CORS_ALLOWED_ORIGINS=https://threads-tool.30cm.net
PLATFORM_XAI_KEY=
PLATFORM_OPENCODE_KEY=
@@ -17,9 +17,9 @@ STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_STARTER_PRICE_ID=
STRIPE_PRO_PRICE_ID=
-STRIPE_SUCCESS_URL="https://threads-tool-dev.30cm.net/app/usage/checkout?result=success&checkout_id={CHECKOUT_ID}&plan={PLAN_ID}"
-STRIPE_CANCEL_URL="https://threads-tool-dev.30cm.net/app/usage/checkout?result=cancel&plan={PLAN_ID}"
-STRIPE_PORTAL_RETURN_URL=https://threads-tool-dev.30cm.net/app/usage/plans
+STRIPE_SUCCESS_URL="https://threads-tool.30cm.net/app/usage/checkout?result=success&checkout_id={CHECKOUT_ID}&plan={PLAN_ID}"
+STRIPE_CANCEL_URL="https://threads-tool.30cm.net/app/usage/checkout?result=cancel&plan={PLAN_ID}"
+STRIPE_PORTAL_RETURN_URL=https://threads-tool.30cm.net/app/usage/plans
MAIL_SENDER="Harbor Desk "
MAIL_SMTP_HOST=
diff --git a/deploy/prod/monitoring/prometheus/targets/tls.yml b/deploy/prod/monitoring/prometheus/targets/tls.yml
index 7adfe59..f6ed691 100644
--- a/deploy/prod/monitoring/prometheus/targets/tls.yml
+++ b/deploy/prod/monitoring/prometheus/targets/tls.yml
@@ -1,3 +1,3 @@
-- targets: [https://threads-tool-dev.30cm.net/health]
+- targets: [https://threads-tool.30cm.net/health]
labels:
service: public-tls
diff --git a/deploy/prod/nginx/harbor-http.conf b/deploy/prod/nginx/harbor-http.conf
index 91221b4..7716506 100644
--- a/deploy/prod/nginx/harbor-http.conf
+++ b/deploy/prod/nginx/harbor-http.conf
@@ -11,7 +11,7 @@ upstream harbor_minio {
server {
listen 80;
listen [::]:80;
- server_name threads-tool-dev.30cm.net 10.0.0.33;
+ server_name threads-tool.30cm.net 10.0.0.33;
root /opt/harbor/current/web;
index index.html;
diff --git a/deploy/prod/nginx/harbor-tls.conf b/deploy/prod/nginx/harbor-tls.conf
index bde22e4..e680caa 100644
--- a/deploy/prod/nginx/harbor-tls.conf
+++ b/deploy/prod/nginx/harbor-tls.conf
@@ -11,17 +11,17 @@ upstream harbor_minio {
server {
listen 80;
listen [::]:80;
- server_name threads-tool-dev.30cm.net;
+ server_name threads-tool.30cm.net;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
- server_name threads-tool-dev.30cm.net;
+ server_name threads-tool.30cm.net;
- ssl_certificate /etc/letsencrypt/live/threads-tool-dev.30cm.net/fullchain.pem;
- ssl_certificate_key /etc/letsencrypt/live/threads-tool-dev.30cm.net/privkey.pem;
+ ssl_certificate /etc/letsencrypt/live/threads-tool.30cm.net/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/threads-tool.30cm.net/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
diff --git a/deploy/prod/remote/bootstrap.sh b/deploy/prod/remote/bootstrap.sh
index 777c03e..f92a951 100755
--- a/deploy/prod/remote/bootstrap.sh
+++ b/deploy/prod/remote/bootstrap.sh
@@ -53,10 +53,10 @@ if [[ ! -f /etc/harbor/harbor.env ]]; then
minio_password=$(openssl rand -hex 24)
grafana_password=$(openssl rand -base64 24 | tr -d '/+=')
cat > /etc/harbor/harbor.env < /etc/nginx/harbor-active-server.conf
-if [[ -f /etc/letsencrypt/live/threads-tool-dev.30cm.net/fullchain.pem ]]; then
+if [[ -f /etc/letsencrypt/live/threads-tool.30cm.net/fullchain.pem ]]; then
install -m 0644 /opt/harbor/deploy/nginx/harbor-tls.conf /etc/nginx/sites-available/harbor.conf
else
install -m 0644 /opt/harbor/deploy/nginx/harbor-http.conf /etc/nginx/sites-available/harbor.conf
diff --git a/deploy/prod/remote/enable-tls.sh b/deploy/prod/remote/enable-tls.sh
index aebea38..0de3d91 100755
--- a/deploy/prod/remote/enable-tls.sh
+++ b/deploy/prod/remote/enable-tls.sh
@@ -5,7 +5,7 @@ if [[ ${EUID} -ne 0 ]]; then
printf '%s\n' "enable-tls must run as root" >&2
exit 1
fi
-domain=threads-tool-dev.30cm.net
+domain=threads-tool.30cm.net
if [[ ! -f "/etc/letsencrypt/live/$domain/fullchain.pem" || ! -f "/etc/letsencrypt/live/$domain/privkey.pem" ]]; then
printf '%s\n' "certificate not found; obtain a DNS-01 certificate for $domain first" >&2
exit 1
diff --git a/deploy/scripts/install-nginx.sh b/deploy/scripts/install-nginx.sh
index 3f1a948..1756dd3 100755
--- a/deploy/scripts/install-nginx.sh
+++ b/deploy/scripts/install-nginx.sh
@@ -14,4 +14,4 @@ sudo cp "$SRC" "$DST"
sudo nginx -t
sudo systemctl reload nginx
log "nginx reloaded"
-log "驗:curl -sI -H 'Host: threads-tool-dev.30cm.net' http://127.0.0.1/api/v1/ping"
+log "驗:curl -sI -H 'Host: threads-tool.30cm.net' http://127.0.0.1/api/v1/ping"
diff --git a/docs/product/growth-loop/GAPS.md b/docs/product/growth-loop/GAPS.md
new file mode 100644
index 0000000..6b4b476
--- /dev/null
+++ b/docs/product/growth-loop/GAPS.md
@@ -0,0 +1,36 @@
+# growth-loop 缺口狀態
+
+> Updated: 2026-07-20 · **P2 已落地**
+
+## P0–P2 交付對照
+
+| 能力 | 狀態 | 入口 |
+|------|------|------|
+| 成果歸因 | ✅ | Today/Usage/海巡回報成交 |
+| 每週健檢 | ✅ | Today |
+| 健康分 | ✅ | Crew + 送出閘 |
+| 邀請贈點 | ✅ | Invite + purchase hook |
+| Workspace | ✅ | 頂欄切換 |
+| **Playbook 市集** | ✅ P2 | `/app/playbooks` |
+| **免費破冰** | ✅ P2 | `/tools/style-quiz` · `/tools/pain-keywords` |
+| **Benchmark** | ✅ P2 | `/app/benchmark`(樣本≥5) |
+| **UTM 點擊歸因** | ✅ P2 | `/app/utm` · `GET /api/v1/public/u/:code` → 302 |
+| **Insights 真摘要** | ✅ P2 | `/app/insights` ← 同步貼文 |
+| **PDF 月報** | ✅ P2 | export `format=pdf`(Helvetica;中文請用 md) |
+| **Workspace 協作** | ✅ P2 | members API + 審核可協作者決 |
+| **白牌報表** | ✅ P2 | branding footer / hide powered-by |
+
+## 已知限制(非未做)
+
+- Threads **自動**對話/追蹤訊號仍 degraded(手動成交 + UTM 點擊已補)
+- PDF 標準字型對 CJK 顯示為 `?`,完整中文請 `format=md`
+- Benchmark 需同利基累積 ≥5 匿名樣本
+- 白牌是**報表文案**,不是自訂域名 DNS
+
+## 驗證
+
+```bash
+curl -sS http://127.0.0.1:8888/api/v1/ping
+curl -sS -X POST http://127.0.0.1:8888/api/v1/public/tools/style-quiz \
+ -H 'Content-Type: application/json' -d '{"samples":"測試貼文?"}'
+```
diff --git a/docs/product/growth-loop/README.md b/docs/product/growth-loop/README.md
index 797293e..1fbda5d 100644
--- a/docs/product/growth-loop/README.md
+++ b/docs/product/growth-loop/README.md
@@ -2,9 +2,8 @@
| 欄位 | 值 |
|------|-----|
-| **Goal** | 從「好用」到「非用不可」:**成果歸因+每週健檢(學習閉環 MVP)優先**,代操模式+帳號健康分其次,市集+邀請自增長墊後 |
-| **Phase** | `awaiting-requirements-approval` |
-| **由來** | 2026-07-20 PM 體檢(對話結論)→ 落成改善計劃 |
+| **Goal** | 從「好用」到「非用不可」:成果歸因+每週健檢優先;健康分+邀請獎勵+Workspace 其次 |
+| **Phase** | `implemented`(P0–P2 已落地;見 GAPS.md) |
| **Frontend / Backend** | `apps/web` / `apps/backend` |
| **Updated** | 2026-07-20 |
@@ -12,28 +11,26 @@
| 檔案 | 狀態 |
|------|------|
-| [requirements.md](./requirements.md) | **draft**(待使用者批准) |
-| spec.md | 未開始(需先批准需求) |
-| plan.md | 未開始 |
-| tasks/ | 未開始 |
+| [requirements.md](./requirements.md) | approved |
+| [spec.md](./spec.md) | approved |
+| [plan.md](./plan.md) | approved |
+| [tasks/INDEX.md](./tasks/INDEX.md) | approved · 30 tasks **done** |
+| [TESTING.md](./TESTING.md) | 測試指令 |
+| [GAPS.md](./GAPS.md) | P2/訊號降級 |
-## 接手說明(給下一位 model)
+## 交付摘要
-1. **先讀** `requirements.md` §3(體檢結論=本 run 存在理由),再讀底座真相:
- - `docs/product/haixun-backend/spec.md`(已交付能力;「Insights 真管線 P2」決策不變)
- - `apps/web`(UI 真相:今日/海巡/用量/Insights 頁)
- - 舊願景:git `main` 分支 `ai_threads_auto_account_ops.md`(MVP1–4、學習閉環原始設計)
-2. **流程**:使用者回「需求 OK」→ `/spec-driven growth-loop` 進 spec 階段 → plan → tasks → 指定 T### 才寫碼。各階段都要等批准,**不得跳層一次寫完**。
-3. **硬性提醒**:
- - P0 兩項(成果歸因、每週健檢)是本 run 的存在理由,**不可被 P1/P2 稀釋或調換順序**。
- - 每週健檢**重用既有同步貼文成效**,不是重做 Insights 真管線。
- - MLM 紅線:邀請回饋**單層、點數、非現金**。
- - 灰帽功能只加護欄(健康分/降速),不新增玩法;所有自動化保留人工路徑。
- - 歸因只用可合理取得的訊號(API 公開計數/UTM/手動回報),API 權限清單在 spec 階段驗證。
+| 能力 | 路徑 |
+|------|------|
+| Outcomes API | `/api/v1/outcomes/*` |
+| Checkups API | `/api/v1/checkups/*`(不扣點) |
+| Account health | `/api/v1/account-health/*` + 自動送出閘 |
+| Workspaces | `/api/v1/workspaces/*` |
+| Invite rewards | 擴充 `/api/v1/invite/me` + purchase hook |
+| FE | Today 成果卡+健檢;Usage 成本|成果並列;growth live repo |
-## 閘門
-
-1. 需求批准 ← **目前在這裡**
-2. spec 批准
-3. plan 批准
-4. tasks 批准 → `ready-to-implement`
+```bash
+cd apps/backend && go test ./internal/module/growth/... -count=1 && make build
+cd apps/web && npm run build
+# indexes: make migrate-up # 000013
+```
diff --git a/docs/product/growth-loop/TESTING.md b/docs/product/growth-loop/TESTING.md
new file mode 100644
index 0000000..6dc6652
--- /dev/null
+++ b/docs/product/growth-loop/TESTING.md
@@ -0,0 +1,31 @@
+# growth-loop 測試
+
+```bash
+cd apps/backend
+go test ./internal/module/growth/... -count=1
+go build -o bin/gateway .
+go build -o bin/worker ./cmd/worker
+
+cd apps/web
+npm run build
+```
+
+## Spec §9 覆蓋(usecase 測試)
+
+| ID | Test |
+|----|------|
+| OC-01 | TestOC_01_CreateOnPublished |
+| OC-02/03 | TestOC_02_03_ApplySignals |
+| OC-04/05 | TestOC_04_05_Conversion |
+| OC-08 | TestOC_08_DegradedStillWorks |
+| CU-* | TestCU_01_02_03_GenerateCheckup |
+| AH-* | TestAH_01_03_HealthGate |
+| IR-* | TestIR_01_03_InviteReward |
+| WS-* | TestWS_01_02_Workspace, TestWS_03_04_ReviewGate, TestWS_05_ExportReport |
+
+## 遷移
+
+```bash
+cd apps/backend && make migrate-up
+# 000013_growth_loop_indexes
+```
diff --git a/docs/product/growth-loop/plan.md b/docs/product/growth-loop/plan.md
new file mode 100644
index 0000000..3854bee
--- /dev/null
+++ b/docs/product/growth-loop/plan.md
@@ -0,0 +1,190 @@
+# Plan: 成長迴路(成果歸因 · 學習閉環 · 健康分 · 邀請獎勵 · Workspace)
+
+> Status: `approved`
+> Status note: 使用者 2026-07-20「plan 通過」
+> Source: `docs/product/growth-loop/spec.md`(**approved** 2026-07-20 使用者「spec 通過」)
+> Requirements: `docs/product/growth-loop/requirements.md`(**approved**)
+> Last updated: `2026-07-20`
+> 底座:`apps/backend` + `apps/web`(haixun-backend M0–M6 已交付)
+
+## 1. 交付策略
+
+1. **先 P0 價值可見**:Outcome 歸因鏈+今日/用量「成本+成果」並列——沒有這塊,本 run 沒有存在理由。
+2. **同波完成學習閉環最小版**:WeeklyCheckup(不扣點)+ Persona/Brand learning 欄位;重用既有同步貼文/海巡資料,**不**開 Insights 真管線。
+3. **P1 護欄先於擴張**:AccountHealth(存亡級)→ InviteReward(增長)→ Workspace/審核/PDF(代操)。
+4. **加值 hook、不重寫主流程**:Outbox/Scout 送出成功後掛 Outcome;既有 API 擴欄優於新平行宇宙。
+5. **前後端同 milestone 可驗**:每 M 結束 FE live 接線+對應 spec §9 ID;禁止整包最後才接前端。
+6. **缺 Threads 訊號就降級**:M1 先探測 scope;OC-08 手動成交+觸達必須先綠,再補自動訊號。
+
+驗收一律對照 **spec §9**(OC / CU / AL / AH / IR / WS / RG);task 完成定義須寫明覆蓋哪些 ID。
+
+## 2. 里程碑
+
+### M0 — 領域殼與契約地基
+
+- **目標:** Outcome/Checkup/Health/Workspace 的 module 殼、`.api` 路由骨架、index init、空實作回**明確未就緒錯誤**(禁止空成功)。
+- **完成定義:**
+ - go-zero `.api` 增加 outcomes/checkups/health/workspaces(或等價分檔)→ `make gen-api`。
+ - Mongo index init 擴充新集合鍵(owner_uid、source、week_key…)。
+ - 共用 envelope/時間/uid 契約回歸仍過。
+ - FE domain types 預留 Outcome/Checkup/Health/Workspace(可先 mock 介面)。
+- **建議 task 區間:** **T300–T309**
+- **驗證:** RG-01~03 抽樣不破;新路由未完成能力 ≠ 102000 空 data。
+
+### M1 — 成果歸因 P0(後端+FE)
+
+- **目標:** 海巡/Outbox 真送或標記完成 → OutcomeEvent;summary/list/手動成交;今日+用量成果區。
+- **完成定義:**
+ - Scout outreach published/標記完成、Outbox step published → 建立 `observing` Outcome。
+ - `outcome_observe` Job:72h 窗;對話/互動/追蹤能讀則寫,不能讀則跳過。
+ - API:summary、list、get、report/update/deleteConversion。
+ - confidence:confirmed|possible 語意正確。
+ - FE:Today 成果卡、Usage 成本|成果並列、Scout 詳情手動成交與回溯。
+ - Threads scope 探測結果寫進 module 註記或 TESTING 附錄。
+- **建議 task 區間:** **T310–T329**
+- **驗證:** OC-01~OC-08;RG 抽樣。
+
+### M2 — 每週健檢+資產複利 P0
+
+- **目標:** WeeklyCheckup 自動/手動產;不扣點;3 actions+deeplink;Persona/Brand learning 可見。
+- **完成定義:**
+ - Job `weekly_checkup`:會員 timezone 週一(無則 UTC);findings 強制 evidence;actions 長度=3。
+ - generateNow:7 日滾動 ≤2 次。
+ - AI 呼叫**不**寫 Usage 扣點/meter。
+ - 成功 → Notification;FE 報告頁/今日入口+行動 deep-link。
+ - Persona/Brand get/list 含 learning_*;空態明確。
+- **建議 task 區間:** **T330–T349**
+- **驗證:** CU-01~CU-05、AL-01~AL-02;用量回歸「健檢無消耗事件」。
+
+### M3 — 帳號健康分 P1
+
+- **目標:** 每帳號 0–100;70/40 門檻;throttle 擋自動送、手動路徑放行。
+- **完成定義:**
+ - `account_health_recompute` 日批+發送事件觸發。
+ - 訊號:24h 發送密度、間隔違規、7 日失敗率、token 狀態;無操作不扣。
+ - ThreadPlay submit/sendOutreach 自動送/compose→Outbox:warn 帶 `health_warning`;throttle **拒絕**。
+ - 標記完成/複製路徑在 throttle 下仍可用。
+ - FE:Crew Badge、Today 警示、送出前錯誤可讀。
+- **建議 task 區間:** **T350–T364**
+- **驗證:** AH-01~AH-05;OB/SC 自動送回歸。
+
+### M4 — 邀請獎勵 P1
+
+- **目標:** 直邀首次 free→starter|pro 入點;月上限 50%;邀請頁可見。
+- **完成定義:**
+ - Plan 生效 hook → InviteReward pending→credited|capped|skipped。
+ - 額度 Starter +100、Pro +300;僅單層、僅首次。
+ - 點數入邀請人平台額度;報表「贈點入帳」與消耗分開。
+ - getMyNetwork 擴充 rewards_summary/list。
+ - FE Invite 頁累計與列表。
+- **建議 task 區間:** **T365–T379**
+- **驗證:** IR-01~IR-06;邀請 apply 一次綁定回歸。
+
+### M5 — Workspace+審核+白牌月報 P1
+
+- **目標:** 同租戶多 Workspace;資源歸屬;自審閘;PDF 月報。
+- **完成定義:**
+ - Workspace CRUD/switch/archive(至少留 1);預設 workspace 遷移既有 brand/persona/account。
+ - 建立資源寫 `workspace_id`;列表預設當前區。
+ - `review_required`:未 approved 擋自動送;同一 Member 自審即可。
+ - exportPdf(workspace, year_month) 可下載;含成果/發送/海巡/健康摘要結構。
+ - 用量/方案/邀請不隨 WS 切分。
+ - FE:殼層切換器、審核狀態、匯出入口。
+- **建議 task 區間:** **T380–T399**
+- **驗證:** WS-01~WS-06;RG 用量不變。
+
+### M6 — 整合、回歸、文件收斂
+
+- **目標:** P0+P1 驗收矩陣;FE↔BE smoke;已知缺口列表(P2 不實作)。
+- **完成定義:**
+ - Integration tests 覆蓋 OC/CU/AL/AH/IR/WS 主路徑(外部 Threads/AI fake)。
+ - FE live 煙測腳本或手測清單勾選。
+ - README phase → `ready-to-implement` 僅在 tasks 批准後;本 M 結束 = 實作完成定義。
+ - 明確列出 P2 未做項與 Threads 訊號降級現況。
+- **建議 task 區間:** **T400–T409**
+- **驗證:** spec §9 全表;haixun-backend 關鍵回歸抽樣。
+
+## 3. 依賴
+
+```text
+M0 領域殼
+ └─► M1 成果歸因 P0 ──┐
+ └─► M2 健檢+資產 P0 ──┼─► M6 整合收斂
+ └─► M3 健康分 P1 ──┤
+ └─► M4 邀請獎勵 ┤
+ └─► M5 Workspace ─┘
+```
+
+| 關係 | 說明 |
+|------|------|
+| M1 → M2 | 健檢輸入含 Outcome 摘要;可弱依賴(無 Outcome 仍可產報告)但建議 M1 先綠 |
+| M1 → M5 PDF | 月報成果區讀 Outcome summary |
+| M3 → M5 審核送出 | 自動送同時受 health + review 雙閘(AND) |
+| M4 獨立於 M3 | 可與 M3 後期平行,但 task 號段仍建議 M3 後以降低衝突 |
+| 硬性順序 | **不可**先做 M5 代操而跳過 M1/M2(requirements §6 #7) |
+
+## 4. 風險與緩解
+
+| 風險 | 緩解 |
+|------|------|
+| Threads 歸因訊號全無 | M1 先交付 OC-08 手動+觸達;observe Job 訊號表可空實作跳過 |
+| 健檢品質不穩/無 evidence | 入庫校驗:無 evidence 的 finding 丟棄;不夠 1 條 finding 則 failed 可重試 |
+| 健檢誤扣點 | CU-03 自動化斷言;meter 白名單不含 checkup |
+| 健康分誤傷正常用戶 | 先 log factors 一週可調權重;門檻 40/70 不改;手動路徑永遠可用 |
+| 邀請刷點 | 僅首次付費、月上限 50%、單層;M4 加簡單同裝置/同支付指紋可選(非必須) |
+| Workspace 遷移弄壞既有資料 | M5 預設 WS + 一次性 backfill;archive 軟刪;測試用副本 |
+| 範圍膨脹塞 P2 | M6 只列 P2 缺口不實作;市集/UTM/benchmark 不進 task |
+| 與 haixun-backend 回歸衝突 | 每 M 跑 RG + 既有 `go test`/web build |
+
+## 5. 刻意不做(本 plan 週期內)
+
+- Insights **真管線**、Islander agent
+- Playbook 市集、免費破冰工具、跨帳號 benchmark、UTM 自動歸因(P2)
+- 多租戶帳單、白牌域名
+- 多層/現金邀請回饋;邀請回饋回溯
+- 新增多帳互回/養帳玩法
+- Admin 覆蓋健康分、Admin 全站 Outcome 分析
+- Workspace 多成員協作審核(P1 僅自審)
+- 真實金流改造(沿用既有 purchase 語意觸發 IR 即可)
+
+## 6. 全域驗證指令
+
+```bash
+# 後端
+cd apps/backend && go test ./... -count=1
+
+# 前端
+cd apps/web && npm run build
+
+# 建議(若有 make 目標則用專案既有)
+# make test && make web-build
+```
+
+里程碑級:
+
+| M | 額外驗證 |
+|---|----------|
+| M1 | `go test` outcomes/scout hook;手測 Today+Usage 成果區 |
+| M2 | `go test` checkup **無** usage debit;手測報告 3 actions |
+| M3 | throttle 帳號自動送 4xx/業務錯;手動標記成功 |
+| M4 | 雙帳號 invite→upgrade 點數;第二次升級不發 |
+| M5 | 兩 WS 資料隔離;PDF 可開 |
+| M6 | spec §9 矩陣勾選 |
+
+## 7. Task 號段總表(供拆 task 用)
+
+| 區間 | Milestone | 內容 |
+|------|-----------|------|
+| T300–T309 | M0 | api 殼、index、FE types |
+| T310–T329 | M1 | Outcome 全链路 + FE |
+| T330–T349 | M2 | Checkup + Asset learning + FE |
+| T350–T364 | M3 | AccountHealth + 送出閘 + FE |
+| T365–T379 | M4 | InviteReward + FE |
+| T380–T399 | M5 | Workspace + review + PDF + FE |
+| T400–T409 | M6 | 整合測試、smoke、缺口文件 |
+
+單一 task 目標 **~100–200 行** 生產碼變更;超大則再拆。拆 task 時每則必須:Goal、Inputs、Outputs、Out of scope、Acceptance(指令)、Depends on、對應 spec ID。
+
+## 8. 批准
+
+- [x] Plan 已批准(2026-07-20/使用者「plan 通過」)
diff --git a/docs/product/growth-loop/requirements.md b/docs/product/growth-loop/requirements.md
index 8221b99..0125e09 100644
--- a/docs/product/growth-loop/requirements.md
+++ b/docs/product/growth-loop/requirements.md
@@ -1,9 +1,11 @@
# Requirements: 成長迴路(從「好用」到「非用不可」)
-> Status: `draft`(待使用者批准)
+> Status: `approved`
+> Status note: 使用者 2026-07-20「需求 OK」
> Slug: `growth-loop`
> Last updated: `2026-07-20`
-> 輸入真相:2026-07-20 PM 體檢結論;`apps/web` 現況 UI;`docs/product/haixun-backend/`(已交付能力);git `main` 分支舊願景文件(`ai_threads_auto_account_ops.md`)
+> 輸入真相:2026-07-20 PM 體檢結論;`apps/web` 現況 UI;`docs/product/haixun-backend/`(已交付能力);git `main` 分支舊願景文件(`ai_threads_auto_account_ops.md`)
+> 修訂:§6 開放問題改為已鎖定決策(PM 預設)
## 1. 一句話
@@ -46,11 +48,12 @@
### 4.1 必須達成(P0)— 價值可見 + 學習閉環最小版
1. **成果歸因(海巡 ROI 優先)**
- - 外展回覆送出後,追蹤可合理取得的訊號:對方回應、帳號追蹤數變化、貼文互動、自帶 UTM 連結點擊;成交先走**手動回報**。
+ - 外展回覆送出後,追蹤可合理取得的訊號(見 §6 決策 #1):對方回應、帳號追蹤數變化、貼文互動、自帶 UTM 連結點擊(P2);成交 **P0 手動回報**。
- 每筆成果可回溯到具體回覆/具體貼文(歸因鏈)。
- **今日頁與用量頁改為「成本+成果」並列**:本週/本月觸達、對話、追蹤、成交摘要卡。
2. **每週帳號健檢+下週行動建議(學習閉環 MVP)**
- 基於**既有已同步貼文成效**(不重做 Insights 真管線),每會員每週自動產出一份 AI 報告:哪類題材/開頭/CTA 有效(**必須引用證據、可解釋**)+下週 **3 個具體行動**,且每個行動可一鍵跳到對應功能(找話題/海巡/寫一則)。
+ - **方案內含、不扣點**(見 §6 決策 #2)。
- 這是舊願景 MVP4 的最小版,**不是**完整 Insights 管線。
3. **資產複利可見**
- 呈現「系統從你的 N 篇貼文/M 次回饋學到什麼」:人設/品牌知識的學習摘要與版本,讓「越用越準」被感知,構成轉換成本。
@@ -58,15 +61,17 @@
### 4.2 應該有(P1)— 客群擴張 + 風險護欄
1. **代操/Agency 模式**
- - 多客戶工作區:每客戶獨立品牌+人設+帳號+方案視圖,工作區可切換、資料隔離。
+ - **同租戶內多工作區(Workspace)**,不重做多租戶帳單(見 §6 決策 #3)。
+ - 每工作區獨立品牌+人設+帳號歸屬;工作區可切換、資料隔離。
- 草稿審核流:草稿 → 待審 → 已准/退回,已准才進 Outbox。
- 白牌月報 PDF 匯出(不含白牌域名)。
2. **帳號健康分(灰帽護欄)**
- - 依操作密度/間隔/失敗率/平台訊號,給每個已連帳號 0–100 健康分與降速建議。
+ - 依操作密度/間隔/失敗率/token 狀態,給每個已連帳號 0–100 健康分與降速建議(訊號與門檻見 §6 決策 #5)。
- ThreadPlay/海巡送出**前**自動檢查;過載時降速並向用戶說明原因;今日頁可見警示。
- **只做護欄,不新增更激進的多帳玩法。**
3. **邀請獎勵落地(單層紅線)**
- - 直邀成員訂閱付費 → 邀請人得**產品點數**回饋(**單層、點數、非現金**,避免 MLM 觀感;文案維持「邀請人/直邀/延伸」)。
+ - 直邀成員**首次升級到付費方案** → 邀請人得**產品點數**回饋(比例與上限見 §6 決策 #4)。
+ - **單層、點數、非現金**;文案維持「邀請人/直邀/延伸」。
- 邀請頁可見累計回饋。
### 4.3 以後再說(P2)
@@ -74,15 +79,17 @@
1. **利基 Playbook 市集**:分享/引用海巡 brief、人設風格、互回劇本模板(保養/母嬰/3C…),需可匿名化。
2. **免費破冰工具**:公開免登入「風格指紋測驗」「痛點關鍵字產生器」,結果頁導註冊(用產品自己在 Threads 擴散)。
3. **跨帳號基準(benchmark)**:匿名聚合「同利基帳號中位數」比較。
-4. 既有 P2 維持不變:Insights 真數據管線完整版、真實金流/發票。
+4. **UTM 落地頁自動歸因**(P0 成交仍手動)。
+5. 既有 P2 維持不變:Insights 真數據管線完整版、真實金流/發票。
## 5. 範圍
### In scope
- 上述 P0/P1 功能的**產品行為定義**;前端 `apps/web` 與後端 `apps/backend` 皆會異動(經 spec → plan → tasks 才動碼)。
-- 歸因以「**可合理取得的訊號**」為限:Threads API 可讀的公開計數、自帶 UTM 連結、手動回報。
-- 每週健檢**重用既有同步貼文成效資料**,不新建 Insights 管線。
+- 歸因以「**可合理取得的訊號**」為限:Threads API 可讀的公開計數、自帶 UTM 連結(P2)、手動回報。
+- 每週健檢**重用既有同步貼文成效資料**,不新建 Insights 管線。
+- 代操=同租戶 Workspace,非多租戶改造。
### Out of scope(明確不做)
@@ -91,26 +98,41 @@
- 不串自動成交/真金流(P2 外)。
- 不重做既有發文/海巡主流程——本 run 是**加值層**,不是重構。
- 不新增灰帽玩法;多帳互回只加護欄。
-- 代操模式 P1 不做白牌域名、不做多租戶帳單。
+- 代操模式 P1 不做白牌域名、不做多租戶帳單、不做跨租戶資料遷移。
+- 健檢報告**不扣平台點數**(方案內含)。
-## 6. 使用者故事(可驗收)
+## 6. 已鎖定決策(2026-07-20)
+
+| # | 議題 | 決策 |
+|---|------|------|
+| 1 | 歸因訊號與降級 | **P0 必做訊號:** (a) 外展回覆是否被對方再回/引用(對話);(b) 送出帳號追蹤數變化(在合理時間窗內觀測,無法 100% 歸因時標「可能相關」);(c) 目標貼文/己方回覆的互動計數變化(若 API 可讀);(d) **成交=手動回報**(金額選填、備註選填)。**P2:** UTM 連結點擊自動歸因。**降級:** API 拿不到某訊號時仍交付其餘訊號+手動回報,**禁止**因缺訊號整段不做。具體 API 欄位對照表 → **spec 列出並驗證**。 |
+| 2 | 每週健檢計費 | **方案內含,不扣點、不佔 AI 文案 meter。** 健檢是留存工具,不是變現點。產出失敗可重試,仍不計費。 |
+| 3 | 代操工作區模型 | **同租戶內多 Workspace**,不引入多租戶帳單/多租戶隔離改造。每個會員預設 1 個 Workspace;可建多個。Workspace 擁有:品牌、人設、Threads 帳號歸屬、草稿審核設定。用量/方案仍綁**會員**(非 Workspace)。Admin 可跨 Workspace 檢視。 |
+| 4 | 邀請點數回饋 | **觸發:** 直邀成員**首次**從 free 升級到付費方案(Starter 或 Pro)時發一次,**不**按月續訂再發。**額度:** 直邀升 Starter → 邀請人 **+100 點**;升 Pro → **+300 點**。**上限:** 邀請人每月透過邀請獲得的點數 ≤ 其當月方案額度的 **50%**(超出部分當月不發、不遞延)。**僅單層**(延伸不發)。點數入平台額度池,BYOK 無關。 |
+| 5 | 健康分規則 | **分數:** 每帳號 0–100,**每日至少重算一次**(有發送事件時可即時重算)。**訊號(權重方向,細節 spec):** 24h 發送次數過高↓、最小間隔違規次數↓、Outbox 近 7 日失敗率↓、token 過期/錯誤↓、連續多日無操作不扣分。**門檻:** ≥70 正常;40–69 警告(送出前提示、建議加倍間隔);**<40 強制降速**(禁止新建 ThreadPlay 送出、海巡自動送出改為僅允許「複製→手動標記」路徑,直到分數回升)。今日頁顯示最低分帳號警示。 |
+| 6 | 成交歸因 | **P0 手動回報**(從成果卡/海巡命中詳情填)。**P2** 才做 UTM 落地頁自動歸因。手動回報可事後改/刪(需留 audit 精神,spec 定)。 |
+| 7 | 交付順序 | **硬性:** P0(歸因+健檢+資產可見)→ P1(健康分 → 邀請獎勵 → 代操)→ P2。P1 內健康分優先於代操(護城河/存亡 > 客群擴張)。 |
+| 8 | 健檢產出節奏 | 每會員每週 **1** 份;週一 UTC 00:00 起可產(或會員時區週一,spec 定一種並寫死)。可手動「提前重產」但每 7 日最多 2 次(防刷)。 |
+| 9 | 人工路徑 | 所有自動發送能力必須保留「複製文案 → 開 Threads → 標記完成」;健康分 <40 時**只留**此路徑給敏感操作。 |
+
+## 7. 使用者故事(可驗收)
### P0
| ID | 作為… | 我想要… | 以便… | 驗收要點 |
|----|--------|---------|--------|----------|
-| US-01 | 品牌島民 | 看到本週海巡帶來的觸達/對話/追蹤/成交 | 判斷值不值得續費 | 今日頁+用量頁可見成果卡;每筆成果可回溯到具體回覆 |
-| US-02 | 創作者 | 每週收到健檢報告+3 個下週行動 | 知道該做什麼 | 報告含證據引用;行動可一鍵跳到對應功能 |
+| US-01 | 品牌島民 | 看到本週海巡帶來的觸達/對話/追蹤/成交 | 判斷值不值得續費 | 今日頁+用量頁可見成果卡;每筆成果可回溯到具體回覆;成交可手動回報 |
+| US-02 | 創作者 | 每週收到健檢報告+3 個下週行動 | 知道該做什麼 | 報告含證據引用;行動可一鍵跳功能;**不扣點** |
| US-03 | 島民 | 看到系統從我的貼文/回饋學到的東西 | 感到越用越準 | 資產頁可見學習摘要與版本號 |
### P1
| ID | 作為… | 我想要… | 以便… | 驗收要點 |
|----|--------|---------|--------|----------|
-| US-10 | 代操 | 切換客戶工作區,各管各的品牌/人設/帳號 | 一套工具服務多客戶 | 工作區資料隔離;月報可匯出白牌 PDF |
-| US-11 | 代操 | 客戶審核草稿後才進 Outbox | 交稿流程可控 | 草稿有 待審/已准/退回 狀態;未准不可送出 |
-| US-12 | 島民 | 看到每個帳號的健康分與降速建議 | 不怕被平台懲罰 | 送出前自動檢查;過載自動降速並說明原因 |
-| US-13 | 島民 | 直邀付費後我得到點數回饋 | 願意主動推廣 | 單層回饋端到端成立;邀請頁可見累計 |
+| US-10 | 代操 | 在同帳號下切換 Workspace,各管各的品牌/人設/帳號 | 一套工具服務多客戶 | ≥2 Workspace 隔離;月報可匯出白牌 PDF;方案仍綁會員 |
+| US-11 | 代操 | 客戶審核草稿後才進 Outbox | 交稿流程可控 | 草稿有 待審/已准/退回;未准不可送出 |
+| US-12 | 島民 | 看到每個帳號的健康分與降速建議 | 不怕被平台懲罰 | 送出前自動檢查;<40 強制降速並說明;今日頁警示 |
+| US-13 | 島民 | 直邀首次付費後我得到點數 | 願意主動推廣 | Starter+100/Pro+300;月上限 50% 方案額度;邀請頁可見累計 |
### P2
@@ -119,54 +141,51 @@
| US-20 | 島民 | 引用別人的利基 playbook 模板 | 快速上手新利基 | 市集可瀏覽/引用;作者可匿名 |
| US-21 | 訪客 | 免登入玩風格指紋測驗 | 認識產品 | 結果頁導註冊;可在 Threads 分享 |
| US-22 | 島民 | 看我跟同利基帳號中位數的比較 | 知道自己程度 | 匿名聚合;樣本不足時不顯示 |
+| US-23 | 品牌島民 | 回覆裡的 UTM 連結自動算點擊成果 | 少手動填 | 點擊進歸因鏈;與手動成交可並存 |
-## 7. 成功標準
+## 8. 成功標準
-- [ ] **P0:** 至少一條「海巡外展 → 送出 → 成果(對方回應或追蹤變化)」完整歸因鏈可展示。
-- [ ] **P0:** 每週健檢報告自動產出,含 ≥3 條**帶證據**的行動建議,且可一鍵跳功能。
+- [ ] **P0:** 至少一條「海巡外展 → 送出 → 成果(對方回應或追蹤變化或手動成交)」完整歸因鏈可展示。
+- [ ] **P0:** 每週健檢報告自動產出,含 ≥3 條**帶證據**的行動建議,可一鍵跳功能,**且不扣點**。
- [ ] **P0:** 用量頁從純成本視角改為成本+成果並列。
-- [ ] **P1:** 代操可在 2 個工作區間切換,各自產出月報 PDF;草稿未經審核不可進 Outbox。
-- [ ] **P1:** 健康分對每個已連帳號可見;高密度操作被自動降速並有說明。
-- [ ] **P1:** 單層邀請點數回饋端到端成立(訂閱 → 點數入邀請人帳)。
+- [ ] **P0:** 人設/品牌資產頁可見學習摘要與版本。
+- [ ] **P1:** 健康分對每個已連帳號可見;<40 時敏感自動送出被擋、僅人工路徑。
+- [ ] **P1:** 直邀首次升 Starter/Pro 分別入帳 100/300 點;月上限生效。
+- [ ] **P1:** 代操可在 2 個 Workspace 間切換,各自產出月報 PDF;草稿未經審核不可進 Outbox。
- [ ] **定性:** 用戶訪談能說出「這工具幫我賺/省了多少」的**價值句**,而非只描述功能。
-## 8. 約束與假設
+## 9. 約束與假設
- **約束:**
- `AGENTS.md` 全部契約精神(UTC nano、envelope、分頁、guarded job、go-zero、etc 單份、數字 uid、權限中介層)。
- 前端 `apps/web`:不引入新 UI 框架;mock|live 同一 repository 介面;TC 台灣語感。
- - **平台政策紅線:** 所有自動化必須保留人工路徑(延伸現有「複製 → 開 Threads 留言 → 標記完成」模式);灰帽功能只加護欄不擴大。
- - 與 `haixun-backend` 的關係:本 run 是**加值層**,其 spec 已交付能力為底座;「Insights 真管線維持 P2」決策不變。
-- **假設(spec 階段須驗證):**
- - Threads API 可取得追蹤數/回覆計數等歸因訊號(權限項待查);拿不到時降級為互動歸因+手動回報。
+ - **平台政策紅線:** 所有自動化必須保留人工路徑;灰帽功能只加護欄不擴大(§6 #5、#9)。
+ - 與 `haixun-backend` 的關係:本 run 是**加值層**;「Insights 真管線維持 P2」決策不變。
+ - MLM 紅線:單層、點數、非現金(§6 #4)。
+- **假設:**
+ - Threads API 至少能支撐「對話/互動計數」之一;全拿不到時仍以手動成交+標記完成支撐歸因 MVP。
- 既有同步貼文成效資料足以支撐每週健檢最小版。
- - 成交回報 P0 先手動,用戶願意填。
+ - 用戶願意手動回報成交(P0);不願意時成果卡仍顯示對話/追蹤。
-## 9. 風險
+## 10. 風險
| 風險 | 影響 | 緩解(需求層) |
|------|------|----------------|
-| 歸因訊號拿不到(API 權限限制) | P0 價值證明變弱 | spec 先驗證權限;降級為對話/互動歸因+手動成交回報,仍優於現況 |
-| Meta 平台政策收緊 | 存亡級 | 健康分+人工路徑+功能開關;不新增灰帽玩法;健康分本身即緩解 |
-| 學習閉環品質不穩、報告不可信 | 核心賣點崩壞 | 強制證據引用;先小範圍給既有用戶試用再全面推 |
-| 邀請獎勵被觀感為 MLM | 品牌風險 | 單層、點數非現金、既有「邀請人/直邀/延伸」用語不變 |
-| 代操模式範圍爆炸 | 工期失控 | P1 只做工作區隔離+審核流+PDF;白牌域名/多租戶帳單明確排除 |
-| P0 被 P1/P2 稀釋 | 本 run 失去存在理由 | 交付順序硬性:歸因+健檢先行,其餘往後排 |
+| 歸因訊號拿不到(API 權限限制) | P0 價值證明變弱 | §6 #1 降級路徑;禁止因缺訊號整段砍掉 |
+| Meta 平台政策收緊 | 存亡級 | 健康分+人工路徑+功能開關;不新增灰帽玩法 |
+| 學習閉環品質不穩、報告不可信 | 核心賣點崩壞 | 強制證據引用;每 7 日最多重產 2 次防刷 |
+| 邀請獎勵被觀感為 MLM | 品牌風險 | 單層、點數非現金、首次升級才發、月上限 50% |
+| 代操模式範圍爆炸 | 工期失控 | 同租戶 Workspace only;不做白牌域名/多租戶帳單 |
+| P0 被 P1/P2 稀釋 | 本 run 失去存在理由 | §6 #7 交付順序硬性 |
+| 邀請刷點(自建小號升級) | 成本黑洞 | 月上限 50%;僅首次付費;後續可加風控(spec) |
-## 10. 開放問題
+## 11. 開放問題(剩餘,僅技術驗證)
-1. 歸因訊號清單與 Threads API 權限驗證 → **spec 階段列出並定案**。
-2. 每週健檢計費:建議**方案內含**(它是留存工具,不是變現點)→ 待批准。
-3. 代操工作區與現有單租戶/島民模型的關係(是否走向多 workspace)→ **spec 決策**。
-4. 邀請點數回饋的比例與每月上限 → plan 前定。
-5. 健康分的訊號來源、計算頻率與降速門檻 → spec 定。
-6. 成果卡的「成交」是否未來接 UTM 落地頁(P2)→ 現在先手動。
+1. Threads API 實際可讀欄位對照表(追蹤數、回覆串、insights)→ **spec 驗證後寫死;缺的走 §6 #1 降級**。
+2. 健檢「週一」用 UTC 還是會員時區 → **spec 選一種寫死**(建議會員 `timezone`,無則 UTC)。
+3. Workspace 與現有 brand/persona/threads_account 資料表歸屬欄位命名 → **spec/plan**。
+4. 健康分權重精確公式 → **spec**(需求只鎖訊號方向與門檻帶)。
-## 11. 批准
+## 12. 批准
-- [ ] 需求已批准(日期/誰):
-
----
-
-**給批准者:** 若整體方向 OK 請回「**需求 OK**」→ 下一回合寫 `spec.md`(歸因狀態機、健檢報告契約、健康分行為、工作區模型、Retain-Replace-Remove)。
-若只想先做 P0 兩項(強烈建議),也請指明,其餘維持文件紀錄即可。
+- [x] 需求已批准(2026-07-20/使用者「需求 OK」)
diff --git a/docs/product/growth-loop/spec.md b/docs/product/growth-loop/spec.md
new file mode 100644
index 0000000..e0e768c
--- /dev/null
+++ b/docs/product/growth-loop/spec.md
@@ -0,0 +1,379 @@
+# Spec: 成長迴路(成果歸因 · 學習閉環 · 健康分 · 邀請獎勵 · Workspace)
+
+> Status: `approved`
+> Status note: 使用者 2026-07-20「spec 通過」
+> Source: `docs/product/growth-loop/requirements.md`(**approved** 2026-07-20「需求 OK」)
+> Last updated: `2026-07-20`
+> 底座:`docs/product/haixun-backend/spec.md`(已交付能力;本 run 為**加值層**)
+> 決策對齊:requirements §6 #1–#9
+
+## 1. 摘要
+
+本規格定義巡樓從「工具組合包」升級為「能證明 ROI、越用越準」時的**對外行為契約**。
+**P0:** 海巡成果歸因鏈、今日/用量「成本+成果」並列、每週帳號健檢(方案內含不扣點)、人設/品牌資產複利可見。
+**P1:** 帳號健康分與強制降速、邀請首次付費點數回饋(單層)、同租戶多 Workspace+草稿審核+白牌月報 PDF。
+**P2(本 spec 只鎖邊界,不細寫 API):** Playbook 市集、免費破冰工具、跨帳號 benchmark、UTM 自動歸因。
+**不做:** Insights 真管線重做、多租戶帳單、多層/現金邀請回饋、新增灰帽玩法。
+
+## 2. 名詞與實體
+
+| 名詞 | 定義 |
+|------|------|
+| **OutcomeEvent** | 一筆可歸因成果:觸發源(海巡外展/Outbox 步驟)+訊號類型+可選手動成交 |
+| **AttributionLink** | OutcomeEvent → 來源實體(`scout_post_id` 和/或 `outbox_step_id`)+送出帳號 id;可回溯 |
+| **OutcomeSummary** | 會員在時間窗(本週/本月)的彙總:觸達、對話、追蹤、成交筆數/金額 |
+| **WeeklyCheckup** | 每會員每週一份 AI 健檢報告:證據引用+恰好 3 個下週行動(含 deep-link) |
+| **AssetLearning** | 人設/品牌的「學到什麼」摘要+版本號(可見複利) |
+| **AccountHealth** | 每個 ThreadsAccount 的 0–100 分、等級、降速狀態、建議文案 |
+| **InviteReward** | 直邀首次付費觸發的點數入帳紀錄(單層) |
+| **Workspace** | 同租戶內工作區;擁有 brand/persona/threads_account 歸屬與審核設定 |
+| **DraftReview** | 草稿審核狀態:`none`|`pending`|`approved`|`rejected`(僅審核開啟的 Workspace) |
+| **Manual path** | 「複製文案 → 開 Threads → 標記完成」;健康分 <40 時敏感操作**只允許**此路徑 |
+
+**沿用底座(不重定義):** Member、ThreadsAccount、ScoutPost(`outreach_status`)、OutboxBundle/Step、Persona、Brand、Usage/Plan、Invite(`parent_uid`/`invite_code`)、Job、Notification。
+
+## 3. 狀態機
+
+### 3.1 OutcomeEvent(歸因)
+
+```text
+(source published | manual_mark_done)
+ --> observing : 進入觀測窗(預設送出後 72h)
+observing --> confirmed : 觀測到對話/互動訊號,或手動回報成交
+observing --> possible : 僅追蹤數變化等弱訊號(標 confidence=possible)
+observing --> expired : 窗結束且無訊號(可不展示於成果卡明細,計入觸達即可)
+confirmed|possible --> amended : 會員改/刪手動成交(留 audit)
+```
+
+- **觸發源必須已「真送出或標記完成」**:`ScoutPost.outreach_status ∈ {published, …標記完成語意}` 或 Outbox step `published`。
+- **禁止**對 draft/new/skipped 建立 OutcomeEvent。
+- `confidence`:`confirmed`|`possible`(弱歸因必須標 possible,UI 不可寫成確定成交)。
+
+### 3.2 WeeklyCheckup
+
+```text
+(none) --> generating : 排程到點或手動重產
+generating --> ready : AI 成功寫入報告
+generating --> failed : 可重試;仍不扣點
+ready --> superseded : 同週再次成功重產(舊份保留可查,列表預設最新)
+```
+
+- 每會員每自然週(見 §4.2)**至多 1 份「當期」**;7 日內手動重產 **≤2 次**。
+- **永不**寫入 UsageEvent 扣點/meter。
+
+### 3.3 AccountHealth 等級
+
+```text
+score >= 70 --> normal : 不干預
+40 <= score < 70 --> warn : 送出前提示;建議加倍間隔
+score < 40 --> throttle : 強制降速(§4.4)
+```
+
+- 每日至少重算一次;有發送/失敗事件可即時重算。
+- `throttle` 解除:分數回升 ≥40 後下一輪重算生效(無人工解鎖按鈕需求;admin 可不擋額度類覆蓋——**本 run 不做** admin 覆蓋健康分,避免繞過護欄)。
+
+### 3.4 InviteReward
+
+```text
+(直邀 parent_uid = 邀請人) + (直邀首次 plan: free → starter|pro 且付款成功語意)
+ --> pending_credit
+pending_credit --> credited : 點數入邀請人平台額度;未超月上限
+pending_credit --> capped : 超過邀請人當月上限;本筆不發、不遞延
+pending_credit --> skipped : 非首次/非直邀/延伸/已發過
+```
+
+- **僅單層**;`延伸` 永不觸發。
+- 額度:Starter **+100**;Pro **+300**(requirements §6 #4)。
+- 月上限:邀請人當月透過邀請獲得點數 ≤ 其**當月方案額度 × 50%**。
+
+### 3.5 Workspace
+
+```text
+member 預設 1 個 default workspace
+--> 可 create 更多
+workspace active 可 archive(軟刪;至少保留 1 個不可 archive)
+```
+
+- 方案/用量/邀請碼仍綁 **Member**,不綁 Workspace。
+
+### 3.6 DraftReview(Workspace 開啟審核時)
+
+```text
+draft --> pending : 提交審核
+pending --> approved : 核准 → 才可進 Outbox/sendOutreach 自動路徑
+pending --> rejected : 退回(含理由字串)
+rejected --> pending : 修改後再提
+approved --> (送出流程沿用 Outbox/Scout)
+```
+
+- Workspace `review_required=false`(預設):行為與現況相同,無 pending 閘。
+- `review_required=true`:未 `approved` **禁止**自動送出;手動標記完成路徑是否放行 → **允許**(代操可先人工貼再標記),但 UI 需標示「未審核僅手動」。
+
+## 4. 使用者流程(行為)
+
+### 4.1 成果歸因(P0)
+
+1. 島民完成海巡外展:**sendOutreach 真送** 或 **標記完成(手動路徑)**。
+2. 系統建立 OutcomeEvent(`source_type=scout_outreach`,連 `scout_post_id`、送出 `threads_account_id`),狀態 `observing`,`window_ends_at = sent_at + 72h`。
+3. Worker/排程在窗內拉取可讀訊號(§5.4):
+ - **對話**:目標串上出現對己方回覆的再回/引用(能判則 `confirmed`)。
+ - **互動**:己方回覆或目標貼的 likes/replies 計數上升(能讀則記;弱則 `possible`)。
+ - **追蹤**:送出帳號 `followers_count` 在窗內淨增加 → 預設 `possible`(不可宣稱「此回覆帶來追蹤」為確定,除非未來有更強訊號)。
+4. 島民可在命中詳情或成果明細 **手動回報成交**(金額選填、備註選填、幣別預設 TWD)→ `confirmed` + `kind=conversion`。
+5. **今日頁**:成果摘要卡(本週:對話數、possible/confirmed 追蹤、成交筆數/金額)。
+6. **用量頁**:成本區(既有 platform/BYOK)**與**成果區並列;不可只顯示成本。
+7. 明細列表:每筆可點回 Scout 命中或 Outbox 步驟。
+8. **降級:** 任一 API 訊號不可用 → 仍提供手動成交+「已送出/標記完成」計入觸達;**禁止**整段歸因 API 回未實作空成功。
+
+### 4.2 每週健檢(P0)
+
+1. **節奏:** 以會員 `timezone` 的**當地週一 00:00** 起可自動產(無 timezone → UTC);Job `weekly_checkup`。
+2. 輸入:該會員已同步貼文成效(既有 own posts/insights 本地資料)、近 14 日海巡送出與 Outcome 摘要、active 人設摘要。
+3. 輸出(`ready`):
+ - `summary` 短文(TC)
+ - `findings[]`:每條含 `claim` + `evidence[]`(貼文 id/permalink/指標,**至少 1 條 evidence**,無 evidence 的 finding 不得入庫)
+ - `actions[3]`:固定 3 個;每項含 `title`、`reason`、`deeplink`(枚舉:`today`|`scout`|`studio_compose`|`studio_inspire`|`crew_persona`|`outbox`)
+4. **計費:** 不扣 credits、不寫 platform meter、不寫 byok 次數。AI 呼叫可用平台 key;失敗可重試。
+5. 手動「提前重產」:7 日滾動窗內 ≤2 次;超過回明確錯誤。
+6. 完成 → 站內通知;今日頁可進最新報告。
+
+### 4.3 資產複利可見(P0)
+
+1. 人設詳情/列表:顯示 `learning_summary`(短)、`learning_version`(遞增整數)、`learned_from_posts_count`、`last_learned_at`。
+2. 品牌詳情:同上語意(來源可為海巡 homework/brief 累積)。
+3. 更新時機(最小):人設分析 Job 成功、健檢 `ready` 時可附帶刷新 summary(不強制每次分析都變 version;有實質變更才 +1)。
+4. 空狀態:尚未學習時明確文案,禁止假數據。
+
+### 4.4 帳號健康分(P1)
+
+1. 每個 `ThreadsAccount` 計算 `score`、`level`(normal|warn|throttle)、`factors[]`(可解釋扣分項)、`advice`。
+2. **訊號方向(權重數值 plan/實作可調,行為門檻鎖定):**
+ - 24h 發送次數過高 → 扣
+ - 最小間隔違規(連續 steps 間隔過短)→ 扣
+ - 近 7 日 Outbox 失敗率高 → 扣
+ - token 過期/error/不可用 → 重扣
+ - 多日無操作 → **不扣**
+3. **送出前檢查(自動路徑):** ThreadPlay submit、sendOutreach 自動送、compose 進 Outbox。
+ - `warn`:API 仍允許,但 response 帶 `health_warning`;前端必顯示。
+ - `throttle`:**拒絕**自動送出(明確錯誤碼);僅允許手動路徑(複製+標記完成)。
+4. 今日頁:顯示分數最低的帳號警示(若有 warn/throttle)。
+5. Crew 帳號列:分數 Badge(與既有 token 健康文案並存,不取代 token 狀態)。
+
+### 4.5 邀請獎勵(P1)
+
+1. 沿用既有邀請綁定(`parent_uid` 一次)。
+2. 當直邀會員**首次**完成 free→starter 或 free→pro 的方案生效(對齊既有 purchase/plan 成功語意):
+ - 解析邀請人 = `parent_uid`
+ - 計算應發點數與月上限 → `credited` 或 `capped`/`skipped`
+3. 點數進入邀請人**平台方案額度池**(與 BYOK 無關);寫 `InviteReward` 紀錄 + 可選 Usage 類「贈點」事件(`key_mode` 不適用;`meter=invite_bonus` 或等價,**不計入**付費消耗進度的「已用」誤解——報表分開「贈點入帳」)。
+4. 邀請頁:累計已獲點數、本月已獲/上限、最近回饋列表。
+5. Admin **不**手改回饋紀錄(防爭議);糾錯走既有調 `parent_uid`(調後**不回溯**已發點數)。
+
+### 4.6 Workspace 與審核(P1)
+
+1. 會員可 list/create/rename/switch/archive Workspace(至少留 1 個)。
+2. Brand、Persona、ThreadsAccount **歸屬** `workspace_id`;建立時寫入當前 Workspace。
+3. 切換 Workspace 後,列表 API 預設只回該區資源;「全部」視圖可選(若做,須明確 query)。
+4. Workspace 設定 `review_required`:開啟後,該區 plays/compose/outreach 進自動發送前須 `approved`。
+5. 審核操作:提交者 pending;審核者(同會員多席或同 Workspace 成員——**P1 最小:同一 Member 自審即可**;多成員協作 P2)approved/rejected。
+6. **白牌月報 PDF:** 選 Workspace+月份 → 匯出含成果摘要、發送數、海巡完成數、健康分概況;無巡樓品牌強制露出(可選 footer「Powered by…」開關預設關)。
+7. 用量/方案/邀請:**不**隨 Workspace 切分。
+
+### 4.7 P2 邊界(只鎖不做細節)
+
+| 能力 | 行為邊界 |
+|------|----------|
+| Playbook 市集 | 可分享 brief/人設風格/劇本模板;可匿名;引用後進自己 Workspace |
+| 免費破冰 | 免登入工具;結果導註冊;不做付費牆內容 |
+| Benchmark | 匿名聚合;樣本不足不顯示 |
+| UTM 歸因 | 點擊進 OutcomeEvent;與手動成交並存 |
+
+## 5. 介面契約
+
+### 5.1 共通
+
+- 沿用 `haixun-backend`:成功 `code=102000`;`page`/`pageSize` → `pagination`+`list`;時間 **unix ns UTC**;`uid` 數字。
+- Auth:Bearer;資源擁有者 = 登入 uid;Admin 另註。
+- **禁止**未實作回成功空資料。
+
+### 5.2 API 能力分組(路徑前綴建議 `/api/v1`;精確 path 由 `.api` 定)
+
+| 域 | 主要行為 | 波次 | 權限 |
+|----|----------|------|------|
+| **Outcomes** | `summary(from,to\|week\|month)`;`list`(filter kind/confidence/source);`get`;`reportConversion`;`updateConversion`;`deleteConversion` | P0 | member |
+| **Checkups** | `latest`;`list`;`get`;`generateNow`(受 7 日 2 次限) | P0 | member |
+| **Asset learning** | 併入既有 Persona/Brand `get`/`list` 回應欄位(§4.3);可選 `refreshLearning` | P0 | member |
+| **Account health** | `listByAccounts`;`get(accountId)`;送出類 API 附加 `health_warning` 或 throttle 錯誤 | P1 | member |
+| **Invite rewards** | 併入 `getMyNetwork` 擴充:`rewards_summary`、`rewards_list`;入帳由 plan 生效內部觸發 | P1 | member |
+| **Workspaces** | CRUD/switch/archive;`setReviewRequired`;資源 create 帶 `workspace_id` | P1 | member |
+| **Draft review** | `submit`/`approve`/`reject`/`listPending` | P1 | member |
+| **Monthly report** | `exportPdf(workspace_id, year_month)` → 檔案 URL 或 stream | P1 | member |
+
+### 5.3 前端頁面
+
+| 路由 | 變更 |
+|------|------|
+| `/app/today` | +成果摘要卡;+最新健檢入口;+健康分警示(P1) |
+| `/app/usage` | 成本|成果雙欄(或上下並列) |
+| `/app/scout` 命中詳情 | +歸因狀態;+手動回報成交 |
+| `/app/crew` 帳號 | +健康分 Badge(P1);token 狀態保留 |
+| `/app/crew` 人設 | +learning 摘要/版本 |
+| `/app/brands` | +learning 摘要/版本 |
+| 新或內嵌 `/app/checkup` | 健檢報告全文+3 行動 deep-link |
+| `/app/invite` | +回饋累計與列表(P1) |
+| Workspace 切換器(殼層) | P1;預設 Workspace 名稱可編 |
+| 月報匯出入口 | P1 在 Workspace 設定或用量/成果區 |
+
+### 5.4 Job / Worker
+
+| Template | 觸發 | 成功 |
+|----------|------|------|
+| `outcome_observe` | 定時/送出後入隊 | 更新 OutcomeEvent 訊號與 confidence |
+| `weekly_checkup` | 週排程/手動 generateNow | Checkup `ready`+通知;**無 Usage 扣點** |
+| `account_health_recompute` | 日排程/發送事件 | 更新 AccountHealth |
+| (既有)`outbox_publish_step` / `scout_scan` 等 | 不變 | 送出成功後**必須** hook 建立 observing Outcome(scout/outbox 源) |
+
+### 5.5 歸因訊號與 Threads API(驗證表)
+
+| 訊號 | 理想來源 | 不可用時 |
+|------|----------|----------|
+| 對話(再回/引用) | 回覆 conversation/replies API | 僅保留手動標記「有對話」可選(P0 可不做此手動,至少保留成交手動) |
+| 互動計數 | media insights / reply fields | 跳過該訊號 |
+| 追蹤淨增 | 使用者檔案 `followers_count` 快照 diff | 跳過 |
+| 成交 | **僅手動**(P0) | — |
+| UTM 點擊 | P2 | — |
+
+Spec 實作前:**用現有 OAuth scope 跑一次探測**;把「可用/不可用」寫進實作註記,但**不得**阻塞手動成交+觸達計數的交付。
+
+### 5.6 錯誤語意(新增 scope 建議)
+
+| 情境 | 行為 |
+|------|------|
+| 健檢重產超過 7 日 2 次 | 明確錯誤,非 102000 |
+| 健康分 throttle 擋自動送 | 明確錯誤;message 含分數與「請用手動路徑」 |
+| 審核未准就自動送 | 明確錯誤 |
+| 邀請回饋 capped | 邀請人側可查 `capped` 紀錄;被邀請人方案仍成功 |
+| PDF 匯出無資料月 | 仍可出「空月」報表或明確錯誤(二選一,實作固定一種) |
+
+## 6. 與現況對照(Retain / Replace / Remove)
+
+| 能力 | Retain | Replace | Remove | 備註 |
+|------|--------|---------|--------|------|
+| 海巡 outreach 狀態機/真送 | ✅ | 送出後 **+** Outcome observing | — | 加值 hook |
+| 今日頁待回/目標 | ✅ | **+**成果卡、健檢入口、健康警示 | — | |
+| 用量頁 platform/BYOK 分欄 | ✅ | **+**成果區並列 | 純成本唯一視角 | |
+| Insights **真管線** | — | — | **仍不做**(haixun-backend P2) | 健檢只用既有同步資料 |
+| Insights 頁 local/既有同步 | ✅ 可當健檢輸入 | — | 不冒充本 run 完成 Insights | |
+| 人設 8D/分析 Job | ✅ | 回應 **+** learning 欄位 | — | |
+| 邀請關係樹/apply | ✅ | **+**首次付費回饋 | 多層/現金 | |
+| Crew token 健康文案 | ✅ | **+**操作健康分(不同維度) | 混成一個「健康」 | 兩者並陳 |
+| 單租戶 Member 模型 | ✅ | **+**同租戶 Workspace | 多租戶帳單 | |
+| ThreadPlay/養帳玩法 | ✅ 既有 | 僅 **throttle 護欄** | 新增更激進玩法 | |
+| 手動「標記完成」路徑 | ✅ | throttle 時升為主路徑 | 刪除人工路徑 | |
+| 模擬/空成功 API | — | — | **禁止** | AGENTS.md |
+
+## 7. 非功能
+
+- **安全/權限:** Outcome/Checkup/Workspace 皆 owner_uid 隔離;PDF 僅己方 Workspace;Admin 不預設讀全站 Outcome(若做 admin 分析 → 另開需求)。
+- **隱私:** 健檢 evidence 不跨會員;benchmark(P2)僅匿名聚合。
+- **計費:** 健檢 AI **不扣點**;邀請贈點不計入「已消耗」誤導;BYOK 規則不變。
+- **可觀測:** Outcome observe Job 進度/錯誤;健檢 failed 原因;健康分重算日誌無 token 明文。
+- **效能:** Outcome 列表 pageSize≤50;observe 窗 72h;健康分重算 O(帳號數) 可接受日批。
+- **語系:** 健檢與成果文案預設繁中台灣語感。
+- **契約:** go-zero `.api` → generate;業務在 logic/module。
+
+## 8. 資料保存(邏輯層)
+
+| 實體 | 關鍵欄位 | 生命週期 |
+|------|----------|----------|
+| OutcomeEvent | id, owner_uid, source_type, source_id, threads_account_id, kind, confidence, metrics, conversion?, window_*, created_at | 送出後建→觀測→終態;成交可 amend |
+| OutcomeSnapshot(可選) | account_id, followers_count, taken_at | 觀測用快照 |
+| WeeklyCheckup | id, owner_uid, week_key, status, summary, findings[], actions[3], created_at | 週產;舊份可查 |
+| AssetLearning 欄位 | 掛 Persona/Brand:summary, version, from_count, last_learned_at | 隨分析/健檢更新 |
+| AccountHealth | threads_account_id, owner_uid, score, level, factors[], advice, computed_at | 日更/事件更 |
+| InviteReward | id, inviter_uid, invitee_uid, plan_id, points, status, created_at | 首次付費一次 |
+| Workspace | id, owner_uid, name, review_required, archived, is_default | CRUD |
+| 資源歸屬 | Brand/Persona/ThreadsAccount.workspace_id | 建立時寫入 |
+| DraftReview | ref_type, ref_id, workspace_id, status, reason?, actor_uid | 審核流 |
+| Report export | 暫存 URL 或短 TTL 物件 | 匯出後可過期 |
+
+## 9. 驗收場景(Given / When / Then)
+
+**共通:** 成功 envelope `102000`;時間 ns UTC;未實作≠空成功。
+
+### 9.1 P0 歸因
+
+| ID | Given | When | Then |
+|----|-------|------|------|
+| OC-01 | 海巡命中已 published 或標記完成 | 系統處理 | 存在 OutcomeEvent observing/終態;可 list 回溯 source |
+| OC-02 | observing 窗內出現可讀對話訊號 | observe Job | confidence=confirmed 或等價 kind=reply;summary 對話數+1 |
+| OC-03 | 僅 followers 淨增 | observe Job | confidence=possible;UI 不顯示為確定成交 |
+| OC-04 | 會員手動回報成交金額 1000 | reportConversion | kind=conversion confirmed;成果卡成交可見 |
+| OC-05 | 改/刪成交 | update/delete | 明細與 summary 一致;非 102000 空操作 |
+| OC-06 | 今日頁 | 開啟 | 可見本週成果摘要(可為 0,但區塊存在) |
+| OC-07 | 用量頁 | 開啟 | 成本區與成果區**同時**存在 |
+| OC-08 | API 全無社群訊號 | 僅手動成交+觸達 | 歸因域仍可用;不得整域 501 空成功 |
+
+### 9.2 P0 健檢與資產
+
+| ID | Given | When | Then |
+|----|-------|------|------|
+| CU-01 | 會員有 timezone=Asia/Taipei | 當地週一後排程 | weekly_checkup Job 產出 ready |
+| CU-02 | ready 報告 | get | findings 每條含 evidence;actions 長度=3 且 deeplink 合法 |
+| CU-03 | 產生健檢 | 查 UsageEvent | **無**因健檢產生的扣點/meter 消耗 |
+| CU-04 | 7 日內已手動重產 2 次 | generateNow 第 3 次 | 明確錯誤 |
+| CU-05 | Job 成功 | 通知 | 站內通知可點進報告 |
+| AL-01 | 人設分析或健檢刷新 learning | get persona | learning_summary 或明確空態;有變更則 version≥1 |
+| AL-02 | 新品牌無學習 | get brand | 空態文案,非假數字 |
+
+### 9.3 P1 健康分
+
+| ID | Given | When | Then |
+|----|-------|------|------|
+| AH-01 | 帳號 connected | get health | 0–100 + level |
+| AH-02 | score 40–69 | 自動送出 | 成功或進行中,但帶 health_warning |
+| AH-03 | score <40 | ThreadPlay submit 或 sendOutreach 自動送 | **拒絕**;message 指手動路徑 |
+| AH-04 | score <40 | 複製+標記完成 | **允許** |
+| AH-05 | 今日頁有 throttle 帳號 | 開啟 | 可見警示 |
+
+### 9.4 P1 邀請獎勵
+
+| ID | Given | When | Then |
+|----|-------|------|------|
+| IR-01 | A 邀請 B;B 首次 free→starter 成功 | 系統 | A 得 +100;InviteReward credited |
+| IR-02 | B 首次 free→pro | 系統 | A +300 |
+| IR-03 | B 再續訂/再升級 | 系統 | **不再**發點 |
+| IR-04 | A 本月邀請點已達方案額度 50% | 再觸發 | capped;B 方案仍成功 |
+| IR-05 | 僅延伸關係非直邀 | 付費 | skipped |
+| IR-06 | 邀請頁 | 開啟 | 可見累計與列表 |
+
+### 9.5 P1 Workspace/審核/PDF
+
+| ID | Given | When | Then |
+|----|-------|------|------|
+| WS-01 | 新會員 | me/list workspaces | ≥1 default |
+| WS-02 | 建 WS-B 並切換 | 建 brand | brand.workspace_id=WS-B;WS-A 列表不可見 |
+| WS-03 | review_required=true | 未審核自動送 | 拒絕 |
+| WS-04 | pending → approved | 再自動送 | 允許(仍受健康分約束) |
+| WS-05 | exportPdf | 請求 | 得 PDF(或 URL);含成果/發送摘要結構 |
+| WS-06 | 用量/方案 | 切 WS | **不變**(仍綁會員) |
+
+### 9.6 回歸(底座不可破壞)
+
+| ID | Given | When | Then |
+|----|-------|------|------|
+| RG-01 | 既有 Outbox 真發送 | 送出 | 仍 published 僅在平台成功後 |
+| RG-02 | BYOK 用量 | 呼叫 | 仍分欄、健檢不混入消耗 |
+| RG-03 | 邀請 apply 一次綁定 | 再 apply | 仍拒絕 |
+
+## 10. 開放規格問題
+
+1. ~~週一起點時區~~ → **已定:會員 timezone,無則 UTC**(§4.2)。
+2. Threads 各訊號實際 scope 探測結果 → **實作前填表**,不阻 P0 手動+觸達。
+3. Draft review P1 是否僅「同一 Member 自審」→ **已定是**;多成員協作 P2。
+4. 健康分權重精確數字 → plan/實作可調;**門檻帶 70/40 鎖定**。
+5. PDF 引擎選型 → plan 定(行為:可下載白牌月報)。
+
+## 11. 批准
+
+- [x] Spec 已批准(2026-07-20/使用者「spec 通過」)
diff --git a/docs/product/growth-loop/tasks/INDEX.md b/docs/product/growth-loop/tasks/INDEX.md
new file mode 100644
index 0000000..26a77ac
--- /dev/null
+++ b/docs/product/growth-loop/tasks/INDEX.md
@@ -0,0 +1,74 @@
+# Tasks INDEX — growth-loop
+
+> Plan:`../plan.md`(**approved**)
+> Spec:`../spec.md` §9(OC / CU / AL / AH / IR / WS / RG)
+> 後端根:`apps/backend` · 前端根:`apps/web`
+> Status:**approved**(2026-07-20 使用者「task ok」)· phase=`implemented`
+> Updated:2026-07-20 · 30/30 done
+
+## 範圍硬規則
+
+| 規則 | 內容 |
+|------|------|
+| 加值層 | 不重寫海巡/Outbox 主流程;送出後 hook Outcome |
+| 健檢不扣點 | WeeklyCheckup **禁止**寫 Usage 扣點/meter |
+| 歸因降級 | API 缺訊號仍交付手動成交+觸達(OC-08) |
+| 健康分 | <40 擋自動送、手動路徑必須可用 |
+| 邀請 | 單層、首次付費、Starter+100/Pro+300、月上限 50% |
+| Workspace | 同租戶;方案仍綁 Member;P1 自審 only |
+| Remove | Insights 真管線、P2 市集/破冰/UTM/benchmark、多層現金回饋、新灰帽玩法 |
+| 契約 | goctl `.api`→gen;logic/module;envelope 102000;uid 數字;時間 ns UTC |
+
+## INDEX
+
+| ID | Title | M | Kind | Status | Est |
+|----|-------|---|------|--------|-----|
+| T300 | api shells outcomes checkups health workspaces | M0 | feat | done | ~160 |
+| T301 | mongo indexes growth-loop collections | M0 | feat | done | ~120 |
+| T302 | fe domain types and repo interfaces | M0 | feat | done | ~150 |
+| T310 | outcome domain model and repository | M1 | feat | done | ~180 |
+| T311 | outcome hooks scout outbox publish | M1 | feat | done | ~160 |
+| T312 | outcome observe job 72h | M1 | feat | done | ~180 |
+| T313 | outcome api summary list conversion | M1 | feat | done | ~180 |
+| T314 | fe outcomes today usage scout | M1 | feat | done | ~200 |
+| T315 | test m1 outcomes integration | M1 | test | done | ~200 |
+| T330 | weekly checkup model and job | M2 | feat | done | ~180 |
+| T331 | checkup api no usage debit | M2 | feat | done | ~160 |
+| T332 | asset learning persona brand fields | M2 | feat | done | ~140 |
+| T333 | fe checkup report and learning ui | M2 | feat | done | ~180 |
+| T334 | test m2 checkup asset integration | M2 | test | done | ~180 |
+| T350 | account health model recompute job | M3 | feat | done | ~180 |
+| T351 | health gate auto send throttle | M3 | feat | done | ~160 |
+| T352 | fe health badge today warn | M3 | feat | done | ~140 |
+| T353 | test m3 health integration | M3 | test | done | ~160 |
+| T365 | invite reward on first paid plan | M4 | feat | done | ~180 |
+| T366 | invite rewards api and fe page | M4 | feat | done | ~150 |
+| T367 | test m4 invite reward integration | M4 | test | done | ~140 |
+| T380 | workspace model backfill default | M5 | feat | done | ~180 |
+| T381 | workspace api resource filter | M5 | feat | done | ~180 |
+| T382 | draft review gate auto send | M5 | feat | done | ~160 |
+| T383 | workspace monthly report pdf | M5 | feat | done | ~180 |
+| T384 | fe workspace shell review export | M5 | feat | done | ~200 |
+| T385 | test m5 workspace integration | M5 | test | done | ~180 |
+| T400 | test matrix p0 p1 acceptance | M6 | test | done | ~200 |
+| T401 | smoke fe be growth-loop | M6 | test | done | ~150 |
+| T402 | gap doc p2 and signal probe | M6 | docs | done | ~100 |
+
+**合計:30 tasks**(feat 22 · test 7 · docs 1)
+
+## 建議實作順序
+
+```text
+T300 → T301 → T302
+ → T310 → T311 → T312 → T313 → T314 → T315
+ → T330 → T331 → T332 → T333 → T334
+ → T350 → T351 → T352 → T353
+ → T365 → T366 → T367
+ → T380 → T381 → T382 → T383 → T384 → T385
+ → T400 → T401 → T402
+```
+
+## 閘門
+
+- ~~tasks 批准~~ → **`ready-to-implement`**
+- 實作時**指定 T###** 才寫碼;一次一題(建議從 **T300** 起)
diff --git a/docs/product/growth-loop/tasks/T300-api-shells-outcomes-checkups-health-workspaces.md b/docs/product/growth-loop/tasks/T300-api-shells-outcomes-checkups-health-workspaces.md
new file mode 100644
index 0000000..94d0948
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T300-api-shells-outcomes-checkups-health-workspaces.md
@@ -0,0 +1,55 @@
+# T300 — api-shells-outcomes-checkups-health-workspaces
+
+> Status: `done`
+> Milestone: `M0`
+> Kind: `feat`
+> Est. change: `~160 lines`
+
+## Goal
+
+完成後系統應:go-zero `.api` 具備 outcomes/checkups/account-health/workspaces 路由骨架,經 `make gen-api` 可編譯;未實作 logic 回**明確錯誤**(非 102000 空 data)。
+
+## Depends on
+
+- —
+
+## Inputs
+
+- Spec:`../spec.md` §5.2
+- 既有:`apps/backend/generate/api/*.api`、`make gen-api`
+- 契約:AGENTS.md envelope/goctl
+
+## Outputs
+
+### 程式變更(預期路徑)
+
+| 路徑 | 動作 | 說明 |
+|------|------|------|
+| `apps/backend/generate/api/*.api` | add/edit | outcomes、checkups、health、workspaces 分組 |
+| `apps/backend/internal/handler/**` | gen | goctl 產出 |
+| `apps/backend/internal/logic/**` | add | stub:未實作錯誤 |
+| `apps/backend/internal/types/**` | gen | req/resp 型別 |
+
+### 行為變更
+
+- 新路由存在;呼叫未完成能力 → `code≠102000`、message 可讀
+
+## Out of scope
+
+- 真實業務邏輯(M1+)
+- FE 接線
+
+## Acceptance
+
+- [ ] `make gen-api`(或專案等價)成功
+- [ ] `go build ./...` 通過
+- [ ] 抽樣 hit 新 API ≠ 102000 空成功
+- [ ] 指令:
+
+```bash
+cd apps/backend && make gen-api && go build ./...
+```
+
+## Notes
+
+路徑前綴建議 `/api/v1`;精確 path 可微調但能力名對齊 spec。
diff --git a/docs/product/growth-loop/tasks/T301-mongo-indexes-growth-loop.md b/docs/product/growth-loop/tasks/T301-mongo-indexes-growth-loop.md
new file mode 100644
index 0000000..3c872ef
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T301-mongo-indexes-growth-loop.md
@@ -0,0 +1,50 @@
+# T301 — mongo-indexes-growth-loop
+
+> Status: `done`
+> Milestone: `M0`
+> Kind: `feat`
+> Est. change: `~120 lines`
+
+## Goal
+
+完成後系統應:`cmd/init`(或既有 index 包)為 Outcome/WeeklyCheckup/AccountHealth/Workspace/InviteReward/DraftReview 建立可重跑 index。
+
+## Depends on
+
+- T300
+
+## Inputs
+
+- Spec:`../spec.md` §8
+- 既有:`apps/backend` init/mongo index 模式(haixun-backend T104)
+
+## Outputs
+
+### 程式變更(預期路徑)
+
+| 路徑 | 動作 | 說明 |
+|------|------|------|
+| `apps/backend/cmd/init/**` 或 `internal/**/index/**` | edit | 新集合 index |
+| 相關 test | add | idempotent 可重跑 |
+
+### 行為變更
+
+- index 含 owner_uid、source 查詢鍵、week_key、workspace_id 等(對齊 spec §8)
+
+## Out of scope
+
+- seeder 示範資料(可另開;非本 task 必須)
+
+## Acceptance
+
+- [ ] init 可重複執行不炸
+- [ ] 指令:
+
+```bash
+cd apps/backend && go test ./... -count=1 -run Index|Init
+# 或專案文件中的 init 指令 dry-run
+```
+
+## Notes
+
+—
diff --git a/docs/product/growth-loop/tasks/T302-fe-domain-types-repo-interfaces.md b/docs/product/growth-loop/tasks/T302-fe-domain-types-repo-interfaces.md
new file mode 100644
index 0000000..2b756c5
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T302-fe-domain-types-repo-interfaces.md
@@ -0,0 +1,51 @@
+# T302 — fe-domain-types-and-repo-interfaces
+
+> Status: `done`
+> Milestone: `M0`
+> Kind: `feat`
+> Est. change: `~150 lines`
+
+## Goal
+
+完成後系統應:`apps/web` 具備 Outcome/Checkup/AccountHealth/Workspace 等 domain 型別與 repository 介面(mock 可先回空列表/明確未接),不破壞既有 build。
+
+## Depends on
+
+- T300
+
+## Inputs
+
+- Spec:`../spec.md` §2、§5.3
+- 既有:`apps/web/src/domain/types.ts`、`apps/web/src/data/repos.ts`
+
+## Outputs
+
+### 程式變更(預期路徑)
+
+| 路徑 | 動作 | 說明 |
+|------|------|------|
+| `apps/web/src/domain/types.ts` | edit | 新實體型別 |
+| `apps/web/src/data/repos.ts` | edit | repo 介面 |
+| `apps/web/src/data/mock/**` 或 live stub | add/edit | 空實作不炸 UI |
+
+### 行為變更
+
+- `npm run build` 通過;既有頁不因型別壞掉
+
+## Out of scope
+
+- 真實 UI 成果卡(T314)
+- live HTTP 完整接線
+
+## Acceptance
+
+- [ ] 型別與 spec 名詞對齊
+- [ ] 指令:
+
+```bash
+cd apps/web && npm run build
+```
+
+## Notes
+
+—
diff --git a/docs/product/growth-loop/tasks/T310-outcome-domain-model-repository.md b/docs/product/growth-loop/tasks/T310-outcome-domain-model-repository.md
new file mode 100644
index 0000000..14da5bb
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T310-outcome-domain-model-repository.md
@@ -0,0 +1,47 @@
+# T310 — outcome-domain-model-and-repository
+
+> Status: `done`
+> Milestone: `M1`
+> Kind: `feat`
+> Est. change: `~180 lines`
+
+## Goal
+
+完成後系統應:OutcomeEvent domain + repository(mongo/memory)可 CRUD;含 source、confidence、window、conversion 欄位。
+
+## Depends on
+
+- T301
+
+## Inputs
+
+- Spec:`../spec.md` §2、§3.1、§8
+- 既有 module 模式:`apps/backend/internal/module/**`
+
+## Outputs
+
+| 路徑 | 動作 | 說明 |
+|------|------|------|
+| `apps/backend/internal/module/**/outcome/**` | add | domain + repo + usecase 殼 |
+
+### 行為變更
+
+- 可建立 observing 事件;list by owner;amend conversion
+
+## Out of scope
+
+- HTTP handler 完整(T313)
+- observe Job(T312)
+
+## Acceptance
+
+- [ ] unit test create/list/amend
+- [ ] 指令:
+
+```bash
+cd apps/backend && go test ./internal/.../outcome/... -count=1
+```
+
+## Notes
+
+confidence: confirmed | possible
diff --git a/docs/product/growth-loop/tasks/T311-outcome-hooks-scout-outbox.md b/docs/product/growth-loop/tasks/T311-outcome-hooks-scout-outbox.md
new file mode 100644
index 0000000..55cc082
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T311-outcome-hooks-scout-outbox.md
@@ -0,0 +1,51 @@
+# T311 — outcome-hooks-scout-outbox-publish
+
+> Status: `done`
+> Milestone: `M1`
+> Kind: `feat`
+> Est. change: `~160 lines`
+
+## Goal
+
+完成後系統應:Scout 外展 published/標記完成、Outbox step published 時自動建立 OutcomeEvent(observing,window=72h)。
+
+## Depends on
+
+- T310
+
+## Inputs
+
+- Spec:`../spec.md` §4.1、OC-01
+- 既有:scout outreach、outbox worker publish 成功路徑
+
+## Outputs
+
+| 路徑 | 動作 | 說明 |
+|------|------|------|
+| `apps/backend/internal/**/scout/**` | edit | hook on publish/mark done |
+| `apps/backend/internal/**/outbox/**` 或 worker | edit | hook on step published |
+| `apps/backend/internal/**/outcome/**` | edit | CreateFromSource |
+
+### 行為變更
+
+- draft/new/skipped **不**建 Outcome
+- 冪等:同一 source 不重複建(或更新同一筆)
+
+## Out of scope
+
+- 自動訊號觀測(T312)
+- FE
+
+## Acceptance
+
+- [ ] Spec:OC-01
+- [ ] Test:`TestOC_01_CreateOnPublished`
+- [ ] 指令:
+
+```bash
+cd apps/backend && go test ./internal/... -run OC_01|OutcomeHook -count=1
+```
+
+## Notes
+
+—
diff --git a/docs/product/growth-loop/tasks/T312-outcome-observe-job.md b/docs/product/growth-loop/tasks/T312-outcome-observe-job.md
new file mode 100644
index 0000000..6a721a2
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T312-outcome-observe-job.md
@@ -0,0 +1,51 @@
+# T312 — outcome-observe-job-72h
+
+> Status: `done`
+> Milestone: `M1`
+> Kind: `feat`
+> Est. change: `~180 lines`
+
+## Goal
+
+完成後系統應:Job/worker `outcome_observe` 在 72h 窗內更新訊號;能讀則寫 confirmed/possible;不能讀則跳過;窗結束 expired。
+
+## Depends on
+
+- T311
+
+## Inputs
+
+- Spec:`../spec.md` §3.1、§5.4、§5.5、OC-02~03、OC-08
+- 既有 job runner/worker
+
+## Outputs
+
+| 路徑 | 動作 | 說明 |
+|------|------|------|
+| `apps/backend/internal/**/job/**` 或 worker | edit | template outcome_observe |
+| `apps/backend/internal/**/outcome/**` | edit | ApplySignals / Expire |
+| 註記檔或 TESTING 附錄 | add | Threads scope 探測結果 |
+
+### 行為變更
+
+- 對話→confirmed(若可得);followers diff→possible
+- 全無訊號仍保留事件(觸達);不整域失敗
+
+## Out of scope
+
+- UTM(P2)
+- 手動成交 API(T313)
+
+## Acceptance
+
+- [ ] Spec:OC-02、OC-03、OC-08(降級)
+- [ ] fake transport 可測「有訊號/無訊號」
+- [ ] 指令:
+
+```bash
+cd apps/backend && go test ./internal/... -run OC_0|Observe -count=1
+```
+
+## Notes
+
+探測表寫進 notes;缺 scope 不阻塞手動路徑交付。
diff --git a/docs/product/growth-loop/tasks/T313-outcome-api-summary-list-conversion.md b/docs/product/growth-loop/tasks/T313-outcome-api-summary-list-conversion.md
new file mode 100644
index 0000000..f11d49f
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T313-outcome-api-summary-list-conversion.md
@@ -0,0 +1,50 @@
+# T313 — outcome-api-summary-list-conversion
+
+> Status: `done`
+> Milestone: `M1`
+> Kind: `feat`
+> Est. change: `~180 lines`
+
+## Goal
+
+完成後系統應:HTTP API 提供 summary(week/month)、list/get、report/update/deleteConversion;owner 隔離。
+
+## Depends on
+
+- T310
+- T312(可弱:無 observe 亦可手動成交)
+
+## Inputs
+
+- Spec:`../spec.md` §5.2 Outcomes、OC-04~07
+- T300 路由殼
+
+## Outputs
+
+| 路徑 | 動作 | 說明 |
+|------|------|------|
+| `apps/backend/internal/logic/**/outcome*/**` | edit | 實作 stub→真邏輯 |
+| `apps/backend/generate/api/*.api` | edit | 欄位對齊 |
+
+### 行為變更
+
+- summary 含對話/追蹤 possible|confirmed/成交筆數金額
+- 成交 amend 留可查紀錄(audit 精神)
+
+## Out of scope
+
+- FE(T314)
+
+## Acceptance
+
+- [ ] Spec:OC-04~OC-07
+- [ ] 未授權跨 uid 不可讀
+- [ ] 指令:
+
+```bash
+cd apps/backend && go test ./internal/... -run OC_0|OutcomeAPI -count=1
+```
+
+## Notes
+
+—
diff --git a/docs/product/growth-loop/tasks/T314-fe-outcomes-today-usage-scout.md b/docs/product/growth-loop/tasks/T314-fe-outcomes-today-usage-scout.md
new file mode 100644
index 0000000..8910919
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T314-fe-outcomes-today-usage-scout.md
@@ -0,0 +1,52 @@
+# T314 — fe-outcomes-today-usage-scout
+
+> Status: `done`
+> Milestone: `M1`
+> Kind: `feat`
+> Est. change: `~200 lines`
+
+## Goal
+
+完成後系統應:Today 成果摘要卡、Usage 成本|成果並列、Scout 命中詳情可手動回報成交並回溯來源;live 接 outcomes API。
+
+## Depends on
+
+- T302
+- T313
+
+## Inputs
+
+- Spec:`../spec.md` §4.1、§5.3、OC-06~07
+- 頁面:`apps/web/src/pages/TodayPage.tsx`、Usage*、ScoutPage
+
+## Outputs
+
+| 路徑 | 動作 | 說明 |
+|------|------|------|
+| `apps/web/src/pages/TodayPage.tsx` | edit | 成果卡 |
+| `apps/web/src/pages/Usage*.tsx` 或等價 | edit | 成果區 |
+| `apps/web/src/pages/ScoutPage.tsx` 等 | edit | 成交回報 |
+| `apps/web/src/data/live/**` | edit | outcomes client |
+
+### 行為變更
+
+- mock|live 同一 repo 介面
+- confidence=possible 文案不可寫成確定成交
+
+## Out of scope
+
+- 健檢 UI(T333)
+- 健康分(T352)
+
+## Acceptance
+
+- [ ] Spec:OC-06、OC-07
+- [ ] 指令:
+
+```bash
+cd apps/web && npm run build
+```
+
+## Notes
+
+—
diff --git a/docs/product/growth-loop/tasks/T315-test-m1-outcomes-integration.md b/docs/product/growth-loop/tasks/T315-test-m1-outcomes-integration.md
new file mode 100644
index 0000000..acf4011
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T315-test-m1-outcomes-integration.md
@@ -0,0 +1,47 @@
+# T315 — test-m1-outcomes-integration
+
+> Status: `done`
+> Milestone: `M1`
+> Kind: `test`
+> Est. change: `~200 lines`
+
+## Goal
+
+完成後系統應:M1 整合測試覆蓋 OC-01~OC-08(fake 外部);回歸 RG 抽樣不破。
+
+## Depends on
+
+- T311
+- T312
+- T313
+- T314
+
+## Inputs
+
+- Spec:`../spec.md` §9.1、§9.6
+- 既有 testkit 模式
+
+## Outputs
+
+| 路徑 | 動作 | 說明 |
+|------|------|------|
+| `apps/backend/internal/**/*_test.go` | add | OC_* cases |
+| 可選 smoke 筆記 | add | FE 手測清單 M1 |
+
+## Out of scope
+
+- CU/AH 等後續 ID
+
+## Acceptance
+
+- [ ] 每個 OC-01~08 可 grep 到 Test 名或矩陣列
+- [ ] 指令:
+
+```bash
+cd apps/backend && go test ./... -count=1 -run OC_
+cd apps/web && npm run build
+```
+
+## Notes
+
+—
diff --git a/docs/product/growth-loop/tasks/T330-weekly-checkup-model-job.md b/docs/product/growth-loop/tasks/T330-weekly-checkup-model-job.md
new file mode 100644
index 0000000..314bc26
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T330-weekly-checkup-model-job.md
@@ -0,0 +1,50 @@
+# T330 — weekly-checkup-model-and-job
+
+> Status: `done`
+> Milestone: `M2`
+> Kind: `feat`
+> Est. change: `~180 lines`
+
+## Goal
+
+完成後系統應:WeeklyCheckup 落庫;Job `weekly_checkup` 依會員 timezone 週一(無則 UTC)產報告;findings 強制 evidence;actions 固定 3 個含 deeplink。
+
+## Depends on
+
+- T301
+- T310(弱:可讀 Outcome 摘要)
+
+## Inputs
+
+- Spec:`../spec.md` §3.2、§4.2、CU-01~02
+- 既有 AI module、own posts 同步資料
+
+## Outputs
+
+| 路徑 | 動作 | 說明 |
+|------|------|------|
+| `apps/backend/internal/module/**/checkup/**` | add | domain/repo/usecase |
+| worker/job template | edit | weekly_checkup |
+
+### 行為變更
+
+- 無 evidence 的 finding 不得入庫
+- 不足則 status=failed 可重試
+
+## Out of scope
+
+- HTTP API(T331)
+- FE(T333)
+
+## Acceptance
+
+- [ ] Spec:CU-01、CU-02(資料層)
+- [ ] 指令:
+
+```bash
+cd apps/backend && go test ./internal/.../checkup/... -count=1
+```
+
+## Notes
+
+deeplink 枚舉:today|scout|studio_compose|studio_inspire|crew_persona|outbox
diff --git a/docs/product/growth-loop/tasks/T331-checkup-api-no-usage-debit.md b/docs/product/growth-loop/tasks/T331-checkup-api-no-usage-debit.md
new file mode 100644
index 0000000..41cd98a
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T331-checkup-api-no-usage-debit.md
@@ -0,0 +1,50 @@
+# T331 — checkup-api-no-usage-debit
+
+> Status: `done`
+> Milestone: `M2`
+> Kind: `feat`
+> Est. change: `~160 lines`
+
+## Goal
+
+完成後系統應:Checkup API latest/list/get/generateNow;7 日≤2 次重產;**任何健檢 AI 路徑不寫 Usage 扣點**;完成發通知。
+
+## Depends on
+
+- T330
+- T300
+
+## Inputs
+
+- Spec:`../spec.md` §4.2、§5.2、CU-03~05
+- 既有 usage recorder、notification
+
+## Outputs
+
+| 路徑 | 動作 | 說明 |
+|------|------|------|
+| `apps/backend/internal/logic/**/checkup*/**` | edit | API |
+| usage 呼叫點 | edit | 健檢 path 跳過 meter |
+
+### 行為變更
+
+- generateNow 超限明確錯誤
+- ready → notification
+
+## Out of scope
+
+- FE
+
+## Acceptance
+
+- [ ] Spec:CU-03、CU-04、CU-05
+- [ ] Test 斷言無 checkup debit UsageEvent
+- [ ] 指令:
+
+```bash
+cd apps/backend && go test ./internal/... -run CU_0|Checkup -count=1
+```
+
+## Notes
+
+—
diff --git a/docs/product/growth-loop/tasks/T332-asset-learning-persona-brand.md b/docs/product/growth-loop/tasks/T332-asset-learning-persona-brand.md
new file mode 100644
index 0000000..26bb4be
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T332-asset-learning-persona-brand.md
@@ -0,0 +1,49 @@
+# T332 — asset-learning-persona-brand-fields
+
+> Status: `done`
+> Milestone: `M2`
+> Kind: `feat`
+> Est. change: `~140 lines`
+
+## Goal
+
+完成後系統應:Persona/Brand get/list 含 learning_summary、learning_version、learned_from_posts_count、last_learned_at;空態明確;分析或健檢成功可更新。
+
+## Depends on
+
+- T330(健檢可觸發刷新)
+
+## Inputs
+
+- Spec:`../spec.md` §4.3、AL-01~02
+- 既有 persona/brand module
+
+## Outputs
+
+| 路徑 | 動作 | 說明 |
+|------|------|------|
+| `apps/backend/internal/**/persona/**` | edit | learning 欄位 |
+| `apps/backend/internal/**/brand/**` | edit | learning 欄位 |
+| `.api` types | edit | 回應擴充 |
+
+### 行為變更
+
+- 無學習時不回假數字
+- 實質變更才 version+1
+
+## Out of scope
+
+- 完整 Insights 管線
+
+## Acceptance
+
+- [ ] Spec:AL-01、AL-02
+- [ ] 指令:
+
+```bash
+cd apps/backend && go test ./internal/... -run AL_|Learning -count=1
+```
+
+## Notes
+
+—
diff --git a/docs/product/growth-loop/tasks/T333-fe-checkup-learning-ui.md b/docs/product/growth-loop/tasks/T333-fe-checkup-learning-ui.md
new file mode 100644
index 0000000..6fc0e17
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T333-fe-checkup-learning-ui.md
@@ -0,0 +1,48 @@
+# T333 — fe-checkup-report-and-learning-ui
+
+> Status: `done`
+> Milestone: `M2`
+> Kind: `feat`
+> Est. change: `~180 lines`
+
+## Goal
+
+完成後系統應:健檢報告頁/今日入口;3 行動 deep-link 可跳;Crew 人設與 Brands 顯示 learning 摘要與版本。
+
+## Depends on
+
+- T302
+- T331
+- T332
+
+## Inputs
+
+- Spec:`../spec.md` §5.3
+- 路由可新 `/app/checkup` 或內嵌 Today
+
+## Outputs
+
+| 路徑 | 動作 | 說明 |
+|------|------|------|
+| `apps/web/src/pages/**` | add/edit | checkup UI |
+| `apps/web/src/pages/CrewPage.tsx` | edit | learning |
+| `apps/web/src/pages/Brands*.tsx` | edit | learning |
+| `apps/web/src/data/live/**` | edit | checkup client |
+| nav | edit | 入口 |
+
+## Out of scope
+
+- PDF 月報(T383)
+
+## Acceptance
+
+- [ ] actions deeplink 可導航
+- [ ] 指令:
+
+```bash
+cd apps/web && npm run build
+```
+
+## Notes
+
+—
diff --git a/docs/product/growth-loop/tasks/T334-test-m2-checkup-asset-integration.md b/docs/product/growth-loop/tasks/T334-test-m2-checkup-asset-integration.md
new file mode 100644
index 0000000..7e8917d
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T334-test-m2-checkup-asset-integration.md
@@ -0,0 +1,40 @@
+# T334 — test-m2-checkup-asset-integration
+
+> Status: `done`
+> Milestone: `M2`
+> Kind: `test`
+> Est. change: `~180 lines`
+
+## Goal
+
+完成後系統應:CU-01~05、AL-01~02 整合測試綠;證明健檢無 usage debit。
+
+## Depends on
+
+- T330
+- T331
+- T332
+- T333
+
+## Inputs
+
+- Spec:`../spec.md` §9.2
+
+## Outputs
+
+| 路徑 | 動作 | 說明 |
+|------|------|------|
+| `*_test.go` | add | CU_* AL_* |
+
+## Acceptance
+
+- [ ] 指令:
+
+```bash
+cd apps/backend && go test ./... -count=1 -run 'CU_|AL_'
+cd apps/web && npm run build
+```
+
+## Notes
+
+—
diff --git a/docs/product/growth-loop/tasks/T350-account-health-model-recompute.md b/docs/product/growth-loop/tasks/T350-account-health-model-recompute.md
new file mode 100644
index 0000000..24b58d4
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T350-account-health-model-recompute.md
@@ -0,0 +1,48 @@
+# T350 — account-health-model-recompute-job
+
+> Status: `done`
+> Milestone: `M3`
+> Kind: `feat`
+> Est. change: `~180 lines`
+
+## Goal
+
+完成後系統應:每 ThreadsAccount 有 score 0–100、level normal|warn|throttle、factors、advice;日批+發送事件可重算。
+
+## Depends on
+
+- T301
+
+## Inputs
+
+- Spec:`../spec.md` §3.3、§4.4、AH-01
+- 既有 outbox 失敗率、token 狀態、發送紀錄
+
+## Outputs
+
+| 路徑 | 動作 | 說明 |
+|------|------|------|
+| `apps/backend/internal/module/**/health/**` | add | domain + recompute |
+| job template | edit | account_health_recompute |
+
+### 行為變更
+
+- 無操作不扣分
+- 門檻:≥70 normal;40–69 warn;<40 throttle
+
+## Out of scope
+
+- 送出閘(T351)
+
+## Acceptance
+
+- [ ] Spec:AH-01
+- [ ] 指令:
+
+```bash
+cd apps/backend && go test ./internal/.../health/... -count=1
+```
+
+## Notes
+
+權重可調;門檻帶鎖定。
diff --git a/docs/product/growth-loop/tasks/T351-health-gate-auto-send-throttle.md b/docs/product/growth-loop/tasks/T351-health-gate-auto-send-throttle.md
new file mode 100644
index 0000000..a1e9a5f
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T351-health-gate-auto-send-throttle.md
@@ -0,0 +1,44 @@
+# T351 — health-gate-auto-send-throttle
+
+> Status: `done`
+> Milestone: `M3`
+> Kind: `feat`
+> Est. change: `~160 lines`
+
+## Goal
+
+完成後系統應:ThreadPlay submit、sendOutreach 自動送、compose→Outbox 受健康分閘;warn 帶 health_warning;throttle **拒絕**自動送;標記完成仍允許。
+
+## Depends on
+
+- T350
+
+## Inputs
+
+- Spec:`../spec.md` §4.4、AH-02~04
+- plays submit、scout sendOutreach、compose publish
+
+## Outputs
+
+| 路徑 | 動作 | 說明 |
+|------|------|------|
+| 上述 logic | edit | pre-check health |
+| 錯誤碼 | edit | throttle 可讀 message |
+
+## Out of scope
+
+- 新灰帽玩法
+- Admin 覆蓋分數
+
+## Acceptance
+
+- [ ] Spec:AH-02、AH-03、AH-04
+- [ ] 指令:
+
+```bash
+cd apps/backend && go test ./internal/... -run AH_0|HealthGate -count=1
+```
+
+## Notes
+
+—
diff --git a/docs/product/growth-loop/tasks/T352-fe-health-badge-today-warn.md b/docs/product/growth-loop/tasks/T352-fe-health-badge-today-warn.md
new file mode 100644
index 0000000..8e80de9
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T352-fe-health-badge-today-warn.md
@@ -0,0 +1,43 @@
+# T352 — fe-health-badge-today-warn
+
+> Status: `done`
+> Milestone: `M3`
+> Kind: `feat`
+> Est. change: `~140 lines`
+
+## Goal
+
+完成後系統應:Crew 帳號列顯示健康分 Badge(與 token 狀態並陳);Today 顯示 warn/throttle 警示;自動送錯誤可讀。
+
+## Depends on
+
+- T302
+- T350
+- T351
+
+## Inputs
+
+- Spec:`../spec.md` §5.3、AH-05
+- CrewPage 既有 token health 文案
+
+## Outputs
+
+| 路徑 | 動作 | 說明 |
+|------|------|------|
+| `apps/web/src/pages/CrewPage.tsx` | edit | score badge |
+| `apps/web/src/pages/TodayPage.tsx` | edit | 警示 |
+| live health client | edit | |
+
+## Acceptance
+
+- [ ] Spec:AH-05
+- [ ] 不取代 token 健康文案
+- [ ] 指令:
+
+```bash
+cd apps/web && npm run build
+```
+
+## Notes
+
+—
diff --git a/docs/product/growth-loop/tasks/T353-test-m3-health-integration.md b/docs/product/growth-loop/tasks/T353-test-m3-health-integration.md
new file mode 100644
index 0000000..9ff0adc
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T353-test-m3-health-integration.md
@@ -0,0 +1,33 @@
+# T353 — test-m3-health-integration
+
+> Status: `done`
+> Milestone: `M3`
+> Kind: `test`
+> Est. change: `~160 lines`
+
+## Goal
+
+完成後系統應:AH-01~05 整合測試綠;Outbox/Scout 自動送回歸。
+
+## Depends on
+
+- T350
+- T351
+- T352
+
+## Inputs
+
+- Spec:`../spec.md` §9.3
+
+## Acceptance
+
+- [ ] 指令:
+
+```bash
+cd apps/backend && go test ./... -count=1 -run AH_
+cd apps/web && npm run build
+```
+
+## Notes
+
+—
diff --git a/docs/product/growth-loop/tasks/T365-invite-reward-on-first-paid-plan.md b/docs/product/growth-loop/tasks/T365-invite-reward-on-first-paid-plan.md
new file mode 100644
index 0000000..e7fe786
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T365-invite-reward-on-first-paid-plan.md
@@ -0,0 +1,51 @@
+# T365 — invite-reward-on-first-paid-plan
+
+> Status: `done`
+> Milestone: `M4`
+> Kind: `feat`
+> Est. change: `~180 lines`
+
+## Goal
+
+完成後系統應:直邀會員首次 free→starter|pro 成功時,邀請人得點(100/300);月上限 50% 方案額度;狀態 credited|capped|skipped;僅單層、僅首次。
+
+## Depends on
+
+- T301
+- 既有 plan purchase 成功路徑(haixun-backend)
+
+## Inputs
+
+- Spec:`../spec.md` §3.4、§4.5、IR-01~05
+- 既有 invite parent_uid、usage plan
+
+## Outputs
+
+| 路徑 | 動作 | 說明 |
+|------|------|------|
+| `apps/backend/internal/module/**/invite*/**` 或 reward | add/edit | InviteReward |
+| plan purchase logic | edit | hook |
+
+### 行為變更
+
+- 續訂/再升級不發
+- 延伸不發
+- 調 parent 不回溯已發
+
+## Out of scope
+
+- 現金/多層
+- FE(T366)
+
+## Acceptance
+
+- [ ] Spec:IR-01~IR-05
+- [ ] 指令:
+
+```bash
+cd apps/backend && go test ./internal/... -run IR_0|InviteReward -count=1
+```
+
+## Notes
+
+—
diff --git a/docs/product/growth-loop/tasks/T366-invite-rewards-api-fe.md b/docs/product/growth-loop/tasks/T366-invite-rewards-api-fe.md
new file mode 100644
index 0000000..91d2d0b
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T366-invite-rewards-api-fe.md
@@ -0,0 +1,41 @@
+# T366 — invite-rewards-api-and-fe-page
+
+> Status: `done`
+> Milestone: `M4`
+> Kind: `feat`
+> Est. change: `~150 lines`
+
+## Goal
+
+完成後系統應:getMyNetwork 擴充 rewards_summary/list;Invite 頁顯示累計、本月已獲/上限、最近回饋。
+
+## Depends on
+
+- T365
+- 既有 FE invite live
+
+## Inputs
+
+- Spec:`../spec.md` §4.5、IR-06
+- `apps/web` Invite 頁
+
+## Outputs
+
+| 路徑 | 動作 | 說明 |
+|------|------|------|
+| invite API logic | edit | 擴充欄位 |
+| `apps/web` Invite page + live | edit | UI |
+
+## Acceptance
+
+- [ ] Spec:IR-06
+- [ ] 指令:
+
+```bash
+cd apps/backend && go test ./internal/... -run Invite -count=1
+cd apps/web && npm run build
+```
+
+## Notes
+
+文案維持邀請人/直邀/延伸
diff --git a/docs/product/growth-loop/tasks/T367-test-m4-invite-reward-integration.md b/docs/product/growth-loop/tasks/T367-test-m4-invite-reward-integration.md
new file mode 100644
index 0000000..d297454
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T367-test-m4-invite-reward-integration.md
@@ -0,0 +1,32 @@
+# T367 — test-m4-invite-reward-integration
+
+> Status: `done`
+> Milestone: `M4`
+> Kind: `test`
+> Est. change: `~140 lines`
+
+## Goal
+
+完成後系統應:IR-01~06 整合測試綠;邀請 apply 一次綁定回歸。
+
+## Depends on
+
+- T365
+- T366
+
+## Inputs
+
+- Spec:`../spec.md` §9.4
+
+## Acceptance
+
+- [ ] 指令:
+
+```bash
+cd apps/backend && go test ./... -count=1 -run IR_
+cd apps/web && npm run build
+```
+
+## Notes
+
+—
diff --git a/docs/product/growth-loop/tasks/T380-workspace-model-backfill-default.md b/docs/product/growth-loop/tasks/T380-workspace-model-backfill-default.md
new file mode 100644
index 0000000..3b9db06
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T380-workspace-model-backfill-default.md
@@ -0,0 +1,46 @@
+# T380 — workspace-model-backfill-default
+
+> Status: `done`
+> Milestone: `M5`
+> Kind: `feat`
+> Est. change: `~180 lines`
+
+## Goal
+
+完成後系統應:Workspace 實體;每會員≥1 default;既有 brand/persona/threads_account backfill `workspace_id`;archive 軟刪且至少留 1。
+
+## Depends on
+
+- T301
+
+## Inputs
+
+- Spec:`../spec.md` §3.5、§4.6、WS-01
+- 既有 brand/persona/account models
+
+## Outputs
+
+| 路徑 | 動作 | 說明 |
+|------|------|------|
+| `apps/backend/internal/module/**/workspace/**` | add | domain/repo |
+| migration/backfill cmd 或 init | add | 一次性歸屬 |
+| brand/persona/account | edit | workspace_id 欄位 |
+
+## Out of scope
+
+- HTTP 完整 CRUD UI(T381/T384)
+- 多租戶帳單
+
+## Acceptance
+
+- [ ] Spec:WS-01(資料層)
+- [ ] backfill idempotent
+- [ ] 指令:
+
+```bash
+cd apps/backend && go test ./internal/.../workspace/... -count=1
+```
+
+## Notes
+
+用量/方案不綁 workspace
diff --git a/docs/product/growth-loop/tasks/T381-workspace-api-resource-filter.md b/docs/product/growth-loop/tasks/T381-workspace-api-resource-filter.md
new file mode 100644
index 0000000..cc7c136
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T381-workspace-api-resource-filter.md
@@ -0,0 +1,49 @@
+# T381 — workspace-api-resource-filter
+
+> Status: `done`
+> Milestone: `M5`
+> Kind: `feat`
+> Est. change: `~180 lines`
+
+## Goal
+
+完成後系統應:Workspace CRUD/switch/archive API;建立資源寫當前 workspace_id;列表預設只回當前區。
+
+## Depends on
+
+- T380
+- T300
+
+## Inputs
+
+- Spec:`../spec.md` §4.6、WS-02、WS-06
+
+## Outputs
+
+| 路徑 | 動作 | 說明 |
+|------|------|------|
+| workspace logic | edit | API |
+| brand/persona/account list/create | edit | filter + 預設 WS |
+
+### 行為變更
+
+- 切 WS 後跨區不可見(預設)
+- 用量/方案 API 不隨 WS 變
+
+## Out of scope
+
+- 審核(T382)
+- PDF(T383)
+
+## Acceptance
+
+- [ ] Spec:WS-02、WS-06
+- [ ] 指令:
+
+```bash
+cd apps/backend && go test ./internal/... -run WS_0|Workspace -count=1
+```
+
+## Notes
+
+—
diff --git a/docs/product/growth-loop/tasks/T382-draft-review-gate-auto-send.md b/docs/product/growth-loop/tasks/T382-draft-review-gate-auto-send.md
new file mode 100644
index 0000000..ae8fc25
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T382-draft-review-gate-auto-send.md
@@ -0,0 +1,43 @@
+# T382 — draft-review-gate-auto-send
+
+> Status: `done`
+> Milestone: `M5`
+> Kind: `feat`
+> Est. change: `~160 lines`
+
+## Goal
+
+完成後系統應:Workspace `review_required` 時,自動送出前須 DraftReview approved;同一 Member 自審;未准拒絕自動送;手動標記完成仍允許(UI 可標未審核)。
+
+## Depends on
+
+- T381
+- T351(與健康分 AND)
+
+## Inputs
+
+- Spec:`../spec.md` §3.6、§4.6、WS-03~04
+
+## Outputs
+
+| 路徑 | 動作 | 說明 |
+|------|------|------|
+| draft review module | add | pending/approve/reject |
+| submit/send paths | edit | dual gate health+review |
+
+## Out of scope
+
+- 多成員協作審核(P2)
+
+## Acceptance
+
+- [ ] Spec:WS-03、WS-04
+- [ ] 指令:
+
+```bash
+cd apps/backend && go test ./internal/... -run Review|WS_03|WS_04 -count=1
+```
+
+## Notes
+
+—
diff --git a/docs/product/growth-loop/tasks/T383-workspace-monthly-report-pdf.md b/docs/product/growth-loop/tasks/T383-workspace-monthly-report-pdf.md
new file mode 100644
index 0000000..90bb54a
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T383-workspace-monthly-report-pdf.md
@@ -0,0 +1,46 @@
+# T383 — workspace-monthly-report-pdf
+
+> Status: `done`
+> Milestone: `M5`
+> Kind: `feat`
+> Est. change: `~180 lines`
+
+## Goal
+
+完成後系統應:exportPdf(workspace_id, year_month) 產出可下載 PDF;含成果摘要、發送數、海巡完成、健康概況;白牌(無強制巡樓 branding,Powered-by 可關)。
+
+## Depends on
+
+- T381
+- T313(成果 summary)
+- T350(健康概況,可降級為 N/A)
+
+## Inputs
+
+- Spec:`../spec.md` §4.6、WS-05
+
+## Outputs
+
+| 路徑 | 動作 | 說明 |
+|------|------|------|
+| report export logic | add | PDF 生成 |
+| storage/URL | edit | 短 TTL 或 stream |
+
+## Out of scope
+
+- 白牌域名
+- 精美設計系統大改
+
+## Acceptance
+
+- [ ] Spec:WS-05
+- [ ] 空月行為固定一種(空報表或明確錯誤)
+- [ ] 指令:
+
+```bash
+cd apps/backend && go test ./internal/... -run Report|PDF|WS_05 -count=1
+```
+
+## Notes
+
+PDF 引擎 plan 可選;行為優先於庫選擇。
diff --git a/docs/product/growth-loop/tasks/T384-fe-workspace-shell-review-export.md b/docs/product/growth-loop/tasks/T384-fe-workspace-shell-review-export.md
new file mode 100644
index 0000000..53ac636
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T384-fe-workspace-shell-review-export.md
@@ -0,0 +1,43 @@
+# T384 — fe-workspace-shell-review-export
+
+> Status: `done`
+> Milestone: `M5`
+> Kind: `feat`
+> Est. change: `~200 lines`
+
+## Goal
+
+完成後系統應:殼層 Workspace 切換器;審核狀態操作;月報匯出入口;列表隨 WS 過濾。
+
+## Depends on
+
+- T302
+- T381
+- T382
+- T383
+
+## Inputs
+
+- Spec:`../spec.md` §5.3
+- `apps/web` app shell/nav
+
+## Outputs
+
+| 路徑 | 動作 | 說明 |
+|------|------|------|
+| shell/layout | edit | WS switcher |
+| 相關 pages | edit | review + export |
+| live clients | edit | |
+
+## Acceptance
+
+- [ ] 兩 WS 切換資料隔離可手測
+- [ ] 指令:
+
+```bash
+cd apps/web && npm run build
+```
+
+## Notes
+
+—
diff --git a/docs/product/growth-loop/tasks/T385-test-m5-workspace-integration.md b/docs/product/growth-loop/tasks/T385-test-m5-workspace-integration.md
new file mode 100644
index 0000000..92ba769
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T385-test-m5-workspace-integration.md
@@ -0,0 +1,35 @@
+# T385 — test-m5-workspace-integration
+
+> Status: `done`
+> Milestone: `M5`
+> Kind: `test`
+> Est. change: `~180 lines`
+
+## Goal
+
+完成後系統應:WS-01~06 整合測試綠。
+
+## Depends on
+
+- T380
+- T381
+- T382
+- T383
+- T384
+
+## Inputs
+
+- Spec:`../spec.md` §9.5
+
+## Acceptance
+
+- [ ] 指令:
+
+```bash
+cd apps/backend && go test ./... -count=1 -run WS_
+cd apps/web && npm run build
+```
+
+## Notes
+
+—
diff --git a/docs/product/growth-loop/tasks/T400-test-matrix-p0-p1-acceptance.md b/docs/product/growth-loop/tasks/T400-test-matrix-p0-p1-acceptance.md
new file mode 100644
index 0000000..ab0af3d
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T400-test-matrix-p0-p1-acceptance.md
@@ -0,0 +1,43 @@
+# T400 — test-matrix-p0-p1-acceptance
+
+> Status: `done`
+> Milestone: `M6`
+> Kind: `test`
+> Est. change: `~200 lines`
+
+## Goal
+
+完成後系統應:spec §9 OC/CU/AL/AH/IR/WS/RG 矩陣可勾選;自動化覆蓋主路徑;缺口僅允許 P2/訊號降級已文件化項。
+
+## Depends on
+
+- T315
+- T334
+- T353
+- T367
+- T385
+
+## Inputs
+
+- Spec:`../spec.md` §9 全表
+- 可選:產出 `docs/product/growth-loop/TESTING.md` 矩陣
+
+## Outputs
+
+| 路徑 | 動作 | 說明 |
+|------|------|------|
+| tests + TESTING.md | add/edit | 矩陣與 CI 可跑子集 |
+
+## Acceptance
+
+- [ ] 矩陣列齊全
+- [ ] 指令:
+
+```bash
+cd apps/backend && go test ./... -count=1
+cd apps/web && npm run build
+```
+
+## Notes
+
+—
diff --git a/docs/product/growth-loop/tasks/T401-smoke-fe-be-growth-loop.md b/docs/product/growth-loop/tasks/T401-smoke-fe-be-growth-loop.md
new file mode 100644
index 0000000..587da9c
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T401-smoke-fe-be-growth-loop.md
@@ -0,0 +1,44 @@
+# T401 — smoke-fe-be-growth-loop
+
+> Status: `done`
+> Milestone: `M6`
+> Kind: `test`
+> Est. change: `~150 lines`
+
+## Goal
+
+完成後系統應:FE↔BE live 煙測腳本或清單覆蓋:登入→海巡送出/標記→成果卡→健檢入口→(P1)健康分/邀請回饋/WS 切換抽樣。
+
+## Depends on
+
+- T314
+- T333
+- T352
+- T366
+- T384
+
+## Inputs
+
+- 既有 smoke 模式(haixun-backend T218)
+
+## Outputs
+
+| 路徑 | 動作 | 說明 |
+|------|------|------|
+| `scripts/smoke-growth-loop.sh` 或 web e2e | add | 煙測 |
+| 手測清單 md | add | 若無法全自動 |
+
+## Acceptance
+
+- [ ] 文件載明如何跑
+- [ ] 指令:
+
+```bash
+# 依交付物
+bash scripts/smoke-growth-loop.sh || true
+cd apps/web && npm run build
+```
+
+## Notes
+
+—
diff --git a/docs/product/growth-loop/tasks/T402-gap-doc-p2-and-signal-probe.md b/docs/product/growth-loop/tasks/T402-gap-doc-p2-and-signal-probe.md
new file mode 100644
index 0000000..d4ddb5f
--- /dev/null
+++ b/docs/product/growth-loop/tasks/T402-gap-doc-p2-and-signal-probe.md
@@ -0,0 +1,37 @@
+# T402 — gap-doc-p2-and-signal-probe
+
+> Status: `done`
+> Milestone: `M6`
+> Kind: `docs`
+> Est. change: `~100 lines`
+
+## Goal
+
+完成後系統應:文件記載 P2 未做項、Threads 歸因訊號探測結果(可用/降級)、已知限制;README phase 可標實作完成狀態(在 tasks 全 done 後)。
+
+## Depends on
+
+- T312(探測註記)
+- T400
+
+## Inputs
+
+- Spec:`../spec.md` §4.7、§5.5
+- Plan §5 刻意不做
+
+## Outputs
+
+| 路徑 | 動作 | 說明 |
+|------|------|------|
+| `docs/product/growth-loop/GAPS.md` 或 README 段 | add | 缺口 |
+| `docs/product/growth-loop/README.md` | edit | 狀態更新 |
+
+## Acceptance
+
+- [ ] P2 清單完整
+- [ ] 訊號表:可用/不可用/降級行為
+- [ ] 無未說明的半套 API
+
+## Notes
+
+本 task 不寫業務碼。