fix frontend
This commit is contained in:
parent
ae58b1e82a
commit
af2e5b4a28
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -124,12 +124,12 @@ Mail:
|
||||||
DevExposeCode: false
|
DevExposeCode: false
|
||||||
|
|
||||||
# 前端 origin:重設信連結 + 預設 logo URL(/brand-mark.jpg)
|
# 前端 origin:重設信連結 + 預設 logo URL(/brand-mark.jpg)
|
||||||
# 遠端 dev:https://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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,10 @@ type (
|
||||||
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"`
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,10 @@ type (
|
||||||
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"`
|
||||||
|
LearningSummary string `json:"learning_summary,optional"`
|
||||||
|
LearningVersion int `json:"learning_version,optional"`
|
||||||
|
LearnedFromPostsCount int `json:"learned_from_posts_count,optional"`
|
||||||
|
LastLearnedAt int64 `json:"last_learned_at,optional"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
]
|
||||||
|
|
@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -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 origin(Threads OAuth redirect_uri 必須 https 公開網址)
|
// PublicAPIBase — 對外 API origin(Threads 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 — 郵件 chrome(hermes);LogoURL 空則用 PublicWebBase + /brand-mark.svg
|
// Brand — 郵件 chrome(hermes);LogoURL 空則用 PublicWebBase + /brand-mark.svg
|
||||||
Brand struct {
|
Brand struct {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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, "月報")
|
||||||
|
}
|
||||||
|
|
@ -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
Loading…
Reference in New Issue