fix frontend

This commit is contained in:
王性驊 2026-07-23 05:56:42 +00:00
parent ae58b1e82a
commit af2e5b4a28
197 changed files with 11270 additions and 285 deletions

View File

@ -28,6 +28,8 @@ import (
memberRepo "apps/backend/internal/module/member/repository" memberRepo "apps/backend/internal/module/member/repository"
scoutDomain "apps/backend/internal/module/scout/domain" scoutDomain "apps/backend/internal/module/scout/domain"
scoutRepo "apps/backend/internal/module/scout/repository" 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" scoutUC "apps/backend/internal/module/scout/usecase"
studioPublish "apps/backend/internal/module/studio/publish" studioPublish "apps/backend/internal/module/studio/publish"
studioRepo "apps/backend/internal/module/studio/repository" studioRepo "apps/backend/internal/module/studio/repository"
@ -143,7 +145,16 @@ func main() {
// worker 執行 job 本體,不經 API 再入列 // worker 執行 job 本體,不經 API 再入列
studio.Jobs = nil 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) fmt.Printf("worker running id=%s interval=%s (Ctrl+C to stop)\n", workerID, interval)
sig := make(chan os.Signal, 1) 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. // 2) One worker owns an outbox tick and renews its lease while claims run.
processOutbox(ctx, studio, outboxLock, workerID, outboxLockTTL) 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 { if purged, err := jobs.PurgeExpiredTerminal(ctx); err != nil {
logx.Errorf("worker %s purge jobs: %v", workerID, err) logx.Errorf("worker %s purge jobs: %v", workerID, err)
} else if purged > 0 { } else if purged > 0 {

View File

@ -124,12 +124,12 @@ Mail:
DevExposeCode: false DevExposeCode: false
# 前端 origin重設信連結 + 預設 logo URL/brand-mark.jpg # 前端 origin重設信連結 + 預設 logo URL/brand-mark.jpg
# 遠端 devhttps://threads-tool-dev.30cm.net # 正式遠端https://threads-tool.30cm.net
PublicWebBase: "https://threads-tool-dev.30cm.net" PublicWebBase: "https://threads-tool.30cm.net"
# 瀏覽器直連 gateway 時的 allowlist同源 proxy 不需要列在此處。 # 瀏覽器直連 gateway 時的 allowlist同源 proxy 不需要列在此處。
# CORS_ALLOWED_ORIGINS 可用逗號附加額外的明確 origin不能使用 *。 # CORS_ALLOWED_ORIGINS 可用逗號附加額外的明確 origin不能使用 *。
CORSAllowedOrigins: CORSAllowedOrigins:
- "https://threads-tool-dev.30cm.net" - "https://threads-tool.30cm.net"
- "http://127.0.0.1:5173" - "http://127.0.0.1:5173"
- "http://localhost:5173" - "http://localhost:5173"
@ -158,5 +158,5 @@ ObjectStorage:
SecretKey: "" SecretKey: ""
UsePathStyle: true UsePathStyle: true
# env HAIXUN_STORAGE_S3_PUBLIC_BASE_URL 會覆寫;勿用 /api/v1/public/assets新後端無此 route # 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"

View File

@ -23,3 +23,5 @@ import "extension.api"
import "studio.api" import "studio.api"
import "m5.api" import "m5.api"
import "invite.api" import "invite.api"
import "growth.api"
import "growth_p2.api"

View File

@ -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)
}

View File

@ -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)
}

View File

@ -34,11 +34,28 @@ type (
Children []InviteTreeNode `json:"children"` 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 { MyInviteNetworkData {
Me InviteMemberBrief `json:"me"` Me InviteMemberBrief `json:"me"`
Upline *InviteMemberBrief `json:"upline,optional"` Upline *InviteMemberBrief `json:"upline,optional"`
Downlines []InviteMemberBrief `json:"downlines"` Downlines []InviteMemberBrief `json:"downlines"`
TotalDownlineCount int `json:"total_downline_count"` TotalDownlineCount int `json:"total_downline_count"`
Rewards *InviteRewardsSummary `json:"rewards,optional"`
} }
ApplyInviteCodeReq { ApplyInviteCodeReq {

View File

@ -152,11 +152,15 @@ type (
// --- Scout --- // --- Scout ---
BrandPublic { BrandPublic {
Id string `json:"id"` Id string `json:"id"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Brief string `json:"brief"` Brief string `json:"brief"`
TargetAudience string `json:"target_audience,optional"` TargetAudience string `json:"target_audience,optional"`
Goals string `json:"goals,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 { BrandListData {
List []BrandPublic `json:"list"` List []BrandPublic `json:"list"`

View File

@ -43,16 +43,20 @@ type (
} }
PersonaPublic { PersonaPublic {
Id string `json:"id"` Id string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Brief string `json:"brief"` Brief string `json:"brief"`
Status string `json:"status"` Status string `json:"status"`
Style PersonaStylePublic `json:"style"` Style PersonaStylePublic `json:"style"`
Guard PersonaGuardPublic `json:"guard"` Guard PersonaGuardPublic `json:"guard"`
Voice string `json:"voice,optional"` Voice string `json:"voice,optional"`
Notes string `json:"notes,optional"` Notes string `json:"notes,optional"`
CreatedAt int64 `json:"created_at"` LearningSummary string `json:"learning_summary,optional"`
UpdatedAt int64 `json:"updated_at"` 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 { PersonaListData {

View File

@ -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" }
]

View File

@ -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" }
]
}
]

View File

@ -101,11 +101,11 @@ type Config struct {
DevExposeCode bool `json:",default=true"` DevExposeCode bool `json:",default=true"`
} `json:",optional"` } `json:",optional"`
// PublicWebBase — 前端 origin用於重設密碼信內連結logo勿尾斜線 // 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"` PublicWebBase string `json:",optional,default=http://127.0.0.1:5173"`
// PublicAPIBase — 對外 API originThreads OAuth redirect_uri 必須 https 公開網址) // PublicAPIBase — 對外 API originThreads OAuth redirect_uri 必須 https 公開網址)
// 空則:若 PublicWebBase 為 https 則同 host否則回落 http://127.0.0.1:Port // 空則:若 PublicWebBase 為 https 則同 host否則回落 http://127.0.0.1:Port
// 例https://threads-tool-dev.30cm.net // 例https://threads-tool.30cm.net
PublicAPIBase string `json:",optional"` PublicAPIBase string `json:",optional"`
// Brand — 郵件 chromehermesLogoURL 空則用 PublicWebBase + /brand-mark.svg // Brand — 郵件 chromehermesLogoURL 空則用 PublicWebBase + /brand-mark.svg
Brand struct { Brand struct {

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,20 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,20 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,20 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -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)
}
}

View File

@ -8,9 +8,13 @@ import (
admin "apps/backend/internal/handler/admin" admin "apps/backend/internal/handler/admin"
auth "apps/backend/internal/handler/auth" auth "apps/backend/internal/handler/auth"
benchmark "apps/backend/internal/handler/benchmark"
billing "apps/backend/internal/handler/billing" billing "apps/backend/internal/handler/billing"
checkups "apps/backend/internal/handler/checkups"
compose "apps/backend/internal/handler/compose" compose "apps/backend/internal/handler/compose"
extension "apps/backend/internal/handler/extension" extension "apps/backend/internal/handler/extension"
health "apps/backend/internal/handler/health"
insightsapi "apps/backend/internal/handler/insightsapi"
inspire "apps/backend/internal/handler/inspire" inspire "apps/backend/internal/handler/inspire"
invite "apps/backend/internal/handler/invite" invite "apps/backend/internal/handler/invite"
jobs "apps/backend/internal/handler/jobs" jobs "apps/backend/internal/handler/jobs"
@ -18,16 +22,22 @@ import (
mentions "apps/backend/internal/handler/mentions" mentions "apps/backend/internal/handler/mentions"
notifications "apps/backend/internal/handler/notifications" notifications "apps/backend/internal/handler/notifications"
outbox "apps/backend/internal/handler/outbox" outbox "apps/backend/internal/handler/outbox"
outcomes "apps/backend/internal/handler/outcomes"
ownposts "apps/backend/internal/handler/ownposts" ownposts "apps/backend/internal/handler/ownposts"
personas "apps/backend/internal/handler/personas" personas "apps/backend/internal/handler/personas"
ping "apps/backend/internal/handler/ping" ping "apps/backend/internal/handler/ping"
playbooks "apps/backend/internal/handler/playbooks"
plays "apps/backend/internal/handler/plays" plays "apps/backend/internal/handler/plays"
proxy "apps/backend/internal/handler/proxy" proxy "apps/backend/internal/handler/proxy"
publictools "apps/backend/internal/handler/publictools"
publicutm "apps/backend/internal/handler/publicutm"
research "apps/backend/internal/handler/research" research "apps/backend/internal/handler/research"
scout "apps/backend/internal/handler/scout" scout "apps/backend/internal/handler/scout"
settings "apps/backend/internal/handler/settings" settings "apps/backend/internal/handler/settings"
threads "apps/backend/internal/handler/threads" threads "apps/backend/internal/handler/threads"
usage "apps/backend/internal/handler/usage" usage "apps/backend/internal/handler/usage"
utm "apps/backend/internal/handler/utm"
workspaces "apps/backend/internal/handler/workspaces"
"apps/backend/internal/svc" "apps/backend/internal/svc"
"github.com/zeromicro/go-zero/rest" "github.com/zeromicro/go-zero/rest"
@ -188,6 +198,20 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1/auth"), 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( server.AddRoutes(
rest.WithMiddlewares( rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT}, []rest.Middleware{serverCtx.AuthJWT},
@ -232,6 +256,35 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1/billing"), 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( server.AddRoutes(
rest.WithMiddlewares( rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT}, []rest.Middleware{serverCtx.AuthJWT},
@ -277,6 +330,39 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1/extension"), 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( server.AddRoutes(
rest.WithMiddlewares( rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT}, []rest.Middleware{serverCtx.AuthJWT},
@ -446,11 +532,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
[]rest.Route{ []rest.Route{
{ {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/upload", Path: "/generate-image",
Handler: media.UploadHandler(serverCtx), Handler: media.GenerateImageHandler(serverCtx),
}, },
}..., }...,
), ),
rest.WithJwt(serverCtx.Config.Auth.AccessSecret),
rest.WithPrefix("/api/v1/media"), rest.WithPrefix("/api/v1/media"),
) )
@ -460,12 +547,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
[]rest.Route{ []rest.Route{
{ {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/generate-image", Path: "/upload",
Handler: media.GenerateImageHandler(serverCtx), Handler: media.UploadHandler(serverCtx),
}, },
}..., }...,
), ),
rest.WithJwt(serverCtx.Config.Auth.AccessSecret),
rest.WithPrefix("/api/v1/media"), rest.WithPrefix("/api/v1/media"),
) )
@ -564,6 +650,45 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1/outbox"), 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( server.AddRoutes(
rest.WithMiddlewares( rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT}, []rest.Middleware{serverCtx.AuthJWT},
@ -675,6 +800,40 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1"), 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( server.AddRoutes(
rest.WithMiddlewares( rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT}, []rest.Middleware{serverCtx.AuthJWT},
@ -755,6 +914,33 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1/proxy"), 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( server.AddRoutes(
rest.WithMiddlewares( rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT}, []rest.Middleware{serverCtx.AuthJWT},
@ -1059,4 +1245,111 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithJwt(serverCtx.Config.Auth.AccessSecret), rest.WithJwt(serverCtx.Config.Auth.AccessSecret),
rest.WithPrefix("/api/v1/usage"), 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"),
)
} }

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,20 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,20 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -30,5 +30,33 @@ func (l *GetMyInviteNetworkLogic) GetMyInviteNetwork() (resp *types.MyInviteNetw
if err != nil { if err != nil {
return nil, err 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
} }

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -29,6 +29,22 @@ func (l *SubmitPlayLogic) SubmitPlay(req *types.PlayIdPath) (*types.OutboxPublic
if !ok { if !ok {
return nil, response.Biz(401, 401001, "missing authorization") 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) b, err := l.svcCtx.Studio.SubmitPlay(l.ctx, uid, req.Id)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -30,6 +30,14 @@ func (l *SendOutreachLogic) SendOutreach(req *types.ScoutSendPathReq) (*types.Sc
return nil, response.Biz(401, 401001, "missing authorization") 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) p, err := l.svcCtx.Scout.SendOutreach(l.ctx, uid, req.Id, req.Text, req.AccountId)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -33,5 +33,36 @@ func (l *PurchasePlanLogic) PurchasePlan(req *types.UsagePurchaseReq) (*types.Us
if err != nil { if err != nil {
return nil, err 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 return &types.UsagePurchasePublic{Id: p.ID, PlanId: p.PlanID, MockRef: p.MockRef, CreatedAt: p.CreatedAt}, nil
} }

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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, "月報")
}

View File

@ -38,6 +38,15 @@ func (fakePersonaSource) ResolvePersona(context.Context, int64, string) (*usecas
}, nil }, 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) { func setupInspireUID(svc *usecase.Service, uid int64) {
_ = svc.Usage.Repo.SavePrefs(context.Background(), &usageDomain.MemberPrefs{ _ = svc.Usage.Repo.SavePrefs(context.Background(), &usageDomain.MemberPrefs{
UID: uid, PlanID: usageDomain.PlanPro, Unlimited: true, UpdatedAt: domain.NowNano(), UID: uid, PlanID: usageDomain.PlanPro, Unlimited: true, UpdatedAt: domain.NowNano(),
@ -93,10 +102,41 @@ func TestIN_02_RefreshTrends(t *testing.T) {
svc := newInspire() svc := newInspire()
uid := int64(5_001_002) uid := int64(5_001_002)
setupInspireUID(svc, uid) 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) _, _ = svc.ListTrends(context.Background(), uid)
list, err := svc.RefreshTrends(context.Background(), uid) list, err := svc.RefreshTrends(context.Background(), uid)
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, list) 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) { func TestIN_03_ElementCRUD(t *testing.T) {

Some files were not shown because too many files have changed in this diff Show More