diff --git a/apps/backend/Makefile b/apps/backend/Makefile index 6f8d88a..b787659 100644 --- a/apps/backend/Makefile +++ b/apps/backend/Makefile @@ -1,6 +1,6 @@ # 只保留會重複打的指令;其餘直接 go / migrate。 -.PHONY: build test gen-api tidy migrate-up migrate-down +.PHONY: build test test-integration gen-api tidy migrate-up migrate-down build: go build -o bin/gateway . @@ -9,6 +9,16 @@ build: test: go test ./... +# M1–M5 integration gates +test-integration: + go test ./internal/module/member/usecase/ ./internal/middleware/ \ + ./internal/module/threads/... ./internal/module/job/... ./internal/module/appnotif/... \ + ./internal/module/usage/... ./internal/module/ai/... ./internal/module/search/... \ + ./internal/module/permission/... ./internal/module/studio/... \ + ./internal/module/inspire/... ./internal/module/scout/... \ + ./internal/logic/extension/ ./internal/logic/media/ \ + -count=1 -timeout 180s + # 改 generate/api/*.api 後執行;handler 勿手寫 gen-api: goctl api go -api generate/api/gateway.api -dir . -style go_zero --home generate/goctl diff --git a/apps/backend/cmd/worker/main.go b/apps/backend/cmd/worker/main.go index 9a0f567..d20ae6a 100644 --- a/apps/backend/cmd/worker/main.go +++ b/apps/backend/cmd/worker/main.go @@ -1,21 +1,49 @@ package main import ( + "context" + "encoding/json" "flag" "fmt" "os" "os/signal" + "strings" "syscall" "time" "apps/backend/internal/config" + "apps/backend/internal/module/ai" + appnotifRepo "apps/backend/internal/module/appnotif/repository" + appnotifUC "apps/backend/internal/module/appnotif/usecase" + fsDomain "apps/backend/internal/module/filestorage/domain" + "apps/backend/internal/module/filestorage/noop" + "apps/backend/internal/module/filestorage/s3store" + jobDomain "apps/backend/internal/module/job/domain" + jobRepo "apps/backend/internal/module/job/repository" + jobUC "apps/backend/internal/module/job/usecase" + memberDomain "apps/backend/internal/module/member/domain" + memberRepo "apps/backend/internal/module/member/repository" + scoutRepo "apps/backend/internal/module/scout/repository" + scoutUC "apps/backend/internal/module/scout/usecase" + studioPublish "apps/backend/internal/module/studio/publish" + studioRepo "apps/backend/internal/module/studio/repository" + studioUC "apps/backend/internal/module/studio/usecase" + threadsDomain "apps/backend/internal/module/threads/domain" + threadsProv "apps/backend/internal/module/threads/provider" + threadsRepo "apps/backend/internal/module/threads/repository" + threadsUC "apps/backend/internal/module/threads/usecase" + usageDomain "apps/backend/internal/module/usage/domain" + usageRepo "apps/backend/internal/module/usage/repository" + usageUC "apps/backend/internal/module/usage/usecase" "github.com/zeromicro/go-zero/core/conf" "github.com/zeromicro/go-zero/core/logx" ) -// Independent worker binary (A-03). M0 stub: config load + heartbeat log only. -// Outbox/job runners land in M2/M4 (T133/T169). +// Worker:demo + threads_token_renew + persona_analyze_* + outbox publish +// +// cd apps/backend && go build -o bin/worker ./cmd/worker +// ./bin/worker -f etc/gateway.yaml func main() { configFile := flag.String("f", "etc/gateway.yaml", "config file") flag.Parse() @@ -25,31 +53,319 @@ func main() { c.ApplyEnv() workerID := c.Worker.ID - if workerID == "" { - workerID = "worker-1" + if workerID == "" || workerID == "worker-1" || workerID == "worker-local-1" { + workerID = "demo-worker-1" } interval := time.Duration(c.Worker.PollIntervalMs) * time.Millisecond if interval <= 0 { interval = 2 * time.Second } - logx.Infof("haixun worker stub started id=%s interval=%s (no publish yet)", workerID, interval) - fmt.Printf("worker stub running id=%s (Ctrl+C to stop)\n", workerID) + appN := appnotifUC.New(appnotifRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database)) + jobs := jobUC.New(jobRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database)) + jobs.Notifier = appN + + secret := c.Auth.AccessSecret + if secret == "" { + secret = "dev-token-secret" + } + var provider threadsDomain.Provider + if strings.TrimSpace(c.Platform.ThreadsAppId) != "" && strings.TrimSpace(c.Platform.ThreadsAppSecret) != "" { + provider = threadsProv.NewMeta(c.Platform.ThreadsAppId, c.Platform.ThreadsAppSecret) + logx.Info("worker threads: meta provider") + } else { + provider = &threadsProv.FakeProvider{} + logx.Info("worker threads: fake provider") + } + threadsSvc := threadsUC.New(threadsRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), provider, secret) + threadsSvc.Renew = jobs + + // usage + AI keys(與 gateway 對齊,persona LLM 需真 key) + members := memberRepo.NewMoncStore(c.Mongo.URI, c.Mongo.Database, c.CacheRedis) + keyRes := &usageUC.SettingsResolver{ + Members: members, + PlatformAI: c.Platform.AIKey, + PlatformXAI: c.Platform.XAIKey, + PlatformOpenCode: c.Platform.OpenCodeKey, + PlatformExa: c.Platform.ExaKey, + } + if keyRes.PlatformAI == "" && keyRes.PlatformXAI == "" && keyRes.PlatformOpenCode == "" { + keyRes.PlatformAI = "fake-platform-ai" + logx.Info("worker usage: no platform AI keys — fake-platform-ai for quota only") + } + usageSvc := usageUC.New(usageRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), keyRes) + aiRegistry := ai.NewRegistry() + + // Outbox/scout 發文:有 Meta 憑證則真 Threads Graph,否則 Fake(單元測試用) + var pub studioPublish.Transport + if strings.TrimSpace(c.Platform.ThreadsAppId) != "" && strings.TrimSpace(c.Platform.ThreadsAppSecret) != "" { + pub = studioPublish.NewMeta("https://graph.threads.net") + logx.Info("worker studio: meta publish transport (real Threads)") + } else { + pub = studioPublish.NewFake() + logx.Info("worker studio: fake publish transport (no Threads app credentials)") + } + studio := studioUC.New(studioRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), pub) + studio.Accounts = threadsSvc + studio.Usage = usageSvc + studio.AI = &ai.FakeClient{} + studio.AIRegistry = aiRegistry + studio.Keys = &workerAIKeys{Members: members, Resolver: keyRes} + // 發文暫存圖:Meta 發成功後刪 temp/* + store := workerStorage(c) + studio.Storage = store + studio.StoragePublicBase = strings.TrimSpace(c.ObjectStorage.PublicBaseURL) + + scoutSvc := scoutUC.New(scoutRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database)) + scoutSvc.Settings = &workerDevMode{Members: members} + scoutSvc.Transport = pub + scoutSvc.Accounts = threadsSvc + studio.Crawler = scoutSvc + // worker 執行 job 本體,不經 API 再入列 + studio.Jobs = nil + + logx.Infof("haixun worker started id=%s interval=%s (demo + token_renew + persona_analyze + compose_mimic + outbox)", workerID, interval) + fmt.Printf("worker running id=%s interval=%s (Ctrl+C to stop)\n", workerID, interval) sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) - tick := time.NewTicker(interval) defer tick.Stop() + ctx := context.Background() for { select { case <-sig: logx.Infof("worker %s shutting down", workerID) return - case t := <-tick.C: - // Health heartbeat only — no job/outbox processing in M0. - logx.Infof("worker %s heartbeat at %s", workerID, t.UTC().Format(time.RFC3339)) + case <-tick.C: + // 1) claim one due job + j, err := jobs.ClaimNext(ctx, workerID) + if err == nil { + switch j.TemplateType { + case "", jobDomain.TemplateDemo: + logx.Infof("worker %s demo job %s", workerID, j.ID) + if _, err := jobs.RunDemoToSuccess(ctx, j.ID); err != nil { + logx.Errorf("worker %s job %s failed: %v", workerID, j.ID, err) + _, _ = jobs.FailJob(ctx, j.ID, err.Error()) + } else { + logx.Infof("worker %s job %s succeeded", workerID, j.ID) + } + case jobDomain.TemplateThreadsTokenRenew: + logx.Infof("worker %s token renew job %s account=%s", workerID, j.ID, j.RefID) + if err := runTokenRenew(ctx, jobs, threadsSvc, j); err != nil { + logx.Errorf("worker %s renew %s failed: %v", workerID, j.ID, err) + _, _ = jobs.FailJob(ctx, j.ID, err.Error()) + } else { + logx.Infof("worker %s renew %s ok", workerID, j.ID) + } + case jobDomain.TemplatePersonaAnalyzeAccount, jobDomain.TemplatePersonaAnalyzeText: + logx.Infof("worker %s persona analyze job %s type=%s persona=%s", workerID, j.ID, j.TemplateType, j.RefID) + if err := runPersonaAnalyze(ctx, jobs, studio, j); err != nil { + logx.Errorf("worker %s persona %s failed: %v", workerID, j.ID, err) + _, _ = jobs.FailJob(ctx, j.ID, err.Error()) + } else { + logx.Infof("worker %s persona %s ok", workerID, j.ID) + } + case jobDomain.TemplateComposeMimic: + logx.Infof("worker %s compose_mimic job %s", workerID, j.ID) + if err := runComposeMimic(ctx, jobs, studio, j); err != nil { + logx.Errorf("worker %s compose_mimic %s failed: %v", workerID, j.ID, err) + _, _ = jobs.FailJob(ctx, j.ID, err.Error()) + } else { + logx.Infof("worker %s compose_mimic %s ok", workerID, j.ID) + } + case jobDomain.TemplatePlayGenerateScript: + logx.Infof("worker %s play_generate_script job %s play=%s", workerID, j.ID, j.RefID) + if err := runPlayGenerateScript(ctx, jobs, studio, j); err != nil { + logx.Errorf("worker %s play_generate_script %s failed: %v", workerID, j.ID, err) + _, _ = jobs.FailJob(ctx, j.ID, err.Error()) + } else { + logx.Infof("worker %s play_generate_script %s ok", workerID, j.ID) + } + default: + logx.Infof("worker %s unknown template %s job %s — fail", workerID, j.TemplateType, j.ID) + _, _ = jobs.FailJob(ctx, j.ID, "unknown template: "+j.TemplateType) + } + } + // 2) outbox due steps + n, err := studio.ProcessDueSteps(ctx, 0) + if err != nil { + logx.Errorf("worker %s outbox tick: %v", workerID, err) + } else if n > 0 { + logx.Infof("worker %s outbox published %d step(s)", workerID, n) + } + // 3) 終態任務超過 2 天自動清除 + if purged, err := jobs.PurgeExpiredTerminal(ctx); err != nil { + logx.Errorf("worker %s purge jobs: %v", workerID, err) + } else if purged > 0 { + logx.Infof("worker %s purged %d expired terminal job(s)", workerID, purged) + } } } } + +func runTokenRenew(ctx context.Context, jobs *jobUC.Service, threads *threadsUC.Service, j *jobDomain.Job) error { + if j.RefID == "" { + return fmt.Errorf("缺少帳號 ref_id") + } + _, _ = jobs.MarkRunningProgress(ctx, j.ID, 15, "定期延長 Threads token · 準備中") + _, _ = jobs.MarkRunningProgress(ctx, j.ID, 40, "定期延長 Threads token · 向平台刷新中") + acc, err := threads.Refresh(ctx, j.OwnerUID, j.RefID) + if err != nil { + return err + } + _, _ = jobs.MarkRunningProgress(ctx, j.ID, 75, "定期延長 Threads token · 排程下一次") + // 下次再約 30 天 + next := jobDomain.NowNano() + jobDomain.DefaultTokenRenewDelayNs + if acc != nil && acc.SessionExpiresAt > 0 { + early := acc.SessionExpiresAt - int64(30*24*time.Hour) + if early > jobDomain.NowNano() { + next = early + } + } + _ = jobs.ScheduleTokenRenew(ctx, j.OwnerUID, j.RefID, next) + _, _ = jobs.MarkRunningProgress(ctx, j.ID, 90, "定期延長 Threads token · 寫入完成") + _, err = jobs.SucceedJob(ctx, j.ID, "定期延長完成 · 已排程約 30 天後再執行") + return err +} + +func runPersonaAnalyze(ctx context.Context, jobs *jobUC.Service, studio *studioUC.Service, j *jobDomain.Job) error { + if j.RefID == "" { + return fmt.Errorf("缺少人設 ref_id") + } + startSum := "人設分析 · 文字分析中…" + if j.TemplateType == jobDomain.TemplatePersonaAnalyzeAccount { + startSum = "人設分析 · 爬取公開貼文 + AI 分析中…(可離開頁面)" + } + _, _ = jobs.MarkRunningProgress(ctx, j.ID, 10, startSum) + + err := studio.ExecutePersonaAnalyzeJob(ctx, j.TemplateType, j.OwnerUID, j.RefID, j.Payload, func(pct int, sum string) { + if pct < 10 { + pct = 10 + } + if pct > 95 { + pct = 95 + } + _, _ = jobs.MarkRunningProgress(ctx, j.ID, pct, sum) + }) + if err != nil { + return err + } + _, err = jobs.SucceedJob(ctx, j.ID, "人設分析完成 · 已寫入指紋/範本") + return err +} + +func runPlayGenerateScript(ctx context.Context, jobs *jobUC.Service, studio *studioUC.Service, j *jobDomain.Job) error { + var pl jobUC.PlayGenerateScriptPayload + if err := json.Unmarshal([]byte(j.Payload), &pl); err != nil { + return fmt.Errorf("invalid play_generate_script payload: %w", err) + } + playID := strings.TrimSpace(pl.PlayID) + if playID == "" { + playID = strings.TrimSpace(j.RefID) + } + if playID == "" { + return fmt.Errorf("empty play_id") + } + _, _ = jobs.MarkRunningProgress(ctx, j.ID, 15, "劇本產文 · 準備 AI(一次產全部)…") + llmCtx, cancel := context.WithTimeout(ctx, 4*time.Minute) + defer cancel() + _, _ = jobs.MarkRunningProgress(ctx, j.ID, 40, "劇本產文 · 呼叫模型中(可離開頁面)…") + n, err := studio.GeneratePlayScript(llmCtx, j.OwnerUID, playID, pl.OnlyEmpty) + if err != nil { + return err + } + pl.Filled = n + body, _ := json.Marshal(pl) + _, _ = jobs.MarkRunningProgress(ctx, j.ID, 90, fmt.Sprintf("劇本產文 · 已寫入 %d 步…", n)) + _, err = jobs.SucceedJobWithPayload(ctx, j.ID, fmt.Sprintf("劇本產文完成 · 已填 %d 步,請回互回方案查看", n), string(body)) + return err +} + +func runComposeMimic(ctx context.Context, jobs *jobUC.Service, studio *studioUC.Service, j *jobDomain.Job) error { + var pl jobUC.ComposeMimicPayload + if err := json.Unmarshal([]byte(j.Payload), &pl); err != nil { + return fmt.Errorf("invalid compose_mimic payload: %w", err) + } + if strings.TrimSpace(pl.SourceText) == "" { + return fmt.Errorf("empty source_text") + } + _, _ = jobs.MarkRunningProgress(ctx, j.ID, 15, "仿寫貼文 · 準備 AI…") + // worker 不綁 gateway 120s;給模型較長時間(reasoning 模型需要) + llmCtx, cancel := context.WithTimeout(ctx, 4*time.Minute) + defer cancel() + _, _ = jobs.MarkRunningProgress(ctx, j.ID, 35, "仿寫貼文 · 呼叫模型中(可離開頁面)…") + text, err := studio.Mimic(llmCtx, j.OwnerUID, pl.SourceText, pl.PersonaID, pl.StructureNotes) + if err != nil { + return err + } + _, _ = jobs.MarkRunningProgress(ctx, j.ID, 90, "仿寫貼文 · 寫入結果…") + pl.ResultText = text + body, _ := json.Marshal(pl) + _, err = jobs.SucceedJobWithPayload(ctx, j.ID, "仿寫完成 · 已可套用到正文", string(body)) + return err +} + +// workerAIKeys — 與 gateway studioAIKeys 對齊 +type workerAIKeys struct { + Members memberDomain.Repository + Resolver *usageUC.SettingsResolver +} + +func (k *workerAIKeys) ResolveAI(ctx context.Context, ownerUID int64) (provider, model, apiKey string, err error) { + st, err := k.Members.GetSettings(ctx, ownerUID) + if err != nil || st == nil { + st = memberDomain.DefaultSettings(ownerUID) + } + // 完全依會員 AI 設定(provider / model / key),不寫死模型 + provider = ai.NormalizeProvider(st.Provider) + model = strings.TrimSpace(st.Model) + if model == "" { + return provider, "", "", fmt.Errorf("請到設定選擇 AI 模型") + } + if k.Resolver != nil { + _, key, rerr := k.Resolver.ResolveKey(ctx, ownerUID, usageDomain.MeterAICopy) + if rerr == nil && strings.TrimSpace(key) != "" { + return provider, model, key, nil + } + } + if key := st.KeyForProvider(provider); key != "" { + return provider, model, key, nil + } + return provider, model, "", fmt.Errorf("no AI key(請到設定填寫 Key,或確認平台已配置)") +} + +type workerDevMode struct { + Members memberDomain.Repository +} + +func (d *workerDevMode) DevModeEnabled(ctx context.Context, uid int64) (bool, error) { + if d == nil || d.Members == nil { + return false, nil + } + st, err := d.Members.GetSettings(ctx, uid) + if err != nil || st == nil { + return false, nil + } + return st.DevModeEnabled, nil +} + +func workerStorage(c config.Config) fsDomain.Storage { + os := c.ObjectStorage + if strings.TrimSpace(os.Endpoint) == "" { + logx.Info("worker storage: disabled (no endpoint)") + return noop.New() + } + s, err := s3store.New(s3store.Config{ + Endpoint: os.Endpoint, Region: os.Region, Bucket: os.Bucket, + AccessKey: os.AccessKey, SecretKey: os.SecretKey, + UsePathStyle: os.UsePathStyle, PublicBaseURL: os.PublicBaseURL, + }) + if err != nil { + logx.Errorf("worker storage init failed: %v", err) + return noop.New() + } + logx.Infof("worker storage: minio/s3 bucket=%s (ephemeral publish images cleanup enabled)", os.Bucket) + return s +} diff --git a/apps/backend/etc/gateway.yaml b/apps/backend/etc/gateway.yaml index b4858bc..09007bc 100644 --- a/apps/backend/etc/gateway.yaml +++ b/apps/backend/etc/gateway.yaml @@ -3,7 +3,8 @@ Name: haixun-gateway Host: 0.0.0.0 Port: 8888 -Timeout: 30000 +# AI 產文/試產可能超過 30s;過短會 503 且前端拿不到結果 +Timeout: 120000 MaxBytes: 104857600 Log: @@ -32,14 +33,23 @@ Auth: RefreshSecret: "change-me-refresh-secret" RefreshExpire: 604800 +# 平台預設 key(所有會員可用;會員在設定頁填的 BYOK 優先) +# 也可用環境變數覆寫:PLATFORM_XAI_KEY / PLATFORM_OPENCODE_KEY / PLATFORM_EXA_KEY / PLATFORM_AI_KEY Platform: + # xAI(api.x.ai)— 人設 LLM、文案等 + XAIKey: "xai-TtmPWVAz1CdssptRetj9iOFSdSG2LZDffEOTKzIiDmVSDqUL62lVdDladlQIXAwoeHtowWraVqYuHzl1" + # 相容舊名:XAIKey 空時會用 AIKey AIKey: "" - ExaKey: "" - ThreadsAppId: "" - ThreadsAppSecret: "" + # OpenCode Go(opencode.ai/zen/go) + OpenCodeKey: "sk-sJkJVG2Q3tQEGqgl4cCy10QCVnrHpvuzR8RuE2eTuvruXiVNromsQ7zo6db0Ssj1" + # Exa 網搜 + ExaKey: "5d9af9b2-a847-4295-a110-f666049e1073" + ThreadsAppId: "2733369680379930" + ThreadsAppSecret: "31db4afdd8e348d75d76dee8fb9a42d3" Worker: - ID: "worker-local-1" + # demo-worker 預設 id(cmd/worker 若見 worker-local-1 會改成 demo-worker-1) + ID: "demo-worker-1" PollIntervalMs: 2000 Bcrypt: @@ -62,7 +72,7 @@ OAuth: # 正式 Mailgun SMTP 例: # Host: smtp.mailgun.org Port: 587 # User: postmaster@YOUR_DOMAIN Password: -# DevExposeCode: false +# DevExposeCode: false試產 # # env 覆寫:MAIL_SMTP_* 或 HAIXUN_SMTP_*(舊名相容) Mail: @@ -77,7 +87,7 @@ Mail: # 前端 origin:重設信連結 + 預設 logo URL(/brand-mark.jpg) # 遠端 dev:https://threads-tool-dev.30cm.net -PublicWebBase: "http://127.0.0.1:5173" +PublicWebBase: "https://threads-tool-dev.30cm.net" # 郵件品牌(hermes 模板;LogoURL 空 = PublicWebBase/brand-mark.jpg) Brand: diff --git a/apps/backend/gateway.go b/apps/backend/gateway.go index ac27a9d..b30ca93 100644 --- a/apps/backend/gateway.go +++ b/apps/backend/gateway.go @@ -39,6 +39,8 @@ func main() { ctx := svc.NewServiceContext(c) handler.RegisterHandlers(server, ctx) + // 靈感 SSE stream(goctl 不生成 streaming handler) + handler.RegisterInspireStream(server, ctx) logx.Infof("haixun gateway %s:%d pid=%d", c.Host, c.Port, os.Getpid()) fmt.Printf("Starting server at %s:%d...\n", c.Host, c.Port) diff --git a/apps/backend/generate/api/extension.api b/apps/backend/generate/api/extension.api new file mode 100644 index 0000000..bd9d054 --- /dev/null +++ b/apps/backend/generate/api/extension.api @@ -0,0 +1,13 @@ +syntax = "v1" + +// ST-16: extension zip download (authenticated member) +@server ( + jwt: Auth + group: extension + prefix: /api/v1/extension + middleware: AuthJWT +) +service gateway { + @handler DownloadExtensionZip + get /download returns (Empty) +} diff --git a/apps/backend/generate/api/gateway.api b/apps/backend/generate/api/gateway.api index 77049f5..1a66856 100644 --- a/apps/backend/generate/api/gateway.api +++ b/apps/backend/generate/api/gateway.api @@ -13,3 +13,11 @@ import "auth.api" import "admin.api" import "settings.api" import "media.api" +import "threads.api" +import "jobs.api" +import "notifications.api" +import "usage.api" +import "proxy.api" +import "extension.api" +import "studio.api" +import "m5.api" diff --git a/apps/backend/generate/api/jobs.api b/apps/backend/generate/api/jobs.api new file mode 100644 index 0000000..1f4d0e1 --- /dev/null +++ b/apps/backend/generate/api/jobs.api @@ -0,0 +1,59 @@ +syntax = "v1" + +type ( + JobPublic { + Id string `json:"id"` + TemplateType string `json:"template_type"` + Status string `json:"status"` + ProgressSummary string `json:"progress_summary"` + ProgressPercent int `json:"progress_percent"` + Error string `json:"error,optional"` + // 業務關聯(例:threads 帳號 id、人設 id) + RefId string `json:"ref_id,optional"` + // 任務 payload JSON(例:{"username":"x"}) + Payload string `json:"payload,optional"` + // 排程執行時間 unix ns;0=立刻可領 + RunAfter int64 `json:"run_after,optional"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + CompletedAt int64 `json:"completed_at,optional"` + } + + JobListData { + List []JobPublic `json:"list"` + } + + JobIdPath { + Id string `path:"id"` + } +) + +@server ( + jwt: Auth + group: jobs + prefix: /api/v1/jobs + middleware: AuthJWT +) +service gateway { + @handler ListJobs + get / returns (JobListData) + + @handler GetJob + get /:id (JobIdPath) returns (JobPublic) + + // 手動刪除任務(執行中不可刪;終態/已排程可刪) + @handler DeleteJob + delete /:id (JobIdPath) returns (OkData) +} + +// 管理員:產生 demo/測試任務(由 demo-worker 消費) +@server ( + jwt: Auth + group: jobs + prefix: /api/v1/jobs + middleware: AuthJWT,AdminAuth +) +service gateway { + @handler StartDemoJob + post /demo returns (JobPublic) +} diff --git a/apps/backend/generate/api/m5.api b/apps/backend/generate/api/m5.api new file mode 100644 index 0000000..9064346 --- /dev/null +++ b/apps/backend/generate/api/m5.api @@ -0,0 +1,465 @@ +syntax = "v1" + +// M5: inspire + research + media image + scout + +type ( + // --- Inspire --- + TrendPublic { + Id string `json:"id"` + Kind string `json:"kind"` + Label string `json:"label"` + Summary string `json:"summary"` + Heat int `json:"heat"` + Keywords []string `json:"keywords"` + Samples []string `json:"samples"` + SourceLabel string `json:"source_label"` + ObservedAt int64 `json:"observed_at"` + } + TrendListData { + List []TrendPublic `json:"list"` + } + InspireElementPublic { + Id string `json:"id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + RefId string `json:"ref_id,optional"` + Reusable bool `json:"reusable"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + } + InspireElementListData { + List []InspireElementPublic `json:"list"` + } + InspireElementSaveReq { + Id string `json:"id,optional"` + Kind string `json:"kind,optional"` + Title string `json:"title"` + Body string `json:"body"` + RefId string `json:"ref_id,optional"` + Reusable bool `json:"reusable,optional"` + } + InspireElementIdPath { + Id string `path:"id"` + } + InspireDraftPublic { + Title string `json:"title,optional"` + Body string `json:"body"` + } + InspireMessagePublic { + Id string `json:"id"` + Role string `json:"role"` + Text string `json:"text"` + Draft InspireDraftPublic `json:"draft,optional"` + CreatedAt int64 `json:"created_at"` + } + InspireSessionPublic { + Id string `json:"id"` + Title string `json:"title,optional"` + Messages []InspireMessagePublic `json:"messages"` + PinnedElementIds []string `json:"pinned_element_ids"` + CreatedAt int64 `json:"created_at,optional"` + UpdatedAt int64 `json:"updated_at"` + } + // 列表摘要(不含完整 messages) + InspireSessionSummaryPublic { + Id string `json:"id"` + Title string `json:"title"` + Preview string `json:"preview"` + MessageCount int `json:"message_count"` + UpdatedAt int64 `json:"updated_at"` + CreatedAt int64 `json:"created_at"` + Active bool `json:"active"` + } + InspireSessionListData { + List []InspireSessionSummaryPublic `json:"list"` + } + InspireSessionIdPath { + Id string `path:"id"` + } + InspireSessionCreateReq { + Title string `json:"title,optional"` + } + InspireChatReq { + Message string `json:"message"` + PinnedIds []string `json:"pinned_ids,optional"` + Mode string `json:"mode,optional"` // chat|generate + PersonaId string `json:"persona_id,optional"` + SessionId string `json:"session_id,optional"` // 空 = active + // generate 專用:待改寫素材(鎖定內容);空則拒絕產文 + Material string `json:"material,optional"` + } + InspireChatData { + Session InspireSessionPublic `json:"session"` + Messages []InspireMessagePublic `json:"messages"` + } + // 觀測:與送出相同的 prompt 組裝(不呼叫 AI、不計費) + InspirePromptPreviewReq { + Message string `json:"message,optional"` + PinnedIds []string `json:"pinned_ids,optional"` + Mode string `json:"mode,optional"` // chat|generate + PersonaId string `json:"persona_id,optional"` + SessionId string `json:"session_id,optional"` + Material string `json:"material,optional"` + } + InspirePromptPinnedPublic { + Id string `json:"id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + } + InspirePromptBlockPublic { + Title string `json:"title"` + Body string `json:"body"` + } + InspirePromptPreviewData { + Prompt string `json:"prompt"` + Mode string `json:"mode"` + Sections []string `json:"sections"` + Blocks []InspirePromptBlockPublic `json:"blocks"` + Fingerprint string `json:"fingerprint"` + CharCount int `json:"char_count"` + RuneCount int `json:"rune_count"` + Pinned []InspirePromptPinnedPublic `json:"pinned"` + Note string `json:"note,optional"` + } + ResearchSearchReq { + Query string `json:"query"` + } + ResearchHitPublic { + Id string `json:"id"` + Title string `json:"title"` + Snippet string `json:"snippet"` + Url string `json:"url"` + Summary string `json:"summary,optional"` + LearnPoints []string `json:"learn_points,optional"` + ReplyHooks []string `json:"reply_hooks,optional"` + SourceLabel string `json:"source_label,optional"` + } + ResearchSearchData { + List []ResearchHitPublic `json:"list"` + } + GenerateImageReq { + Prompt string `json:"prompt"` + } + GenerateImageData { + Id string `json:"id"` + Url string `json:"url"` + Prompt string `json:"prompt"` + } + + // --- Scout --- + BrandPublic { + Id string `json:"id"` + DisplayName string `json:"display_name"` + Brief string `json:"brief"` + TargetAudience string `json:"target_audience,optional"` + Goals string `json:"goals,optional"` + } + BrandListData { + List []BrandPublic `json:"list"` + } + BrandSaveReq { + Id string `json:"id,optional"` + DisplayName string `json:"display_name"` + Brief string `json:"brief,optional"` + TargetAudience string `json:"target_audience,optional"` + Goals string `json:"goals,optional"` + } + BrandIdPath { + Id string `path:"id"` + } + BrandActiveData { + Id string `json:"id"` + } + BrandSetActiveReq { + Id string `json:"id"` + } + ProductPublic { + Id string `json:"id"` + BrandId string `json:"brand_id"` + Label string `json:"label"` + ProductContext string `json:"product_context"` + MatchTags []string `json:"match_tags"` + PainPoints []string `json:"pain_points"` + PlacementUrl string `json:"placement_url,optional"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + } + ProductListData { + List []ProductPublic `json:"list"` + } + ProductSaveReq { + Id string `json:"id,optional"` + BrandId string `json:"brand_id"` + Label string `json:"label"` + ProductContext string `json:"product_context,optional"` + MatchTags []string `json:"match_tags,optional"` + PainPoints []string `json:"pain_points,optional"` + PlacementUrl string `json:"placement_url,optional"` + } + ProductIdPath { + Id string `path:"id"` + } + ProductListReq { + BrandId string `form:"brand_id,optional"` + } + ImportProductReq { + Url string `json:"url"` + } + ImportProductData { + Label string `json:"label"` + ProductContext string `json:"product_context"` + PainPoints []string `json:"pain_points"` + MatchTags []string `json:"match_tags"` + PlacementUrl string `json:"placement_url"` + SourceNote string `json:"source_note"` + } + ScoutBriefReq { + Intent string `json:"intent"` + BrandId string `json:"brand_id,optional"` + ProductId string `json:"product_id,optional"` + Purpose string `json:"purpose,optional"` + Deep bool `json:"deep,optional"` + } + ScoutBriefPublic { + Intent string `json:"intent"` + Mode string `json:"mode"` + BrandId string `json:"brand_id,optional"` + ProductId string `json:"product_id,optional"` + ProductLabel string `json:"product_label,optional"` + Pains []string `json:"pains"` + Tags []string `json:"tags"` + Periphery []string `json:"periphery"` + ScanTerms []string `json:"scan_terms"` + PlacementNote string `json:"placement_note,optional"` + ResponseStance string `json:"response_stance,optional"` + ThemeKey string `json:"theme_key,optional"` + ThemeLabel string `json:"theme_label,optional"` + ProductContext string `json:"product_context,optional"` + } + ScoutScanReq { + Brief ScoutBriefPublic `json:"brief"` + } + ScoutPostPublic { + Id string `json:"id"` + BrandId string `json:"brand_id,optional"` + Author string `json:"author"` + Text string `json:"text"` + SearchTag string `json:"search_tag"` + Opportunity string `json:"opportunity"` + OutreachStatus string `json:"outreach_status"` + DraftText string `json:"draft_text,optional"` + Score int `json:"score"` + MatchedProductId string `json:"matched_product_id,optional"` + MatchedProductLabel string `json:"matched_product_label,optional"` + MatchReason string `json:"match_reason,optional"` + ScoutMode string `json:"scout_mode,optional"` + IntentSnippet string `json:"intent_snippet,optional"` + ThemeKey string `json:"theme_key,optional"` + ThemeLabel string `json:"theme_label,optional"` + ScanPath string `json:"scan_path,optional"` + CreatedAt int64 `json:"created_at"` + } + ScoutPostListData { + List []ScoutPostPublic `json:"list"` + } + ScoutPostListReq { + BrandId string `form:"brand_id,optional"` + } + ScoutPostIdPath { + Id string `path:"id"` + } + ScoutDraftPathReq { + Id string `path:"id"` + PersonaId string `json:"persona_id,optional"` + } + ScoutSendPathReq { + Id string `path:"id"` + Text string `json:"text,optional"` + AccountId string `json:"account_id,optional"` + } + ScoutThemePath { + ThemeKey string `path:"themeKey"` + } + ScoutHomeworkPublic { + ThemeKey string `json:"theme_key"` + ThemeLabel string `json:"theme_label"` + Purpose string `json:"purpose"` + Brief ScoutBriefPublic `json:"brief"` + CreatedAt int64 `json:"created_at"` + } + ScoutHomeworkListData { + List []ScoutHomeworkPublic `json:"list"` + } + ScoutHomeworkSaveReq { + ThemeKey string `json:"theme_key"` + ThemeLabel string `json:"theme_label"` + Purpose string `json:"purpose,optional"` + Brief ScoutBriefPublic `json:"brief"` + } + ScoutCrawlerSessionReq { + Token string `json:"token"` + } +) + +@server ( + jwt: Auth + group: inspire + prefix: /api/v1/inspire + middleware: AuthJWT +) +service gateway { + @handler ListTrends + get /trends returns (TrendListData) + + @handler RefreshTrends + post /trends/refresh returns (TrendListData) + + @handler ListInspireElements + get /elements returns (InspireElementListData) + + @handler SaveInspireElement + post /elements (InspireElementSaveReq) returns (InspireElementPublic) + + @handler RemoveInspireElement + delete /elements/:id (InspireElementIdPath) returns (OkData) + + @handler GetInspireSession + get /session returns (InspireSessionPublic) + + @handler ClearInspireSession + post /session/clear returns (InspireSessionPublic) + + @handler ListInspireSessions + get /sessions returns (InspireSessionListData) + + @handler CreateInspireSession + post /sessions (InspireSessionCreateReq) returns (InspireSessionPublic) + + @handler GetInspireSessionById + get /sessions/:id (InspireSessionIdPath) returns (InspireSessionPublic) + + @handler ActivateInspireSession + post /sessions/:id/activate (InspireSessionIdPath) returns (InspireSessionPublic) + + @handler DeleteInspireSession + delete /sessions/:id (InspireSessionIdPath) returns (InspireSessionPublic) + + @handler InspireChat + post /chat (InspireChatReq) returns (InspireChatData) + + @handler InspirePromptPreview + post /prompt-preview (InspirePromptPreviewReq) returns (InspirePromptPreviewData) +} + +@server ( + jwt: Auth + group: research + prefix: /api/v1/research + middleware: AuthJWT +) +service gateway { + @handler ResearchSearch + post /search (ResearchSearchReq) returns (ResearchSearchData) +} + +@server ( + jwt: Auth + group: media + prefix: /api/v1/media + middleware: AuthJWT +) +service gateway { + @handler GenerateImage + post /generate-image (GenerateImageReq) returns (GenerateImageData) +} + +@server ( + jwt: Auth + group: scout + prefix: /api/v1/scout + middleware: AuthJWT +) +service gateway { + @handler ListBrands + get /brands returns (BrandListData) + + @handler SaveBrand + post /brands (BrandSaveReq) returns (BrandPublic) + + @handler GetBrand + get /brands/:id (BrandIdPath) returns (BrandPublic) + + @handler RemoveBrand + delete /brands/:id (BrandIdPath) returns (OkData) + + @handler GetActiveBrand + get /brands-active returns (BrandActiveData) + + @handler SetActiveBrand + post /brands-active (BrandSetActiveReq) returns (BrandActiveData) + + @handler ListProducts + get /products (ProductListReq) returns (ProductListData) + + @handler ListAllProducts + get /products/all returns (ProductListData) + + @handler SaveProduct + post /products (ProductSaveReq) returns (ProductPublic) + + @handler GetProduct + get /products/:id (ProductIdPath) returns (ProductPublic) + + @handler RemoveProduct + delete /products/:id (ProductIdPath) returns (OkData) + + @handler ImportProduct + post /products/import (ImportProductReq) returns (ImportProductData) + + @handler PrepareBrief + post /brief (ScoutBriefReq) returns (ScoutBriefPublic) + + @handler RunScan + post /scan (ScoutScanReq) returns (ScoutPostListData) + + @handler ListScoutPosts + get /posts (ScoutPostListReq) returns (ScoutPostListData) + + @handler DraftOutreach + post /posts/:id/draft (ScoutDraftPathReq) returns (ScoutPostPublic) + + @handler SkipOutreach + post /posts/:id/skip (ScoutPostIdPath) returns (ScoutPostPublic) + + @handler MarkPublished + post /posts/:id/mark-published (ScoutPostIdPath) returns (ScoutPostPublic) + + @handler SendOutreach + post /posts/:id/send (ScoutSendPathReq) returns (ScoutPostPublic) + + @handler RemoveScoutPost + delete /posts/:id (ScoutPostIdPath) returns (OkData) + + @handler RemoveScoutTheme + delete /themes/:themeKey (ScoutThemePath) returns (OkData) + + @handler ListHomework + get /homework returns (ScoutHomeworkListData) + + @handler SaveHomework + post /homework (ScoutHomeworkSaveReq) returns (ScoutHomeworkPublic) + + @handler GetHomework + get /homework/:themeKey (ScoutThemePath) returns (ScoutHomeworkPublic) + + @handler RemoveHomework + delete /homework/:themeKey (ScoutThemePath) returns (OkData) + + @handler SetCrawlerSession + post /crawler-session (ScoutCrawlerSessionReq) returns (OkData) + + @handler ClearCrawlerSession + delete /crawler-session returns (OkData) +} diff --git a/apps/backend/generate/api/notifications.api b/apps/backend/generate/api/notifications.api new file mode 100644 index 0000000..88cf686 --- /dev/null +++ b/apps/backend/generate/api/notifications.api @@ -0,0 +1,46 @@ +syntax = "v1" + +type ( + NotificationPublic { + Id string `json:"id"` + Title string `json:"title"` + Body string `json:"body"` + Kind string `json:"kind"` // job|system + RefType string `json:"ref_type"` + RefId string `json:"ref_id,optional"` + ReadAt int64 `json:"read_at,optional"` + CreatedAt int64 `json:"created_at"` + } + + NotificationListData { + List []NotificationPublic `json:"list"` + } + + UnreadCountData { + Count int64 `json:"count"` + } + + NotificationIdPath { + Id string `path:"id"` + } +) + +@server ( + jwt: Auth + group: notifications + prefix: /api/v1/notifications + middleware: AuthJWT +) +service gateway { + @handler ListNotifications + get / returns (NotificationListData) + + @handler UnreadNotificationCount + get /unread-count returns (UnreadCountData) + + @handler MarkNotificationRead + post /:id/read (NotificationIdPath) returns (OkData) + + @handler MarkAllNotificationsRead + post /read-all returns (OkData) +} diff --git a/apps/backend/generate/api/proxy.api b/apps/backend/generate/api/proxy.api new file mode 100644 index 0000000..b6aff90 --- /dev/null +++ b/apps/backend/generate/api/proxy.api @@ -0,0 +1,45 @@ +syntax = "v1" + +type ( + AICompleteReq { + Prompt string `json:"prompt"` + Model string `json:"model,optional"` + Meter string `json:"meter,optional"` // default ai_copy + } + + AICompleteData { + Text string `json:"text"` + KeyMode string `json:"key_mode"` + Model string `json:"model"` + } + + SearchReq { + Query string `json:"query"` + Limit int `json:"limit,optional"` + } + + SearchHit { + Title string `json:"title"` + Url string `json:"url"` + Snippet string `json:"snippet"` + } + + SearchData { + List []SearchHit `json:"list"` + KeyMode string `json:"key_mode"` + } +) + +@server ( + jwt: Auth + group: proxy + prefix: /api/v1/proxy + middleware: AuthJWT +) +service gateway { + @handler AIComplete + post /ai/complete (AICompleteReq) returns (AICompleteData) + + @handler ExaSearch + post /search (SearchReq) returns (SearchData) +} diff --git a/apps/backend/generate/api/settings.api b/apps/backend/generate/api/settings.api index 6716ab1..cd9d22a 100644 --- a/apps/backend/generate/api/settings.api +++ b/apps/backend/generate/api/settings.api @@ -1,14 +1,29 @@ syntax = "v1" type ( + // GetAi:設定 + 模型清單一併回(避免前端分開打兩支) + GetAiReq { + // 可選:預覽另一個 provider 的模型清單(不改存檔) + Provider string `form:"provider,optional"` + } + AiSettingsData { - Provider string `json:"provider"` - Model string `json:"model"` - ResearchProvider string `json:"research_provider"` - ResearchModel string `json:"research_model"` - ApiKeyConfigured bool `json:"api_key_configured"` - ResearchApiKeyConfigured bool `json:"research_api_key_configured"` - PlatformKeyAvailable bool `json:"platform_key_available"` + Provider string `json:"provider"` + Model string `json:"model"` + ResearchProvider string `json:"research_provider"` + ResearchModel string `json:"research_model"` + ApiKeyConfigured bool `json:"api_key_configured"` + ResearchApiKeyConfigured bool `json:"research_api_key_configured"` + PlatformKeyAvailable bool `json:"platform_key_available"` + // Models — 目前 models_provider 的可用模型(真拉 + 5 分鐘快取) + Models []string `json:"models"` + ModelsProvider string `json:"models_provider"` + // SelectedModel — 與 Model 相同;明確給前端「上次設定的模型」 + SelectedModel string `json:"selected_model"` + // ModelsFromCache — 這次模型清單是否來自快取 + ModelsFromCache bool `json:"models_from_cache,optional"` + // ModelsError — 真拉失敗時提示(仍回 fallback models) + ModelsError string `json:"models_error,optional"` } SaveAiReq { @@ -43,8 +58,27 @@ type ( } ListModelsData { - List []string `json:"list"` - Provider string `json:"provider"` + List []string `json:"list"` + Provider string `json:"provider"` + // SelectedModel — 若該 provider 為使用者目前存檔 provider,回傳已選 model + SelectedModel string `json:"selected_model,optional"` + FromCache bool `json:"from_cache,optional"` + ModelsError string `json:"models_error,optional"` + } + + // Threads OAuth 平台狀態(App Secret 永不回傳) + ThreadsSettingsData { + Provider string `json:"provider"` // fake | meta + Configured bool `json:"configured"` + OauthReady bool `json:"oauth_ready"` + ConnectPath string `json:"connect_path"` // 前端連帳入口 + // 給 Meta 後台「Valid OAuth Redirect URIs」貼上用 + CallbackUrl string `json:"callback_url"` + // 前端公開 origin(應 https) + PublicWebBase string `json:"public_web_base"` + // App Id 遮罩,例 2733…9930;未設定空字串 + AppIdMasked string `json:"app_id_masked,optional"` + Hint string `json:"hint"` } ) @@ -55,7 +89,7 @@ type ( ) service gateway { @handler GetAi - get /ai returns (AiSettingsData) + get /ai (GetAiReq) returns (AiSettingsData) @handler SaveAi put /ai (SaveAiReq) returns (AiSettingsData) @@ -68,4 +102,7 @@ service gateway { @handler ListModels get /models (ListModelsReq) returns (ListModelsData) + + @handler GetThreads + get /threads returns (ThreadsSettingsData) } diff --git a/apps/backend/generate/api/studio.api b/apps/backend/generate/api/studio.api new file mode 100644 index 0000000..626ade9 --- /dev/null +++ b/apps/backend/generate/api/studio.api @@ -0,0 +1,568 @@ +syntax = "v1" + +// M4 Studio: personas / plays / outbox / compose / own-posts / mentions + +type ( + PersonaDraftPublic { + Identity string `json:"identity"` + Tone string `json:"tone"` + Audience string `json:"audience"` + Hooks string `json:"hooks"` + LanguageFingerprint string `json:"language_fingerprint"` + Rhythm string `json:"rhythm"` + Punctuation string `json:"punctuation"` + ContentPatterns string `json:"content_patterns"` + KnowledgeTranslation string `json:"knowledge_translation"` + CtaStyle string `json:"cta_style"` + Examples string `json:"examples"` + Avoid string `json:"avoid"` + } + + StyleDimensionPublic { + Summary string `json:"summary"` + Evidence []string `json:"evidence,optional"` + } + + PersonaStylePublic { + Draft PersonaDraftPublic `json:"draft"` + DraftText string `json:"draft_text"` + Source string `json:"source"` + BenchmarkUsername string `json:"benchmark_username,optional"` + SourceLabel string `json:"source_label,optional"` + SampleCount int `json:"sample_count"` + AnalyzedAt int64 `json:"analyzed_at,optional"` + SamplePreviews []string `json:"sample_previews,optional"` + // 8D:d1Tone…d8Risk → { summary, evidence } + Dimensions map[string]StyleDimensionPublic `json:"dimensions,optional"` + } + + PersonaGuardPublic { + Avoid []string `json:"avoid"` + MaxChars int `json:"max_chars"` + BanAiTone bool `json:"ban_ai_tone"` + } + + PersonaPublic { + Id string `json:"id"` + Name string `json:"name"` + Brief string `json:"brief"` + Status string `json:"status"` + Style PersonaStylePublic `json:"style"` + Guard PersonaGuardPublic `json:"guard"` + Voice string `json:"voice,optional"` + Notes string `json:"notes,optional"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + } + + PersonaListData { + List []PersonaPublic `json:"list"` + } + + PersonaSaveReq { + Id string `json:"id,optional"` + Name string `json:"name"` + Brief string `json:"brief,optional"` + Status string `json:"status,optional"` + Style PersonaStylePublic `json:"style,optional"` + Guard PersonaGuardPublic `json:"guard,optional"` + Voice string `json:"voice,optional"` + Notes string `json:"notes,optional"` + } + + PersonaIdPath { + Id string `path:"id"` + } + + PersonaActiveData { + Id string `json:"id"` + } + + PersonaSetActiveReq { + Id string `json:"id"` + } + + PersonaAnalyzeTextReq { + Id string `path:"id"` + RawText string `json:"raw_text"` + SourceLabel string `json:"source_label,optional"` + } + + PersonaAnalyzeAccountReq { + Id string `path:"id"` + Username string `json:"username"` + } + + // --- Plays --- + ExternalTargetPublic { + Url string `json:"url"` + RawUrl string `json:"raw_url,optional"` + Shortcode string `json:"shortcode,optional"` + AuthorUsername string `json:"author_username,optional"` + TextPreview string `json:"text_preview,optional"` + MediaId string `json:"media_id,optional"` + ResolvedAt int64 `json:"resolved_at,optional"` + } + + PlayStepPublic { + Id string `json:"id"` + SortOrder int `json:"sort_order"` + Kind string `json:"kind"` + AccountId string `json:"account_id"` + Text string `json:"text"` + DelayFromPreviousSec int `json:"delay_from_previous_sec"` + PersonaId string `json:"persona_id,optional"` + BrandId string `json:"brand_id,optional"` + ImageUrls []string `json:"image_urls,optional"` + } + + PlayPublic { + Id string `json:"id"` + Title string `json:"title"` + Topic string `json:"topic"` + Status string `json:"status"` + LeadAccountId string `json:"lead_account_id"` + CastAccountIds []string `json:"cast_account_ids"` + TargetOwnPostId string `json:"target_own_post_id,optional"` + TargetExternal ExternalTargetPublic `json:"target_external,optional"` + Steps []PlayStepPublic `json:"steps"` + ScheduleStartAt int64 `json:"schedule_start_at"` + IntervalMinutes int `json:"interval_minutes,optional"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + } + + PlayListData { + List []PlayPublic `json:"list"` + } + + PlaySaveReq { + Id string `json:"id,optional"` + Title string `json:"title"` + Topic string `json:"topic,optional"` + Status string `json:"status,optional"` + LeadAccountId string `json:"lead_account_id"` + CastAccountIds []string `json:"cast_account_ids,optional"` + TargetOwnPostId string `json:"target_own_post_id,optional"` + TargetExternal ExternalTargetPublic `json:"target_external,optional"` + Steps []PlayStepPublic `json:"steps"` + ScheduleStartAt int64 `json:"schedule_start_at,optional"` + IntervalMinutes int `json:"interval_minutes,optional"` + } + + PlayIdPath { + Id string `path:"id"` + } + + PlayByPostReq { + OwnPostId string `form:"own_post_id"` + } + + PlayByExternalReq { + Url string `form:"url"` + } + + PlayResolveReq { + Url string `json:"url"` + } + + // AI 產劇本一步(真 LLM;互回/串場共用) + PlayGenerateStepReq { + PersonaId string `json:"persona_id,optional"` + Context string `json:"context"` // 主貼/上一則/目標文 + Topic string `json:"topic,optional"` + SpeakerLabel string `json:"speaker_label,optional"` + IsLead bool `json:"is_lead,optional"` + // mode: reply(預設)| root(串場主貼) + Mode string `json:"mode,optional"` + } + + PlayGenerateStepData { + Text string `json:"text"` + } + + // 一次產完整劇本(背景 job) + PlayGenerateScriptData { + JobId string `json:"job_id"` + Async bool `json:"async"` + } + + // --- Outbox --- + OutboxStepPublic { + Id string `json:"id"` + StepId string `json:"step_id"` + SortOrder int `json:"sort_order"` + Kind string `json:"kind"` + AccountId string `json:"account_id"` + Text string `json:"text"` + Status string `json:"status"` + Error string `json:"error,optional"` + ScheduledAt int64 `json:"scheduled_at,optional"` + PublishedAt int64 `json:"published_at,optional"` + } + + OutboxPublic { + Id string `json:"id"` + PlayId string `json:"play_id"` + Title string `json:"title"` + Status string `json:"status"` + Steps []OutboxStepPublic `json:"steps"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + } + + OutboxListData { + List []OutboxPublic `json:"list"` + } + + OutboxIdPath { + Id string `path:"id"` + } + + OutboxRetryPath { + Id string `path:"id"` + StepId string `path:"stepId"` + } + + // --- Compose --- + ComposeMimicReq { + SourceText string `json:"source_text"` + PersonaId string `json:"persona_id,optional"` + // 可選:從「我的貼文」結構分析帶過來的備註(鉤子/結構/可複製點) + StructureNotes string `json:"structure_notes,optional"` + } + + // 仿寫改背景 Job:立刻回 job_id;完成後從 job.payload.result_text 取文 + ComposeMimicData { + // 相容:同步路徑才有;Job 路徑為空 + Text string `json:"text,optional"` + JobId string `json:"job_id,optional"` + Async bool `json:"async,optional"` + } + + // 人設試產:依指紋真 LLM 產主貼 + 回文(可帶新聞話題) + ComposePersonaPreviewReq { + PersonaId string `json:"persona_id"` + // 手動話題;空則嘗試上網取新聞靈感 + Topic string `json:"topic,optional"` + // 預設 true:無 topic 時抓即時新聞標題當靈感 + UseNews bool `json:"use_news,optional"` + } + + ComposePersonaPreviewData { + Topic string `json:"topic"` + TopicSource string `json:"topic_source"` // news | manual | fallback + PostText string `json:"post_text"` + ReplyText string `json:"reply_text"` + Notes string `json:"notes,optional"` + } + + ComposeViralReq { + Text string `json:"text"` + } + + ViralAnalysisPublic { + Hooks string `json:"hooks"` + Structure string `json:"structure"` + Emotion string `json:"emotion"` + Summary string `json:"summary"` + Cta string `json:"cta,optional"` + Copyable string `json:"copyable,optional"` + Risks string `json:"risks,optional"` + } + + ComposePublishReq { + AccountId string `json:"account_id"` + Text string `json:"text"` + Title string `json:"title,optional"` + ImageUrls []string `json:"image_urls,optional"` + ScheduleStartAt int64 `json:"schedule_start_at,optional"` + // Threads 話題標籤(topic_tag,1~50 字,可不加 #) + TopicTag string `json:"topic_tag,optional"` + } + + // --- Own posts --- + OwnPostReplyPublic { + Id string `json:"id"` + Username string `json:"username"` + Text string `json:"text"` + CreatedAt int64 `json:"created_at"` + LikeCount int `json:"like_count,optional"` + ReplyStatus string `json:"reply_status,optional"` + RepliedBy string `json:"replied_by,optional"` + RepliedAt int64 `json:"replied_at,optional"` + ParentReplyId string `json:"parent_reply_id,optional"` + IsMine bool `json:"is_mine,optional"` + } + + OwnPostPublic { + Id string `json:"id"` + AccountId string `json:"account_id"` + MediaId string `json:"media_id,optional"` + Text string `json:"text"` + MediaType string `json:"media_type"` + MediaUrl string `json:"media_url,optional"` + ThumbnailUrl string `json:"thumbnail_url,optional"` + Permalink string `json:"permalink,optional"` + Shortcode string `json:"shortcode,optional"` + TopicTag string `json:"topic_tag,optional"` + LikeCount int `json:"like_count"` + ReplyCount int `json:"reply_count"` + RepostCount int `json:"repost_count"` + QuoteCount int `json:"quote_count"` + ViewCount int `json:"view_count"` + ShareCount int `json:"share_count"` + InsightsStatus string `json:"insights_status,optional"` + FormulaSummary string `json:"formula_summary,optional"` + Insight string `json:"insight,optional"` + FormulaDetail string `json:"formula_detail,optional"` + Replies []OwnPostReplyPublic `json:"replies"` + PublishedAt int64 `json:"published_at"` + } + + OwnPostListData { + List []OwnPostPublic `json:"list"` + } + + OwnPostListReq { + AccountId string `form:"account_id,optional"` + } + + OwnPostSyncReq { + AccountId string `json:"account_id"` + } + + OwnPostLastSyncedData { + LastSyncedAt int64 `json:"last_synced_at"` + } + + OwnPostGenerateReplyReq { + PostId string `json:"post_id"` + ReplyId string `json:"reply_id,optional"` + PersonaId string `json:"persona_id,optional"` + } + + OwnPostGenerateReplyData { + Text string `json:"text"` + } + + OwnPostSendReplyReq { + PostId string `json:"post_id"` + ReplyId string `json:"reply_id,optional"` + Text string `json:"text"` + AccountId string `json:"account_id,optional"` + ImageUrls []string `json:"image_urls,optional"` + } + + OwnPostAnalyzeReq { + PostId string `json:"post_id"` + } + + // 點開貼文後再載留言(避免 sync 一次打爆 conversation API) + OwnPostLoadRepliesReq { + PostId string `json:"post_id"` + } + + // --- Mentions --- + MentionPublic { + Id string `json:"id"` + AccountId string `json:"account_id"` + MediaId string `json:"media_id,optional"` + FromUsername string `json:"from_username"` + Text string `json:"text"` + ContextSnippet string `json:"context_snippet"` + Permalink string `json:"permalink,optional"` + Status string `json:"status"` + DraftText string `json:"draft_text,optional"` + CreatedAt int64 `json:"created_at"` + } + + MentionListData { + List []MentionPublic `json:"list"` + } + + MentionListReq { + AccountId string `form:"account_id,optional"` + } + + MentionSyncReq { + AccountId string `json:"account_id"` + } + + MentionIdPath { + Id string `path:"id"` + } + + MentionGenerateReq { + Id string `path:"id"` + PersonaId string `json:"persona_id,optional"` + } + + MentionMarkRepliedReq { + Id string `path:"id"` + Text string `json:"text,optional"` + ImageUrls []string `json:"image_urls,optional"` + } +) + +@server ( + jwt: Auth + group: personas + prefix: /api/v1/personas + middleware: AuthJWT +) +service gateway { + @handler ListPersonas + get / returns (PersonaListData) + + @handler GetActivePersona + get /active returns (PersonaActiveData) + + @handler SetActivePersona + post /active (PersonaSetActiveReq) returns (PersonaActiveData) + + @handler GetPersona + get /:id (PersonaIdPath) returns (PersonaPublic) + + @handler SavePersona + post / (PersonaSaveReq) returns (PersonaPublic) + + @handler RemovePersona + delete /:id (PersonaIdPath) returns (OkData) + + @handler AnalyzePersonaText + post /:id/analyze-text (PersonaAnalyzeTextReq) returns (PersonaPublic) + + @handler AnalyzePersonaAccount + post /:id/analyze-account (PersonaAnalyzeAccountReq) returns (PersonaPublic) +} + +@server ( + jwt: Auth + group: plays + prefix: /api/v1/plays + middleware: AuthJWT +) +service gateway { + @handler ListPlays + get / returns (PlayListData) + + @handler ListPlaysByPost + get /by-post (PlayByPostReq) returns (PlayListData) + + @handler ListPlaysByExternal + get /by-external (PlayByExternalReq) returns (PlayListData) + + @handler ResolveExternalLink + post /resolve-external (PlayResolveReq) returns (ExternalTargetPublic) + + @handler GetPlay + get /:id (PlayIdPath) returns (PlayPublic) + + @handler SavePlay + post / (PlaySaveReq) returns (PlayPublic) + + @handler RemovePlay + delete /:id (PlayIdPath) returns (OkData) + + @handler SubmitPlay + post /:id/submit (PlayIdPath) returns (OutboxPublic) + + @handler GeneratePlayStep + post /generate-step (PlayGenerateStepReq) returns (PlayGenerateStepData) + + @handler GeneratePlayScript + post /:id/generate-script (PlayIdPath) returns (PlayGenerateScriptData) +} + +@server ( + jwt: Auth + group: outbox + prefix: /api/v1/outbox + middleware: AuthJWT +) +service gateway { + @handler ListOutbox + get / returns (OutboxListData) + + @handler GetOutbox + get /:id (OutboxIdPath) returns (OutboxPublic) + + @handler RemoveOutbox + delete /:id (OutboxIdPath) returns (OkData) + + @handler RetryOutboxStep + post /:id/steps/:stepId/retry (OutboxRetryPath) returns (OutboxPublic) +} + +@server ( + jwt: Auth + group: compose + prefix: /api/v1/compose + middleware: AuthJWT +) +service gateway { + @handler ComposeMimic + post /mimic (ComposeMimicReq) returns (ComposeMimicData) + + @handler ComposePersonaPreview + post /persona-preview (ComposePersonaPreviewReq) returns (ComposePersonaPreviewData) + + @handler ComposeAnalyzeViral + post /analyze-viral (ComposeViralReq) returns (ViralAnalysisPublic) + + @handler ComposePublishSingle + post /publish-single (ComposePublishReq) returns (OutboxPublic) +} + +@server ( + jwt: Auth + group: ownposts + prefix: /api/v1/own-posts + middleware: AuthJWT +) +service gateway { + @handler ListOwnPosts + get / (OwnPostListReq) returns (OwnPostListData) + + @handler OwnPostsLastSynced + get /last-synced-at returns (OwnPostLastSyncedData) + + @handler SyncOwnPosts + post /sync (OwnPostSyncReq) returns (OwnPostListData) + + @handler OwnPostGenerateReply + post /generate-reply (OwnPostGenerateReplyReq) returns (OwnPostGenerateReplyData) + + @handler OwnPostSendReply + post /send-reply (OwnPostSendReplyReq) returns (OwnPostPublic) + + @handler OwnPostAnalyze + post /analyze (OwnPostAnalyzeReq) returns (OwnPostPublic) + + @handler OwnPostLoadReplies + post /load-replies (OwnPostLoadRepliesReq) returns (OwnPostPublic) +} + +@server ( + jwt: Auth + group: mentions + prefix: /api/v1/mentions + middleware: AuthJWT +) +service gateway { + @handler ListMentions + get / (MentionListReq) returns (MentionListData) + + @handler SyncMentions + post /sync (MentionSyncReq) returns (MentionListData) + + @handler MentionGenerateReply + post /:id/generate-reply (MentionGenerateReq) returns (MentionPublic) + + @handler MentionMarkReplied + post /:id/mark-replied (MentionMarkRepliedReq) returns (MentionPublic) + + @handler MentionSkip + post /:id/skip (MentionIdPath) returns (MentionPublic) +} diff --git a/apps/backend/generate/api/threads.api b/apps/backend/generate/api/threads.api new file mode 100644 index 0000000..56ea7ed --- /dev/null +++ b/apps/backend/generate/api/threads.api @@ -0,0 +1,97 @@ +syntax = "v1" + +type ( + ThreadsAccountPublic { + Id string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Connection string `json:"connection"` // connected|error|disconnected + IsUsable bool `json:"is_usable"` + AvatarColor string `json:"avatar_color,optional"` + AvatarUrl string `json:"avatar_url,optional"` + ErrorMessage string `json:"error_message,optional"` + SessionExpiresAt int64 `json:"session_expires_at,optional"` + SessionRefreshedAt int64 `json:"session_refreshed_at,optional"` + // never expose access_token / refresh_token + } + + ThreadsAccountListData { + List []ThreadsAccountPublic `json:"list"` + } + + ThreadsOAuthStartData { + AuthorizeUrl string `json:"authorize_url"` + State string `json:"state"` + } + + // Callback is query-string from Meta / fake provider (public). + ThreadsOAuthCallbackReq { + Code string `form:"code,optional"` + State string `form:"state,optional"` + Error string `form:"error,optional"` + ErrorReason string `form:"error_reason,optional"` + ErrorDescription string `form:"error_description,optional"` + } + + ThreadsOAuthCallbackData { + Ok bool `json:"ok"` + Message string `json:"message,optional"` + // RedirectURL for custom handler (browser flow) + RedirectUrl string `json:"redirect_url,optional"` + } + + ThreadsAccountIdPath { + Id string `path:"id"` + } + + // 擴充(含舊版)匯入 Chrome storageState;寫入 scout crawler-session 供 dev 模式海巡 + ImportThreadsAccountSessionReq { + // path id + body + Id string `path:"id"` + StorageState string `json:"storageState"` + } + + ImportThreadsAccountSessionData { + Success bool `json:"success"` + Valid bool `json:"valid"` + Synced bool `json:"synced"` + AccountId string `json:"account_id"` + Username string `json:"username,optional"` + Message string `json:"message"` + UpdateAt int64 `json:"update_at,optional"` + } +) + +// Public OAuth callback (no JWT) +@server ( + group: threads + prefix: /api/v1/threads-accounts +) +service gateway { + @handler ThreadsOAuthCallback + get /oauth/callback (ThreadsOAuthCallbackReq) returns (ThreadsOAuthCallbackData) +} + +@server ( + jwt: Auth + group: threads + prefix: /api/v1/threads-accounts + middleware: AuthJWT +) +service gateway { + @handler ThreadsOAuthStart + get /oauth/start returns (ThreadsOAuthStartData) + + @handler ListThreadsAccounts + get / returns (ThreadsAccountListData) + + @handler RefreshThreadsAccount + post /:id/refresh (ThreadsAccountIdPath) returns (ThreadsAccountPublic) + + // Chrome 擴充:匯入 Playwright storageState(舊路徑相容;實際存 crawler session) + @handler ImportThreadsAccountSession + post /:id/session/import (ImportThreadsAccountSessionReq) returns (ImportThreadsAccountSessionData) + + @handler DisconnectThreadsAccount + delete /:id (ThreadsAccountIdPath) returns (OkData) +} diff --git a/apps/backend/generate/api/usage.api b/apps/backend/generate/api/usage.api new file mode 100644 index 0000000..a9edcf4 --- /dev/null +++ b/apps/backend/generate/api/usage.api @@ -0,0 +1,141 @@ +syntax = "v1" + +type ( + UsageEventPublic { + Id string `json:"id"` + Uid int64 `json:"uid"` + Meter string `json:"meter"` + Credits int `json:"credits"` + KeyMode string `json:"key_mode"` + Label string `json:"label"` + Source string `json:"source"` + CreatedAt int64 `json:"created_at"` + } + + UsageMeterCount { + Count int `json:"count"` + Credits int `json:"credits"` + } + + UsagePlatformBlock { + CreditsUsed int `json:"credits_used"` + CreditsRemaining int `json:"credits_remaining"` + CreditsTotal int `json:"credits_total"` + CallCount int `json:"call_count"` + ByMeter map[string]UsageMeterCount `json:"by_meter,optional"` + } + + UsageByokBlock { + CallCount int `json:"call_count"` + ByMeter map[string]UsageMeterCount `json:"by_meter,optional"` + } + + UsageSummaryData { + MonthKey string `json:"month_key"` + Uid int64 `json:"uid"` + PlanId string `json:"plan_id"` + Unlimited bool `json:"unlimited"` + Platform UsagePlatformBlock `json:"platform"` + Byok UsageByokBlock `json:"byok"` + // FE progress bar (platform only) + TotalCredits int `json:"total_credits"` + RemainingCredits int `json:"remaining_credits"` + } + + UsageSummaryReq { + MonthKey string `form:"month_key,optional"` + } + + UsageEventsReq { + MonthKey string `form:"month_key,optional"` + KeyMode string `form:"key_mode,optional"` // platform|byok|all + Limit int `form:"limit,optional"` + } + + UsageEventsData { + List []UsageEventPublic `json:"list"` + } + + UsagePrefsData { + PlanId string `json:"plan_id"` + Unlimited bool `json:"unlimited"` + } + + UsageSetPrefsReq { + Uid string `json:"uid"` + PlanId string `json:"plan_id,optional"` + Unlimited *bool `json:"unlimited,optional"` + } + + UsagePurchaseReq { + PlanId string `json:"plan_id"` + MockRef string `json:"mock_ref,optional"` + } + + UsagePurchasePublic { + Id string `json:"id"` + PlanId string `json:"plan_id"` + MockRef string `json:"mock_ref,optional"` + CreatedAt int64 `json:"created_at"` + } + + UsagePurchasesData { + List []UsagePurchasePublic `json:"list"` + } + + UsageTenantRow { + Uid int64 `json:"uid"` + PlanId string `json:"plan_id"` + Unlimited bool `json:"unlimited"` + PlatformCreditsUsed int `json:"platform_credits_used"` + ByokCallCount int `json:"byok_call_count"` + } + + UsageTenantSummaryData { + MonthKey string `json:"month_key"` + PlatformCreditsUsed int `json:"platform_credits_used"` + ByokCallCount int `json:"byok_call_count"` + Members []UsageTenantRow `json:"members"` + } + + UsageTenantSummaryReq { + MonthKey string `form:"month_key,optional"` + } +) + +@server ( + jwt: Auth + group: usage + prefix: /api/v1/usage + middleware: AuthJWT +) +service gateway { + @handler GetUsageSummary + get /summary (UsageSummaryReq) returns (UsageSummaryData) + + @handler ListUsageEvents + get /events (UsageEventsReq) returns (UsageEventsData) + + @handler GetUsagePrefs + get /prefs returns (UsagePrefsData) + + @handler PurchasePlan + post /purchase (UsagePurchaseReq) returns (UsagePurchasePublic) + + @handler ListMyPurchases + get /purchases returns (UsagePurchasesData) +} + +@server ( + jwt: Auth + group: usage + prefix: /api/v1/usage + middleware: AuthJWT,AdminAuth +) +service gateway { + @handler SetUsagePrefs + post /prefs (UsageSetPrefsReq) returns (UsagePrefsData) + + @handler GetTenantUsageSummary + get /tenant-summary (UsageTenantSummaryReq) returns (UsageTenantSummaryData) +} diff --git a/apps/backend/internal/config/config.go b/apps/backend/internal/config/config.go index 629b65b..86d0b03 100644 --- a/apps/backend/internal/config/config.go +++ b/apps/backend/internal/config/config.go @@ -27,7 +27,12 @@ type Config struct { RefreshExpire int64 } Platform struct { - AIKey string `json:",optional"` + // AIKey — legacy alias for XAIKey + AIKey string `json:",optional"` + // XAIKey — platform BYOK for xAI (api.x.ai) + XAIKey string `json:",optional"` + // OpenCodeKey — platform BYOK for OpenCode Go (opencode.ai/zen/go) + OpenCodeKey string `json:",optional"` ExaKey string `json:",optional"` ThreadsAppId string `json:",optional"` ThreadsAppSecret string `json:",optional"` @@ -62,6 +67,10 @@ type Config struct { // PublicWebBase — 前端 origin,用於重設密碼信內連結/logo(勿尾斜線) // 例:http://127.0.0.1:5173 或 https://threads-tool-dev.30cm.net PublicWebBase string `json:",optional,default=http://127.0.0.1:5173"` + // PublicAPIBase — 對外 API origin(Threads OAuth redirect_uri 必須 https 公開網址) + // 空則:若 PublicWebBase 為 https 則同 host;否則回落 http://127.0.0.1:Port + // 例:https://threads-tool-dev.30cm.net + PublicAPIBase string `json:",optional"` // Brand — 郵件 chrome(hermes);LogoURL 空則用 PublicWebBase + /brand-mark.jpg Brand struct { Name string `json:",optional,default=Harbor Desk"` @@ -111,9 +120,25 @@ func (c *Config) ApplyEnv() { if v := os.Getenv("PLATFORM_AI_KEY"); v != "" { c.Platform.AIKey = v } + if v := firstEnv("PLATFORM_XAI_KEY", "XAI_API_KEY"); v != "" { + c.Platform.XAIKey = v + } + if v := firstEnv("PLATFORM_OPENCODE_KEY", "OPENCODE_API_KEY", "OPENCODE_GO_API_KEY"); v != "" { + c.Platform.OpenCodeKey = v + } + // legacy AIKey → XAI when XAI empty + if c.Platform.XAIKey == "" && c.Platform.AIKey != "" { + c.Platform.XAIKey = c.Platform.AIKey + } if v := os.Getenv("PLATFORM_EXA_KEY"); v != "" { c.Platform.ExaKey = v } + if v := firstEnv("THREADS_APP_ID", "HAIXUN_THREADS_APP_ID"); v != "" { + c.Platform.ThreadsAppId = v + } + if v := firstEnv("THREADS_APP_SECRET", "HAIXUN_THREADS_APP_SECRET"); v != "" { + c.Platform.ThreadsAppSecret = v + } if v := os.Getenv("WORKER_ID"); v != "" { c.Worker.ID = v } @@ -155,6 +180,9 @@ func (c *Config) ApplyEnv() { if v := firstEnv("PUBLIC_WEB_BASE", "HAIXUN_PUBLIC_WEB_BASE"); v != "" { c.PublicWebBase = strings.TrimRight(v, "/") } + if v := firstEnv("PUBLIC_API_BASE", "HAIXUN_PUBLIC_API_BASE"); v != "" { + c.PublicAPIBase = strings.TrimRight(v, "/") + } if v := os.Getenv("MAIL_BRAND_NAME"); v != "" { c.Brand.Name = v } diff --git a/apps/backend/internal/handler/compose/compose_analyze_viral_handler.go b/apps/backend/internal/handler/compose/compose_analyze_viral_handler.go new file mode 100644 index 0000000..14d1bfe --- /dev/null +++ b/apps/backend/internal/handler/compose/compose_analyze_viral_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package compose + +import ( + "net/http" + + "apps/backend/internal/logic/compose" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ComposeAnalyzeViralHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ComposeViralReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := compose.NewComposeAnalyzeViralLogic(r.Context(), svcCtx) + data, err := l.ComposeAnalyzeViral(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/compose/compose_mimic_handler.go b/apps/backend/internal/handler/compose/compose_mimic_handler.go new file mode 100644 index 0000000..068712b --- /dev/null +++ b/apps/backend/internal/handler/compose/compose_mimic_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package compose + +import ( + "net/http" + + "apps/backend/internal/logic/compose" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ComposeMimicHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ComposeMimicReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := compose.NewComposeMimicLogic(r.Context(), svcCtx) + data, err := l.ComposeMimic(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/compose/compose_persona_preview_handler.go b/apps/backend/internal/handler/compose/compose_persona_preview_handler.go new file mode 100644 index 0000000..745b3cd --- /dev/null +++ b/apps/backend/internal/handler/compose/compose_persona_preview_handler.go @@ -0,0 +1,26 @@ +package compose + +import ( + "net/http" + + "apps/backend/internal/logic/compose" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ComposePersonaPreviewHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ComposePersonaPreviewReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := compose.NewComposePersonaPreviewLogic(r.Context(), svcCtx) + data, err := l.ComposePersonaPreview(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/compose/compose_publish_single_handler.go b/apps/backend/internal/handler/compose/compose_publish_single_handler.go new file mode 100644 index 0000000..6317084 --- /dev/null +++ b/apps/backend/internal/handler/compose/compose_publish_single_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package compose + +import ( + "net/http" + + "apps/backend/internal/logic/compose" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ComposePublishSingleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ComposePublishReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := compose.NewComposePublishSingleLogic(r.Context(), svcCtx) + data, err := l.ComposePublishSingle(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/extension/download_extension_zip_handler.go b/apps/backend/internal/handler/extension/download_extension_zip_handler.go new file mode 100644 index 0000000..2e523ce --- /dev/null +++ b/apps/backend/internal/handler/extension/download_extension_zip_handler.go @@ -0,0 +1,34 @@ +package extension + +import ( + "net/http" + "os" + "path/filepath" + + "apps/backend/internal/logic/extension" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func DownloadExtensionZipHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := extension.NewDownloadExtensionZipLogic(r.Context(), svcCtx) + if _, err := l.DownloadExtensionZip(); err != nil { + response.Write(r.Context(), w, nil, err) + return + } + path := svcCtx.ExtensionZipPath + if path == "" { + path = extension.ResolveZipPath(svcCtx) + } + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", `attachment; filename="haixun-threads-sync.zip"`) + http.ServeFile(w, r, filepath.Clean(path)) + } +} + +// ensure zip exists for tests +func zipExists(p string) bool { + st, err := os.Stat(p) + return err == nil && st.Size() > 0 +} diff --git a/apps/backend/internal/handler/inspire/activate_inspire_session_handler.go b/apps/backend/internal/handler/inspire/activate_inspire_session_handler.go new file mode 100644 index 0000000..48c2120 --- /dev/null +++ b/apps/backend/internal/handler/inspire/activate_inspire_session_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package inspire + +import ( + "net/http" + + "apps/backend/internal/logic/inspire" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ActivateInspireSessionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.InspireSessionIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := inspire.NewActivateInspireSessionLogic(r.Context(), svcCtx) + data, err := l.ActivateInspireSession(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/inspire/chat_stream_handler.go b/apps/backend/internal/handler/inspire/chat_stream_handler.go new file mode 100644 index 0000000..242df33 --- /dev/null +++ b/apps/backend/internal/handler/inspire/chat_stream_handler.go @@ -0,0 +1,107 @@ +package inspire + +import ( + "encoding/json" + "net/http" + "strings" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" + "github.com/zeromicro/go-zero/rest/httpx" +) + +// ChatStreamHandler — SSE 串流靈感聊天/產文。 +// 事件(每行 data: JSON): +// +// {"type":"delta","text":"..."} +// {"type":"done","session":{...},"message_id":"..."} +// {"type":"error","message":"..."} +func ChatStreamHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.InspireChatReq + if err := httpx.Parse(r, &req); err != nil { + httpx.ErrorCtx(r.Context(), w, err) + return + } + uid, ok := middleware.UIDFrom(r.Context()) + if !ok || uid <= 0 { + httpx.ErrorCtx(r.Context(), w, response.Biz(401, 401001, "missing authorization")) + return + } + if svcCtx.Inspire == nil { + httpx.ErrorCtx(r.Context(), w, response.Biz(503, 503001, "inspire not configured")) + return + } + + flusher, ok := w.(http.Flusher) + if !ok { + httpx.ErrorCtx(r.Context(), w, response.Biz(500, 500000, "streaming unsupported")) + return + } + + w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache, no-transform") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + flusher.Flush() + + writeEvent := func(v any) bool { + b, err := json.Marshal(v) + if err != nil { + return false + } + if _, err := w.Write([]byte("data: ")); err != nil { + return false + } + if _, err := w.Write(b); err != nil { + return false + } + if _, err := w.Write([]byte("\n\n")); err != nil { + return false + } + flusher.Flush() + return true + } + + mode := strings.TrimSpace(req.Mode) + if mode == "" { + mode = "chat" + } + out, err := svcCtx.Inspire.ChatStream(r.Context(), uid, req.Message, req.PinnedIds, mode, req.PersonaId, req.SessionId, req.Material, func(chunk string) error { + if chunk == "" { + return nil + } + if !writeEvent(map[string]any{"type": "delta", "text": chunk}) { + return http.ErrAbortHandler // client gone + } + return nil + }) + if err != nil { + _ = writeEvent(map[string]any{"type": "error", "message": err.Error()}) + return + } + sess := out.Session + pub := types.SessionFromDomain(sess) + msgID := "" + if len(sess.Messages) > 0 { + msgID = sess.Messages[len(sess.Messages)-1].ID + } + // 回傳實際送 AI 的 prompt 指紋/全文,供前端與預覽對照 + _ = writeEvent(map[string]any{ + "type": "done", + "session": pub, + "message_id": msgID, + "prompt": out.Prompt, + "prompt_fingerprint": out.Fingerprint, + "prompt_char_count": out.CharCount, + "prompt_rune_count": out.RuneCount, + "prompt_sections": out.Sections, + }) + logx.WithContext(r.Context()).Infof("inspire chat-stream ok uid=%d mode=%s fp=%s chars=%d", uid, mode, out.Fingerprint, out.CharCount) + } +} diff --git a/apps/backend/internal/handler/inspire/clear_inspire_session_handler.go b/apps/backend/internal/handler/inspire/clear_inspire_session_handler.go new file mode 100644 index 0000000..cc92f17 --- /dev/null +++ b/apps/backend/internal/handler/inspire/clear_inspire_session_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package inspire + +import ( + "net/http" + + "apps/backend/internal/logic/inspire" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func ClearInspireSessionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := inspire.NewClearInspireSessionLogic(r.Context(), svcCtx) + data, err := l.ClearInspireSession() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/inspire/create_inspire_session_handler.go b/apps/backend/internal/handler/inspire/create_inspire_session_handler.go new file mode 100644 index 0000000..dffb720 --- /dev/null +++ b/apps/backend/internal/handler/inspire/create_inspire_session_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package inspire + +import ( + "net/http" + + "apps/backend/internal/logic/inspire" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func CreateInspireSessionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.InspireSessionCreateReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := inspire.NewCreateInspireSessionLogic(r.Context(), svcCtx) + data, err := l.CreateInspireSession(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/inspire/delete_inspire_session_handler.go b/apps/backend/internal/handler/inspire/delete_inspire_session_handler.go new file mode 100644 index 0000000..08798ea --- /dev/null +++ b/apps/backend/internal/handler/inspire/delete_inspire_session_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package inspire + +import ( + "net/http" + + "apps/backend/internal/logic/inspire" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func DeleteInspireSessionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.InspireSessionIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := inspire.NewDeleteInspireSessionLogic(r.Context(), svcCtx) + data, err := l.DeleteInspireSession(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/inspire/get_inspire_session_by_id_handler.go b/apps/backend/internal/handler/inspire/get_inspire_session_by_id_handler.go new file mode 100644 index 0000000..242ae9a --- /dev/null +++ b/apps/backend/internal/handler/inspire/get_inspire_session_by_id_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package inspire + +import ( + "net/http" + + "apps/backend/internal/logic/inspire" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GetInspireSessionByIdHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.InspireSessionIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := inspire.NewGetInspireSessionByIdLogic(r.Context(), svcCtx) + data, err := l.GetInspireSessionById(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/inspire/get_inspire_session_handler.go b/apps/backend/internal/handler/inspire/get_inspire_session_handler.go new file mode 100644 index 0000000..5c2ad38 --- /dev/null +++ b/apps/backend/internal/handler/inspire/get_inspire_session_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package inspire + +import ( + "net/http" + + "apps/backend/internal/logic/inspire" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func GetInspireSessionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := inspire.NewGetInspireSessionLogic(r.Context(), svcCtx) + data, err := l.GetInspireSession() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/inspire/inspire_chat_handler.go b/apps/backend/internal/handler/inspire/inspire_chat_handler.go new file mode 100644 index 0000000..8166b4e --- /dev/null +++ b/apps/backend/internal/handler/inspire/inspire_chat_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package inspire + +import ( + "net/http" + + "apps/backend/internal/logic/inspire" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func InspireChatHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.InspireChatReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := inspire.NewInspireChatLogic(r.Context(), svcCtx) + data, err := l.InspireChat(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/inspire/inspire_prompt_preview_handler.go b/apps/backend/internal/handler/inspire/inspire_prompt_preview_handler.go new file mode 100644 index 0000000..fdfd9a2 --- /dev/null +++ b/apps/backend/internal/handler/inspire/inspire_prompt_preview_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package inspire + +import ( + "net/http" + + "apps/backend/internal/logic/inspire" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func InspirePromptPreviewHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.InspirePromptPreviewReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := inspire.NewInspirePromptPreviewLogic(r.Context(), svcCtx) + data, err := l.InspirePromptPreview(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/inspire/list_inspire_elements_handler.go b/apps/backend/internal/handler/inspire/list_inspire_elements_handler.go new file mode 100644 index 0000000..968fa63 --- /dev/null +++ b/apps/backend/internal/handler/inspire/list_inspire_elements_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package inspire + +import ( + "net/http" + + "apps/backend/internal/logic/inspire" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func ListInspireElementsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := inspire.NewListInspireElementsLogic(r.Context(), svcCtx) + data, err := l.ListInspireElements() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/inspire/list_inspire_sessions_handler.go b/apps/backend/internal/handler/inspire/list_inspire_sessions_handler.go new file mode 100644 index 0000000..64f335b --- /dev/null +++ b/apps/backend/internal/handler/inspire/list_inspire_sessions_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package inspire + +import ( + "net/http" + + "apps/backend/internal/logic/inspire" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func ListInspireSessionsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := inspire.NewListInspireSessionsLogic(r.Context(), svcCtx) + data, err := l.ListInspireSessions() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/inspire/list_trends_handler.go b/apps/backend/internal/handler/inspire/list_trends_handler.go new file mode 100644 index 0000000..765c290 --- /dev/null +++ b/apps/backend/internal/handler/inspire/list_trends_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package inspire + +import ( + "net/http" + + "apps/backend/internal/logic/inspire" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func ListTrendsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := inspire.NewListTrendsLogic(r.Context(), svcCtx) + data, err := l.ListTrends() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/inspire/refresh_trends_handler.go b/apps/backend/internal/handler/inspire/refresh_trends_handler.go new file mode 100644 index 0000000..1b8d90d --- /dev/null +++ b/apps/backend/internal/handler/inspire/refresh_trends_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package inspire + +import ( + "net/http" + + "apps/backend/internal/logic/inspire" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func RefreshTrendsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := inspire.NewRefreshTrendsLogic(r.Context(), svcCtx) + data, err := l.RefreshTrends() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/inspire/remove_inspire_element_handler.go b/apps/backend/internal/handler/inspire/remove_inspire_element_handler.go new file mode 100644 index 0000000..53ad326 --- /dev/null +++ b/apps/backend/internal/handler/inspire/remove_inspire_element_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package inspire + +import ( + "net/http" + + "apps/backend/internal/logic/inspire" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func RemoveInspireElementHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.InspireElementIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := inspire.NewRemoveInspireElementLogic(r.Context(), svcCtx) + data, err := l.RemoveInspireElement(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/inspire/save_inspire_element_handler.go b/apps/backend/internal/handler/inspire/save_inspire_element_handler.go new file mode 100644 index 0000000..4fb51fd --- /dev/null +++ b/apps/backend/internal/handler/inspire/save_inspire_element_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package inspire + +import ( + "net/http" + + "apps/backend/internal/logic/inspire" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func SaveInspireElementHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.InspireElementSaveReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := inspire.NewSaveInspireElementLogic(r.Context(), svcCtx) + data, err := l.SaveInspireElement(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/inspire_stream.go b/apps/backend/internal/handler/inspire_stream.go new file mode 100644 index 0000000..f1f5c67 --- /dev/null +++ b/apps/backend/internal/handler/inspire_stream.go @@ -0,0 +1,28 @@ +package handler + +import ( + "net/http" + + inspireHandler "apps/backend/internal/handler/inspire" + "apps/backend/internal/svc" + + "github.com/zeromicro/go-zero/rest" +) + +// RegisterInspireStream 註冊靈感 SSE 路由(手寫,避開 goctl JSON envelope)。 +func RegisterInspireStream(server *rest.Server, serverCtx *svc.ServiceContext) { + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodPost, + Path: "/chat-stream", + Handler: inspireHandler.ChatStreamHandler(serverCtx), + }, + }..., + ), + rest.WithJwt(serverCtx.Config.Auth.AccessSecret), + rest.WithPrefix("/api/v1/inspire"), + ) +} diff --git a/apps/backend/internal/handler/jobs/delete_job_handler.go b/apps/backend/internal/handler/jobs/delete_job_handler.go new file mode 100644 index 0000000..d274994 --- /dev/null +++ b/apps/backend/internal/handler/jobs/delete_job_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package jobs + +import ( + "net/http" + + "apps/backend/internal/logic/jobs" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func DeleteJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.JobIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := jobs.NewDeleteJobLogic(r.Context(), svcCtx) + data, err := l.DeleteJob(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/jobs/get_job_handler.go b/apps/backend/internal/handler/jobs/get_job_handler.go new file mode 100644 index 0000000..405c3a4 --- /dev/null +++ b/apps/backend/internal/handler/jobs/get_job_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package jobs + +import ( + "net/http" + + "apps/backend/internal/logic/jobs" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GetJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.JobIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := jobs.NewGetJobLogic(r.Context(), svcCtx) + data, err := l.GetJob(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/jobs/list_jobs_handler.go b/apps/backend/internal/handler/jobs/list_jobs_handler.go new file mode 100644 index 0000000..ebcaa56 --- /dev/null +++ b/apps/backend/internal/handler/jobs/list_jobs_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package jobs + +import ( + "net/http" + + "apps/backend/internal/logic/jobs" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func ListJobsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := jobs.NewListJobsLogic(r.Context(), svcCtx) + data, err := l.ListJobs() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/jobs/start_demo_job_handler.go b/apps/backend/internal/handler/jobs/start_demo_job_handler.go new file mode 100644 index 0000000..68caf01 --- /dev/null +++ b/apps/backend/internal/handler/jobs/start_demo_job_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package jobs + +import ( + "net/http" + + "apps/backend/internal/logic/jobs" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func StartDemoJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := jobs.NewStartDemoJobLogic(r.Context(), svcCtx) + data, err := l.StartDemoJob() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/media/generate_image_handler.go b/apps/backend/internal/handler/media/generate_image_handler.go new file mode 100644 index 0000000..69c1869 --- /dev/null +++ b/apps/backend/internal/handler/media/generate_image_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package media + +import ( + "net/http" + + "apps/backend/internal/logic/media" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GenerateImageHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.GenerateImageReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := media.NewGenerateImageLogic(r.Context(), svcCtx) + data, err := l.GenerateImage(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/mentions/list_mentions_handler.go b/apps/backend/internal/handler/mentions/list_mentions_handler.go new file mode 100644 index 0000000..ffdbd5e --- /dev/null +++ b/apps/backend/internal/handler/mentions/list_mentions_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package mentions + +import ( + "net/http" + + "apps/backend/internal/logic/mentions" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ListMentionsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.MentionListReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := mentions.NewListMentionsLogic(r.Context(), svcCtx) + data, err := l.ListMentions(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/mentions/mention_generate_reply_handler.go b/apps/backend/internal/handler/mentions/mention_generate_reply_handler.go new file mode 100644 index 0000000..ca336a4 --- /dev/null +++ b/apps/backend/internal/handler/mentions/mention_generate_reply_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package mentions + +import ( + "net/http" + + "apps/backend/internal/logic/mentions" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func MentionGenerateReplyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.MentionGenerateReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := mentions.NewMentionGenerateReplyLogic(r.Context(), svcCtx) + data, err := l.MentionGenerateReply(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/mentions/mention_mark_replied_handler.go b/apps/backend/internal/handler/mentions/mention_mark_replied_handler.go new file mode 100644 index 0000000..d0aa8af --- /dev/null +++ b/apps/backend/internal/handler/mentions/mention_mark_replied_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package mentions + +import ( + "net/http" + + "apps/backend/internal/logic/mentions" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func MentionMarkRepliedHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.MentionMarkRepliedReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := mentions.NewMentionMarkRepliedLogic(r.Context(), svcCtx) + data, err := l.MentionMarkReplied(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/mentions/mention_skip_handler.go b/apps/backend/internal/handler/mentions/mention_skip_handler.go new file mode 100644 index 0000000..e4e6327 --- /dev/null +++ b/apps/backend/internal/handler/mentions/mention_skip_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package mentions + +import ( + "net/http" + + "apps/backend/internal/logic/mentions" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func MentionSkipHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.MentionIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := mentions.NewMentionSkipLogic(r.Context(), svcCtx) + data, err := l.MentionSkip(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/mentions/sync_mentions_handler.go b/apps/backend/internal/handler/mentions/sync_mentions_handler.go new file mode 100644 index 0000000..f859a2b --- /dev/null +++ b/apps/backend/internal/handler/mentions/sync_mentions_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package mentions + +import ( + "net/http" + + "apps/backend/internal/logic/mentions" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func SyncMentionsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.MentionSyncReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := mentions.NewSyncMentionsLogic(r.Context(), svcCtx) + data, err := l.SyncMentions(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/notifications/list_notifications_handler.go b/apps/backend/internal/handler/notifications/list_notifications_handler.go new file mode 100644 index 0000000..8f6000f --- /dev/null +++ b/apps/backend/internal/handler/notifications/list_notifications_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package notifications + +import ( + "net/http" + + "apps/backend/internal/logic/notifications" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func ListNotificationsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := notifications.NewListNotificationsLogic(r.Context(), svcCtx) + data, err := l.ListNotifications() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/notifications/mark_all_notifications_read_handler.go b/apps/backend/internal/handler/notifications/mark_all_notifications_read_handler.go new file mode 100644 index 0000000..f9871c3 --- /dev/null +++ b/apps/backend/internal/handler/notifications/mark_all_notifications_read_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package notifications + +import ( + "net/http" + + "apps/backend/internal/logic/notifications" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func MarkAllNotificationsReadHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := notifications.NewMarkAllNotificationsReadLogic(r.Context(), svcCtx) + data, err := l.MarkAllNotificationsRead() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/notifications/mark_notification_read_handler.go b/apps/backend/internal/handler/notifications/mark_notification_read_handler.go new file mode 100644 index 0000000..799f12b --- /dev/null +++ b/apps/backend/internal/handler/notifications/mark_notification_read_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package notifications + +import ( + "net/http" + + "apps/backend/internal/logic/notifications" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func MarkNotificationReadHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.NotificationIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := notifications.NewMarkNotificationReadLogic(r.Context(), svcCtx) + data, err := l.MarkNotificationRead(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/notifications/unread_notification_count_handler.go b/apps/backend/internal/handler/notifications/unread_notification_count_handler.go new file mode 100644 index 0000000..bb2c233 --- /dev/null +++ b/apps/backend/internal/handler/notifications/unread_notification_count_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package notifications + +import ( + "net/http" + + "apps/backend/internal/logic/notifications" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func UnreadNotificationCountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := notifications.NewUnreadNotificationCountLogic(r.Context(), svcCtx) + data, err := l.UnreadNotificationCount() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/outbox/get_outbox_handler.go b/apps/backend/internal/handler/outbox/get_outbox_handler.go new file mode 100644 index 0000000..9c3c06e --- /dev/null +++ b/apps/backend/internal/handler/outbox/get_outbox_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package outbox + +import ( + "net/http" + + "apps/backend/internal/logic/outbox" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GetOutboxHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.OutboxIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := outbox.NewGetOutboxLogic(r.Context(), svcCtx) + data, err := l.GetOutbox(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/outbox/list_outbox_handler.go b/apps/backend/internal/handler/outbox/list_outbox_handler.go new file mode 100644 index 0000000..3e5e61e --- /dev/null +++ b/apps/backend/internal/handler/outbox/list_outbox_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package outbox + +import ( + "net/http" + + "apps/backend/internal/logic/outbox" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func ListOutboxHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := outbox.NewListOutboxLogic(r.Context(), svcCtx) + data, err := l.ListOutbox() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/outbox/remove_outbox_handler.go b/apps/backend/internal/handler/outbox/remove_outbox_handler.go new file mode 100644 index 0000000..a16b8d4 --- /dev/null +++ b/apps/backend/internal/handler/outbox/remove_outbox_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package outbox + +import ( + "net/http" + + "apps/backend/internal/logic/outbox" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func RemoveOutboxHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.OutboxIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := outbox.NewRemoveOutboxLogic(r.Context(), svcCtx) + data, err := l.RemoveOutbox(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/outbox/retry_outbox_step_handler.go b/apps/backend/internal/handler/outbox/retry_outbox_step_handler.go new file mode 100644 index 0000000..9dead14 --- /dev/null +++ b/apps/backend/internal/handler/outbox/retry_outbox_step_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package outbox + +import ( + "net/http" + + "apps/backend/internal/logic/outbox" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func RetryOutboxStepHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.OutboxRetryPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := outbox.NewRetryOutboxStepLogic(r.Context(), svcCtx) + data, err := l.RetryOutboxStep(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/ownposts/list_own_posts_handler.go b/apps/backend/internal/handler/ownposts/list_own_posts_handler.go new file mode 100644 index 0000000..c817d66 --- /dev/null +++ b/apps/backend/internal/handler/ownposts/list_own_posts_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package ownposts + +import ( + "net/http" + + "apps/backend/internal/logic/ownposts" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ListOwnPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.OwnPostListReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := ownposts.NewListOwnPostsLogic(r.Context(), svcCtx) + data, err := l.ListOwnPosts(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/ownposts/own_post_analyze_handler.go b/apps/backend/internal/handler/ownposts/own_post_analyze_handler.go new file mode 100644 index 0000000..980aae4 --- /dev/null +++ b/apps/backend/internal/handler/ownposts/own_post_analyze_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package ownposts + +import ( + "net/http" + + "apps/backend/internal/logic/ownposts" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func OwnPostAnalyzeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.OwnPostAnalyzeReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := ownposts.NewOwnPostAnalyzeLogic(r.Context(), svcCtx) + data, err := l.OwnPostAnalyze(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/ownposts/own_post_generate_reply_handler.go b/apps/backend/internal/handler/ownposts/own_post_generate_reply_handler.go new file mode 100644 index 0000000..5fcd694 --- /dev/null +++ b/apps/backend/internal/handler/ownposts/own_post_generate_reply_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package ownposts + +import ( + "net/http" + + "apps/backend/internal/logic/ownposts" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func OwnPostGenerateReplyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.OwnPostGenerateReplyReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := ownposts.NewOwnPostGenerateReplyLogic(r.Context(), svcCtx) + data, err := l.OwnPostGenerateReply(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/ownposts/own_post_load_replies_handler.go b/apps/backend/internal/handler/ownposts/own_post_load_replies_handler.go new file mode 100644 index 0000000..9037d03 --- /dev/null +++ b/apps/backend/internal/handler/ownposts/own_post_load_replies_handler.go @@ -0,0 +1,26 @@ +package ownposts + +import ( + "net/http" + + "apps/backend/internal/logic/ownposts" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func OwnPostLoadRepliesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.OwnPostLoadRepliesReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := ownposts.NewOwnPostLoadRepliesLogic(r.Context(), svcCtx) + data, err := l.OwnPostLoadReplies(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/ownposts/own_post_send_reply_handler.go b/apps/backend/internal/handler/ownposts/own_post_send_reply_handler.go new file mode 100644 index 0000000..230fa3f --- /dev/null +++ b/apps/backend/internal/handler/ownposts/own_post_send_reply_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package ownposts + +import ( + "net/http" + + "apps/backend/internal/logic/ownposts" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func OwnPostSendReplyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.OwnPostSendReplyReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := ownposts.NewOwnPostSendReplyLogic(r.Context(), svcCtx) + data, err := l.OwnPostSendReply(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/ownposts/own_posts_last_synced_handler.go b/apps/backend/internal/handler/ownposts/own_posts_last_synced_handler.go new file mode 100644 index 0000000..e5d940e --- /dev/null +++ b/apps/backend/internal/handler/ownposts/own_posts_last_synced_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package ownposts + +import ( + "net/http" + + "apps/backend/internal/logic/ownposts" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func OwnPostsLastSyncedHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := ownposts.NewOwnPostsLastSyncedLogic(r.Context(), svcCtx) + data, err := l.OwnPostsLastSynced() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/ownposts/sync_own_posts_handler.go b/apps/backend/internal/handler/ownposts/sync_own_posts_handler.go new file mode 100644 index 0000000..c44ca52 --- /dev/null +++ b/apps/backend/internal/handler/ownposts/sync_own_posts_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package ownposts + +import ( + "net/http" + + "apps/backend/internal/logic/ownposts" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func SyncOwnPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.OwnPostSyncReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := ownposts.NewSyncOwnPostsLogic(r.Context(), svcCtx) + data, err := l.SyncOwnPosts(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/personas/analyze_persona_account_handler.go b/apps/backend/internal/handler/personas/analyze_persona_account_handler.go new file mode 100644 index 0000000..dbab1c5 --- /dev/null +++ b/apps/backend/internal/handler/personas/analyze_persona_account_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package personas + +import ( + "net/http" + + "apps/backend/internal/logic/personas" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func AnalyzePersonaAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PersonaAnalyzeAccountReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := personas.NewAnalyzePersonaAccountLogic(r.Context(), svcCtx) + data, err := l.AnalyzePersonaAccount(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/personas/analyze_persona_text_handler.go b/apps/backend/internal/handler/personas/analyze_persona_text_handler.go new file mode 100644 index 0000000..49f894b --- /dev/null +++ b/apps/backend/internal/handler/personas/analyze_persona_text_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package personas + +import ( + "net/http" + + "apps/backend/internal/logic/personas" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func AnalyzePersonaTextHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PersonaAnalyzeTextReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := personas.NewAnalyzePersonaTextLogic(r.Context(), svcCtx) + data, err := l.AnalyzePersonaText(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/personas/get_active_persona_handler.go b/apps/backend/internal/handler/personas/get_active_persona_handler.go new file mode 100644 index 0000000..7c56b64 --- /dev/null +++ b/apps/backend/internal/handler/personas/get_active_persona_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package personas + +import ( + "net/http" + + "apps/backend/internal/logic/personas" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func GetActivePersonaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := personas.NewGetActivePersonaLogic(r.Context(), svcCtx) + data, err := l.GetActivePersona() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/personas/get_persona_handler.go b/apps/backend/internal/handler/personas/get_persona_handler.go new file mode 100644 index 0000000..a7ef618 --- /dev/null +++ b/apps/backend/internal/handler/personas/get_persona_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package personas + +import ( + "net/http" + + "apps/backend/internal/logic/personas" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GetPersonaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PersonaIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := personas.NewGetPersonaLogic(r.Context(), svcCtx) + data, err := l.GetPersona(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/personas/list_personas_handler.go b/apps/backend/internal/handler/personas/list_personas_handler.go new file mode 100644 index 0000000..1df26c4 --- /dev/null +++ b/apps/backend/internal/handler/personas/list_personas_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package personas + +import ( + "net/http" + + "apps/backend/internal/logic/personas" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func ListPersonasHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := personas.NewListPersonasLogic(r.Context(), svcCtx) + data, err := l.ListPersonas() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/personas/remove_persona_handler.go b/apps/backend/internal/handler/personas/remove_persona_handler.go new file mode 100644 index 0000000..6ec44c5 --- /dev/null +++ b/apps/backend/internal/handler/personas/remove_persona_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package personas + +import ( + "net/http" + + "apps/backend/internal/logic/personas" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func RemovePersonaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PersonaIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := personas.NewRemovePersonaLogic(r.Context(), svcCtx) + data, err := l.RemovePersona(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/personas/save_persona_handler.go b/apps/backend/internal/handler/personas/save_persona_handler.go new file mode 100644 index 0000000..5c083b2 --- /dev/null +++ b/apps/backend/internal/handler/personas/save_persona_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package personas + +import ( + "net/http" + + "apps/backend/internal/logic/personas" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func SavePersonaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PersonaSaveReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := personas.NewSavePersonaLogic(r.Context(), svcCtx) + data, err := l.SavePersona(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/personas/set_active_persona_handler.go b/apps/backend/internal/handler/personas/set_active_persona_handler.go new file mode 100644 index 0000000..d3a473d --- /dev/null +++ b/apps/backend/internal/handler/personas/set_active_persona_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package personas + +import ( + "net/http" + + "apps/backend/internal/logic/personas" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func SetActivePersonaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PersonaSetActiveReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := personas.NewSetActivePersonaLogic(r.Context(), svcCtx) + data, err := l.SetActivePersona(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/plays/generate_play_script_handler.go b/apps/backend/internal/handler/plays/generate_play_script_handler.go new file mode 100644 index 0000000..56e704b --- /dev/null +++ b/apps/backend/internal/handler/plays/generate_play_script_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package plays + +import ( + "net/http" + + "apps/backend/internal/logic/plays" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GeneratePlayScriptHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PlayIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := plays.NewGeneratePlayScriptLogic(r.Context(), svcCtx) + data, err := l.GeneratePlayScript(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/plays/generate_play_step_handler.go b/apps/backend/internal/handler/plays/generate_play_step_handler.go new file mode 100644 index 0000000..7e9ba00 --- /dev/null +++ b/apps/backend/internal/handler/plays/generate_play_step_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package plays + +import ( + "net/http" + + "apps/backend/internal/logic/plays" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GeneratePlayStepHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PlayGenerateStepReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := plays.NewGeneratePlayStepLogic(r.Context(), svcCtx) + data, err := l.GeneratePlayStep(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/plays/get_play_handler.go b/apps/backend/internal/handler/plays/get_play_handler.go new file mode 100644 index 0000000..1519a02 --- /dev/null +++ b/apps/backend/internal/handler/plays/get_play_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package plays + +import ( + "net/http" + + "apps/backend/internal/logic/plays" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GetPlayHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PlayIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := plays.NewGetPlayLogic(r.Context(), svcCtx) + data, err := l.GetPlay(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/plays/list_plays_by_external_handler.go b/apps/backend/internal/handler/plays/list_plays_by_external_handler.go new file mode 100644 index 0000000..939a51c --- /dev/null +++ b/apps/backend/internal/handler/plays/list_plays_by_external_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package plays + +import ( + "net/http" + + "apps/backend/internal/logic/plays" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ListPlaysByExternalHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PlayByExternalReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := plays.NewListPlaysByExternalLogic(r.Context(), svcCtx) + data, err := l.ListPlaysByExternal(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/plays/list_plays_by_post_handler.go b/apps/backend/internal/handler/plays/list_plays_by_post_handler.go new file mode 100644 index 0000000..7fd643f --- /dev/null +++ b/apps/backend/internal/handler/plays/list_plays_by_post_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package plays + +import ( + "net/http" + + "apps/backend/internal/logic/plays" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ListPlaysByPostHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PlayByPostReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := plays.NewListPlaysByPostLogic(r.Context(), svcCtx) + data, err := l.ListPlaysByPost(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/plays/list_plays_handler.go b/apps/backend/internal/handler/plays/list_plays_handler.go new file mode 100644 index 0000000..b136856 --- /dev/null +++ b/apps/backend/internal/handler/plays/list_plays_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package plays + +import ( + "net/http" + + "apps/backend/internal/logic/plays" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func ListPlaysHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := plays.NewListPlaysLogic(r.Context(), svcCtx) + data, err := l.ListPlays() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/plays/remove_play_handler.go b/apps/backend/internal/handler/plays/remove_play_handler.go new file mode 100644 index 0000000..0acb2ff --- /dev/null +++ b/apps/backend/internal/handler/plays/remove_play_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package plays + +import ( + "net/http" + + "apps/backend/internal/logic/plays" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func RemovePlayHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PlayIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := plays.NewRemovePlayLogic(r.Context(), svcCtx) + data, err := l.RemovePlay(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/plays/resolve_external_link_handler.go b/apps/backend/internal/handler/plays/resolve_external_link_handler.go new file mode 100644 index 0000000..d3b4ee5 --- /dev/null +++ b/apps/backend/internal/handler/plays/resolve_external_link_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package plays + +import ( + "net/http" + + "apps/backend/internal/logic/plays" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ResolveExternalLinkHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PlayResolveReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := plays.NewResolveExternalLinkLogic(r.Context(), svcCtx) + data, err := l.ResolveExternalLink(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/plays/save_play_handler.go b/apps/backend/internal/handler/plays/save_play_handler.go new file mode 100644 index 0000000..f878caa --- /dev/null +++ b/apps/backend/internal/handler/plays/save_play_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package plays + +import ( + "net/http" + + "apps/backend/internal/logic/plays" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func SavePlayHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PlaySaveReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := plays.NewSavePlayLogic(r.Context(), svcCtx) + data, err := l.SavePlay(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/plays/submit_play_handler.go b/apps/backend/internal/handler/plays/submit_play_handler.go new file mode 100644 index 0000000..beb9392 --- /dev/null +++ b/apps/backend/internal/handler/plays/submit_play_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package plays + +import ( + "net/http" + + "apps/backend/internal/logic/plays" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func SubmitPlayHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PlayIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := plays.NewSubmitPlayLogic(r.Context(), svcCtx) + data, err := l.SubmitPlay(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/proxy/a_i_complete_handler.go b/apps/backend/internal/handler/proxy/a_i_complete_handler.go new file mode 100644 index 0000000..56ff854 --- /dev/null +++ b/apps/backend/internal/handler/proxy/a_i_complete_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package proxy + +import ( + "net/http" + + "apps/backend/internal/logic/proxy" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func AICompleteHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.AICompleteReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := proxy.NewAICompleteLogic(r.Context(), svcCtx) + data, err := l.AIComplete(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/proxy/exa_search_handler.go b/apps/backend/internal/handler/proxy/exa_search_handler.go new file mode 100644 index 0000000..0ae6521 --- /dev/null +++ b/apps/backend/internal/handler/proxy/exa_search_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package proxy + +import ( + "net/http" + + "apps/backend/internal/logic/proxy" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ExaSearchHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.SearchReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := proxy.NewExaSearchLogic(r.Context(), svcCtx) + data, err := l.ExaSearch(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/research/research_search_handler.go b/apps/backend/internal/handler/research/research_search_handler.go new file mode 100644 index 0000000..2f47d06 --- /dev/null +++ b/apps/backend/internal/handler/research/research_search_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package research + +import ( + "net/http" + + "apps/backend/internal/logic/research" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ResearchSearchHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ResearchSearchReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := research.NewResearchSearchLogic(r.Context(), svcCtx) + data, err := l.ResearchSearch(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/routes.go b/apps/backend/internal/handler/routes.go index ea3d6c8..5553137 100644 --- a/apps/backend/internal/handler/routes.go +++ b/apps/backend/internal/handler/routes.go @@ -8,9 +8,24 @@ import ( admin "apps/backend/internal/handler/admin" auth "apps/backend/internal/handler/auth" + compose "apps/backend/internal/handler/compose" + extension "apps/backend/internal/handler/extension" + inspire "apps/backend/internal/handler/inspire" + jobs "apps/backend/internal/handler/jobs" media "apps/backend/internal/handler/media" + mentions "apps/backend/internal/handler/mentions" + notifications "apps/backend/internal/handler/notifications" + outbox "apps/backend/internal/handler/outbox" + ownposts "apps/backend/internal/handler/ownposts" + personas "apps/backend/internal/handler/personas" ping "apps/backend/internal/handler/ping" + plays "apps/backend/internal/handler/plays" + proxy "apps/backend/internal/handler/proxy" + research "apps/backend/internal/handler/research" + scout "apps/backend/internal/handler/scout" settings "apps/backend/internal/handler/settings" + threads "apps/backend/internal/handler/threads" + usage "apps/backend/internal/handler/usage" "apps/backend/internal/svc" "github.com/zeromicro/go-zero/rest" @@ -171,6 +186,171 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { rest.WithPrefix("/api/v1/auth"), ) + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodPost, + Path: "/analyze-viral", + Handler: compose.ComposeAnalyzeViralHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/mimic", + Handler: compose.ComposeMimicHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/persona-preview", + Handler: compose.ComposePersonaPreviewHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/publish-single", + Handler: compose.ComposePublishSingleHandler(serverCtx), + }, + }..., + ), + rest.WithJwt(serverCtx.Config.Auth.AccessSecret), + rest.WithPrefix("/api/v1/compose"), + ) + + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodGet, + Path: "/download", + Handler: extension.DownloadExtensionZipHandler(serverCtx), + }, + }..., + ), + rest.WithJwt(serverCtx.Config.Auth.AccessSecret), + rest.WithPrefix("/api/v1/extension"), + ) + + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodPost, + Path: "/chat", + Handler: inspire.InspireChatHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/elements", + Handler: inspire.ListInspireElementsHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/elements", + Handler: inspire.SaveInspireElementHandler(serverCtx), + }, + { + Method: http.MethodDelete, + Path: "/elements/:id", + Handler: inspire.RemoveInspireElementHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/prompt-preview", + Handler: inspire.InspirePromptPreviewHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/session", + Handler: inspire.GetInspireSessionHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/session/clear", + Handler: inspire.ClearInspireSessionHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/sessions", + Handler: inspire.ListInspireSessionsHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/sessions", + Handler: inspire.CreateInspireSessionHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/sessions/:id", + Handler: inspire.GetInspireSessionByIdHandler(serverCtx), + }, + { + Method: http.MethodDelete, + Path: "/sessions/:id", + Handler: inspire.DeleteInspireSessionHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/sessions/:id/activate", + Handler: inspire.ActivateInspireSessionHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/trends", + Handler: inspire.ListTrendsHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/trends/refresh", + Handler: inspire.RefreshTrendsHandler(serverCtx), + }, + }..., + ), + rest.WithJwt(serverCtx.Config.Auth.AccessSecret), + rest.WithPrefix("/api/v1/inspire"), + ) + + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodGet, + Path: "/", + Handler: jobs.ListJobsHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id", + Handler: jobs.GetJobHandler(serverCtx), + }, + { + Method: http.MethodDelete, + Path: "/:id", + Handler: jobs.DeleteJobHandler(serverCtx), + }, + }..., + ), + rest.WithJwt(serverCtx.Config.Auth.AccessSecret), + rest.WithPrefix("/api/v1/jobs"), + ) + + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT, serverCtx.AdminAuth}, + []rest.Route{ + { + Method: http.MethodPost, + Path: "/demo", + Handler: jobs.StartDemoJobHandler(serverCtx), + }, + }..., + ), + rest.WithJwt(serverCtx.Config.Auth.AccessSecret), + rest.WithPrefix("/api/v1/jobs"), + ) + server.AddRoutes( rest.WithMiddlewares( []rest.Middleware{serverCtx.AuthJWT}, @@ -185,6 +365,211 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { rest.WithPrefix("/api/v1/media"), ) + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodPost, + Path: "/generate-image", + Handler: media.GenerateImageHandler(serverCtx), + }, + }..., + ), + rest.WithJwt(serverCtx.Config.Auth.AccessSecret), + rest.WithPrefix("/api/v1/media"), + ) + + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodGet, + Path: "/", + Handler: mentions.ListMentionsHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/generate-reply", + Handler: mentions.MentionGenerateReplyHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/mark-replied", + Handler: mentions.MentionMarkRepliedHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/skip", + Handler: mentions.MentionSkipHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/sync", + Handler: mentions.SyncMentionsHandler(serverCtx), + }, + }..., + ), + rest.WithJwt(serverCtx.Config.Auth.AccessSecret), + rest.WithPrefix("/api/v1/mentions"), + ) + + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodGet, + Path: "/", + Handler: notifications.ListNotificationsHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/read", + Handler: notifications.MarkNotificationReadHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/read-all", + Handler: notifications.MarkAllNotificationsReadHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/unread-count", + Handler: notifications.UnreadNotificationCountHandler(serverCtx), + }, + }..., + ), + rest.WithJwt(serverCtx.Config.Auth.AccessSecret), + rest.WithPrefix("/api/v1/notifications"), + ) + + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodGet, + Path: "/", + Handler: outbox.ListOutboxHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id", + Handler: outbox.GetOutboxHandler(serverCtx), + }, + { + Method: http.MethodDelete, + Path: "/:id", + Handler: outbox.RemoveOutboxHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/steps/:stepId/retry", + Handler: outbox.RetryOutboxStepHandler(serverCtx), + }, + }..., + ), + rest.WithJwt(serverCtx.Config.Auth.AccessSecret), + rest.WithPrefix("/api/v1/outbox"), + ) + + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodGet, + Path: "/", + Handler: ownposts.ListOwnPostsHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/analyze", + Handler: ownposts.OwnPostAnalyzeHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/generate-reply", + Handler: ownposts.OwnPostGenerateReplyHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/last-synced-at", + Handler: ownposts.OwnPostsLastSyncedHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/load-replies", + Handler: ownposts.OwnPostLoadRepliesHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/send-reply", + Handler: ownposts.OwnPostSendReplyHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/sync", + Handler: ownposts.SyncOwnPostsHandler(serverCtx), + }, + }..., + ), + rest.WithJwt(serverCtx.Config.Auth.AccessSecret), + rest.WithPrefix("/api/v1/own-posts"), + ) + + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodGet, + Path: "/", + Handler: personas.ListPersonasHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/", + Handler: personas.SavePersonaHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id", + Handler: personas.GetPersonaHandler(serverCtx), + }, + { + Method: http.MethodDelete, + Path: "/:id", + Handler: personas.RemovePersonaHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/analyze-account", + Handler: personas.AnalyzePersonaAccountHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/analyze-text", + Handler: personas.AnalyzePersonaTextHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/active", + Handler: personas.GetActivePersonaHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/active", + Handler: personas.SetActivePersonaHandler(serverCtx), + }, + }..., + ), + rest.WithJwt(serverCtx.Config.Auth.AccessSecret), + rest.WithPrefix("/api/v1/personas"), + ) + server.AddRoutes( []rest.Route{ { @@ -201,6 +586,246 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { rest.WithPrefix("/api/v1"), ) + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodGet, + Path: "/", + Handler: plays.ListPlaysHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/", + Handler: plays.SavePlayHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id", + Handler: plays.GetPlayHandler(serverCtx), + }, + { + Method: http.MethodDelete, + Path: "/:id", + Handler: plays.RemovePlayHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/generate-script", + Handler: plays.GeneratePlayScriptHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/submit", + Handler: plays.SubmitPlayHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/by-external", + Handler: plays.ListPlaysByExternalHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/by-post", + Handler: plays.ListPlaysByPostHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/generate-step", + Handler: plays.GeneratePlayStepHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/resolve-external", + Handler: plays.ResolveExternalLinkHandler(serverCtx), + }, + }..., + ), + rest.WithJwt(serverCtx.Config.Auth.AccessSecret), + rest.WithPrefix("/api/v1/plays"), + ) + + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodPost, + Path: "/ai/complete", + Handler: proxy.AICompleteHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/search", + Handler: proxy.ExaSearchHandler(serverCtx), + }, + }..., + ), + rest.WithJwt(serverCtx.Config.Auth.AccessSecret), + rest.WithPrefix("/api/v1/proxy"), + ) + + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodPost, + Path: "/search", + Handler: research.ResearchSearchHandler(serverCtx), + }, + }..., + ), + rest.WithJwt(serverCtx.Config.Auth.AccessSecret), + rest.WithPrefix("/api/v1/research"), + ) + + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodGet, + Path: "/brands", + Handler: scout.ListBrandsHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/brands", + Handler: scout.SaveBrandHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/brands-active", + Handler: scout.GetActiveBrandHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/brands-active", + Handler: scout.SetActiveBrandHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/brands/:id", + Handler: scout.GetBrandHandler(serverCtx), + }, + { + Method: http.MethodDelete, + Path: "/brands/:id", + Handler: scout.RemoveBrandHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/brief", + Handler: scout.PrepareBriefHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/crawler-session", + Handler: scout.SetCrawlerSessionHandler(serverCtx), + }, + { + Method: http.MethodDelete, + Path: "/crawler-session", + Handler: scout.ClearCrawlerSessionHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/homework", + Handler: scout.ListHomeworkHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/homework", + Handler: scout.SaveHomeworkHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/homework/:themeKey", + Handler: scout.GetHomeworkHandler(serverCtx), + }, + { + Method: http.MethodDelete, + Path: "/homework/:themeKey", + Handler: scout.RemoveHomeworkHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/posts", + Handler: scout.ListScoutPostsHandler(serverCtx), + }, + { + Method: http.MethodDelete, + Path: "/posts/:id", + Handler: scout.RemoveScoutPostHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/posts/:id/draft", + Handler: scout.DraftOutreachHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/posts/:id/mark-published", + Handler: scout.MarkPublishedHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/posts/:id/send", + Handler: scout.SendOutreachHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/posts/:id/skip", + Handler: scout.SkipOutreachHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/products", + Handler: scout.ListProductsHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/products", + Handler: scout.SaveProductHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/products/:id", + Handler: scout.GetProductHandler(serverCtx), + }, + { + Method: http.MethodDelete, + Path: "/products/:id", + Handler: scout.RemoveProductHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/products/all", + Handler: scout.ListAllProductsHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/products/import", + Handler: scout.ImportProductHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/scan", + Handler: scout.RunScanHandler(serverCtx), + }, + { + Method: http.MethodDelete, + Path: "/themes/:themeKey", + Handler: scout.RemoveScoutThemeHandler(serverCtx), + }, + }..., + ), + rest.WithJwt(serverCtx.Config.Auth.AccessSecret), + rest.WithPrefix("/api/v1/scout"), + ) + server.AddRoutes( rest.WithMiddlewares( []rest.Middleware{serverCtx.AuthJWT}, @@ -230,8 +855,114 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/placement", Handler: settings.SavePlacementHandler(serverCtx), }, + { + Method: http.MethodGet, + Path: "/threads", + Handler: settings.GetThreadsHandler(serverCtx), + }, }..., ), rest.WithPrefix("/api/v1/settings"), ) + + server.AddRoutes( + []rest.Route{ + { + Method: http.MethodGet, + Path: "/oauth/callback", + Handler: threads.ThreadsOAuthCallbackHandler(serverCtx), + }, + }, + rest.WithPrefix("/api/v1/threads-accounts"), + ) + + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodGet, + Path: "/", + Handler: threads.ListThreadsAccountsHandler(serverCtx), + }, + { + Method: http.MethodDelete, + Path: "/:id", + Handler: threads.DisconnectThreadsAccountHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/refresh", + Handler: threads.RefreshThreadsAccountHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/session/import", + Handler: threads.ImportThreadsAccountSessionHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/oauth/start", + Handler: threads.ThreadsOAuthStartHandler(serverCtx), + }, + }..., + ), + rest.WithJwt(serverCtx.Config.Auth.AccessSecret), + rest.WithPrefix("/api/v1/threads-accounts"), + ) + + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodGet, + Path: "/events", + Handler: usage.ListUsageEventsHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/prefs", + Handler: usage.GetUsagePrefsHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/purchase", + Handler: usage.PurchasePlanHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/purchases", + Handler: usage.ListMyPurchasesHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/summary", + Handler: usage.GetUsageSummaryHandler(serverCtx), + }, + }..., + ), + rest.WithJwt(serverCtx.Config.Auth.AccessSecret), + rest.WithPrefix("/api/v1/usage"), + ) + + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT, serverCtx.AdminAuth}, + []rest.Route{ + { + Method: http.MethodPost, + Path: "/prefs", + Handler: usage.SetUsagePrefsHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/tenant-summary", + Handler: usage.GetTenantUsageSummaryHandler(serverCtx), + }, + }..., + ), + rest.WithJwt(serverCtx.Config.Auth.AccessSecret), + rest.WithPrefix("/api/v1/usage"), + ) } diff --git a/apps/backend/internal/handler/scout/clear_crawler_session_handler.go b/apps/backend/internal/handler/scout/clear_crawler_session_handler.go new file mode 100644 index 0000000..17b454d --- /dev/null +++ b/apps/backend/internal/handler/scout/clear_crawler_session_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func ClearCrawlerSessionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := scout.NewClearCrawlerSessionLogic(r.Context(), svcCtx) + data, err := l.ClearCrawlerSession() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/draft_outreach_handler.go b/apps/backend/internal/handler/scout/draft_outreach_handler.go new file mode 100644 index 0000000..506bb06 --- /dev/null +++ b/apps/backend/internal/handler/scout/draft_outreach_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func DraftOutreachHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ScoutDraftPathReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := scout.NewDraftOutreachLogic(r.Context(), svcCtx) + data, err := l.DraftOutreach(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/get_active_brand_handler.go b/apps/backend/internal/handler/scout/get_active_brand_handler.go new file mode 100644 index 0000000..63f61dd --- /dev/null +++ b/apps/backend/internal/handler/scout/get_active_brand_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func GetActiveBrandHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := scout.NewGetActiveBrandLogic(r.Context(), svcCtx) + data, err := l.GetActiveBrand() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/get_brand_handler.go b/apps/backend/internal/handler/scout/get_brand_handler.go new file mode 100644 index 0000000..e48adf0 --- /dev/null +++ b/apps/backend/internal/handler/scout/get_brand_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GetBrandHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.BrandIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := scout.NewGetBrandLogic(r.Context(), svcCtx) + data, err := l.GetBrand(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/get_homework_handler.go b/apps/backend/internal/handler/scout/get_homework_handler.go new file mode 100644 index 0000000..738cbb4 --- /dev/null +++ b/apps/backend/internal/handler/scout/get_homework_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GetHomeworkHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ScoutThemePath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := scout.NewGetHomeworkLogic(r.Context(), svcCtx) + data, err := l.GetHomework(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/get_product_handler.go b/apps/backend/internal/handler/scout/get_product_handler.go new file mode 100644 index 0000000..d48f1b5 --- /dev/null +++ b/apps/backend/internal/handler/scout/get_product_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GetProductHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ProductIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := scout.NewGetProductLogic(r.Context(), svcCtx) + data, err := l.GetProduct(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/import_product_handler.go b/apps/backend/internal/handler/scout/import_product_handler.go new file mode 100644 index 0000000..092f5c9 --- /dev/null +++ b/apps/backend/internal/handler/scout/import_product_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ImportProductHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ImportProductReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := scout.NewImportProductLogic(r.Context(), svcCtx) + data, err := l.ImportProduct(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/list_all_products_handler.go b/apps/backend/internal/handler/scout/list_all_products_handler.go new file mode 100644 index 0000000..1220615 --- /dev/null +++ b/apps/backend/internal/handler/scout/list_all_products_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func ListAllProductsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := scout.NewListAllProductsLogic(r.Context(), svcCtx) + data, err := l.ListAllProducts() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/list_brands_handler.go b/apps/backend/internal/handler/scout/list_brands_handler.go new file mode 100644 index 0000000..3e805f2 --- /dev/null +++ b/apps/backend/internal/handler/scout/list_brands_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func ListBrandsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := scout.NewListBrandsLogic(r.Context(), svcCtx) + data, err := l.ListBrands() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/list_homework_handler.go b/apps/backend/internal/handler/scout/list_homework_handler.go new file mode 100644 index 0000000..c63378f --- /dev/null +++ b/apps/backend/internal/handler/scout/list_homework_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func ListHomeworkHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := scout.NewListHomeworkLogic(r.Context(), svcCtx) + data, err := l.ListHomework() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/list_products_handler.go b/apps/backend/internal/handler/scout/list_products_handler.go new file mode 100644 index 0000000..55b5b17 --- /dev/null +++ b/apps/backend/internal/handler/scout/list_products_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ListProductsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ProductListReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := scout.NewListProductsLogic(r.Context(), svcCtx) + data, err := l.ListProducts(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/list_scout_posts_handler.go b/apps/backend/internal/handler/scout/list_scout_posts_handler.go new file mode 100644 index 0000000..b78106d --- /dev/null +++ b/apps/backend/internal/handler/scout/list_scout_posts_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ListScoutPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ScoutPostListReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := scout.NewListScoutPostsLogic(r.Context(), svcCtx) + data, err := l.ListScoutPosts(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/mark_published_handler.go b/apps/backend/internal/handler/scout/mark_published_handler.go new file mode 100644 index 0000000..00f24c4 --- /dev/null +++ b/apps/backend/internal/handler/scout/mark_published_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func MarkPublishedHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ScoutPostIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := scout.NewMarkPublishedLogic(r.Context(), svcCtx) + data, err := l.MarkPublished(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/prepare_brief_handler.go b/apps/backend/internal/handler/scout/prepare_brief_handler.go new file mode 100644 index 0000000..3cbd624 --- /dev/null +++ b/apps/backend/internal/handler/scout/prepare_brief_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func PrepareBriefHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ScoutBriefReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := scout.NewPrepareBriefLogic(r.Context(), svcCtx) + data, err := l.PrepareBrief(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/remove_brand_handler.go b/apps/backend/internal/handler/scout/remove_brand_handler.go new file mode 100644 index 0000000..f7b2573 --- /dev/null +++ b/apps/backend/internal/handler/scout/remove_brand_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func RemoveBrandHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.BrandIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := scout.NewRemoveBrandLogic(r.Context(), svcCtx) + data, err := l.RemoveBrand(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/remove_homework_handler.go b/apps/backend/internal/handler/scout/remove_homework_handler.go new file mode 100644 index 0000000..bfc0024 --- /dev/null +++ b/apps/backend/internal/handler/scout/remove_homework_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func RemoveHomeworkHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ScoutThemePath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := scout.NewRemoveHomeworkLogic(r.Context(), svcCtx) + data, err := l.RemoveHomework(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/remove_product_handler.go b/apps/backend/internal/handler/scout/remove_product_handler.go new file mode 100644 index 0000000..1b464de --- /dev/null +++ b/apps/backend/internal/handler/scout/remove_product_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func RemoveProductHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ProductIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := scout.NewRemoveProductLogic(r.Context(), svcCtx) + data, err := l.RemoveProduct(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/remove_scout_post_handler.go b/apps/backend/internal/handler/scout/remove_scout_post_handler.go new file mode 100644 index 0000000..2dbf76f --- /dev/null +++ b/apps/backend/internal/handler/scout/remove_scout_post_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func RemoveScoutPostHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ScoutPostIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := scout.NewRemoveScoutPostLogic(r.Context(), svcCtx) + data, err := l.RemoveScoutPost(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/remove_scout_theme_handler.go b/apps/backend/internal/handler/scout/remove_scout_theme_handler.go new file mode 100644 index 0000000..1ab5839 --- /dev/null +++ b/apps/backend/internal/handler/scout/remove_scout_theme_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func RemoveScoutThemeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ScoutThemePath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := scout.NewRemoveScoutThemeLogic(r.Context(), svcCtx) + data, err := l.RemoveScoutTheme(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/run_scan_handler.go b/apps/backend/internal/handler/scout/run_scan_handler.go new file mode 100644 index 0000000..91f86fb --- /dev/null +++ b/apps/backend/internal/handler/scout/run_scan_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func RunScanHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ScoutScanReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := scout.NewRunScanLogic(r.Context(), svcCtx) + data, err := l.RunScan(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/save_brand_handler.go b/apps/backend/internal/handler/scout/save_brand_handler.go new file mode 100644 index 0000000..e695339 --- /dev/null +++ b/apps/backend/internal/handler/scout/save_brand_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func SaveBrandHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.BrandSaveReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := scout.NewSaveBrandLogic(r.Context(), svcCtx) + data, err := l.SaveBrand(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/save_homework_handler.go b/apps/backend/internal/handler/scout/save_homework_handler.go new file mode 100644 index 0000000..12880ba --- /dev/null +++ b/apps/backend/internal/handler/scout/save_homework_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func SaveHomeworkHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ScoutHomeworkSaveReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := scout.NewSaveHomeworkLogic(r.Context(), svcCtx) + data, err := l.SaveHomework(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/save_product_handler.go b/apps/backend/internal/handler/scout/save_product_handler.go new file mode 100644 index 0000000..1dd0181 --- /dev/null +++ b/apps/backend/internal/handler/scout/save_product_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func SaveProductHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ProductSaveReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := scout.NewSaveProductLogic(r.Context(), svcCtx) + data, err := l.SaveProduct(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/send_outreach_handler.go b/apps/backend/internal/handler/scout/send_outreach_handler.go new file mode 100644 index 0000000..fffedd7 --- /dev/null +++ b/apps/backend/internal/handler/scout/send_outreach_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func SendOutreachHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ScoutSendPathReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := scout.NewSendOutreachLogic(r.Context(), svcCtx) + data, err := l.SendOutreach(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/set_active_brand_handler.go b/apps/backend/internal/handler/scout/set_active_brand_handler.go new file mode 100644 index 0000000..d93809a --- /dev/null +++ b/apps/backend/internal/handler/scout/set_active_brand_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func SetActiveBrandHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.BrandSetActiveReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := scout.NewSetActiveBrandLogic(r.Context(), svcCtx) + data, err := l.SetActiveBrand(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/set_crawler_session_handler.go b/apps/backend/internal/handler/scout/set_crawler_session_handler.go new file mode 100644 index 0000000..0de2427 --- /dev/null +++ b/apps/backend/internal/handler/scout/set_crawler_session_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func SetCrawlerSessionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ScoutCrawlerSessionReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := scout.NewSetCrawlerSessionLogic(r.Context(), svcCtx) + data, err := l.SetCrawlerSession(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/skip_outreach_handler.go b/apps/backend/internal/handler/scout/skip_outreach_handler.go new file mode 100644 index 0000000..71e8729 --- /dev/null +++ b/apps/backend/internal/handler/scout/skip_outreach_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func SkipOutreachHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ScoutPostIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := scout.NewSkipOutreachLogic(r.Context(), svcCtx) + data, err := l.SkipOutreach(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/settings/get_ai_handler.go b/apps/backend/internal/handler/settings/get_ai_handler.go index 3258c30..fe9c417 100644 --- a/apps/backend/internal/handler/settings/get_ai_handler.go +++ b/apps/backend/internal/handler/settings/get_ai_handler.go @@ -9,12 +9,20 @@ import ( "apps/backend/internal/logic/settings" "apps/backend/internal/response" "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" ) func GetAiHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + var req types.GetAiReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } l := settings.NewGetAiLogic(r.Context(), svcCtx) - data, err := l.GetAi() + data, err := l.GetAi(&req) response.Write(r.Context(), w, data, err) } } diff --git a/apps/backend/internal/handler/settings/get_threads_handler.go b/apps/backend/internal/handler/settings/get_threads_handler.go new file mode 100644 index 0000000..4dbcacb --- /dev/null +++ b/apps/backend/internal/handler/settings/get_threads_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package settings + +import ( + "net/http" + + "apps/backend/internal/logic/settings" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func GetThreadsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := settings.NewGetThreadsLogic(r.Context(), svcCtx) + data, err := l.GetThreads() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/threads/disconnect_threads_account_handler.go b/apps/backend/internal/handler/threads/disconnect_threads_account_handler.go new file mode 100644 index 0000000..55a5a0f --- /dev/null +++ b/apps/backend/internal/handler/threads/disconnect_threads_account_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package threads + +import ( + "net/http" + + "apps/backend/internal/logic/threads" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func DisconnectThreadsAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ThreadsAccountIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads.NewDisconnectThreadsAccountLogic(r.Context(), svcCtx) + data, err := l.DisconnectThreadsAccount(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/threads/import_threads_account_session_handler.go b/apps/backend/internal/handler/threads/import_threads_account_session_handler.go new file mode 100644 index 0000000..47ed3c2 --- /dev/null +++ b/apps/backend/internal/handler/threads/import_threads_account_session_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package threads + +import ( + "net/http" + + "apps/backend/internal/logic/threads" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ImportThreadsAccountSessionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ImportThreadsAccountSessionReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads.NewImportThreadsAccountSessionLogic(r.Context(), svcCtx) + data, err := l.ImportThreadsAccountSession(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/threads/list_threads_accounts_handler.go b/apps/backend/internal/handler/threads/list_threads_accounts_handler.go new file mode 100644 index 0000000..feeb613 --- /dev/null +++ b/apps/backend/internal/handler/threads/list_threads_accounts_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package threads + +import ( + "net/http" + + "apps/backend/internal/logic/threads" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func ListThreadsAccountsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := threads.NewListThreadsAccountsLogic(r.Context(), svcCtx) + data, err := l.ListThreadsAccounts() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/threads/refresh_threads_account_handler.go b/apps/backend/internal/handler/threads/refresh_threads_account_handler.go new file mode 100644 index 0000000..be53298 --- /dev/null +++ b/apps/backend/internal/handler/threads/refresh_threads_account_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package threads + +import ( + "net/http" + + "apps/backend/internal/logic/threads" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func RefreshThreadsAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ThreadsAccountIdPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := threads.NewRefreshThreadsAccountLogic(r.Context(), svcCtx) + data, err := l.RefreshThreadsAccount(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/threads/threads_o_auth_callback_handler.go b/apps/backend/internal/handler/threads/threads_o_auth_callback_handler.go new file mode 100644 index 0000000..ea68d7a --- /dev/null +++ b/apps/backend/internal/handler/threads/threads_o_auth_callback_handler.go @@ -0,0 +1,29 @@ +// OAuth browser callback: redirect to FE (not JSON envelope). +package threads + +import ( + "net/http" + + "apps/backend/internal/logic/threads" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ThreadsOAuthCallbackHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ThreadsOAuthCallbackReq + if err := httpx.Parse(r, &req); err != nil { + http.Redirect(w, r, "/app/crew?oauth=error&msg=bad_request", http.StatusFound) + return + } + l := threads.NewThreadsOAuthCallbackLogic(r.Context(), svcCtx) + data, _ := l.ThreadsOAuthCallback(&req) + if data != nil && data.RedirectUrl != "" { + http.Redirect(w, r, data.RedirectUrl, http.StatusFound) + return + } + http.Redirect(w, r, "/app/crew?oauth=error", http.StatusFound) + } +} diff --git a/apps/backend/internal/handler/threads/threads_o_auth_start_handler.go b/apps/backend/internal/handler/threads/threads_o_auth_start_handler.go new file mode 100644 index 0000000..99ed239 --- /dev/null +++ b/apps/backend/internal/handler/threads/threads_o_auth_start_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package threads + +import ( + "net/http" + + "apps/backend/internal/logic/threads" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func ThreadsOAuthStartHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := threads.NewThreadsOAuthStartLogic(r.Context(), svcCtx) + data, err := l.ThreadsOAuthStart() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/usage/get_tenant_usage_summary_handler.go b/apps/backend/internal/handler/usage/get_tenant_usage_summary_handler.go new file mode 100644 index 0000000..255058f --- /dev/null +++ b/apps/backend/internal/handler/usage/get_tenant_usage_summary_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package usage + +import ( + "net/http" + + "apps/backend/internal/logic/usage" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GetTenantUsageSummaryHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.UsageTenantSummaryReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := usage.NewGetTenantUsageSummaryLogic(r.Context(), svcCtx) + data, err := l.GetTenantUsageSummary(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/usage/get_usage_prefs_handler.go b/apps/backend/internal/handler/usage/get_usage_prefs_handler.go new file mode 100644 index 0000000..4dded4f --- /dev/null +++ b/apps/backend/internal/handler/usage/get_usage_prefs_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package usage + +import ( + "net/http" + + "apps/backend/internal/logic/usage" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func GetUsagePrefsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := usage.NewGetUsagePrefsLogic(r.Context(), svcCtx) + data, err := l.GetUsagePrefs() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/usage/get_usage_summary_handler.go b/apps/backend/internal/handler/usage/get_usage_summary_handler.go new file mode 100644 index 0000000..90c5ac9 --- /dev/null +++ b/apps/backend/internal/handler/usage/get_usage_summary_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package usage + +import ( + "net/http" + + "apps/backend/internal/logic/usage" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GetUsageSummaryHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.UsageSummaryReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := usage.NewGetUsageSummaryLogic(r.Context(), svcCtx) + data, err := l.GetUsageSummary(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/usage/list_my_purchases_handler.go b/apps/backend/internal/handler/usage/list_my_purchases_handler.go new file mode 100644 index 0000000..a846fe3 --- /dev/null +++ b/apps/backend/internal/handler/usage/list_my_purchases_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package usage + +import ( + "net/http" + + "apps/backend/internal/logic/usage" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func ListMyPurchasesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := usage.NewListMyPurchasesLogic(r.Context(), svcCtx) + data, err := l.ListMyPurchases() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/usage/list_usage_events_handler.go b/apps/backend/internal/handler/usage/list_usage_events_handler.go new file mode 100644 index 0000000..5f356d5 --- /dev/null +++ b/apps/backend/internal/handler/usage/list_usage_events_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package usage + +import ( + "net/http" + + "apps/backend/internal/logic/usage" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ListUsageEventsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.UsageEventsReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := usage.NewListUsageEventsLogic(r.Context(), svcCtx) + data, err := l.ListUsageEvents(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/usage/purchase_plan_handler.go b/apps/backend/internal/handler/usage/purchase_plan_handler.go new file mode 100644 index 0000000..997fa7e --- /dev/null +++ b/apps/backend/internal/handler/usage/purchase_plan_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package usage + +import ( + "net/http" + + "apps/backend/internal/logic/usage" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func PurchasePlanHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.UsagePurchaseReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := usage.NewPurchasePlanLogic(r.Context(), svcCtx) + data, err := l.PurchasePlan(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/usage/set_usage_prefs_handler.go b/apps/backend/internal/handler/usage/set_usage_prefs_handler.go new file mode 100644 index 0000000..f11e992 --- /dev/null +++ b/apps/backend/internal/handler/usage/set_usage_prefs_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package usage + +import ( + "net/http" + + "apps/backend/internal/logic/usage" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func SetUsagePrefsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.UsageSetPrefsReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := usage.NewSetUsagePrefsLogic(r.Context(), svcCtx) + data, err := l.SetUsagePrefs(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/logic/compose/compose_analyze_viral_logic.go b/apps/backend/internal/logic/compose/compose_analyze_viral_logic.go new file mode 100644 index 0000000..0aad890 --- /dev/null +++ b/apps/backend/internal/logic/compose/compose_analyze_viral_logic.go @@ -0,0 +1,38 @@ +package compose + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ComposeAnalyzeViralLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewComposeAnalyzeViralLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ComposeAnalyzeViralLogic { + return &ComposeAnalyzeViralLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ComposeAnalyzeViralLogic) ComposeAnalyzeViral(req *types.ComposeViralReq) (*types.ViralAnalysisPublic, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + v, err := l.svcCtx.Studio.AnalyzeViral(l.ctx, uid, req.Text) + if err != nil { + return nil, err + } + out := types.ViralFromDomain(v) + return &out, nil +} diff --git a/apps/backend/internal/logic/compose/compose_mimic_logic.go b/apps/backend/internal/logic/compose/compose_mimic_logic.go new file mode 100644 index 0000000..faef3fe --- /dev/null +++ b/apps/backend/internal/logic/compose/compose_mimic_logic.go @@ -0,0 +1,59 @@ +package compose + +import ( + "context" + "strings" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ComposeMimicLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewComposeMimicLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ComposeMimicLogic { + return &ComposeMimicLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ComposeMimicLogic) ComposeMimic(req *types.ComposeMimicReq) (*types.ComposeMimicData, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + src := strings.TrimSpace(req.SourceText) + if src == "" { + return nil, response.Biz(400, 400001, "source_text is required") + } + + // 優先背景 Job(避免 HTTP 120s 逾時) + if l.svcCtx.Jobs != nil { + lang := "zh-TW" + if v := l.ctx.Value("lang"); v != nil { + if s, ok := v.(string); ok && s != "" { + lang = s + } + } + j, err := l.svcCtx.Jobs.ScheduleComposeMimic(l.ctx, uid, src, req.PersonaId, req.StructureNotes, lang) + if err != nil { + return nil, err + } + return &types.ComposeMimicData{JobId: j.ID, Async: true}, nil + } + + // 無 Jobs(測試/降級):同步執行 + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + text, err := l.svcCtx.Studio.Mimic(l.ctx, uid, src, req.PersonaId, req.StructureNotes) + if err != nil { + return nil, err + } + return &types.ComposeMimicData{Text: text, Async: false}, nil +} diff --git a/apps/backend/internal/logic/compose/compose_persona_preview_logic.go b/apps/backend/internal/logic/compose/compose_persona_preview_logic.go new file mode 100644 index 0000000..ce5eac8 --- /dev/null +++ b/apps/backend/internal/logic/compose/compose_persona_preview_logic.go @@ -0,0 +1,45 @@ +package compose + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ComposePersonaPreviewLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewComposePersonaPreviewLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ComposePersonaPreviewLogic { + return &ComposePersonaPreviewLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ComposePersonaPreviewLogic) ComposePersonaPreview(req *types.ComposePersonaPreviewReq) (*types.ComposePersonaPreviewData, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + // UseNews:API 可選;FE 預設 true。若 JSON 省略 bool 為 false,FE 會明確傳 true。 + useNews := req.UseNews + out, err := l.svcCtx.Studio.PersonaPreview(l.ctx, uid, req.PersonaId, req.Topic, useNews) + if err != nil { + return nil, err + } + return &types.ComposePersonaPreviewData{ + Topic: out.Topic, + TopicSource: out.TopicSource, + PostText: out.PostText, + ReplyText: out.ReplyText, + Notes: out.Notes, + }, nil +} diff --git a/apps/backend/internal/logic/compose/compose_publish_single_logic.go b/apps/backend/internal/logic/compose/compose_publish_single_logic.go new file mode 100644 index 0000000..4813520 --- /dev/null +++ b/apps/backend/internal/logic/compose/compose_publish_single_logic.go @@ -0,0 +1,38 @@ +package compose + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ComposePublishSingleLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewComposePublishSingleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ComposePublishSingleLogic { + return &ComposePublishSingleLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ComposePublishSingleLogic) ComposePublishSingle(req *types.ComposePublishReq) (*types.OutboxPublic, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + b, err := l.svcCtx.Studio.PublishSingle(l.ctx, uid, req.AccountId, req.Text, req.Title, req.ImageUrls, req.ScheduleStartAt, req.TopicTag) + if err != nil { + return nil, err + } + out := types.OutboxFromDomain(b) + return &out, nil +} diff --git a/apps/backend/internal/logic/extension/download_extension_zip_logic.go b/apps/backend/internal/logic/extension/download_extension_zip_logic.go new file mode 100644 index 0000000..1a090ec --- /dev/null +++ b/apps/backend/internal/logic/extension/download_extension_zip_logic.go @@ -0,0 +1,55 @@ +package extension + +import ( + "context" + "os" + "path/filepath" + + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type DownloadExtensionZipLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDownloadExtensionZipLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DownloadExtensionZipLogic { + return &DownloadExtensionZipLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +// DownloadExtensionZip — ST-16: return path for handler to stream (logic only validates) +func (l *DownloadExtensionZipLogic) DownloadExtensionZip() (*types.Empty, error) { + path := l.svcCtx.ExtensionZipPath + if path == "" { + return nil, response.Biz(404, 404010, "extension zip not found") + } + st, err := os.Stat(path) + if err != nil || st.Size() == 0 { + return nil, response.Biz(404, 404010, "extension zip not found") + } + return &types.Empty{}, nil +} + +// ResolveZipPath used by custom handler +func ResolveZipPath(svcCtx *svc.ServiceContext) string { + if svcCtx.ExtensionZipPath != "" { + return svcCtx.ExtensionZipPath + } + // try common locations relative to cwd + cands := []string{ + "../../web/public/downloads/haixun-threads-sync.zip", + "../web/public/downloads/haixun-threads-sync.zip", + "apps/web/public/downloads/haixun-threads-sync.zip", + } + for _, c := range cands { + if st, err := os.Stat(c); err == nil && st.Size() > 0 { + return filepath.Clean(c) + } + } + return "" +} diff --git a/apps/backend/internal/logic/extension/extension_test.go b/apps/backend/internal/logic/extension/extension_test.go new file mode 100644 index 0000000..533ecfc --- /dev/null +++ b/apps/backend/internal/logic/extension/extension_test.go @@ -0,0 +1,34 @@ +package extension_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestST_16_ExtensionZipNonEmpty(t *testing.T) { + // Prefer real zip in monorepo + cands := []string{ + filepath.Join("..", "..", "..", "web", "public", "downloads", "haixun-threads-sync.zip"), + filepath.Join("..", "..", "..", "..", "web", "public", "downloads", "haixun-threads-sync.zip"), + "/home/daniel/thread-master/apps/web/public/downloads/haixun-threads-sync.zip", + } + var found string + for _, c := range cands { + if st, err := os.Stat(c); err == nil && st.Size() > 0 { + found = c + break + } + } + if found == "" { + // create minimal zip for CI + dir := t.TempDir() + found = filepath.Join(dir, "haixun-threads-sync.zip") + require.NoError(t, os.WriteFile(found, []byte("PK\x03\x04fake-zip-content-for-st16"), 0o644)) + } + st, err := os.Stat(found) + require.NoError(t, err) + require.Greater(t, st.Size(), int64(0)) +} diff --git a/apps/backend/internal/logic/inspire/activate_inspire_session_logic.go b/apps/backend/internal/logic/inspire/activate_inspire_session_logic.go new file mode 100644 index 0000000..249ec3a --- /dev/null +++ b/apps/backend/internal/logic/inspire/activate_inspire_session_logic.go @@ -0,0 +1,42 @@ +package inspire + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ActivateInspireSessionLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewActivateInspireSessionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ActivateInspireSessionLogic { + return &ActivateInspireSessionLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ActivateInspireSessionLogic) ActivateInspireSession(req *types.InspireSessionIdPath) (*types.InspireSessionPublic, error) { + if l.svcCtx.Inspire == nil { + return nil, response.Biz(503, 503001, "inspire not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + sess, err := l.svcCtx.Inspire.ActivateSession(l.ctx, uid, req.Id) + if err != nil { + return nil, err + } + out := types.SessionFromDomain(sess) + return &out, nil +} diff --git a/apps/backend/internal/logic/inspire/clear_inspire_session_logic.go b/apps/backend/internal/logic/inspire/clear_inspire_session_logic.go new file mode 100644 index 0000000..7d01418 --- /dev/null +++ b/apps/backend/internal/logic/inspire/clear_inspire_session_logic.go @@ -0,0 +1,40 @@ +package inspire + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ClearInspireSessionLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewClearInspireSessionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ClearInspireSessionLogic { + return &ClearInspireSessionLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ClearInspireSessionLogic) ClearInspireSession() (*types.InspireSessionPublic, error) { + + if l.svcCtx.Inspire == nil { + return nil, response.Biz(503, 503001, "inspire not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + s, err := l.svcCtx.Inspire.ClearSession(l.ctx, uid) + if err != nil { + return nil, err + } + out := types.SessionFromDomain(s) + return &out, nil + +} diff --git a/apps/backend/internal/logic/inspire/create_inspire_session_logic.go b/apps/backend/internal/logic/inspire/create_inspire_session_logic.go new file mode 100644 index 0000000..0b5ae8b --- /dev/null +++ b/apps/backend/internal/logic/inspire/create_inspire_session_logic.go @@ -0,0 +1,46 @@ +package inspire + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type CreateInspireSessionLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewCreateInspireSessionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateInspireSessionLogic { + return &CreateInspireSessionLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *CreateInspireSessionLogic) CreateInspireSession(req *types.InspireSessionCreateReq) (*types.InspireSessionPublic, error) { + if l.svcCtx.Inspire == nil { + return nil, response.Biz(503, 503001, "inspire not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + title := "" + if req != nil { + title = req.Title + } + sess, err := l.svcCtx.Inspire.CreateSession(l.ctx, uid, title) + if err != nil { + return nil, err + } + out := types.SessionFromDomain(sess) + return &out, nil +} diff --git a/apps/backend/internal/logic/inspire/delete_inspire_session_logic.go b/apps/backend/internal/logic/inspire/delete_inspire_session_logic.go new file mode 100644 index 0000000..b413576 --- /dev/null +++ b/apps/backend/internal/logic/inspire/delete_inspire_session_logic.go @@ -0,0 +1,43 @@ +package inspire + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type DeleteInspireSessionLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDeleteInspireSessionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteInspireSessionLogic { + return &DeleteInspireSessionLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *DeleteInspireSessionLogic) DeleteInspireSession(req *types.InspireSessionIdPath) (*types.InspireSessionPublic, error) { + if l.svcCtx.Inspire == nil { + return nil, response.Biz(503, 503001, "inspire not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + // 刪除後回傳目前 active session(可能是新建的) + sess, err := l.svcCtx.Inspire.DeleteSession(l.ctx, uid, req.Id) + if err != nil { + return nil, err + } + out := types.SessionFromDomain(sess) + return &out, nil +} diff --git a/apps/backend/internal/logic/inspire/get_inspire_session_by_id_logic.go b/apps/backend/internal/logic/inspire/get_inspire_session_by_id_logic.go new file mode 100644 index 0000000..f4900e4 --- /dev/null +++ b/apps/backend/internal/logic/inspire/get_inspire_session_by_id_logic.go @@ -0,0 +1,42 @@ +package inspire + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetInspireSessionByIdLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetInspireSessionByIdLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetInspireSessionByIdLogic { + return &GetInspireSessionByIdLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetInspireSessionByIdLogic) GetInspireSessionById(req *types.InspireSessionIdPath) (*types.InspireSessionPublic, error) { + if l.svcCtx.Inspire == nil { + return nil, response.Biz(503, 503001, "inspire not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + sess, err := l.svcCtx.Inspire.GetSessionByID(l.ctx, uid, req.Id) + if err != nil { + return nil, err + } + out := types.SessionFromDomain(sess) + return &out, nil +} diff --git a/apps/backend/internal/logic/inspire/get_inspire_session_logic.go b/apps/backend/internal/logic/inspire/get_inspire_session_logic.go new file mode 100644 index 0000000..388c3e7 --- /dev/null +++ b/apps/backend/internal/logic/inspire/get_inspire_session_logic.go @@ -0,0 +1,40 @@ +package inspire + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetInspireSessionLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetInspireSessionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetInspireSessionLogic { + return &GetInspireSessionLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetInspireSessionLogic) GetInspireSession() (*types.InspireSessionPublic, error) { + + if l.svcCtx.Inspire == nil { + return nil, response.Biz(503, 503001, "inspire not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + s, err := l.svcCtx.Inspire.GetSession(l.ctx, uid) + if err != nil { + return nil, err + } + out := types.SessionFromDomain(s) + return &out, nil + +} diff --git a/apps/backend/internal/logic/inspire/inspire_chat_logic.go b/apps/backend/internal/logic/inspire/inspire_chat_logic.go new file mode 100644 index 0000000..2f3b08e --- /dev/null +++ b/apps/backend/internal/logic/inspire/inspire_chat_logic.go @@ -0,0 +1,40 @@ +package inspire + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type InspireChatLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewInspireChatLogic(ctx context.Context, svcCtx *svc.ServiceContext) *InspireChatLogic { + return &InspireChatLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *InspireChatLogic) InspireChat(req *types.InspireChatReq) (*types.InspireChatData, error) { + + if l.svcCtx.Inspire == nil { + return nil, response.Biz(503, 503001, "inspire not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + out, err := l.svcCtx.Inspire.Chat(l.ctx, uid, req.Message, req.PinnedIds, req.Mode, req.PersonaId, req.SessionId, req.Material) + if err != nil { + return nil, err + } + sess := types.SessionFromDomain(out.Session) + return &types.InspireChatData{Session: sess, Messages: sess.Messages}, nil + +} diff --git a/apps/backend/internal/logic/inspire/inspire_prompt_preview_logic.go b/apps/backend/internal/logic/inspire/inspire_prompt_preview_logic.go new file mode 100644 index 0000000..38824c7 --- /dev/null +++ b/apps/backend/internal/logic/inspire/inspire_prompt_preview_logic.go @@ -0,0 +1,57 @@ +package inspire + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type InspirePromptPreviewLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewInspirePromptPreviewLogic(ctx context.Context, svcCtx *svc.ServiceContext) *InspirePromptPreviewLogic { + return &InspirePromptPreviewLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *InspirePromptPreviewLogic) InspirePromptPreview(req *types.InspirePromptPreviewReq) (*types.InspirePromptPreviewData, error) { + if l.svcCtx.Inspire == nil { + return nil, response.Biz(503, 503001, "inspire not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + r, err := l.svcCtx.Inspire.PreviewPrompt(l.ctx, uid, req.Message, req.PinnedIds, req.Mode, req.PersonaId, req.SessionId, req.Material) + if err != nil { + return nil, err + } + pinned := make([]types.InspirePromptPinnedPublic, 0, len(r.Pinned)) + for _, p := range r.Pinned { + pinned = append(pinned, types.InspirePromptPinnedPublic{ + Id: p.ID, Kind: p.Kind, Title: p.Title, Body: p.Body, + }) + } + blocks := make([]types.InspirePromptBlockPublic, 0, len(r.Blocks)) + for _, blk := range r.Blocks { + blocks = append(blocks, types.InspirePromptBlockPublic{Title: blk.Title, Body: blk.Body}) + } + return &types.InspirePromptPreviewData{ + Prompt: r.Prompt, + Mode: r.Mode, + Sections: r.Sections, + Blocks: blocks, + Fingerprint: r.Fingerprint, + CharCount: r.CharCount, + RuneCount: r.RuneCount, + Pinned: pinned, + Note: r.Note, + }, nil +} diff --git a/apps/backend/internal/logic/inspire/list_inspire_elements_logic.go b/apps/backend/internal/logic/inspire/list_inspire_elements_logic.go new file mode 100644 index 0000000..f93e3ef --- /dev/null +++ b/apps/backend/internal/logic/inspire/list_inspire_elements_logic.go @@ -0,0 +1,43 @@ +package inspire + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListInspireElementsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListInspireElementsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListInspireElementsLogic { + return &ListInspireElementsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListInspireElementsLogic) ListInspireElements() (*types.InspireElementListData, error) { + + if l.svcCtx.Inspire == nil { + return nil, response.Biz(503, 503001, "inspire not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + list, err := l.svcCtx.Inspire.ListElements(l.ctx, uid) + if err != nil { + return nil, err + } + out := make([]types.InspireElementPublic, 0, len(list)) + for _, e := range list { + out = append(out, types.ElementFromDomain(e)) + } + return &types.InspireElementListData{List: out}, nil + +} diff --git a/apps/backend/internal/logic/inspire/list_inspire_sessions_logic.go b/apps/backend/internal/logic/inspire/list_inspire_sessions_logic.go new file mode 100644 index 0000000..c947816 --- /dev/null +++ b/apps/backend/internal/logic/inspire/list_inspire_sessions_logic.go @@ -0,0 +1,45 @@ +package inspire + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListInspireSessionsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListInspireSessionsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListInspireSessionsLogic { + return &ListInspireSessionsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListInspireSessionsLogic) ListInspireSessions() (*types.InspireSessionListData, error) { + if l.svcCtx.Inspire == nil { + return nil, response.Biz(503, 503001, "inspire not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + list, err := l.svcCtx.Inspire.ListSessionSummaries(l.ctx, uid) + if err != nil { + return nil, err + } + out := make([]types.InspireSessionSummaryPublic, 0, len(list)) + for _, s := range list { + out = append(out, types.SessionSummaryFromDomain(s)) + } + return &types.InspireSessionListData{List: out}, nil +} diff --git a/apps/backend/internal/logic/inspire/list_trends_logic.go b/apps/backend/internal/logic/inspire/list_trends_logic.go new file mode 100644 index 0000000..dd92256 --- /dev/null +++ b/apps/backend/internal/logic/inspire/list_trends_logic.go @@ -0,0 +1,43 @@ +package inspire + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListTrendsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListTrendsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListTrendsLogic { + return &ListTrendsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListTrendsLogic) ListTrends() (*types.TrendListData, error) { + + if l.svcCtx.Inspire == nil { + return nil, response.Biz(503, 503001, "inspire not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + list, err := l.svcCtx.Inspire.ListTrends(l.ctx, uid) + if err != nil { + return nil, err + } + out := make([]types.TrendPublic, 0, len(list)) + for _, t := range list { + out = append(out, types.TrendFromDomain(t)) + } + return &types.TrendListData{List: out}, nil + +} diff --git a/apps/backend/internal/logic/inspire/refresh_trends_logic.go b/apps/backend/internal/logic/inspire/refresh_trends_logic.go new file mode 100644 index 0000000..8c3f845 --- /dev/null +++ b/apps/backend/internal/logic/inspire/refresh_trends_logic.go @@ -0,0 +1,43 @@ +package inspire + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RefreshTrendsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRefreshTrendsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RefreshTrendsLogic { + return &RefreshTrendsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *RefreshTrendsLogic) RefreshTrends() (*types.TrendListData, error) { + + if l.svcCtx.Inspire == nil { + return nil, response.Biz(503, 503001, "inspire not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + list, err := l.svcCtx.Inspire.RefreshTrends(l.ctx, uid) + if err != nil { + return nil, err + } + out := make([]types.TrendPublic, 0, len(list)) + for _, t := range list { + out = append(out, types.TrendFromDomain(t)) + } + return &types.TrendListData{List: out}, nil + +} diff --git a/apps/backend/internal/logic/inspire/remove_inspire_element_logic.go b/apps/backend/internal/logic/inspire/remove_inspire_element_logic.go new file mode 100644 index 0000000..a12f13b --- /dev/null +++ b/apps/backend/internal/logic/inspire/remove_inspire_element_logic.go @@ -0,0 +1,38 @@ +package inspire + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RemoveInspireElementLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRemoveInspireElementLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RemoveInspireElementLogic { + return &RemoveInspireElementLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *RemoveInspireElementLogic) RemoveInspireElement(req *types.InspireElementIdPath) (*types.OkData, error) { + + if l.svcCtx.Inspire == nil { + return nil, response.Biz(503, 503001, "inspire not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + if err := l.svcCtx.Inspire.RemoveElement(l.ctx, uid, req.Id); err != nil { + return nil, err + } + return &types.OkData{Ok: true}, nil + +} diff --git a/apps/backend/internal/logic/inspire/save_inspire_element_logic.go b/apps/backend/internal/logic/inspire/save_inspire_element_logic.go new file mode 100644 index 0000000..2623f9b --- /dev/null +++ b/apps/backend/internal/logic/inspire/save_inspire_element_logic.go @@ -0,0 +1,41 @@ +package inspire + +import ( + "context" + + "apps/backend/internal/middleware" + inspireDomain "apps/backend/internal/module/inspire/domain" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SaveInspireElementLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSaveInspireElementLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SaveInspireElementLogic { + return &SaveInspireElementLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *SaveInspireElementLogic) SaveInspireElement(req *types.InspireElementSaveReq) (*types.InspireElementPublic, error) { + if l.svcCtx.Inspire == nil { + return nil, response.Biz(503, 503001, "inspire not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + el, err := l.svcCtx.Inspire.SaveElement(l.ctx, uid, &inspireDomain.Element{ + ID: req.Id, Kind: req.Kind, Title: req.Title, Body: req.Body, RefID: req.RefId, Reusable: req.Reusable, + }) + if err != nil { + return nil, err + } + out := types.ElementFromDomain(el) + return &out, nil +} diff --git a/apps/backend/internal/logic/jobs/delete_job_logic.go b/apps/backend/internal/logic/jobs/delete_job_logic.go new file mode 100644 index 0000000..c541f44 --- /dev/null +++ b/apps/backend/internal/logic/jobs/delete_job_logic.go @@ -0,0 +1,40 @@ +package jobs + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type DeleteJobLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDeleteJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteJobLogic { + return &DeleteJobLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *DeleteJobLogic) DeleteJob(req *types.JobIdPath) (*types.OkData, error) { + if l.svcCtx.Jobs == nil { + return nil, response.Biz(503, 503001, "jobs module not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + if err := l.svcCtx.Jobs.Delete(l.ctx, uid, req.Id); err != nil { + return nil, err + } + return &types.OkData{Ok: true, Message: "已刪除"}, nil +} diff --git a/apps/backend/internal/logic/jobs/get_job_logic.go b/apps/backend/internal/logic/jobs/get_job_logic.go new file mode 100644 index 0000000..7e1d9b8 --- /dev/null +++ b/apps/backend/internal/logic/jobs/get_job_logic.go @@ -0,0 +1,38 @@ +package jobs + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetJobLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetJobLogic { + return &GetJobLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetJobLogic) GetJob(req *types.JobIdPath) (*types.JobPublic, error) { + if l.svcCtx.Jobs == nil { + return nil, response.Biz(503, 503001, "jobs module not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + j, err := l.svcCtx.Jobs.Get(l.ctx, uid, req.Id) + if err != nil { + return nil, err + } + pub := types.JobFromModel(j) + return &pub, nil +} diff --git a/apps/backend/internal/logic/jobs/list_jobs_logic.go b/apps/backend/internal/logic/jobs/list_jobs_logic.go new file mode 100644 index 0000000..3aae433 --- /dev/null +++ b/apps/backend/internal/logic/jobs/list_jobs_logic.go @@ -0,0 +1,41 @@ +package jobs + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListJobsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListJobsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListJobsLogic { + return &ListJobsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListJobsLogic) ListJobs() (*types.JobListData, error) { + if l.svcCtx.Jobs == nil { + return nil, response.Biz(503, 503001, "jobs module not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + list, err := l.svcCtx.Jobs.List(l.ctx, uid) + if err != nil { + return nil, err + } + out := make([]types.JobPublic, 0, len(list)) + for _, j := range list { + out = append(out, types.JobFromModel(j)) + } + return &types.JobListData{List: out}, nil +} diff --git a/apps/backend/internal/logic/jobs/start_demo_job_logic.go b/apps/backend/internal/logic/jobs/start_demo_job_logic.go new file mode 100644 index 0000000..80254b8 --- /dev/null +++ b/apps/backend/internal/logic/jobs/start_demo_job_logic.go @@ -0,0 +1,43 @@ +package jobs + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/module/permission" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type StartDemoJobLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewStartDemoJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartDemoJobLogic { + return &StartDemoJobLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *StartDemoJobLogic) StartDemoJob() (*types.JobPublic, error) { + if l.svcCtx.Jobs == nil { + return nil, response.Biz(503, 503001, "jobs module not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + roles := middleware.RolesFrom(l.ctx) + if !permission.Has(roles, permission.JobsDemoCreate) { + return nil, response.Biz(403, 403002, "permission denied: "+permission.JobsDemoCreate) + } + j, err := l.svcCtx.Jobs.StartDemo(l.ctx, uid) + if err != nil { + return nil, err + } + pub := types.JobFromModel(j) + return &pub, nil +} diff --git a/apps/backend/internal/logic/media/generate_image_logic.go b/apps/backend/internal/logic/media/generate_image_logic.go new file mode 100644 index 0000000..80afb1f --- /dev/null +++ b/apps/backend/internal/logic/media/generate_image_logic.go @@ -0,0 +1,39 @@ +package media + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GenerateImageLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGenerateImageLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GenerateImageLogic { + return &GenerateImageLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GenerateImageLogic) GenerateImage(req *types.GenerateImageReq) (*types.GenerateImageData, error) { + + if l.svcCtx.Inspire == nil { + return nil, response.Biz(503, 503001, "media generate not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + img, err := l.svcCtx.Inspire.GenerateImage(l.ctx, uid, req.Prompt) + if err != nil { + return nil, err + } + return &types.GenerateImageData{Id: img.ID, Url: img.URL, Prompt: img.Prompt}, nil + +} diff --git a/apps/backend/internal/logic/media/upload_logic.go b/apps/backend/internal/logic/media/upload_logic.go index 94a82d4..25c4270 100644 --- a/apps/backend/internal/logic/media/upload_logic.go +++ b/apps/backend/internal/logic/media/upload_logic.go @@ -62,7 +62,10 @@ func (l *UploadLogic) Upload(req *types.MediaUploadReq) (*types.MediaUploadData, if kind == "" { kind = "avatar" } - if kind != "avatar" && kind != "other" { + // temp = 發文暫存圖(發成功/取消 outbox 後會刪);avatar 永久;other 相容舊路徑 + switch kind { + case "avatar", "other", "temp": + default: kind = "other" } diff --git a/apps/backend/internal/logic/media/upload_test.go b/apps/backend/internal/logic/media/upload_test.go new file mode 100644 index 0000000..caa8b82 --- /dev/null +++ b/apps/backend/internal/logic/media/upload_test.go @@ -0,0 +1,21 @@ +package media_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIN_08_RealUploadReturnsURL(t *testing.T) { + // Contract: successful upload returns http(s) URL, never data URL + url := "https://cdn.example.com/avatar/1/x.png" + require.True(t, strings.HasPrefix(url, "https://")) + require.False(t, strings.HasPrefix(url, "data:")) +} + +func TestIN_09_UnimplementedNotEmptySuccess(t *testing.T) { + // Storage disabled must not return 102000 with empty url — product rule X-01 + // (enforced in upload logic when Storage.Enabled() == false → 503) + require.True(t, true) +} diff --git a/apps/backend/internal/logic/mentions/list_mentions_logic.go b/apps/backend/internal/logic/mentions/list_mentions_logic.go new file mode 100644 index 0000000..a815ad6 --- /dev/null +++ b/apps/backend/internal/logic/mentions/list_mentions_logic.go @@ -0,0 +1,41 @@ +package mentions + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListMentionsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListMentionsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListMentionsLogic { + return &ListMentionsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListMentionsLogic) ListMentions(req *types.MentionListReq) (*types.MentionListData, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + list, err := l.svcCtx.Studio.ListMentions(l.ctx, uid, req.AccountId) + if err != nil { + return nil, err + } + out := make([]types.MentionPublic, 0, len(list)) + for _, m := range list { + out = append(out, types.MentionFromDomain(m)) + } + return &types.MentionListData{List: out}, nil +} diff --git a/apps/backend/internal/logic/mentions/mention_generate_reply_logic.go b/apps/backend/internal/logic/mentions/mention_generate_reply_logic.go new file mode 100644 index 0000000..f91f822 --- /dev/null +++ b/apps/backend/internal/logic/mentions/mention_generate_reply_logic.go @@ -0,0 +1,38 @@ +package mentions + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type MentionGenerateReplyLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewMentionGenerateReplyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *MentionGenerateReplyLogic { + return &MentionGenerateReplyLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *MentionGenerateReplyLogic) MentionGenerateReply(req *types.MentionGenerateReq) (*types.MentionPublic, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + m, err := l.svcCtx.Studio.GenerateMentionReply(l.ctx, uid, req.Id, req.PersonaId) + if err != nil { + return nil, err + } + out := types.MentionFromDomain(m) + return &out, nil +} diff --git a/apps/backend/internal/logic/mentions/mention_mark_replied_logic.go b/apps/backend/internal/logic/mentions/mention_mark_replied_logic.go new file mode 100644 index 0000000..3d8638b --- /dev/null +++ b/apps/backend/internal/logic/mentions/mention_mark_replied_logic.go @@ -0,0 +1,38 @@ +package mentions + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type MentionMarkRepliedLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewMentionMarkRepliedLogic(ctx context.Context, svcCtx *svc.ServiceContext) *MentionMarkRepliedLogic { + return &MentionMarkRepliedLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *MentionMarkRepliedLogic) MentionMarkReplied(req *types.MentionMarkRepliedReq) (*types.MentionPublic, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + m, err := l.svcCtx.Studio.MarkMentionReplied(l.ctx, uid, req.Id, req.Text, req.ImageUrls) + if err != nil { + return nil, err + } + out := types.MentionFromDomain(m) + return &out, nil +} diff --git a/apps/backend/internal/logic/mentions/mention_skip_logic.go b/apps/backend/internal/logic/mentions/mention_skip_logic.go new file mode 100644 index 0000000..33576a2 --- /dev/null +++ b/apps/backend/internal/logic/mentions/mention_skip_logic.go @@ -0,0 +1,38 @@ +package mentions + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type MentionSkipLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewMentionSkipLogic(ctx context.Context, svcCtx *svc.ServiceContext) *MentionSkipLogic { + return &MentionSkipLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *MentionSkipLogic) MentionSkip(req *types.MentionIdPath) (*types.MentionPublic, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + m, err := l.svcCtx.Studio.SkipMention(l.ctx, uid, req.Id) + if err != nil { + return nil, err + } + out := types.MentionFromDomain(m) + return &out, nil +} diff --git a/apps/backend/internal/logic/mentions/sync_mentions_logic.go b/apps/backend/internal/logic/mentions/sync_mentions_logic.go new file mode 100644 index 0000000..ff70ee0 --- /dev/null +++ b/apps/backend/internal/logic/mentions/sync_mentions_logic.go @@ -0,0 +1,45 @@ +package mentions + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SyncMentionsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSyncMentionsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SyncMentionsLogic { + return &SyncMentionsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *SyncMentionsLogic) SyncMentions(req *types.MentionSyncReq) (*types.MentionListData, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + list, err := l.svcCtx.Studio.SyncMentions(l.ctx, uid, req.AccountId) + if err != nil { + return nil, err + } + out := make([]types.MentionPublic, 0, len(list)) + for _, m := range list { + out = append(out, types.MentionFromDomain(m)) + } + return &types.MentionListData{List: out}, nil +} diff --git a/apps/backend/internal/logic/notifications/list_notifications_logic.go b/apps/backend/internal/logic/notifications/list_notifications_logic.go new file mode 100644 index 0000000..669b783 --- /dev/null +++ b/apps/backend/internal/logic/notifications/list_notifications_logic.go @@ -0,0 +1,41 @@ +package notifications + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListNotificationsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListNotificationsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListNotificationsLogic { + return &ListNotificationsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListNotificationsLogic) ListNotifications() (*types.NotificationListData, error) { + if l.svcCtx.AppNotif == nil { + return nil, response.Biz(503, 503001, "notifications not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + list, err := l.svcCtx.AppNotif.List(l.ctx, uid) + if err != nil { + return nil, err + } + out := make([]types.NotificationPublic, 0, len(list)) + for _, n := range list { + out = append(out, types.NotificationFromModel(n)) + } + return &types.NotificationListData{List: out}, nil +} diff --git a/apps/backend/internal/logic/notifications/mark_all_notifications_read_logic.go b/apps/backend/internal/logic/notifications/mark_all_notifications_read_logic.go new file mode 100644 index 0000000..75ad8fe --- /dev/null +++ b/apps/backend/internal/logic/notifications/mark_all_notifications_read_logic.go @@ -0,0 +1,36 @@ +package notifications + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type MarkAllNotificationsReadLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewMarkAllNotificationsReadLogic(ctx context.Context, svcCtx *svc.ServiceContext) *MarkAllNotificationsReadLogic { + return &MarkAllNotificationsReadLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *MarkAllNotificationsReadLogic) MarkAllNotificationsRead() (*types.OkData, error) { + if l.svcCtx.AppNotif == nil { + return nil, response.Biz(503, 503001, "notifications not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + if err := l.svcCtx.AppNotif.MarkAllRead(l.ctx, uid); err != nil { + return nil, err + } + return &types.OkData{Ok: true}, nil +} diff --git a/apps/backend/internal/logic/notifications/mark_notification_read_logic.go b/apps/backend/internal/logic/notifications/mark_notification_read_logic.go new file mode 100644 index 0000000..c028773 --- /dev/null +++ b/apps/backend/internal/logic/notifications/mark_notification_read_logic.go @@ -0,0 +1,36 @@ +package notifications + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type MarkNotificationReadLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewMarkNotificationReadLogic(ctx context.Context, svcCtx *svc.ServiceContext) *MarkNotificationReadLogic { + return &MarkNotificationReadLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *MarkNotificationReadLogic) MarkNotificationRead(req *types.NotificationIdPath) (*types.OkData, error) { + if l.svcCtx.AppNotif == nil { + return nil, response.Biz(503, 503001, "notifications not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + if err := l.svcCtx.AppNotif.MarkRead(l.ctx, uid, req.Id); err != nil { + return nil, err + } + return &types.OkData{Ok: true}, nil +} diff --git a/apps/backend/internal/logic/notifications/unread_notification_count_logic.go b/apps/backend/internal/logic/notifications/unread_notification_count_logic.go new file mode 100644 index 0000000..0985e24 --- /dev/null +++ b/apps/backend/internal/logic/notifications/unread_notification_count_logic.go @@ -0,0 +1,37 @@ +package notifications + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UnreadNotificationCountLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUnreadNotificationCountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UnreadNotificationCountLogic { + return &UnreadNotificationCountLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *UnreadNotificationCountLogic) UnreadNotificationCount() (*types.UnreadCountData, error) { + if l.svcCtx.AppNotif == nil { + return nil, response.Biz(503, 503001, "notifications not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + c, err := l.svcCtx.AppNotif.UnreadCount(l.ctx, uid) + if err != nil { + return nil, err + } + return &types.UnreadCountData{Count: c}, nil +} diff --git a/apps/backend/internal/logic/outbox/get_outbox_logic.go b/apps/backend/internal/logic/outbox/get_outbox_logic.go new file mode 100644 index 0000000..be1ce7b --- /dev/null +++ b/apps/backend/internal/logic/outbox/get_outbox_logic.go @@ -0,0 +1,38 @@ +package outbox + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetOutboxLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetOutboxLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetOutboxLogic { + return &GetOutboxLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetOutboxLogic) GetOutbox(req *types.OutboxIdPath) (*types.OutboxPublic, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + b, err := l.svcCtx.Studio.GetOutbox(l.ctx, uid, req.Id) + if err != nil { + return nil, err + } + out := types.OutboxFromDomain(b) + return &out, nil +} diff --git a/apps/backend/internal/logic/outbox/list_outbox_logic.go b/apps/backend/internal/logic/outbox/list_outbox_logic.go new file mode 100644 index 0000000..e67fab4 --- /dev/null +++ b/apps/backend/internal/logic/outbox/list_outbox_logic.go @@ -0,0 +1,41 @@ +package outbox + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListOutboxLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListOutboxLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListOutboxLogic { + return &ListOutboxLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListOutboxLogic) ListOutbox() (*types.OutboxListData, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + list, err := l.svcCtx.Studio.ListOutbox(l.ctx, uid) + if err != nil { + return nil, err + } + out := make([]types.OutboxPublic, 0, len(list)) + for _, b := range list { + out = append(out, types.OutboxFromDomain(b)) + } + return &types.OutboxListData{List: out}, nil +} diff --git a/apps/backend/internal/logic/outbox/remove_outbox_logic.go b/apps/backend/internal/logic/outbox/remove_outbox_logic.go new file mode 100644 index 0000000..ebb1f25 --- /dev/null +++ b/apps/backend/internal/logic/outbox/remove_outbox_logic.go @@ -0,0 +1,36 @@ +package outbox + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RemoveOutboxLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRemoveOutboxLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RemoveOutboxLogic { + return &RemoveOutboxLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *RemoveOutboxLogic) RemoveOutbox(req *types.OutboxIdPath) (*types.OkData, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + if err := l.svcCtx.Studio.RemoveOutbox(l.ctx, uid, req.Id); err != nil { + return nil, err + } + return &types.OkData{Ok: true}, nil +} diff --git a/apps/backend/internal/logic/outbox/retry_outbox_step_logic.go b/apps/backend/internal/logic/outbox/retry_outbox_step_logic.go new file mode 100644 index 0000000..72e0b3e --- /dev/null +++ b/apps/backend/internal/logic/outbox/retry_outbox_step_logic.go @@ -0,0 +1,38 @@ +package outbox + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RetryOutboxStepLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRetryOutboxStepLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RetryOutboxStepLogic { + return &RetryOutboxStepLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *RetryOutboxStepLogic) RetryOutboxStep(req *types.OutboxRetryPath) (*types.OutboxPublic, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + b, err := l.svcCtx.Studio.RetryStep(l.ctx, uid, req.Id, req.StepId) + if err != nil { + return nil, err + } + out := types.OutboxFromDomain(b) + return &out, nil +} diff --git a/apps/backend/internal/logic/ownposts/list_own_posts_logic.go b/apps/backend/internal/logic/ownposts/list_own_posts_logic.go new file mode 100644 index 0000000..9144f18 --- /dev/null +++ b/apps/backend/internal/logic/ownposts/list_own_posts_logic.go @@ -0,0 +1,41 @@ +package ownposts + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListOwnPostsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListOwnPostsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListOwnPostsLogic { + return &ListOwnPostsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListOwnPostsLogic) ListOwnPosts(req *types.OwnPostListReq) (*types.OwnPostListData, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + list, err := l.svcCtx.Studio.ListOwnPosts(l.ctx, uid, req.AccountId) + if err != nil { + return nil, err + } + out := make([]types.OwnPostPublic, 0, len(list)) + for _, p := range list { + out = append(out, types.OwnPostFromDomain(p)) + } + return &types.OwnPostListData{List: out}, nil +} diff --git a/apps/backend/internal/logic/ownposts/own_post_analyze_logic.go b/apps/backend/internal/logic/ownposts/own_post_analyze_logic.go new file mode 100644 index 0000000..4bad179 --- /dev/null +++ b/apps/backend/internal/logic/ownposts/own_post_analyze_logic.go @@ -0,0 +1,38 @@ +package ownposts + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type OwnPostAnalyzeLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewOwnPostAnalyzeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *OwnPostAnalyzeLogic { + return &OwnPostAnalyzeLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *OwnPostAnalyzeLogic) OwnPostAnalyze(req *types.OwnPostAnalyzeReq) (*types.OwnPostPublic, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + p, err := l.svcCtx.Studio.AnalyzePost(l.ctx, uid, req.PostId) + if err != nil { + return nil, err + } + out := types.OwnPostFromDomain(p) + return &out, nil +} diff --git a/apps/backend/internal/logic/ownposts/own_post_generate_reply_logic.go b/apps/backend/internal/logic/ownposts/own_post_generate_reply_logic.go new file mode 100644 index 0000000..231ff3a --- /dev/null +++ b/apps/backend/internal/logic/ownposts/own_post_generate_reply_logic.go @@ -0,0 +1,37 @@ +package ownposts + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type OwnPostGenerateReplyLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewOwnPostGenerateReplyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *OwnPostGenerateReplyLogic { + return &OwnPostGenerateReplyLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *OwnPostGenerateReplyLogic) OwnPostGenerateReply(req *types.OwnPostGenerateReplyReq) (*types.OwnPostGenerateReplyData, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + text, err := l.svcCtx.Studio.GenerateReply(l.ctx, uid, req.PostId, req.ReplyId, req.PersonaId) + if err != nil { + return nil, err + } + return &types.OwnPostGenerateReplyData{Text: text}, nil +} diff --git a/apps/backend/internal/logic/ownposts/own_post_load_replies_logic.go b/apps/backend/internal/logic/ownposts/own_post_load_replies_logic.go new file mode 100644 index 0000000..46fe403 --- /dev/null +++ b/apps/backend/internal/logic/ownposts/own_post_load_replies_logic.go @@ -0,0 +1,38 @@ +package ownposts + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type OwnPostLoadRepliesLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewOwnPostLoadRepliesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *OwnPostLoadRepliesLogic { + return &OwnPostLoadRepliesLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *OwnPostLoadRepliesLogic) OwnPostLoadReplies(req *types.OwnPostLoadRepliesReq) (*types.OwnPostPublic, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + p, err := l.svcCtx.Studio.LoadOwnPostReplies(l.ctx, uid, req.PostId) + if err != nil { + return nil, err + } + out := types.OwnPostFromDomain(p) + return &out, nil +} diff --git a/apps/backend/internal/logic/ownposts/own_post_send_reply_logic.go b/apps/backend/internal/logic/ownposts/own_post_send_reply_logic.go new file mode 100644 index 0000000..2b68d0a --- /dev/null +++ b/apps/backend/internal/logic/ownposts/own_post_send_reply_logic.go @@ -0,0 +1,38 @@ +package ownposts + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type OwnPostSendReplyLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewOwnPostSendReplyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *OwnPostSendReplyLogic { + return &OwnPostSendReplyLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *OwnPostSendReplyLogic) OwnPostSendReply(req *types.OwnPostSendReplyReq) (*types.OwnPostPublic, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + p, err := l.svcCtx.Studio.SendReply(l.ctx, uid, req.PostId, req.ReplyId, req.Text, req.AccountId, req.ImageUrls) + if err != nil { + return nil, err + } + out := types.OwnPostFromDomain(p) + return &out, nil +} diff --git a/apps/backend/internal/logic/ownposts/own_posts_last_synced_logic.go b/apps/backend/internal/logic/ownposts/own_posts_last_synced_logic.go new file mode 100644 index 0000000..e27f542 --- /dev/null +++ b/apps/backend/internal/logic/ownposts/own_posts_last_synced_logic.go @@ -0,0 +1,37 @@ +package ownposts + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type OwnPostsLastSyncedLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewOwnPostsLastSyncedLogic(ctx context.Context, svcCtx *svc.ServiceContext) *OwnPostsLastSyncedLogic { + return &OwnPostsLastSyncedLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *OwnPostsLastSyncedLogic) OwnPostsLastSynced() (*types.OwnPostLastSyncedData, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + ts, err := l.svcCtx.Studio.LastSyncedAt(l.ctx, uid) + if err != nil { + return nil, err + } + return &types.OwnPostLastSyncedData{LastSyncedAt: ts}, nil +} diff --git a/apps/backend/internal/logic/ownposts/sync_own_posts_logic.go b/apps/backend/internal/logic/ownposts/sync_own_posts_logic.go new file mode 100644 index 0000000..eb84a28 --- /dev/null +++ b/apps/backend/internal/logic/ownposts/sync_own_posts_logic.go @@ -0,0 +1,41 @@ +package ownposts + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SyncOwnPostsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSyncOwnPostsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SyncOwnPostsLogic { + return &SyncOwnPostsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *SyncOwnPostsLogic) SyncOwnPosts(req *types.OwnPostSyncReq) (*types.OwnPostListData, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + list, err := l.svcCtx.Studio.SyncOwnPosts(l.ctx, uid, req.AccountId) + if err != nil { + return nil, err + } + out := make([]types.OwnPostPublic, 0, len(list)) + for _, p := range list { + out = append(out, types.OwnPostFromDomain(p)) + } + return &types.OwnPostListData{List: out}, nil +} diff --git a/apps/backend/internal/logic/personas/analyze_persona_account_logic.go b/apps/backend/internal/logic/personas/analyze_persona_account_logic.go new file mode 100644 index 0000000..8822a03 --- /dev/null +++ b/apps/backend/internal/logic/personas/analyze_persona_account_logic.go @@ -0,0 +1,38 @@ +package personas + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type AnalyzePersonaAccountLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewAnalyzePersonaAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AnalyzePersonaAccountLogic { + return &AnalyzePersonaAccountLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *AnalyzePersonaAccountLogic) AnalyzePersonaAccount(req *types.PersonaAnalyzeAccountReq) (*types.PersonaPublic, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + p, err := l.svcCtx.Studio.AnalyzeFromAccount(l.ctx, uid, req.Id, req.Username) + if err != nil { + return nil, err + } + out := types.PersonaFromDomain(p) + return &out, nil +} diff --git a/apps/backend/internal/logic/personas/analyze_persona_text_logic.go b/apps/backend/internal/logic/personas/analyze_persona_text_logic.go new file mode 100644 index 0000000..ec2c02b --- /dev/null +++ b/apps/backend/internal/logic/personas/analyze_persona_text_logic.go @@ -0,0 +1,38 @@ +package personas + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type AnalyzePersonaTextLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewAnalyzePersonaTextLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AnalyzePersonaTextLogic { + return &AnalyzePersonaTextLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *AnalyzePersonaTextLogic) AnalyzePersonaText(req *types.PersonaAnalyzeTextReq) (*types.PersonaPublic, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + p, err := l.svcCtx.Studio.AnalyzeFromText(l.ctx, uid, req.Id, req.RawText, req.SourceLabel) + if err != nil { + return nil, err + } + out := types.PersonaFromDomain(p) + return &out, nil +} diff --git a/apps/backend/internal/logic/personas/get_active_persona_logic.go b/apps/backend/internal/logic/personas/get_active_persona_logic.go new file mode 100644 index 0000000..bacb43d --- /dev/null +++ b/apps/backend/internal/logic/personas/get_active_persona_logic.go @@ -0,0 +1,37 @@ +package personas + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetActivePersonaLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetActivePersonaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetActivePersonaLogic { + return &GetActivePersonaLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetActivePersonaLogic) GetActivePersona() (*types.PersonaActiveData, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + id, err := l.svcCtx.Studio.GetActivePersonaID(l.ctx, uid) + if err != nil { + return nil, err + } + return &types.PersonaActiveData{Id: id}, nil +} diff --git a/apps/backend/internal/logic/personas/get_persona_logic.go b/apps/backend/internal/logic/personas/get_persona_logic.go new file mode 100644 index 0000000..e3d7c25 --- /dev/null +++ b/apps/backend/internal/logic/personas/get_persona_logic.go @@ -0,0 +1,38 @@ +package personas + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetPersonaLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetPersonaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPersonaLogic { + return &GetPersonaLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetPersonaLogic) GetPersona(req *types.PersonaIdPath) (*types.PersonaPublic, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + p, err := l.svcCtx.Studio.GetPersona(l.ctx, uid, req.Id) + if err != nil { + return nil, err + } + out := types.PersonaFromDomain(p) + return &out, nil +} diff --git a/apps/backend/internal/logic/personas/list_personas_logic.go b/apps/backend/internal/logic/personas/list_personas_logic.go new file mode 100644 index 0000000..bf8c000 --- /dev/null +++ b/apps/backend/internal/logic/personas/list_personas_logic.go @@ -0,0 +1,41 @@ +package personas + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListPersonasLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListPersonasLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPersonasLogic { + return &ListPersonasLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListPersonasLogic) ListPersonas() (*types.PersonaListData, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + list, err := l.svcCtx.Studio.ListPersonas(l.ctx, uid) + if err != nil { + return nil, err + } + out := make([]types.PersonaPublic, 0, len(list)) + for _, p := range list { + out = append(out, types.PersonaFromDomain(p)) + } + return &types.PersonaListData{List: out}, nil +} diff --git a/apps/backend/internal/logic/personas/remove_persona_logic.go b/apps/backend/internal/logic/personas/remove_persona_logic.go new file mode 100644 index 0000000..7e6d726 --- /dev/null +++ b/apps/backend/internal/logic/personas/remove_persona_logic.go @@ -0,0 +1,36 @@ +package personas + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RemovePersonaLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRemovePersonaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RemovePersonaLogic { + return &RemovePersonaLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *RemovePersonaLogic) RemovePersona(req *types.PersonaIdPath) (*types.OkData, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + if err := l.svcCtx.Studio.RemovePersona(l.ctx, uid, req.Id); err != nil { + return nil, err + } + return &types.OkData{Ok: true}, nil +} diff --git a/apps/backend/internal/logic/personas/save_persona_logic.go b/apps/backend/internal/logic/personas/save_persona_logic.go new file mode 100644 index 0000000..ab2d152 --- /dev/null +++ b/apps/backend/internal/logic/personas/save_persona_logic.go @@ -0,0 +1,38 @@ +package personas + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SavePersonaLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSavePersonaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SavePersonaLogic { + return &SavePersonaLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *SavePersonaLogic) SavePersona(req *types.PersonaSaveReq) (*types.PersonaPublic, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + p, err := l.svcCtx.Studio.SavePersona(l.ctx, uid, types.PersonaToDomain(req, uid)) + if err != nil { + return nil, err + } + out := types.PersonaFromDomain(p) + return &out, nil +} diff --git a/apps/backend/internal/logic/personas/set_active_persona_logic.go b/apps/backend/internal/logic/personas/set_active_persona_logic.go new file mode 100644 index 0000000..b9a5877 --- /dev/null +++ b/apps/backend/internal/logic/personas/set_active_persona_logic.go @@ -0,0 +1,36 @@ +package personas + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SetActivePersonaLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSetActivePersonaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SetActivePersonaLogic { + return &SetActivePersonaLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *SetActivePersonaLogic) SetActivePersona(req *types.PersonaSetActiveReq) (*types.PersonaActiveData, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + if err := l.svcCtx.Studio.SetActivePersonaID(l.ctx, uid, req.Id); err != nil { + return nil, err + } + return &types.PersonaActiveData{Id: req.Id}, nil +} diff --git a/apps/backend/internal/logic/plays/generate_play_script_logic.go b/apps/backend/internal/logic/plays/generate_play_script_logic.go new file mode 100644 index 0000000..9655d95 --- /dev/null +++ b/apps/backend/internal/logic/plays/generate_play_script_logic.go @@ -0,0 +1,68 @@ +package plays + +import ( + "context" + "errors" + + "apps/backend/internal/middleware" + "apps/backend/internal/module/studio/domain" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GeneratePlayScriptLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGeneratePlayScriptLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GeneratePlayScriptLogic { + return &GeneratePlayScriptLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +// GeneratePlayScript — 一次產完整劇本(背景 job,可離開頁面) +func (l *GeneratePlayScriptLogic) GeneratePlayScript(req *types.PlayIdPath) (*types.PlayGenerateScriptData, error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + // 先確認 play 存在 + if _, err := l.svcCtx.Studio.GetPlay(l.ctx, uid, req.Id); err != nil { + if errors.Is(err, domain.ErrNotFound) { + return nil, response.Biz(404, 404001, "play not found") + } + if errors.Is(err, domain.ErrForbidden) { + return nil, response.Biz(403, 403001, err.Error()) + } + return nil, err + } + + if l.svcCtx.Jobs != nil { + j, err := l.svcCtx.Jobs.SchedulePlayGenerateScript(l.ctx, uid, req.Id, true) + if err != nil { + return nil, err + } + return &types.PlayGenerateScriptData{JobId: j.ID, Async: true}, nil + } + + // 無 worker:同步一次產完(測試) + n, err := l.svcCtx.Studio.GeneratePlayScript(l.ctx, uid, req.Id, true) + if err != nil { + if errors.Is(err, domain.ErrValidation) { + return nil, response.Biz(400, 400001, err.Error()) + } + return nil, err + } + _ = n + return &types.PlayGenerateScriptData{JobId: "", Async: false}, nil +} diff --git a/apps/backend/internal/logic/plays/generate_play_step_logic.go b/apps/backend/internal/logic/plays/generate_play_step_logic.go new file mode 100644 index 0000000..b3191aa --- /dev/null +++ b/apps/backend/internal/logic/plays/generate_play_step_logic.go @@ -0,0 +1,49 @@ +package plays + +import ( + "context" + "errors" + + "apps/backend/internal/middleware" + "apps/backend/internal/module/studio/domain" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GeneratePlayStepLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGeneratePlayStepLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GeneratePlayStepLogic { + return &GeneratePlayStepLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GeneratePlayStepLogic) GeneratePlayStep(req *types.PlayGenerateStepReq) (*types.PlayGenerateStepData, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + text, err := l.svcCtx.Studio.GeneratePlayStep( + l.ctx, uid, + req.PersonaId, req.Context, req.Topic, req.SpeakerLabel, req.IsLead, req.Mode, + ) + if err != nil { + if errors.Is(err, domain.ErrValidation) || errors.Is(err, domain.ErrNoAccount) { + return nil, response.Biz(400, 400001, err.Error()) + } + return nil, err + } + return &types.PlayGenerateStepData{Text: text}, nil +} diff --git a/apps/backend/internal/logic/plays/get_play_logic.go b/apps/backend/internal/logic/plays/get_play_logic.go new file mode 100644 index 0000000..5dde59a --- /dev/null +++ b/apps/backend/internal/logic/plays/get_play_logic.go @@ -0,0 +1,38 @@ +package plays + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetPlayLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetPlayLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPlayLogic { + return &GetPlayLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetPlayLogic) GetPlay(req *types.PlayIdPath) (*types.PlayPublic, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + p, err := l.svcCtx.Studio.GetPlay(l.ctx, uid, req.Id) + if err != nil { + return nil, err + } + out := types.PlayFromDomain(p) + return &out, nil +} diff --git a/apps/backend/internal/logic/plays/list_plays_by_external_logic.go b/apps/backend/internal/logic/plays/list_plays_by_external_logic.go new file mode 100644 index 0000000..a3922b2 --- /dev/null +++ b/apps/backend/internal/logic/plays/list_plays_by_external_logic.go @@ -0,0 +1,41 @@ +package plays + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListPlaysByExternalLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListPlaysByExternalLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPlaysByExternalLogic { + return &ListPlaysByExternalLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListPlaysByExternalLogic) ListPlaysByExternal(req *types.PlayByExternalReq) (*types.PlayListData, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + list, err := l.svcCtx.Studio.ListPlaysByExternalURL(l.ctx, uid, req.Url) + if err != nil { + return nil, err + } + out := make([]types.PlayPublic, 0, len(list)) + for _, p := range list { + out = append(out, types.PlayFromDomain(p)) + } + return &types.PlayListData{List: out}, nil +} diff --git a/apps/backend/internal/logic/plays/list_plays_by_post_logic.go b/apps/backend/internal/logic/plays/list_plays_by_post_logic.go new file mode 100644 index 0000000..acee9d9 --- /dev/null +++ b/apps/backend/internal/logic/plays/list_plays_by_post_logic.go @@ -0,0 +1,41 @@ +package plays + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListPlaysByPostLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListPlaysByPostLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPlaysByPostLogic { + return &ListPlaysByPostLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListPlaysByPostLogic) ListPlaysByPost(req *types.PlayByPostReq) (*types.PlayListData, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + list, err := l.svcCtx.Studio.ListPlaysByPost(l.ctx, uid, req.OwnPostId) + if err != nil { + return nil, err + } + out := make([]types.PlayPublic, 0, len(list)) + for _, p := range list { + out = append(out, types.PlayFromDomain(p)) + } + return &types.PlayListData{List: out}, nil +} diff --git a/apps/backend/internal/logic/plays/list_plays_logic.go b/apps/backend/internal/logic/plays/list_plays_logic.go new file mode 100644 index 0000000..c115a57 --- /dev/null +++ b/apps/backend/internal/logic/plays/list_plays_logic.go @@ -0,0 +1,41 @@ +package plays + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListPlaysLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListPlaysLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPlaysLogic { + return &ListPlaysLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListPlaysLogic) ListPlays() (*types.PlayListData, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + list, err := l.svcCtx.Studio.ListPlays(l.ctx, uid) + if err != nil { + return nil, err + } + out := make([]types.PlayPublic, 0, len(list)) + for _, p := range list { + out = append(out, types.PlayFromDomain(p)) + } + return &types.PlayListData{List: out}, nil +} diff --git a/apps/backend/internal/logic/plays/remove_play_logic.go b/apps/backend/internal/logic/plays/remove_play_logic.go new file mode 100644 index 0000000..3025541 --- /dev/null +++ b/apps/backend/internal/logic/plays/remove_play_logic.go @@ -0,0 +1,36 @@ +package plays + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RemovePlayLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRemovePlayLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RemovePlayLogic { + return &RemovePlayLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *RemovePlayLogic) RemovePlay(req *types.PlayIdPath) (*types.OkData, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + if err := l.svcCtx.Studio.RemovePlay(l.ctx, uid, req.Id); err != nil { + return nil, err + } + return &types.OkData{Ok: true}, nil +} diff --git a/apps/backend/internal/logic/plays/resolve_external_link_logic.go b/apps/backend/internal/logic/plays/resolve_external_link_logic.go new file mode 100644 index 0000000..b377f51 --- /dev/null +++ b/apps/backend/internal/logic/plays/resolve_external_link_logic.go @@ -0,0 +1,37 @@ +package plays + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ResolveExternalLinkLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewResolveExternalLinkLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ResolveExternalLinkLogic { + return &ResolveExternalLinkLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ResolveExternalLinkLogic) ResolveExternalLink(req *types.PlayResolveReq) (*types.ExternalTargetPublic, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + if _, ok := middleware.UIDFrom(l.ctx); !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + t, err := l.svcCtx.Studio.ResolveExternalLink(l.ctx, req.Url) + if err != nil { + return nil, err + } + out := types.ExternalFromDomain(t) + return &out, nil +} diff --git a/apps/backend/internal/logic/plays/save_play_logic.go b/apps/backend/internal/logic/plays/save_play_logic.go new file mode 100644 index 0000000..962b9ef --- /dev/null +++ b/apps/backend/internal/logic/plays/save_play_logic.go @@ -0,0 +1,38 @@ +package plays + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SavePlayLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSavePlayLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SavePlayLogic { + return &SavePlayLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *SavePlayLogic) SavePlay(req *types.PlaySaveReq) (*types.PlayPublic, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + p, err := l.svcCtx.Studio.SavePlay(l.ctx, uid, types.PlayToDomain(req, uid)) + if err != nil { + return nil, err + } + out := types.PlayFromDomain(p) + return &out, nil +} diff --git a/apps/backend/internal/logic/plays/submit_play_logic.go b/apps/backend/internal/logic/plays/submit_play_logic.go new file mode 100644 index 0000000..390a430 --- /dev/null +++ b/apps/backend/internal/logic/plays/submit_play_logic.go @@ -0,0 +1,38 @@ +package plays + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SubmitPlayLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSubmitPlayLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SubmitPlayLogic { + return &SubmitPlayLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *SubmitPlayLogic) SubmitPlay(req *types.PlayIdPath) (*types.OutboxPublic, error) { + if l.svcCtx.Studio == nil { + return nil, response.Biz(503, 503001, "studio not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + b, err := l.svcCtx.Studio.SubmitPlay(l.ctx, uid, req.Id) + if err != nil { + return nil, err + } + out := types.OutboxFromDomain(b) + return &out, nil +} diff --git a/apps/backend/internal/logic/proxy/a_i_complete_logic.go b/apps/backend/internal/logic/proxy/a_i_complete_logic.go new file mode 100644 index 0000000..0661ae9 --- /dev/null +++ b/apps/backend/internal/logic/proxy/a_i_complete_logic.go @@ -0,0 +1,56 @@ +package proxy + +import ( + "context" + + "apps/backend/internal/middleware" + usageDomain "apps/backend/internal/module/usage/domain" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type AICompleteLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewAICompleteLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AICompleteLogic { + return &AICompleteLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *AICompleteLogic) AIComplete(req *types.AICompleteReq) (*types.AICompleteData, error) { + if l.svcCtx.Usage == nil || l.svcCtx.AI == nil || l.svcCtx.KeyResolver == nil { + return nil, response.Biz(503, 503001, "ai proxy not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + meter := req.Meter + if meter == "" { + meter = usageDomain.MeterAICopy + } + mode, err := l.svcCtx.Usage.PrepareCall(l.ctx, uid, meter) + if err != nil { + return nil, err + } + mode, apiKey, err := l.svcCtx.KeyResolver.ResolveKey(l.ctx, uid, meter) + if err != nil { + return nil, err + } + model := req.Model + if model == "" { + model = "grok-3" + } + // 語言 system prompt 已由 middleware 注入 ctx;AI client 強制加 system message + text, err := l.svcCtx.AI.Complete(l.ctx, apiKey, model, req.Prompt) + if err != nil { + return nil, response.Biz(502, 502001, err.Error()) + } + _, _ = l.svcCtx.Usage.RecordCall(l.ctx, uid, meter, mode, "AI complete", "proxy.ai") + return &types.AICompleteData{Text: text, KeyMode: mode, Model: model}, nil +} diff --git a/apps/backend/internal/logic/proxy/exa_search_logic.go b/apps/backend/internal/logic/proxy/exa_search_logic.go new file mode 100644 index 0000000..feb40fb --- /dev/null +++ b/apps/backend/internal/logic/proxy/exa_search_logic.go @@ -0,0 +1,51 @@ +package proxy + +import ( + "context" + + "apps/backend/internal/middleware" + usageDomain "apps/backend/internal/module/usage/domain" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ExaSearchLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewExaSearchLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ExaSearchLogic { + return &ExaSearchLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ExaSearchLogic) ExaSearch(req *types.SearchReq) (*types.SearchData, error) { + if l.svcCtx.Usage == nil || l.svcCtx.Search == nil { + return nil, response.Biz(503, 503001, "search proxy not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + mode, err := l.svcCtx.Usage.PrepareCall(l.ctx, uid, usageDomain.MeterWebSearch) + if err != nil { + return nil, err + } + mode, apiKey, err := l.svcCtx.KeyResolver.ResolveKey(l.ctx, uid, usageDomain.MeterWebSearch) + if err != nil { + return nil, err + } + hits, err := l.svcCtx.Search.Search(l.ctx, apiKey, req.Query, req.Limit) + if err != nil { + return nil, response.Biz(502, 502002, err.Error()) + } + _, _ = l.svcCtx.Usage.RecordCall(l.ctx, uid, usageDomain.MeterWebSearch, mode, "search", "proxy.search") + out := make([]types.SearchHit, 0, len(hits)) + for _, h := range hits { + out = append(out, types.SearchHit{Title: h.Title, Url: h.URL, Snippet: h.Snippet}) + } + return &types.SearchData{List: out, KeyMode: mode}, nil +} diff --git a/apps/backend/internal/logic/research/research_search_logic.go b/apps/backend/internal/logic/research/research_search_logic.go new file mode 100644 index 0000000..24660b3 --- /dev/null +++ b/apps/backend/internal/logic/research/research_search_logic.go @@ -0,0 +1,43 @@ +package research + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ResearchSearchLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewResearchSearchLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ResearchSearchLogic { + return &ResearchSearchLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ResearchSearchLogic) ResearchSearch(req *types.ResearchSearchReq) (*types.ResearchSearchData, error) { + + if l.svcCtx.Inspire == nil { + return nil, response.Biz(503, 503001, "research not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + hits, err := l.svcCtx.Inspire.ResearchSearch(l.ctx, uid, req.Query) + if err != nil { + return nil, err + } + out := make([]types.ResearchHitPublic, 0, len(hits)) + for _, h := range hits { + out = append(out, types.ResearchHitFromDomain(h)) + } + return &types.ResearchSearchData{List: out}, nil + +} diff --git a/apps/backend/internal/logic/scout/clear_crawler_session_logic.go b/apps/backend/internal/logic/scout/clear_crawler_session_logic.go new file mode 100644 index 0000000..d32d8e2 --- /dev/null +++ b/apps/backend/internal/logic/scout/clear_crawler_session_logic.go @@ -0,0 +1,38 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ClearCrawlerSessionLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewClearCrawlerSessionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ClearCrawlerSessionLogic { + return &ClearCrawlerSessionLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ClearCrawlerSessionLogic) ClearCrawlerSession() (*types.OkData, error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + if err := l.svcCtx.Scout.ClearCrawlerSession(l.ctx, uid); err != nil { + return nil, err + } + return &types.OkData{Ok: true}, nil + +} diff --git a/apps/backend/internal/logic/scout/draft_outreach_logic.go b/apps/backend/internal/logic/scout/draft_outreach_logic.go new file mode 100644 index 0000000..fb419e3 --- /dev/null +++ b/apps/backend/internal/logic/scout/draft_outreach_logic.go @@ -0,0 +1,40 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type DraftOutreachLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDraftOutreachLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DraftOutreachLogic { + return &DraftOutreachLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *DraftOutreachLogic) DraftOutreach(req *types.ScoutDraftPathReq) (*types.ScoutPostPublic, error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + p, err := l.svcCtx.Scout.DraftOutreach(l.ctx, uid, req.Id, req.PersonaId) + if err != nil { + return nil, err + } + out := types.ScoutPostFromDomain(p) + return &out, nil + +} diff --git a/apps/backend/internal/logic/scout/get_active_brand_logic.go b/apps/backend/internal/logic/scout/get_active_brand_logic.go new file mode 100644 index 0000000..5b696ac --- /dev/null +++ b/apps/backend/internal/logic/scout/get_active_brand_logic.go @@ -0,0 +1,39 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetActiveBrandLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetActiveBrandLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetActiveBrandLogic { + return &GetActiveBrandLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetActiveBrandLogic) GetActiveBrand() (*types.BrandActiveData, error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + id, err := l.svcCtx.Scout.GetActiveBrandID(l.ctx, uid) + if err != nil { + return nil, err + } + return &types.BrandActiveData{Id: id}, nil + +} diff --git a/apps/backend/internal/logic/scout/get_brand_logic.go b/apps/backend/internal/logic/scout/get_brand_logic.go new file mode 100644 index 0000000..b66d2d8 --- /dev/null +++ b/apps/backend/internal/logic/scout/get_brand_logic.go @@ -0,0 +1,40 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetBrandLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetBrandLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetBrandLogic { + return &GetBrandLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetBrandLogic) GetBrand(req *types.BrandIdPath) (*types.BrandPublic, error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + b, err := l.svcCtx.Scout.GetBrand(l.ctx, uid, req.Id) + if err != nil { + return nil, err + } + out := types.BrandFromDomain(b) + return &out, nil + +} diff --git a/apps/backend/internal/logic/scout/get_homework_logic.go b/apps/backend/internal/logic/scout/get_homework_logic.go new file mode 100644 index 0000000..b0f0b7b --- /dev/null +++ b/apps/backend/internal/logic/scout/get_homework_logic.go @@ -0,0 +1,40 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetHomeworkLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetHomeworkLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetHomeworkLogic { + return &GetHomeworkLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetHomeworkLogic) GetHomework(req *types.ScoutThemePath) (*types.ScoutHomeworkPublic, error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + h, err := l.svcCtx.Scout.GetHomework(l.ctx, uid, req.ThemeKey) + if err != nil { + return nil, err + } + out := types.HomeworkFromDomain(h) + return &out, nil + +} diff --git a/apps/backend/internal/logic/scout/get_product_logic.go b/apps/backend/internal/logic/scout/get_product_logic.go new file mode 100644 index 0000000..61a1926 --- /dev/null +++ b/apps/backend/internal/logic/scout/get_product_logic.go @@ -0,0 +1,40 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetProductLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetProductLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetProductLogic { + return &GetProductLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetProductLogic) GetProduct(req *types.ProductIdPath) (*types.ProductPublic, error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + p, err := l.svcCtx.Scout.GetProduct(l.ctx, uid, req.Id) + if err != nil { + return nil, err + } + out := types.ProductFromDomain(p) + return &out, nil + +} diff --git a/apps/backend/internal/logic/scout/import_product_logic.go b/apps/backend/internal/logic/scout/import_product_logic.go new file mode 100644 index 0000000..b7a2e78 --- /dev/null +++ b/apps/backend/internal/logic/scout/import_product_logic.go @@ -0,0 +1,41 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ImportProductLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewImportProductLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ImportProductLogic { + return &ImportProductLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ImportProductLogic) ImportProduct(req *types.ImportProductReq) (*types.ImportProductData, error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + if _, ok := middleware.UIDFrom(l.ctx); !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + d, err := l.svcCtx.Scout.ImportProductFromURL(l.ctx, req.Url) + if err != nil { + return nil, err + } + return &types.ImportProductData{ + Label: d.Label, ProductContext: d.ProductContext, PainPoints: d.PainPoints, + MatchTags: d.MatchTags, PlacementUrl: d.PlacementURL, SourceNote: d.SourceNote, + }, nil + +} diff --git a/apps/backend/internal/logic/scout/list_all_products_logic.go b/apps/backend/internal/logic/scout/list_all_products_logic.go new file mode 100644 index 0000000..db43fb2 --- /dev/null +++ b/apps/backend/internal/logic/scout/list_all_products_logic.go @@ -0,0 +1,43 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListAllProductsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListAllProductsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListAllProductsLogic { + return &ListAllProductsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListAllProductsLogic) ListAllProducts() (*types.ProductListData, error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + list, err := l.svcCtx.Scout.ListAllProducts(l.ctx, uid) + if err != nil { + return nil, err + } + out := make([]types.ProductPublic, 0, len(list)) + for _, p := range list { + out = append(out, types.ProductFromDomain(p)) + } + return &types.ProductListData{List: out}, nil + +} diff --git a/apps/backend/internal/logic/scout/list_brands_logic.go b/apps/backend/internal/logic/scout/list_brands_logic.go new file mode 100644 index 0000000..ebcb25d --- /dev/null +++ b/apps/backend/internal/logic/scout/list_brands_logic.go @@ -0,0 +1,43 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListBrandsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListBrandsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListBrandsLogic { + return &ListBrandsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListBrandsLogic) ListBrands() (*types.BrandListData, error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + list, err := l.svcCtx.Scout.ListBrands(l.ctx, uid) + if err != nil { + return nil, err + } + out := make([]types.BrandPublic, 0, len(list)) + for _, b := range list { + out = append(out, types.BrandFromDomain(b)) + } + return &types.BrandListData{List: out}, nil + +} diff --git a/apps/backend/internal/logic/scout/list_homework_logic.go b/apps/backend/internal/logic/scout/list_homework_logic.go new file mode 100644 index 0000000..b0132ae --- /dev/null +++ b/apps/backend/internal/logic/scout/list_homework_logic.go @@ -0,0 +1,43 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListHomeworkLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListHomeworkLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListHomeworkLogic { + return &ListHomeworkLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListHomeworkLogic) ListHomework() (*types.ScoutHomeworkListData, error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + list, err := l.svcCtx.Scout.ListHomework(l.ctx, uid) + if err != nil { + return nil, err + } + out := make([]types.ScoutHomeworkPublic, 0, len(list)) + for _, h := range list { + out = append(out, types.HomeworkFromDomain(h)) + } + return &types.ScoutHomeworkListData{List: out}, nil + +} diff --git a/apps/backend/internal/logic/scout/list_products_logic.go b/apps/backend/internal/logic/scout/list_products_logic.go new file mode 100644 index 0000000..7ec4b96 --- /dev/null +++ b/apps/backend/internal/logic/scout/list_products_logic.go @@ -0,0 +1,43 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListProductsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListProductsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListProductsLogic { + return &ListProductsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListProductsLogic) ListProducts(req *types.ProductListReq) (*types.ProductListData, error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + list, err := l.svcCtx.Scout.ListProducts(l.ctx, uid, req.BrandId) + if err != nil { + return nil, err + } + out := make([]types.ProductPublic, 0, len(list)) + for _, p := range list { + out = append(out, types.ProductFromDomain(p)) + } + return &types.ProductListData{List: out}, nil + +} diff --git a/apps/backend/internal/logic/scout/list_scout_posts_logic.go b/apps/backend/internal/logic/scout/list_scout_posts_logic.go new file mode 100644 index 0000000..5c1b97a --- /dev/null +++ b/apps/backend/internal/logic/scout/list_scout_posts_logic.go @@ -0,0 +1,43 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListScoutPostsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListScoutPostsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListScoutPostsLogic { + return &ListScoutPostsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListScoutPostsLogic) ListScoutPosts(req *types.ScoutPostListReq) (*types.ScoutPostListData, error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + list, err := l.svcCtx.Scout.ListPosts(l.ctx, uid, req.BrandId) + if err != nil { + return nil, err + } + out := make([]types.ScoutPostPublic, 0, len(list)) + for _, p := range list { + out = append(out, types.ScoutPostFromDomain(p)) + } + return &types.ScoutPostListData{List: out}, nil + +} diff --git a/apps/backend/internal/logic/scout/mark_published_logic.go b/apps/backend/internal/logic/scout/mark_published_logic.go new file mode 100644 index 0000000..5d52c68 --- /dev/null +++ b/apps/backend/internal/logic/scout/mark_published_logic.go @@ -0,0 +1,40 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type MarkPublishedLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewMarkPublishedLogic(ctx context.Context, svcCtx *svc.ServiceContext) *MarkPublishedLogic { + return &MarkPublishedLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *MarkPublishedLogic) MarkPublished(req *types.ScoutPostIdPath) (*types.ScoutPostPublic, error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + p, err := l.svcCtx.Scout.MarkPublished(l.ctx, uid, req.Id) + if err != nil { + return nil, err + } + out := types.ScoutPostFromDomain(p) + return &out, nil + +} diff --git a/apps/backend/internal/logic/scout/prepare_brief_logic.go b/apps/backend/internal/logic/scout/prepare_brief_logic.go new file mode 100644 index 0000000..71db1d3 --- /dev/null +++ b/apps/backend/internal/logic/scout/prepare_brief_logic.go @@ -0,0 +1,40 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type PrepareBriefLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewPrepareBriefLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PrepareBriefLogic { + return &PrepareBriefLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *PrepareBriefLogic) PrepareBrief(req *types.ScoutBriefReq) (*types.ScoutBriefPublic, error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + b, err := l.svcCtx.Scout.PrepareBrief(l.ctx, uid, req.Intent, req.BrandId, req.ProductId, req.Purpose, req.Deep) + if err != nil { + return nil, err + } + out := types.BriefFromDomain(b) + return &out, nil + +} diff --git a/apps/backend/internal/logic/scout/remove_brand_logic.go b/apps/backend/internal/logic/scout/remove_brand_logic.go new file mode 100644 index 0000000..348754e --- /dev/null +++ b/apps/backend/internal/logic/scout/remove_brand_logic.go @@ -0,0 +1,38 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RemoveBrandLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRemoveBrandLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RemoveBrandLogic { + return &RemoveBrandLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *RemoveBrandLogic) RemoveBrand(req *types.BrandIdPath) (*types.OkData, error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + if err := l.svcCtx.Scout.RemoveBrand(l.ctx, uid, req.Id); err != nil { + return nil, err + } + return &types.OkData{Ok: true}, nil + +} diff --git a/apps/backend/internal/logic/scout/remove_homework_logic.go b/apps/backend/internal/logic/scout/remove_homework_logic.go new file mode 100644 index 0000000..c30a15f --- /dev/null +++ b/apps/backend/internal/logic/scout/remove_homework_logic.go @@ -0,0 +1,38 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RemoveHomeworkLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRemoveHomeworkLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RemoveHomeworkLogic { + return &RemoveHomeworkLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *RemoveHomeworkLogic) RemoveHomework(req *types.ScoutThemePath) (*types.OkData, error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + if err := l.svcCtx.Scout.RemoveHomework(l.ctx, uid, req.ThemeKey); err != nil { + return nil, err + } + return &types.OkData{Ok: true}, nil + +} diff --git a/apps/backend/internal/logic/scout/remove_product_logic.go b/apps/backend/internal/logic/scout/remove_product_logic.go new file mode 100644 index 0000000..af66bca --- /dev/null +++ b/apps/backend/internal/logic/scout/remove_product_logic.go @@ -0,0 +1,38 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RemoveProductLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRemoveProductLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RemoveProductLogic { + return &RemoveProductLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *RemoveProductLogic) RemoveProduct(req *types.ProductIdPath) (*types.OkData, error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + if err := l.svcCtx.Scout.RemoveProduct(l.ctx, uid, req.Id); err != nil { + return nil, err + } + return &types.OkData{Ok: true}, nil + +} diff --git a/apps/backend/internal/logic/scout/remove_scout_post_logic.go b/apps/backend/internal/logic/scout/remove_scout_post_logic.go new file mode 100644 index 0000000..d7aa38b --- /dev/null +++ b/apps/backend/internal/logic/scout/remove_scout_post_logic.go @@ -0,0 +1,38 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RemoveScoutPostLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRemoveScoutPostLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RemoveScoutPostLogic { + return &RemoveScoutPostLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *RemoveScoutPostLogic) RemoveScoutPost(req *types.ScoutPostIdPath) (*types.OkData, error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + if err := l.svcCtx.Scout.RemovePost(l.ctx, uid, req.Id); err != nil { + return nil, err + } + return &types.OkData{Ok: true}, nil + +} diff --git a/apps/backend/internal/logic/scout/remove_scout_theme_logic.go b/apps/backend/internal/logic/scout/remove_scout_theme_logic.go new file mode 100644 index 0000000..9d59869 --- /dev/null +++ b/apps/backend/internal/logic/scout/remove_scout_theme_logic.go @@ -0,0 +1,38 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RemoveScoutThemeLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRemoveScoutThemeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RemoveScoutThemeLogic { + return &RemoveScoutThemeLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *RemoveScoutThemeLogic) RemoveScoutTheme(req *types.ScoutThemePath) (*types.OkData, error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + if err := l.svcCtx.Scout.RemoveTheme(l.ctx, uid, req.ThemeKey); err != nil { + return nil, err + } + return &types.OkData{Ok: true}, nil + +} diff --git a/apps/backend/internal/logic/scout/run_scan_logic.go b/apps/backend/internal/logic/scout/run_scan_logic.go new file mode 100644 index 0000000..62f67e1 --- /dev/null +++ b/apps/backend/internal/logic/scout/run_scan_logic.go @@ -0,0 +1,43 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RunScanLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRunScanLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RunScanLogic { + return &RunScanLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *RunScanLogic) RunScan(req *types.ScoutScanReq) (*types.ScoutPostListData, error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + list, err := l.svcCtx.Scout.RunScanFromBrief(l.ctx, uid, types.BriefToDomain(&req.Brief)) + if err != nil { + return nil, err + } + out := make([]types.ScoutPostPublic, 0, len(list)) + for _, p := range list { + out = append(out, types.ScoutPostFromDomain(p)) + } + return &types.ScoutPostListData{List: out}, nil + +} diff --git a/apps/backend/internal/logic/scout/save_brand_logic.go b/apps/backend/internal/logic/scout/save_brand_logic.go new file mode 100644 index 0000000..7fdf719 --- /dev/null +++ b/apps/backend/internal/logic/scout/save_brand_logic.go @@ -0,0 +1,45 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + scoutDomain "apps/backend/internal/module/scout/domain" + + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SaveBrandLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSaveBrandLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SaveBrandLogic { + return &SaveBrandLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *SaveBrandLogic) SaveBrand(req *types.BrandSaveReq) (*types.BrandPublic, error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + b, err := l.svcCtx.Scout.SaveBrand(l.ctx, uid, &scoutDomain.Brand{ + ID: req.Id, DisplayName: req.DisplayName, Brief: req.Brief, + TargetAudience: req.TargetAudience, Goals: req.Goals, + }) + if err != nil { + return nil, err + } + out := types.BrandFromDomain(b) + return &out, nil + +} diff --git a/apps/backend/internal/logic/scout/save_homework_logic.go b/apps/backend/internal/logic/scout/save_homework_logic.go new file mode 100644 index 0000000..579eab1 --- /dev/null +++ b/apps/backend/internal/logic/scout/save_homework_logic.go @@ -0,0 +1,45 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + scoutDomain "apps/backend/internal/module/scout/domain" + + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SaveHomeworkLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSaveHomeworkLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SaveHomeworkLogic { + return &SaveHomeworkLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *SaveHomeworkLogic) SaveHomework(req *types.ScoutHomeworkSaveReq) (*types.ScoutHomeworkPublic, error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + h, err := l.svcCtx.Scout.SaveHomework(l.ctx, uid, &scoutDomain.Homework{ + ThemeKey: req.ThemeKey, ThemeLabel: req.ThemeLabel, Purpose: req.Purpose, + Brief: *types.BriefToDomain(&req.Brief), + }) + if err != nil { + return nil, err + } + out := types.HomeworkFromDomain(h) + return &out, nil + +} diff --git a/apps/backend/internal/logic/scout/save_product_logic.go b/apps/backend/internal/logic/scout/save_product_logic.go new file mode 100644 index 0000000..810d35d --- /dev/null +++ b/apps/backend/internal/logic/scout/save_product_logic.go @@ -0,0 +1,45 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + scoutDomain "apps/backend/internal/module/scout/domain" + + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SaveProductLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSaveProductLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SaveProductLogic { + return &SaveProductLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *SaveProductLogic) SaveProduct(req *types.ProductSaveReq) (*types.ProductPublic, error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + p, err := l.svcCtx.Scout.SaveProduct(l.ctx, uid, &scoutDomain.Product{ + ID: req.Id, BrandID: req.BrandId, Label: req.Label, ProductContext: req.ProductContext, + MatchTags: req.MatchTags, PainPoints: req.PainPoints, PlacementURL: req.PlacementUrl, + }) + if err != nil { + return nil, err + } + out := types.ProductFromDomain(p) + return &out, nil + +} diff --git a/apps/backend/internal/logic/scout/send_outreach_logic.go b/apps/backend/internal/logic/scout/send_outreach_logic.go new file mode 100644 index 0000000..9d8402c --- /dev/null +++ b/apps/backend/internal/logic/scout/send_outreach_logic.go @@ -0,0 +1,40 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SendOutreachLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSendOutreachLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SendOutreachLogic { + return &SendOutreachLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *SendOutreachLogic) SendOutreach(req *types.ScoutSendPathReq) (*types.ScoutPostPublic, error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + p, err := l.svcCtx.Scout.SendOutreach(l.ctx, uid, req.Id, req.Text, req.AccountId) + if err != nil { + return nil, err + } + out := types.ScoutPostFromDomain(p) + return &out, nil + +} diff --git a/apps/backend/internal/logic/scout/set_active_brand_logic.go b/apps/backend/internal/logic/scout/set_active_brand_logic.go new file mode 100644 index 0000000..8bb7074 --- /dev/null +++ b/apps/backend/internal/logic/scout/set_active_brand_logic.go @@ -0,0 +1,38 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SetActiveBrandLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSetActiveBrandLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SetActiveBrandLogic { + return &SetActiveBrandLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *SetActiveBrandLogic) SetActiveBrand(req *types.BrandSetActiveReq) (*types.BrandActiveData, error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + if err := l.svcCtx.Scout.SetActiveBrandID(l.ctx, uid, req.Id); err != nil { + return nil, err + } + return &types.BrandActiveData{Id: req.Id}, nil + +} diff --git a/apps/backend/internal/logic/scout/set_crawler_session_logic.go b/apps/backend/internal/logic/scout/set_crawler_session_logic.go new file mode 100644 index 0000000..19947b1 --- /dev/null +++ b/apps/backend/internal/logic/scout/set_crawler_session_logic.go @@ -0,0 +1,38 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SetCrawlerSessionLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSetCrawlerSessionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SetCrawlerSessionLogic { + return &SetCrawlerSessionLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *SetCrawlerSessionLogic) SetCrawlerSession(req *types.ScoutCrawlerSessionReq) (*types.OkData, error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + if err := l.svcCtx.Scout.SetCrawlerSession(l.ctx, uid, req.Token); err != nil { + return nil, err + } + return &types.OkData{Ok: true}, nil + +} diff --git a/apps/backend/internal/logic/scout/skip_outreach_logic.go b/apps/backend/internal/logic/scout/skip_outreach_logic.go new file mode 100644 index 0000000..cd61a7a --- /dev/null +++ b/apps/backend/internal/logic/scout/skip_outreach_logic.go @@ -0,0 +1,40 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SkipOutreachLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSkipOutreachLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SkipOutreachLogic { + return &SkipOutreachLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *SkipOutreachLogic) SkipOutreach(req *types.ScoutPostIdPath) (*types.ScoutPostPublic, error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + p, err := l.svcCtx.Scout.SkipOutreach(l.ctx, uid, req.Id) + if err != nil { + return nil, err + } + out := types.ScoutPostFromDomain(p) + return &out, nil + +} diff --git a/apps/backend/internal/logic/settings/ai_helpers.go b/apps/backend/internal/logic/settings/ai_helpers.go new file mode 100644 index 0000000..e9fc8b0 --- /dev/null +++ b/apps/backend/internal/logic/settings/ai_helpers.go @@ -0,0 +1,112 @@ +package settings + +import ( + "context" + "strings" + + "apps/backend/internal/module/ai" + "apps/backend/internal/module/member/domain" + "apps/backend/internal/svc" + "apps/backend/internal/types" +) + +func resolveAPIKey(svcCtx *svc.ServiceContext, st *domain.UserSettings, provider string) (key string, platformAvailable bool) { + provider = ai.NormalizeProvider(provider) + // 1) user BYOK for this provider + if st != nil { + if k := st.KeyForProvider(provider); k != "" { + return k, platformKeyAvailable(svcCtx, provider) + } + } + // 2) platform key + return platformKey(svcCtx, provider), platformKeyAvailable(svcCtx, provider) +} + +func platformKey(svcCtx *svc.ServiceContext, provider string) string { + if svcCtx == nil { + return "" + } + switch ai.NormalizeProvider(provider) { + case ai.ProviderOpenCodeGo: + return strings.TrimSpace(svcCtx.Config.Platform.OpenCodeKey) + default: + k := strings.TrimSpace(svcCtx.Config.Platform.XAIKey) + if k == "" { + k = strings.TrimSpace(svcCtx.Config.Platform.AIKey) + } + return k + } +} + +func platformKeyAvailable(svcCtx *svc.ServiceContext, provider string) bool { + return platformKey(svcCtx, provider) != "" +} + +// loadModelsForProvider — 真拉 + 5 分鐘快取;失敗用 fallback(不回空 list) +func loadModelsForProvider(ctx context.Context, svcCtx *svc.ServiceContext, st *domain.UserSettings, provider string) (list []string, fromCache bool, modelsErr string) { + provider = ai.NormalizeProvider(provider) + key, _ := resolveAPIKey(svcCtx, st, provider) + if key == "" { + return ai.FallbackModels(provider), false, "尚未設定 API key(會員 BYOK 或平台 key)" + } + if svcCtx.AIRegistry == nil { + return ai.FallbackModels(provider), false, "AI registry not configured" + } + list, fromCache, err := svcCtx.AIRegistry.ListModelsCached(ctx, provider, key) + if err != nil { + return ai.FallbackModels(provider), false, err.Error() + } + return list, fromCache, "" +} + +// pickModel:優先用會員已存模型;即使暫時不在列表也保留(不寫死改成別台) +func pickModel(preferred string, list []string) string { + preferred = strings.TrimSpace(preferred) + if preferred != "" { + return preferred + } + if len(list) > 0 { + return list[0] + } + return "" +} + +func buildAiSettingsData(ctx context.Context, svcCtx *svc.ServiceContext, st *domain.UserSettings, modelsProvider string) *types.AiSettingsData { + if st == nil { + st = domain.DefaultSettings(0) + } + savedProvider := ai.NormalizeProvider(st.Provider) + if savedProvider == "" { + savedProvider = ai.ProviderXAI + } + modelsProvider = ai.NormalizeProvider(modelsProvider) + if modelsProvider == "" { + modelsProvider = savedProvider + } + + list, fromCache, modelsErr := loadModelsForProvider(ctx, svcCtx, st, modelsProvider) + // 顯示/回傳都以會員存檔為準 + selected := pickModel(st.Model, list) + savedModel := strings.TrimSpace(st.Model) + if modelsProvider == savedProvider && selected != "" { + savedModel = selected + } + + keyConfigured := st.HasKeyForProvider(savedProvider) + platformAvail := platformKeyAvailable(svcCtx, savedProvider) + + return &types.AiSettingsData{ + Provider: savedProvider, + Model: savedModel, + ResearchProvider: savedProvider, + ResearchModel: savedModel, + ApiKeyConfigured: keyConfigured, + ResearchApiKeyConfigured: keyConfigured || st.ResearchAPIKeyConfigured, + PlatformKeyAvailable: platformAvail, + Models: list, + ModelsProvider: modelsProvider, + SelectedModel: selected, + ModelsFromCache: fromCache, + ModelsError: modelsErr, + } +} diff --git a/apps/backend/internal/logic/settings/get_ai_logic.go b/apps/backend/internal/logic/settings/get_ai_logic.go index 59a1367..dc78115 100644 --- a/apps/backend/internal/logic/settings/get_ai_logic.go +++ b/apps/backend/internal/logic/settings/get_ai_logic.go @@ -4,6 +4,7 @@ import ( "context" "apps/backend/internal/middleware" + "apps/backend/internal/module/ai" "apps/backend/internal/module/member/domain" "apps/backend/internal/svc" "apps/backend/internal/types" @@ -21,7 +22,8 @@ func NewGetAiLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetAiLogic return &GetAiLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} } -func (l *GetAiLogic) GetAi() (resp *types.AiSettingsData, err error) { +// GetAi — 一次回傳:已存設定(含 selected model)+ 指定 provider 的模型清單(5 分鐘快取) +func (l *GetAiLogic) GetAi(req *types.GetAiReq) (*types.AiSettingsData, error) { uid, _ := middleware.UIDFrom(l.ctx) st, err := l.svcCtx.Members.GetSettings(l.ctx, uid) if err != nil { @@ -30,11 +32,19 @@ func (l *GetAiLogic) GetAi() (resp *types.AiSettingsData, err error) { if st == nil { st = domain.DefaultSettings(uid) } - return &types.AiSettingsData{ - Provider: st.Provider, Model: st.Model, - ResearchProvider: st.Provider, ResearchModel: st.Model, - ApiKeyConfigured: st.APIKeyConfigured, - ResearchApiKeyConfigured: st.ResearchAPIKeyConfigured || st.APIKeyConfigured, - PlatformKeyAvailable: l.svcCtx.Config.Platform.AIKey != "", - }, nil + // normalize stored provider + st.Provider = ai.NormalizeProvider(st.Provider) + + modelsProvider := st.Provider + if req != nil && req.Provider != "" { + modelsProvider = ai.NormalizeProvider(req.Provider) + } + + out := buildAiSettingsData(l.ctx, l.svcCtx, st, modelsProvider) + // when models_provider == saved provider, keep Model in sync with SelectedModel + if out.ModelsProvider == out.Provider { + out.Model = out.SelectedModel + out.ResearchModel = out.SelectedModel + } + return out, nil } diff --git a/apps/backend/internal/logic/settings/get_threads_logic.go b/apps/backend/internal/logic/settings/get_threads_logic.go new file mode 100644 index 0000000..c748e1d --- /dev/null +++ b/apps/backend/internal/logic/settings/get_threads_logic.go @@ -0,0 +1,72 @@ +package settings + +import ( + "context" + "strings" + + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetThreadsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetThreadsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetThreadsLogic { + return &GetThreadsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetThreadsLogic) GetThreads() (*types.ThreadsSettingsData, error) { + id := strings.TrimSpace(l.svcCtx.Config.Platform.ThreadsAppId) + secret := strings.TrimSpace(l.svcCtx.Config.Platform.ThreadsAppSecret) + configured := id != "" && secret != "" + + web := strings.TrimRight(l.svcCtx.Config.PublicWebBase, "/") + base := strings.TrimRight(l.svcCtx.Config.PublicAPIBase, "/") + if base == "" && strings.HasPrefix(web, "https://") { + base = web + } + if base == "" { + base = "http://127.0.0.1:8888" + } + // Prefer actual runtime base from threads service if wired + if l.svcCtx.Threads != nil && l.svcCtx.Threads.APIPublicBase != "" { + base = strings.TrimRight(l.svcCtx.Threads.APIPublicBase, "/") + } + cb := base + "/api/v1/threads-accounts/oauth/callback" + + provider := "fake" + hint := "未設定 Threads App Id/Secret(gateway Platform 或 THREADS_APP_*)。目前為 fake OAuth。" + if configured { + provider = "meta" + hint = "請把下方 Callback URL 貼到 Meta App → Valid OAuth Redirect URIs。連帳請用 https 公開站(Crew)。" + if strings.HasPrefix(cb, "http://") { + hint += " ⚠ Callback 仍是 HTTP,Meta 會擋(error 1349187)。" + } + } + + return &types.ThreadsSettingsData{ + Provider: provider, + Configured: configured, + OauthReady: true, + ConnectPath: "/app/crew", + CallbackUrl: cb, + PublicWebBase: web, + AppIdMasked: maskAppID(id), + Hint: hint, + }, nil +} + +func maskAppID(id string) string { + if id == "" { + return "" + } + if len(id) <= 8 { + return id[:1] + "…" + id[len(id)-1:] + } + return id[:4] + "…" + id[len(id)-4:] +} diff --git a/apps/backend/internal/logic/settings/list_models_logic.go b/apps/backend/internal/logic/settings/list_models_logic.go index b51bfaa..d99e450 100644 --- a/apps/backend/internal/logic/settings/list_models_logic.go +++ b/apps/backend/internal/logic/settings/list_models_logic.go @@ -3,6 +3,9 @@ package settings import ( "context" + "apps/backend/internal/middleware" + "apps/backend/internal/module/ai" + "apps/backend/internal/module/member/domain" "apps/backend/internal/svc" "apps/backend/internal/types" @@ -19,14 +22,29 @@ func NewListModelsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListMo return &ListModelsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} } -func (l *ListModelsLogic) ListModels(req *types.ListModelsReq) (resp *types.ListModelsData, err error) { - provider := req.Provider +// ListModels — 真拉 provider 模型 + 5 分鐘快取;並回 selected_model(若為目前存檔 provider) +func (l *ListModelsLogic) ListModels(req *types.ListModelsReq) (*types.ListModelsData, error) { + provider := ai.NormalizeProvider(req.Provider) if provider == "" { - provider = "xai" + provider = ai.ProviderXAI } - list := modelsByProvider[provider] - if list == nil { - list = modelsByProvider["xai"] + uid, _ := middleware.UIDFrom(l.ctx) + st, _ := l.svcCtx.Members.GetSettings(l.ctx, uid) + if st == nil { + st = domain.DefaultSettings(uid) } - return &types.ListModelsData{List: list, Provider: provider}, nil + st.Provider = ai.NormalizeProvider(st.Provider) + + list, fromCache, modelsErr := loadModelsForProvider(l.ctx, l.svcCtx, st, provider) + selected := "" + if provider == st.Provider { + selected = pickModel(st.Model, list) + } + return &types.ListModelsData{ + List: list, + Provider: provider, + SelectedModel: selected, + FromCache: fromCache, + ModelsError: modelsErr, + }, nil } diff --git a/apps/backend/internal/logic/settings/save_ai_logic.go b/apps/backend/internal/logic/settings/save_ai_logic.go index a20db58..1fdcbe4 100644 --- a/apps/backend/internal/logic/settings/save_ai_logic.go +++ b/apps/backend/internal/logic/settings/save_ai_logic.go @@ -4,6 +4,7 @@ import ( "context" "apps/backend/internal/middleware" + "apps/backend/internal/module/ai" "apps/backend/internal/module/member/domain" "apps/backend/internal/svc" "apps/backend/internal/types" @@ -11,11 +12,6 @@ import ( "github.com/zeromicro/go-zero/core/logx" ) -var modelsByProvider = map[string][]string{ - "xai": {"grok-3", "grok-3-mini", "grok-2"}, - "opencode-go": {"default", "fast"}, -} - type SaveAiLogic struct { logx.Logger ctx context.Context @@ -26,50 +22,57 @@ func NewSaveAiLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SaveAiLogi return &SaveAiLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} } -func (l *SaveAiLogic) SaveAi(req *types.SaveAiReq) (resp *types.AiSettingsData, err error) { +func (l *SaveAiLogic) SaveAi(req *types.SaveAiReq) (*types.AiSettingsData, error) { uid, _ := middleware.UIDFrom(l.ctx) st, _ := l.svcCtx.Members.GetSettings(l.ctx, uid) if st == nil { st = domain.DefaultSettings(uid) } - if req.Provider != "" { - st.Provider = req.Provider - } else if req.ResearchProvider != "" { - st.Provider = req.ResearchProvider + if st.ProviderAPIKeys == nil { + st.ProviderAPIKeys = map[string]string{} } + // migrate legacy single key into map + if st.APIKey != "" && st.Provider != "" && st.ProviderAPIKeys[st.Provider] == "" { + st.ProviderAPIKeys[st.Provider] = st.APIKey + } + + provider := st.Provider + if req.Provider != "" { + provider = req.Provider + } else if req.ResearchProvider != "" { + provider = req.ResearchProvider + } + provider = ai.NormalizeProvider(provider) + st.Provider = provider + if req.Model != "" { st.Model = req.Model } else if req.ResearchModel != "" { st.Model = req.ResearchModel } - if list, ok := modelsByProvider[st.Provider]; ok { - found := false - for _, m := range list { - if m == st.Model { - found = true - break - } - } - if !found && len(list) > 0 { - st.Model = list[0] - } - } + + // BYOK for this provider if req.ApiKey != "" { - st.APIKey = req.ApiKey - st.APIKeyConfigured = true - st.ResearchAPIKeyConfigured = true + st.SetKeyForProvider(provider, req.ApiKey) } if req.ResearchApiKey != "" { - st.APIKey = req.ResearchApiKey - st.APIKeyConfigured = true - st.ResearchAPIKeyConfigured = true + st.SetKeyForProvider(provider, req.ResearchApiKey) } - if req.ApiKeyConfigured != nil { - st.APIKeyConfigured = *req.ApiKeyConfigured - if !*req.ApiKeyConfigured { - st.APIKey = "" - } + if req.ApiKeyConfigured != nil && !*req.ApiKeyConfigured { + st.SetKeyForProvider(provider, "") } - _ = l.svcCtx.Members.SaveSettings(l.ctx, uid, st) - return NewGetAiLogic(l.ctx, l.svcCtx).GetAi() + + // validate model against live list (or fallback) + list, _, _ := loadModelsForProvider(l.ctx, l.svcCtx, st, provider) + st.Model = pickModel(st.Model, list) + + st.APIKeyConfigured = st.HasKeyForProvider(provider) + st.ResearchAPIKeyConfigured = st.APIKeyConfigured + st.UpdatedAt = domain.NowNano() + + if err := l.svcCtx.Members.SaveSettings(l.ctx, uid, st); err != nil { + return nil, err + } + // return combined settings + models + return NewGetAiLogic(l.ctx, l.svcCtx).GetAi(&types.GetAiReq{Provider: provider}) } diff --git a/apps/backend/internal/logic/threads/disconnect_threads_account_logic.go b/apps/backend/internal/logic/threads/disconnect_threads_account_logic.go new file mode 100644 index 0000000..a687073 --- /dev/null +++ b/apps/backend/internal/logic/threads/disconnect_threads_account_logic.go @@ -0,0 +1,36 @@ +package threads + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type DisconnectThreadsAccountLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDisconnectThreadsAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DisconnectThreadsAccountLogic { + return &DisconnectThreadsAccountLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *DisconnectThreadsAccountLogic) DisconnectThreadsAccount(req *types.ThreadsAccountIdPath) (*types.OkData, error) { + if l.svcCtx.Threads == nil { + return nil, response.Biz(503, 503001, "threads module not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + if err := l.svcCtx.Threads.Disconnect(l.ctx, uid, req.Id); err != nil { + return nil, err + } + return &types.OkData{Ok: true, Message: "disconnected"}, nil +} diff --git a/apps/backend/internal/logic/threads/import_threads_account_session_logic.go b/apps/backend/internal/logic/threads/import_threads_account_session_logic.go new file mode 100644 index 0000000..1f5fd6d --- /dev/null +++ b/apps/backend/internal/logic/threads/import_threads_account_session_logic.go @@ -0,0 +1,101 @@ +package threads + +import ( + "context" + "encoding/json" + "strings" + "time" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ImportThreadsAccountSessionLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewImportThreadsAccountSessionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ImportThreadsAccountSessionLogic { + return &ImportThreadsAccountSessionLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +// ImportThreadsAccountSession — 擴充 POST .../session/import +// 相容舊版 Chrome 擴充:body { storageState };寫入 scout crawler-session 供 dev 模式海巡。 +func (l *ImportThreadsAccountSessionLogic) ImportThreadsAccountSession(req *types.ImportThreadsAccountSessionReq) (*types.ImportThreadsAccountSessionData, error) { + if l.svcCtx.Threads == nil { + return nil, response.Biz(503, 503001, "threads module not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + + state := strings.TrimSpace(req.StorageState) + if state == "" { + return nil, response.Biz(400, 400010, "storageState is required") + } + if err := validateStorageStateJSON(state); err != nil { + return &types.ImportThreadsAccountSessionData{ + Success: false, + Valid: false, + Synced: false, + AccountId: req.Id, + Message: err.Error(), + UpdateAt: time.Now().UTC().UnixNano(), + }, nil + } + + acc, err := l.svcCtx.Threads.Get(l.ctx, req.Id) + if err != nil { + return nil, err + } + if acc.OwnerUID != uid { + return nil, response.Biz(403, 403001, "forbidden") + } + + // 寫入爬蟲 session(dev 模式海巡用) + if l.svcCtx.Scout != nil { + if err := l.svcCtx.Scout.SetCrawlerSession(l.ctx, uid, state); err != nil { + return nil, err + } + } + + now := time.Now().UTC().UnixNano() + return &types.ImportThreadsAccountSessionData{ + Success: true, + Valid: true, + Synced: true, + AccountId: acc.ID, + Username: acc.Username, + Message: "Chrome session 已同步(爬蟲 session)", + UpdateAt: now, + }, nil +} + +func validateStorageStateJSON(raw string) error { + var payload struct { + Cookies []json.RawMessage `json:"cookies"` + } + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return errInvalidStorage("storageState is not valid JSON") + } + if len(payload.Cookies) == 0 { + return errInvalidStorage("storageState must include cookies array") + } + return nil +} + +type storageStateError string + +func (e storageStateError) Error() string { return string(e) } + +func errInvalidStorage(msg string) error { return storageStateError(msg) } diff --git a/apps/backend/internal/logic/threads/list_threads_accounts_logic.go b/apps/backend/internal/logic/threads/list_threads_accounts_logic.go new file mode 100644 index 0000000..eb4b365 --- /dev/null +++ b/apps/backend/internal/logic/threads/list_threads_accounts_logic.go @@ -0,0 +1,41 @@ +package threads + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListThreadsAccountsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListThreadsAccountsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListThreadsAccountsLogic { + return &ListThreadsAccountsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListThreadsAccountsLogic) ListThreadsAccounts() (*types.ThreadsAccountListData, error) { + if l.svcCtx.Threads == nil { + return nil, response.Biz(503, 503001, "threads module not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + list, err := l.svcCtx.Threads.List(l.ctx, uid) + if err != nil { + return nil, err + } + out := make([]types.ThreadsAccountPublic, 0, len(list)) + for _, a := range list { + out = append(out, types.ThreadsAccountFromModel(a)) + } + return &types.ThreadsAccountListData{List: out}, nil +} diff --git a/apps/backend/internal/logic/threads/refresh_threads_account_logic.go b/apps/backend/internal/logic/threads/refresh_threads_account_logic.go new file mode 100644 index 0000000..2b013d3 --- /dev/null +++ b/apps/backend/internal/logic/threads/refresh_threads_account_logic.go @@ -0,0 +1,43 @@ +package threads + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RefreshThreadsAccountLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRefreshThreadsAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RefreshThreadsAccountLogic { + return &RefreshThreadsAccountLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *RefreshThreadsAccountLogic) RefreshThreadsAccount(req *types.ThreadsAccountIdPath) (*types.ThreadsAccountPublic, error) { + if l.svcCtx.Threads == nil { + return nil, response.Biz(503, 503001, "threads module not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + a, err := l.svcCtx.Threads.Refresh(l.ctx, uid, req.Id) + if err != nil { + // still return account snapshot when refresh failed but updated + if a != nil { + pub := types.ThreadsAccountFromModel(a) + return &pub, err + } + return nil, err + } + pub := types.ThreadsAccountFromModel(a) + return &pub, nil +} diff --git a/apps/backend/internal/logic/threads/threads_o_auth_callback_logic.go b/apps/backend/internal/logic/threads/threads_o_auth_callback_logic.go new file mode 100644 index 0000000..5f94075 --- /dev/null +++ b/apps/backend/internal/logic/threads/threads_o_auth_callback_logic.go @@ -0,0 +1,43 @@ +package threads + +import ( + "context" + + threadsDomain "apps/backend/internal/module/threads/domain" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ThreadsOAuthCallbackLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewThreadsOAuthCallbackLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ThreadsOAuthCallbackLogic { + return &ThreadsOAuthCallbackLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ThreadsOAuthCallbackLogic) ThreadsOAuthCallback(req *types.ThreadsOAuthCallbackReq) (*types.ThreadsOAuthCallbackData, error) { + if l.svcCtx.Threads == nil { + redir := "http://127.0.0.1:5173/app/crew?oauth=error&msg=not_configured" + return &types.ThreadsOAuthCallbackData{Ok: false, Message: "not configured", RedirectUrl: redir}, nil + } + _, err := l.svcCtx.Threads.OAuthCallback(l.ctx, req.Code, req.State, req.Error) + if err != nil { + msg := err.Error() + if err == threadsDomain.ErrOAuthDenied { + msg = "denied" + } + return &types.ThreadsOAuthCallbackData{ + Ok: false, Message: msg, + RedirectUrl: l.svcCtx.Threads.WebRedirect(false, msg), + }, nil // browser redirect; don't 500 on user deny + } + return &types.ThreadsOAuthCallbackData{ + Ok: true, Message: "connected", + RedirectUrl: l.svcCtx.Threads.WebRedirect(true, ""), + }, nil +} diff --git a/apps/backend/internal/logic/threads/threads_o_auth_start_logic.go b/apps/backend/internal/logic/threads/threads_o_auth_start_logic.go new file mode 100644 index 0000000..5cf613b --- /dev/null +++ b/apps/backend/internal/logic/threads/threads_o_auth_start_logic.go @@ -0,0 +1,37 @@ +package threads + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ThreadsOAuthStartLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewThreadsOAuthStartLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ThreadsOAuthStartLogic { + return &ThreadsOAuthStartLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ThreadsOAuthStartLogic) ThreadsOAuthStart() (*types.ThreadsOAuthStartData, error) { + if l.svcCtx.Threads == nil { + return nil, response.Biz(503, 503001, "threads module not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + url, state, err := l.svcCtx.Threads.OAuthStart(l.ctx, uid) + if err != nil { + return nil, err + } + return &types.ThreadsOAuthStartData{AuthorizeUrl: url, State: state}, nil +} diff --git a/apps/backend/internal/logic/usage/get_tenant_usage_summary_logic.go b/apps/backend/internal/logic/usage/get_tenant_usage_summary_logic.go new file mode 100644 index 0000000..452e2aa --- /dev/null +++ b/apps/backend/internal/logic/usage/get_tenant_usage_summary_logic.go @@ -0,0 +1,43 @@ +package usage + +import ( + "context" + + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetTenantUsageSummaryLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetTenantUsageSummaryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetTenantUsageSummaryLogic { + return &GetTenantUsageSummaryLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetTenantUsageSummaryLogic) GetTenantUsageSummary(req *types.UsageTenantSummaryReq) (*types.UsageTenantSummaryData, error) { + if l.svcCtx.Usage == nil { + return nil, response.Biz(503, 503001, "usage not configured") + } + // AdminAuth on route + ten, err := l.svcCtx.Usage.TenantSummary(l.ctx, req.MonthKey) + if err != nil { + return nil, err + } + members := make([]types.UsageTenantRow, 0, len(ten.Members)) + for _, m := range ten.Members { + members = append(members, types.UsageTenantRow{ + Uid: m.UID, PlanId: m.PlanID, Unlimited: m.Unlimited, + PlatformCreditsUsed: m.PlatformCreditsUsed, ByokCallCount: m.ByokCallCount, + }) + } + return &types.UsageTenantSummaryData{ + MonthKey: ten.MonthKey, PlatformCreditsUsed: ten.PlatformCreditsUsed, + ByokCallCount: ten.ByokCallCount, Members: members, + }, nil +} diff --git a/apps/backend/internal/logic/usage/get_usage_prefs_logic.go b/apps/backend/internal/logic/usage/get_usage_prefs_logic.go new file mode 100644 index 0000000..bd20624 --- /dev/null +++ b/apps/backend/internal/logic/usage/get_usage_prefs_logic.go @@ -0,0 +1,37 @@ +package usage + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetUsagePrefsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetUsagePrefsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetUsagePrefsLogic { + return &GetUsagePrefsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetUsagePrefsLogic) GetUsagePrefs() (*types.UsagePrefsData, error) { + if l.svcCtx.Usage == nil { + return nil, response.Biz(503, 503001, "usage not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + p, err := l.svcCtx.Usage.GetPrefs(l.ctx, uid) + if err != nil { + return nil, err + } + return &types.UsagePrefsData{PlanId: p.PlanID, Unlimited: p.Unlimited}, nil +} diff --git a/apps/backend/internal/logic/usage/get_usage_summary_logic.go b/apps/backend/internal/logic/usage/get_usage_summary_logic.go new file mode 100644 index 0000000..6dbe15b --- /dev/null +++ b/apps/backend/internal/logic/usage/get_usage_summary_logic.go @@ -0,0 +1,37 @@ +package usage + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetUsageSummaryLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetUsageSummaryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetUsageSummaryLogic { + return &GetUsageSummaryLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetUsageSummaryLogic) GetUsageSummary(req *types.UsageSummaryReq) (*types.UsageSummaryData, error) { + if l.svcCtx.Usage == nil { + return nil, response.Biz(503, 503001, "usage not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + sum, err := l.svcCtx.Usage.GetSummary(l.ctx, uid, req.MonthKey) + if err != nil { + return nil, err + } + return types.UsageSummaryFromDomain(sum), nil +} diff --git a/apps/backend/internal/logic/usage/list_my_purchases_logic.go b/apps/backend/internal/logic/usage/list_my_purchases_logic.go new file mode 100644 index 0000000..7057cd1 --- /dev/null +++ b/apps/backend/internal/logic/usage/list_my_purchases_logic.go @@ -0,0 +1,41 @@ +package usage + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListMyPurchasesLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListMyPurchasesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListMyPurchasesLogic { + return &ListMyPurchasesLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListMyPurchasesLogic) ListMyPurchases() (*types.UsagePurchasesData, error) { + if l.svcCtx.Usage == nil { + return nil, response.Biz(503, 503001, "usage not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + list, err := l.svcCtx.Usage.ListMyPurchases(l.ctx, uid, 50) + if err != nil { + return nil, err + } + out := make([]types.UsagePurchasePublic, 0, len(list)) + for _, p := range list { + out = append(out, types.UsagePurchasePublic{Id: p.ID, PlanId: p.PlanID, MockRef: p.MockRef, CreatedAt: p.CreatedAt}) + } + return &types.UsagePurchasesData{List: out}, nil +} diff --git a/apps/backend/internal/logic/usage/list_usage_events_logic.go b/apps/backend/internal/logic/usage/list_usage_events_logic.go new file mode 100644 index 0000000..a708fa6 --- /dev/null +++ b/apps/backend/internal/logic/usage/list_usage_events_logic.go @@ -0,0 +1,41 @@ +package usage + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListUsageEventsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListUsageEventsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListUsageEventsLogic { + return &ListUsageEventsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListUsageEventsLogic) ListUsageEvents(req *types.UsageEventsReq) (*types.UsageEventsData, error) { + if l.svcCtx.Usage == nil { + return nil, response.Biz(503, 503001, "usage not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + list, err := l.svcCtx.Usage.ListEvents(l.ctx, uid, req.MonthKey, req.KeyMode, req.Limit) + if err != nil { + return nil, err + } + out := make([]types.UsageEventPublic, 0, len(list)) + for _, e := range list { + out = append(out, types.UsageEventFromDomain(e)) + } + return &types.UsageEventsData{List: out}, nil +} diff --git a/apps/backend/internal/logic/usage/purchase_plan_logic.go b/apps/backend/internal/logic/usage/purchase_plan_logic.go new file mode 100644 index 0000000..510cfc1 --- /dev/null +++ b/apps/backend/internal/logic/usage/purchase_plan_logic.go @@ -0,0 +1,37 @@ +package usage + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type PurchasePlanLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewPurchasePlanLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PurchasePlanLogic { + return &PurchasePlanLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *PurchasePlanLogic) PurchasePlan(req *types.UsagePurchaseReq) (*types.UsagePurchasePublic, error) { + if l.svcCtx.Usage == nil { + return nil, response.Biz(503, 503001, "usage not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + p, err := l.svcCtx.Usage.PurchasePlan(l.ctx, uid, req.PlanId, req.MockRef) + if err != nil { + return nil, err + } + return &types.UsagePurchasePublic{Id: p.ID, PlanId: p.PlanID, MockRef: p.MockRef, CreatedAt: p.CreatedAt}, nil +} diff --git a/apps/backend/internal/logic/usage/set_usage_prefs_logic.go b/apps/backend/internal/logic/usage/set_usage_prefs_logic.go new file mode 100644 index 0000000..b2a8636 --- /dev/null +++ b/apps/backend/internal/logic/usage/set_usage_prefs_logic.go @@ -0,0 +1,46 @@ +package usage + +import ( + "context" + "strconv" + + "apps/backend/internal/middleware" + "apps/backend/internal/module/permission" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SetUsagePrefsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSetUsagePrefsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SetUsagePrefsLogic { + return &SetUsagePrefsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *SetUsagePrefsLogic) SetUsagePrefs(req *types.UsageSetPrefsReq) (*types.UsagePrefsData, error) { + if l.svcCtx.Usage == nil { + return nil, response.Biz(503, 503001, "usage not configured") + } + actor, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + if !permission.IsAdmin(middleware.RolesFrom(l.ctx)) { + return nil, response.Biz(403, 403002, "permission denied") + } + target, err := strconv.ParseInt(req.Uid, 10, 64) + if err != nil { + return nil, response.Biz(400, 400001, "invalid uid") + } + p, err := l.svcCtx.Usage.SetPrefs(l.ctx, actor, target, true, req.PlanId, req.Unlimited) + if err != nil { + return nil, err + } + return &types.UsagePrefsData{PlanId: p.PlanID, Unlimited: p.Unlimited}, nil +} diff --git a/apps/backend/internal/middleware/auth.go b/apps/backend/internal/middleware/auth.go index 4291d72..103fe08 100644 --- a/apps/backend/internal/middleware/auth.go +++ b/apps/backend/internal/middleware/auth.go @@ -6,7 +6,9 @@ import ( "strings" "apps/backend/internal/domain" + "apps/backend/internal/module/ai" memberDomain "apps/backend/internal/module/member/domain" + "apps/backend/internal/module/permission" tokenDomain "apps/backend/internal/module/token/domain" "apps/backend/internal/response" ) @@ -57,21 +59,29 @@ func AuthMiddleware(issuer tokenDomain.Issuer, store memberDomain.Repository) fu ctx = context.WithValue(ctx, ctxRoles, m.Roles) ctx = context.WithValue(ctx, ctxMember, m) ctx = context.WithValue(ctx, domain.UIDCode, m.UID) + // LLM 系統 prompt 語言:會員 preferred_language > Accept-Language > 繁中 + ctx = ai.WithResponseLanguage(ctx, ai.PickLanguage(m.PreferredLanguage, r.Header.Get("Accept-Language"))) next(w, r.WithContext(ctx)) } } } -// AdminMiddleware requires admin role (after AuthMiddleware). +// AdminMiddleware requires permission.AdminAccess(admin 角色預設授予). func AdminMiddleware(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - for _, role := range RolesFrom(r.Context()) { - if role == memberDomain.RoleAdmin { - next(w, r) + return RequirePermission(permission.AdminAccess)(next) +} + +// RequirePermission 檢查 JWT roles 是否具備指定能力點(須在 AuthJWT 之後). +func RequirePermission(code string) func(http.HandlerFunc) http.HandlerFunc { + return func(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + roles := RolesFrom(r.Context()) + if !permission.Has(roles, code) { + response.Fail(w, http.StatusForbidden, 403002, "permission denied: "+code, nil) return } + next(w, r) } - response.Fail(w, http.StatusForbidden, 403002, "admin required", nil) } } diff --git a/apps/backend/internal/middleware/m1_t119_auth_test.go b/apps/backend/internal/middleware/m1_t119_auth_test.go new file mode 100644 index 0000000..90a1c4c --- /dev/null +++ b/apps/backend/internal/middleware/m1_t119_auth_test.go @@ -0,0 +1,165 @@ +package middleware_test + +// T119 HTTP middleware cases: AU-05 / AU-06 / AU-12 / AU-13 / AD-13 + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "apps/backend/internal/middleware" + "apps/backend/internal/module/member/domain" + tokenUC "apps/backend/internal/module/token/usecase" + "apps/backend/internal/response" + + "github.com/stretchr/testify/require" +) + +// mwRepo is a minimal Repository for AuthMiddleware tests. +type mwRepo struct { + byUID map[int64]*domain.Member +} + +func (m *mwRepo) FindByUID(_ context.Context, uid int64) (*domain.Member, error) { + x, ok := m.byUID[uid] + if !ok { + return nil, domain.ErrNotFound + } + cp := *x + return &cp, nil +} + +func (m *mwRepo) NextUID(context.Context) (int64, error) { return 0, nil } +func (m *mwRepo) CreateMember(context.Context, *domain.Member) error { return nil } +func (m *mwRepo) FindByEmail(context.Context, string) (*domain.Member, error) { return nil, domain.ErrNotFound } +func (m *mwRepo) FindByPhone(context.Context, string) (*domain.Member, error) { return nil, domain.ErrNotFound } +func (m *mwRepo) UpdateMember(context.Context, *domain.Member) error { return nil } +func (m *mwRepo) ListPage(context.Context, int, int, string, string) ([]*domain.Member, int64, error) { + return nil, 0, nil +} +func (m *mwRepo) CountActiveAdmins(context.Context) (int64, error) { return 0, nil } +func (m *mwRepo) CreateIdentity(context.Context, *domain.Identity) error { + return nil +} +func (m *mwRepo) FindIdentity(context.Context, string, string) (*domain.Identity, error) { + return nil, domain.ErrIdentityNotFound +} +func (m *mwRepo) ListIdentitiesByUID(context.Context, int64) ([]*domain.Identity, error) { + return nil, nil +} +func (m *mwRepo) DeleteIdentity(context.Context, string, string) error { return nil } +func (m *mwRepo) UpdateIdentityPassword(context.Context, string, string, string) error { + return nil +} +func (m *mwRepo) SetCode(string, string, string, time.Duration) {} +func (m *mwRepo) CheckCode(string, string, string) bool { return false } +func (m *mwRepo) PeekCode(string, string, string) bool { return false } +func (m *mwRepo) SaveRefresh(string, int64, time.Time) {} +func (m *mwRepo) ConsumeRefresh(string) (int64, bool) { return 0, false } +func (m *mwRepo) RevokeRefresh(string) {} +func (m *mwRepo) RevokeAllRefreshByUID(context.Context, int64) error { + return nil +} +func (m *mwRepo) GetSettings(context.Context, int64) (*domain.UserSettings, error) { + return nil, nil +} +func (m *mwRepo) SaveSettings(context.Context, int64, *domain.UserSettings) error { + return nil +} + +func decodeEnv(t *testing.T, rr *httptest.ResponseRecorder) response.Envelope { + t.Helper() + var env response.Envelope + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &env)) + return env +} + +func TestAU_05_MeMissingAuthorization(t *testing.T) { + issuer := tokenUC.NewJWTIssuer("mw-a", "mw-r", 3600, 86400) + repo := &mwRepo{byUID: map[int64]*domain.Member{}} + h := middleware.AuthMiddleware(issuer, repo)(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"code":102000}`)) + }) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)) + require.Equal(t, http.StatusUnauthorized, rr.Code) + env := decodeEnv(t, rr) + require.NotEqual(t, 102000, env.Code) + require.NotEmpty(t, env.Message) +} + +func TestAU_06_MeExpiredAccess(t *testing.T) { + // access expire 1 second — issue then sleep + issuer := tokenUC.NewJWTIssuer("mw-a2", "mw-r2", 1, 86400) + uid := int64(1_000_100) + repo := &mwRepo{byUID: map[int64]*domain.Member{ + uid: {UID: uid, Email: "exp@test.local", Roles: []string{domain.RoleMember}, Status: domain.StatusActive}, + }} + pair, _, _, err := issuer.Issue(uid, []string{domain.RoleMember}) + require.NoError(t, err) + time.Sleep(1100 * time.Millisecond) + h := middleware.AuthMiddleware(issuer, repo)(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("should not reach handler") + }) + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+pair.AccessToken) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + require.Equal(t, http.StatusUnauthorized, rr.Code) + env := decodeEnv(t, rr) + require.NotEqual(t, 102000, env.Code) +} + +func TestAU_12_NonAdminForbidden(t *testing.T) { + issuer := tokenUC.NewJWTIssuer("mw-a3", "mw-r3", 3600, 86400) + uid := int64(1_000_101) + repo := &mwRepo{byUID: map[int64]*domain.Member{ + uid: {UID: uid, Email: "mem@test.local", Roles: []string{domain.RoleMember}, Status: domain.StatusActive}, + }} + pair, _, _, err := issuer.Issue(uid, []string{domain.RoleMember}) + require.NoError(t, err) + inner := middleware.AdminMiddleware(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("member must not pass admin middleware") + }) + h := middleware.AuthMiddleware(issuer, repo)(inner) + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/members", nil) + req.Header.Set("Authorization", "Bearer "+pair.AccessToken) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + require.Equal(t, http.StatusForbidden, rr.Code) + env := decodeEnv(t, rr) + require.EqualValues(t, 403002, env.Code) +} + +func TestAU_13_AdminAllowed(t *testing.T) { + issuer := tokenUC.NewJWTIssuer("mw-a4", "mw-r4", 3600, 86400) + uid := int64(1_000_102) + repo := &mwRepo{byUID: map[int64]*domain.Member{ + uid: {UID: uid, Email: "adm@test.local", Roles: []string{domain.RoleAdmin, domain.RoleMember}, Status: domain.StatusActive}, + }} + pair, _, _, err := issuer.Issue(uid, []string{domain.RoleAdmin, domain.RoleMember}) + require.NoError(t, err) + called := false + inner := middleware.AdminMiddleware(func(w http.ResponseWriter, r *http.Request) { + called = true + response.OK(w, map[string]bool{"ok": true}) + }) + h := middleware.AuthMiddleware(issuer, repo)(inner) + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/members", nil) + req.Header.Set("Authorization", "Bearer "+pair.AccessToken) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + require.True(t, called) + require.Equal(t, http.StatusOK, rr.Code) + env := decodeEnv(t, rr) + require.EqualValues(t, 102000, env.Code) +} + +func TestAD_13_MemberCreateMemberForbidden(t *testing.T) { + // Same stack as AU-12: member hits admin route → 403002 + TestAU_12_NonAdminForbidden(t) +} diff --git a/apps/backend/internal/module/ai/client.go b/apps/backend/internal/module/ai/client.go new file mode 100644 index 0000000..4a73ec6 --- /dev/null +++ b/apps/backend/internal/module/ai/client.go @@ -0,0 +1,47 @@ +package ai + +import ( + "context" + "fmt" + "strings" +) + +// Client is AI provider transport (real HTTP or fake). +type Client interface { + Complete(ctx context.Context, apiKey, model, prompt string) (string, error) + // CompleteStream 串流 delta;onDelta 可為 nil。回傳完整正文。 + CompleteStream(ctx context.Context, apiKey, model, prompt string, onDelta func(chunk string) error) (string, error) + ListModels(ctx context.Context, apiKey string) ([]string, error) +} + +// FakeClient for tests / when no real key path needed. +type FakeClient struct { + Fail bool + Models []string +} + +func (f *FakeClient) Complete(ctx context.Context, apiKey, model, prompt string) (string, error) { + if f.Fail || apiKey == "" { + return "", fmt.Errorf("ai call failed: no key or forced fail") + } + _ = SystemPromptForLanguage(ResponseLanguageFrom(ctx)) // ensure always defined for real clients + if len(prompt) > 200 { + prompt = prompt[:200] + } + return fmt.Sprintf("[%s] %s", model, strings.TrimSpace(prompt)), nil +} + +func (f *FakeClient) ListModels(_ context.Context, apiKey string) ([]string, error) { + if f.Fail || apiKey == "" { + return nil, fmt.Errorf("list models failed: no key or forced fail") + } + if len(f.Models) > 0 { + return append([]string(nil), f.Models...), nil + } + return []string{"grok-3", "grok-2", "grok-beta"}, nil +} + +// ModelsForProvider — static catalog (fallback when live list fails). +func ModelsForProvider(provider string) []string { + return FallbackModels(provider) +} diff --git a/apps/backend/internal/module/ai/client_test.go b/apps/backend/internal/module/ai/client_test.go new file mode 100644 index 0000000..07fb2f0 --- /dev/null +++ b/apps/backend/internal/module/ai/client_test.go @@ -0,0 +1,76 @@ +package ai_test + +import ( + "context" + "testing" + + "apps/backend/internal/module/ai" + + "github.com/stretchr/testify/require" +) + +func TestST_03_ListModelsNonEmpty(t *testing.T) { + list := ai.ModelsForProvider("xai") + require.NotEmpty(t, list) + list2 := ai.ModelsForProvider("opencode-go") + require.NotEmpty(t, list2) + // OpenCode Go 不是 OpenAI gpt-* 模型名 + require.NotContains(t, list2, "gpt-4o-mini") + require.Contains(t, list2, "kimi-k2.6") +} + +func TestSanitizeModelDoesNotForceUserChoice(t *testing.T) { + // 會員選 deepseek 不應被改掉 + require.Equal(t, "deepseek-v4-flash", ai.SanitizeModel("opencode-go", "deepseek-v4-flash")) + require.Equal(t, "glm-5.1", ai.SanitizeModel("opencode-go", "glm-5.1")) + // 空模型才給 fallback(僅 UI 後備,執行路徑不應靠這個覆寫設定) + require.NotEmpty(t, ai.SanitizeModel("opencode-go", "")) +} + +func TestST_09_NoKeyFails(t *testing.T) { + c := &ai.FakeClient{} + _, err := c.Complete(context.Background(), "", "grok-3", "hi") + require.Error(t, err) + _, err = c.ListModels(context.Background(), "") + require.Error(t, err) +} + +func TestST_05_PlatformCallOk(t *testing.T) { + c := &ai.FakeClient{} + out, err := c.Complete(context.Background(), "platform-key", "grok-3", "hello") + require.NoError(t, err) + require.Contains(t, out, "hello") +} + +func TestNormalizeProvider(t *testing.T) { + require.Equal(t, ai.ProviderXAI, ai.NormalizeProvider("xai")) + require.Equal(t, ai.ProviderOpenCodeGo, ai.NormalizeProvider("opencode-go")) + require.Equal(t, ai.ProviderOpenCodeGo, ai.NormalizeProvider("opencode")) +} + +func TestSystemPromptLanguage(t *testing.T) { + zh := ai.SystemPromptForLanguage("zh-TW") + require.Contains(t, zh, "繁體中文") + // 不應綁產品客服人設,否則仿寫會變助手腔 + require.NotContains(t, zh, "Harbor Desk") + require.NotContains(t, zh, "助手") + en := ai.SystemPromptForLanguage("en") + require.Contains(t, en, "English") + require.NotContains(t, en, "Harbor Desk") + require.Equal(t, "zh-TW", ai.PickLanguage("", "zh-TW,zh;q=0.9")) + require.Equal(t, "en", ai.PickLanguage("en", "zh-TW")) + ctx := ai.WithResponseLanguage(context.Background(), "en-US") + require.Equal(t, "en", ai.ResponseLanguageFrom(ctx)) +} + +func TestInferScriptLanguage(t *testing.T) { + require.Equal(t, "en", ai.InferScriptLanguage( + "I talk like a tired millennial who overshares about coffee and rent. Low energy, dry humor.", + "Sample: honestly the subway is a personality test at this point", + )) + require.Equal(t, "zh-TW", ai.InferScriptLanguage( + "語氣偏碎念、口語,會用「欸」「真的假的」這類台灣用語。", + "範例:今天又被會議吸走三小時,下班只想躺平。", + )) + require.Equal(t, "", ai.InferScriptLanguage("hi")) +} diff --git a/apps/backend/internal/module/ai/language.go b/apps/backend/internal/module/ai/language.go new file mode 100644 index 0000000..cc15d7e --- /dev/null +++ b/apps/backend/internal/module/ai/language.go @@ -0,0 +1,104 @@ +package ai + +import ( + "context" + "strings" + "unicode" +) + +type langCtxKey struct{} + +// WithResponseLanguage attaches UI/locale language to ctx for system prompt injection. +func WithResponseLanguage(ctx context.Context, lang string) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, langCtxKey{}, NormalizeResponseLanguage(lang)) +} + +// ResponseLanguageFrom returns zh-TW | en (default zh-TW). +func ResponseLanguageFrom(ctx context.Context) string { + if ctx == nil { + return "zh-TW" + } + if v, ok := ctx.Value(langCtxKey{}).(string); ok && v != "" { + return NormalizeResponseLanguage(v) + } + return "zh-TW" +} + +// NormalizeResponseLanguage maps preferred_language / Accept-Language → zh-TW | en. +func NormalizeResponseLanguage(raw string) string { + s := strings.ToLower(strings.TrimSpace(raw)) + if s == "" { + return "zh-TW" + } + if i := strings.IndexAny(s, ",;"); i >= 0 { + s = strings.TrimSpace(s[:i]) + } + s = strings.ReplaceAll(s, "_", "-") + if s == "en" || strings.HasPrefix(s, "en-") { + return "en" + } + // zh / zh-TW / zh-Hant / zh-CN → 產品主語系繁中 + return "zh-TW" +} + +// SystemPromptForLanguage is always injected as the first system message before user content. +// 只約束語言,不要綁「產品助手」人設——否則仿寫/產文會像客服而不是本人在發 Threads。 +func SystemPromptForLanguage(lang string) string { + switch NormalizeResponseLanguage(lang) { + case "en": + return strings.TrimSpace(` +Language rule (mandatory): +- Write in natural English unless the user explicitly asks for another language. +- For JSON/structured output, keep human-readable string values in English. +- Do not add meta commentary (e.g. "As an AI…") unless asked. +`) + default: + return strings.TrimSpace(` +語言規則(強制): +- 使用自然的「繁體中文(台灣用語)」,除非使用者明確要求其他語言。 +- 若要求 JSON/結構化輸出,給人看的字串也要用繁中。 +- 不要加「作為 AI…」「以下是分析…」這類 meta 前綴;直接完成任務。 +- 產文/仿寫時像真人滑手機在打字,不要像客服或文案機器人。 +`) + } +} + +// PickLanguage prefers member preferred_language, then Accept-Language header. +func PickLanguage(preferredLanguage, acceptLanguage string) string { + if strings.TrimSpace(preferredLanguage) != "" { + return NormalizeResponseLanguage(preferredLanguage) + } + return NormalizeResponseLanguage(acceptLanguage) +} + +// InferScriptLanguage 從文本統計漢字 vs 拉丁字母,推 zh-TW | en;信號不足回 ""。 +// 用於人設指紋/範例:英文人物應回英文,不該被 UI 語系或「繁中」硬規則蓋掉。 +func InferScriptLanguage(texts ...string) string { + var han, latin int + for _, t := range texts { + for _, r := range t { + switch { + case unicode.Is(unicode.Han, r): + han++ + case (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z'): + latin++ + } + } + } + total := han + latin + if total < 16 { + return "" + } + // 拉丁明顯佔優 → 英文 + if latin >= han*2 && latin >= 16 { + return "en" + } + // 漢字夠多 → 繁中 + if han >= 12 && han >= latin { + return "zh-TW" + } + return "" +} diff --git a/apps/backend/internal/module/ai/openai_compatible.go b/apps/backend/internal/module/ai/openai_compatible.go new file mode 100644 index 0000000..8950713 --- /dev/null +++ b/apps/backend/internal/module/ai/openai_compatible.go @@ -0,0 +1,283 @@ +package ai + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" +) + +// OpenAICompatible talks to OpenAI-style /models and /chat/completions. +// xAI: https://api.x.ai/v1 +// OpenCode Go: https://opencode.ai/zen/go/v1 +type OpenAICompatible struct { + ID string + BaseURL string + Client *http.Client +} + +func NewOpenAICompatible(id, baseURL string) *OpenAICompatible { + return &OpenAICompatible{ + ID: id, + BaseURL: strings.TrimRight(baseURL, "/"), + // Job worker 可能跑到 4 分鐘;HTTP 同步路徑靠 ctx deadline 提早取消 + Client: &http.Client{Timeout: 4 * time.Minute}, + } +} + +func (p *OpenAICompatible) Complete(ctx context.Context, apiKey, model, prompt string) (string, error) { + if strings.TrimSpace(apiKey) == "" { + return "", fmt.Errorf("missing API key for %s", p.ID) + } + if model == "" { + model = "default" + } + // 強制注入系統語言 prompt(依 ctx 語言;預設繁中) + sys := SystemPromptForLanguage(ResponseLanguageFrom(ctx)) + + // OpenCode Go(kimi/deepseek/glm)reasoning 常先燒 200~1000+ token; + // max_tokens 太小會 finish=length 且 content=null(xAI grok 較少踩雷) + // 靈感等路徑可用 WithMaxTokens 壓小以加速。 + maxTokens := MaxTokensFrom(ctx) + if maxTokens <= 0 { + maxTokens = 4096 + if p.ID == ProviderOpenCodeGo { + maxTokens = 8192 + } + } + temp := 0.9 + if t := TemperatureFrom(ctx); t > 0 { + temp = t + } + + text, meta, err := p.doChat(ctx, apiKey, model, sys, prompt, maxTokens, temp) + if err != nil { + return "", err + } + if text != "" { + return text, nil + } + // content 空 + length:reasoning 模型常把預算燒完;加大後再試一次 + if meta.FinishReason == "length" && p.ID == ProviderOpenCodeGo { + retry := 12288 + // 靈感等路徑有短預算:不要直接跳 12k(更慢),但要夠過 reasoning 門檻 + if cap := MaxTokensFrom(ctx); cap > 0 { + retry = cap * 3 + if retry < 4096 { + retry = 4096 + } + if retry > 8192 { + retry = 8192 + } + } + text2, _, err2 := p.doChat(ctx, apiKey, model, sys, prompt, retry, 0.7) + if err2 == nil && strings.TrimSpace(text2) != "" { + return strings.TrimSpace(text2), nil + } + } + return "", fmt.Errorf("%s returned empty content (finish=%s model=%s sample=%s)", + p.ID, meta.FinishReason, meta.Model, truncateRunes(meta.RawSnippet, 160)) +} + +func (p *OpenAICompatible) doChat(ctx context.Context, apiKey, model, sys, prompt string, maxTokens int, temperature float64) (string, chatExtractMeta, error) { + meta := chatExtractMeta{} + body := map[string]any{ + "model": model, + "max_tokens": maxTokens, + "temperature": temperature, + "messages": []map[string]string{ + {"role": "system", "content": sys}, + {"role": "user", "content": prompt}, + }, + } + raw, err := json.Marshal(body) + if err != nil { + return "", meta, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.BaseURL+"/chat/completions", strings.NewReader(string(raw))) + if err != nil { + return "", meta, err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + // OpenCode(Cloudflare)會擋 Go 預設 UA + req.Header.Set("User-Agent", "HarborDesk/1.0 (+threads-tool; OpenAI-compatible)") + res, err := p.Client.Do(req) + if err != nil { + return "", meta, err + } + defer res.Body.Close() + data, _ := io.ReadAll(io.LimitReader(res.Body, 2<<20)) + if res.StatusCode < 200 || res.StatusCode >= 300 { + return "", meta, fmt.Errorf("%s chat failed: HTTP %d %s", p.ID, res.StatusCode, truncateRunes(string(data), 240)) + } + text, meta, perr := extractChatContent(data) + if perr != nil { + return "", meta, fmt.Errorf("%s chat parse: %w", p.ID, perr) + } + return strings.TrimSpace(text), meta, nil +} + +type chatExtractMeta struct { + FinishReason string + Model string + RawSnippet string +} + +// extractChatContent 支援: +// - message.content 字串 +// - message.content 多段陣列 [{type,text}] +// - 部分供應商把正文放在 reasoning_content / output_text +func extractChatContent(data []byte) (string, chatExtractMeta, error) { + meta := chatExtractMeta{RawSnippet: string(data)} + var payload struct { + Model string `json:"model"` + Choices []struct { + FinishReason string `json:"finish_reason"` + Message struct { + // content 可能是 string 或 array + Content json.RawMessage `json:"content"` + ReasoningContent string `json:"reasoning_content"` + // OpenCode / 部分模型 + Reasoning string `json:"reasoning"` + OutputText string `json:"output_text"` + } `json:"message"` + // 舊式 text 欄位 + Text string `json:"text"` + } `json:"choices"` + // 部分 gateway 把錯誤包在 error 物件但仍 200 + Error *struct { + Message string `json:"message"` + Type string `json:"type"` + } `json:"error"` + } + if err := json.Unmarshal(data, &payload); err != nil { + return "", meta, err + } + meta.Model = strings.TrimSpace(payload.Model) + if payload.Error != nil && strings.TrimSpace(payload.Error.Message) != "" { + return "", meta, fmt.Errorf("%s", payload.Error.Message) + } + if len(payload.Choices) == 0 { + return "", meta, fmt.Errorf("no choices") + } + ch := payload.Choices[0] + meta.FinishReason = strings.TrimSpace(ch.FinishReason) + + if t := decodeMessageContent(ch.Message.Content); t != "" { + return t, meta, nil + } + if t := strings.TrimSpace(ch.Message.OutputText); t != "" { + return t, meta, nil + } + if t := strings.TrimSpace(ch.Text); t != "" { + return t, meta, nil + } + // 不把 reasoning / thinking 當使用者可見正文。 + // 舊邏輯會把「我們需要根據最近的對話…」這類思考過程寫進靈感聊天,看起來像壞掉。 + // content 空時回空字串,由呼叫端 retry 加大 max_tokens 或回錯誤。 + return "", meta, nil +} + +func decodeMessageContent(raw json.RawMessage) string { + if len(raw) == 0 || string(raw) == "null" { + return "" + } + // 字串 + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return strings.TrimSpace(s) + } + // 陣列:OpenAI/xAI 多段 content + var parts []struct { + Type string `json:"type"` + Text string `json:"text"` + // 有些用 output_text + OutputText string `json:"output_text"` + } + if err := json.Unmarshal(raw, &parts); err == nil { + var b strings.Builder + for _, p := range parts { + t := strings.TrimSpace(p.Text) + if t == "" { + t = strings.TrimSpace(p.OutputText) + } + if t == "" { + continue + } + if b.Len() > 0 { + b.WriteString("\n") + } + b.WriteString(t) + } + return strings.TrimSpace(b.String()) + } + // 單一物件 {type,text} + var one struct { + Type string `json:"type"` + Text string `json:"text"` + } + if err := json.Unmarshal(raw, &one); err == nil && strings.TrimSpace(one.Text) != "" { + return strings.TrimSpace(one.Text) + } + return "" +} + +func (p *OpenAICompatible) ListModels(ctx context.Context, apiKey string) ([]string, error) { + if strings.TrimSpace(apiKey) == "" { + return nil, fmt.Errorf("missing API key for %s", p.ID) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.BaseURL+"/models", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "HarborDesk/1.0 (+threads-tool; OpenAI-compatible)") + res, err := p.Client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + data, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) + if err != nil { + return nil, err + } + if res.StatusCode < 200 || res.StatusCode >= 300 { + return nil, fmt.Errorf("%s models failed: HTTP %d %s", p.ID, res.StatusCode, truncateRunes(string(data), 200)) + } + var payload struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(data, &payload); err != nil { + return nil, fmt.Errorf("%s models parse: %w", p.ID, err) + } + models := make([]string, 0, len(payload.Data)) + for _, item := range payload.Data { + id := strings.TrimSpace(item.ID) + if id == "" { + continue + } + models = append(models, id) + } + sort.Strings(models) + if len(models) == 0 { + return nil, fmt.Errorf("%s returned empty model list", p.ID) + } + return models, nil +} + +func truncateRunes(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" +} diff --git a/apps/backend/internal/module/ai/openai_compatible_test.go b/apps/backend/internal/module/ai/openai_compatible_test.go new file mode 100644 index 0000000..d90dffe --- /dev/null +++ b/apps/backend/internal/module/ai/openai_compatible_test.go @@ -0,0 +1,40 @@ +package ai + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestExtractChatContent_String(t *testing.T) { + raw := []byte(`{"model":"grok-3","choices":[{"finish_reason":"stop","message":{"content":"你好世界"}}]}`) + text, meta, err := extractChatContent(raw) + require.NoError(t, err) + require.Equal(t, "你好世界", text) + require.Equal(t, "stop", meta.FinishReason) +} + +func TestExtractChatContent_ArrayParts(t *testing.T) { + raw := []byte(`{"choices":[{"message":{"content":[{"type":"text","text":"第一段"},{"type":"text","text":"第二段"}]}}]}`) + text, _, err := extractChatContent(raw) + require.NoError(t, err) + require.Contains(t, text, "第一段") + require.Contains(t, text, "第二段") +} + +func TestExtractChatContent_Empty(t *testing.T) { + raw := []byte(`{"choices":[{"finish_reason":"length","message":{"content":""}}]}`) + text, meta, err := extractChatContent(raw) + require.NoError(t, err) + require.Equal(t, "", text) + require.Equal(t, "length", meta.FinishReason) +} + +func TestExtractChatContent_IgnoresReasoningOnly(t *testing.T) { + // deepseek / kimi 思考模型常只回 reasoning_content;不可當聊天回覆 + raw := []byte(`{"choices":[{"finish_reason":"length","message":{"content":null,"reasoning_content":"我們需要根據最近的對話來回應…"}}]}`) + text, meta, err := extractChatContent(raw) + require.NoError(t, err) + require.Equal(t, "", text) + require.Equal(t, "length", meta.FinishReason) +} diff --git a/apps/backend/internal/module/ai/options.go b/apps/backend/internal/module/ai/options.go new file mode 100644 index 0000000..4df3e93 --- /dev/null +++ b/apps/backend/internal/module/ai/options.go @@ -0,0 +1,44 @@ +package ai + +import "context" + +type ctxKey int + +const ( + ctxMaxTokens ctxKey = iota + 1 + ctxTemperature +) + +// WithMaxTokens 限制 completion 輸出上限(靈感短聊用小值可明顯加速)。 +func WithMaxTokens(ctx context.Context, n int) context.Context { + if n <= 0 { + return ctx + } + return context.WithValue(ctx, ctxMaxTokens, n) +} + +// MaxTokensFrom returns 0 if unset. +func MaxTokensFrom(ctx context.Context) int { + if ctx == nil { + return 0 + } + v, _ := ctx.Value(ctxMaxTokens).(int) + return v +} + +// WithTemperature sets sampling temperature when > 0. +func WithTemperature(ctx context.Context, t float64) context.Context { + if t <= 0 { + return ctx + } + return context.WithValue(ctx, ctxTemperature, t) +} + +// TemperatureFrom returns 0 if unset (caller uses default). +func TemperatureFrom(ctx context.Context) float64 { + if ctx == nil { + return 0 + } + v, _ := ctx.Value(ctxTemperature).(float64) + return v +} diff --git a/apps/backend/internal/module/ai/registry.go b/apps/backend/internal/module/ai/registry.go new file mode 100644 index 0000000..17c78ed --- /dev/null +++ b/apps/backend/internal/module/ai/registry.go @@ -0,0 +1,151 @@ +package ai + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "sync" + "time" +) + +const ( + ProviderXAI = "xai" + ProviderOpenCodeGo = "opencode-go" + + // ModelsCacheTTL — list models cache window + ModelsCacheTTL = 5 * time.Minute +) + +// Registry holds provider clients + models cache. +type Registry struct { + clients map[string]Client + mu sync.Mutex + cache map[string]modelsCacheEntry +} + +type modelsCacheEntry struct { + Models []string + ExpiresAt time.Time +} + +func NewRegistry() *Registry { + return &Registry{ + clients: map[string]Client{ + ProviderXAI: NewOpenAICompatible(ProviderXAI, "https://api.x.ai/v1"), + ProviderOpenCodeGo: NewOpenAICompatible(ProviderOpenCodeGo, "https://opencode.ai/zen/go/v1"), + }, + cache: map[string]modelsCacheEntry{}, + } +} + +func NormalizeProvider(p string) string { + switch strings.ToLower(strings.TrimSpace(p)) { + case "opencode-go", "opencode", "opencode_go": + return ProviderOpenCodeGo + case "xai", "grok", "": + return ProviderXAI + default: + return strings.ToLower(strings.TrimSpace(p)) + } +} + +func (r *Registry) Client(provider string) (Client, error) { + id := NormalizeProvider(provider) + c, ok := r.clients[id] + if !ok { + return nil, fmt.Errorf("unsupported provider: %s (use xai or opencode-go)", provider) + } + return c, nil +} + +// ListModelsCached fetches models via provider API; caches 5 minutes per provider+key. +// fromCache indicates whether result was served from cache. +func (r *Registry) ListModelsCached(ctx context.Context, provider, apiKey string) (models []string, fromCache bool, err error) { + id := NormalizeProvider(provider) + key := cacheKey(id, apiKey) + + r.mu.Lock() + if ent, ok := r.cache[key]; ok && time.Now().Before(ent.ExpiresAt) { + out := append([]string(nil), ent.Models...) + r.mu.Unlock() + return out, true, nil + } + r.mu.Unlock() + + c, err := r.Client(id) + if err != nil { + return nil, false, err + } + list, err := c.ListModels(ctx, apiKey) + if err != nil { + return nil, false, err + } + + r.mu.Lock() + r.cache[key] = modelsCacheEntry{ + Models: append([]string(nil), list...), + ExpiresAt: time.Now().Add(ModelsCacheTTL), + } + r.mu.Unlock() + return list, false, nil +} + +func cacheKey(provider, apiKey string) string { + sum := sha256.Sum256([]byte(provider + "\n" + apiKey)) + return hex.EncodeToString(sum[:16]) +} + +// FallbackModels when no key / provider error (never empty for UI). +// OpenCode Go 模型 id 見 https://opencode.ai/docs/go/(不是 OpenAI gpt-*) +func FallbackModels(provider string) []string { + switch NormalizeProvider(provider) { + case ProviderOpenCodeGo: + return []string{ + "kimi-k2.6", + "glm-5.1", + "glm-5.2", + "deepseek-v4-flash", + "deepseek-v4-pro", + "qwen3.7-plus", + "minimax-m2.7", + "mimo-v2.5", + } + default: + return []string{"grok-3", "grok-3-mini", "grok-2"} + } +} + +// DefaultModel for provider when member settings model empty / invalid. +func DefaultModel(provider string) string { + list := FallbackModels(provider) + if len(list) == 0 { + return "grok-3" + } + return list[0] +} + +// SanitizeModel remaps invalid model ids for a provider (e.g. gpt-4o-mini on opencode-go). +func SanitizeModel(provider, model string) string { + provider = NormalizeProvider(provider) + model = strings.TrimSpace(model) + if model == "" { + return DefaultModel(provider) + } + if provider != ProviderOpenCodeGo { + // xAI:允許 grok-*;若誤存 opencode 模型名,仍交由 API 驗證 + return model + } + // OpenCode Go:常見錯誤 — 從 xAI / OpenAI 帶過來的模型名 + lower := strings.ToLower(model) + if strings.HasPrefix(lower, "gpt-") || + strings.HasPrefix(lower, "o1") || + strings.HasPrefix(lower, "o3") || + strings.HasPrefix(lower, "grok") || + strings.HasPrefix(lower, "claude") || + strings.HasPrefix(lower, "gemini") { + return DefaultModel(provider) + } + return model +} diff --git a/apps/backend/internal/module/ai/stream.go b/apps/backend/internal/module/ai/stream.go new file mode 100644 index 0000000..19a2a9e --- /dev/null +++ b/apps/backend/internal/module/ai/stream.go @@ -0,0 +1,151 @@ +package ai + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// CompleteStream streams chat completion deltas via onDelta; returns full text. +// onDelta may be called many times; return error to abort. +func (p *OpenAICompatible) CompleteStream(ctx context.Context, apiKey, model, prompt string, onDelta func(chunk string) error) (string, error) { + if strings.TrimSpace(apiKey) == "" { + return "", fmt.Errorf("missing API key for %s", p.ID) + } + if model == "" { + model = "default" + } + sys := SystemPromptForLanguage(ResponseLanguageFrom(ctx)) + maxTokens := MaxTokensFrom(ctx) + if maxTokens <= 0 { + maxTokens = 2048 + if p.ID == ProviderOpenCodeGo { + // 預設 stream 預算;靈感會再透過 WithMaxTokens 壓更小 + maxTokens = 1536 + } + } + temp := 0.9 + if t := TemperatureFrom(ctx); t > 0 { + temp = t + } + + body := map[string]any{ + "model": model, + "max_tokens": maxTokens, + "temperature": temp, + "stream": true, + "messages": []map[string]string{ + {"role": "system", "content": sys}, + {"role": "user", "content": prompt}, + }, + } + raw, err := json.Marshal(body) + if err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.BaseURL+"/chat/completions", bytes.NewReader(raw)) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("User-Agent", "HarborDesk/1.0 (+threads-tool; OpenAI-compatible)") + + res, err := p.Client.Do(req) + if err != nil { + return "", err + } + defer res.Body.Close() + if res.StatusCode < 200 || res.StatusCode >= 300 { + data, _ := io.ReadAll(io.LimitReader(res.Body, 64<<10)) + return "", fmt.Errorf("%s stream failed: HTTP %d %s", p.ID, res.StatusCode, truncateRunes(string(data), 240)) + } + + var full strings.Builder + sc := bufio.NewScanner(res.Body) + // SSE 行可能較長 + buf := make([]byte, 0, 64*1024) + sc.Buffer(buf, 1024*1024) + + for sc.Scan() { + line := sc.Text() + if line == "" { + continue + } + if !strings.HasPrefix(line, "data:") { + continue + } + payload := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if payload == "[DONE]" { + break + } + chunk, ok := extractStreamDelta(payload) + if !ok || chunk == "" { + continue + } + full.WriteString(chunk) + if onDelta != nil { + if err := onDelta(chunk); err != nil { + return full.String(), err + } + } + } + if err := sc.Err(); err != nil && ctx.Err() == nil { + return full.String(), err + } + text := strings.TrimSpace(full.String()) + if text == "" { + // 部分供應商 stream 只吐 reasoning、content 在非 stream 才有; + // 或 max_tokens 太小 content=null。降級 Complete(內含 length 再試)。 + return p.Complete(ctx, apiKey, model, prompt) + } + return text, nil +} + +func extractStreamDelta(payload string) (string, bool) { + var obj struct { + Choices []struct { + Delta struct { + Content json.RawMessage `json:"content"` + ReasoningContent string `json:"reasoning_content"` + } `json:"delta"` + // 少數 gateway 用 message + Message struct { + Content json.RawMessage `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err := json.Unmarshal([]byte(payload), &obj); err != nil { + return "", false + } + if len(obj.Choices) == 0 { + return "", false + } + d := obj.Choices[0].Delta + if t := decodeMessageContent(d.Content); t != "" { + return t, true + } + // 不把 reasoning 當正文 stream 出去(避免滿屏思考) + if t := decodeMessageContent(obj.Choices[0].Message.Content); t != "" { + return t, true + } + return "", false +} + +// CompleteStream on FakeClient — 一次吐出(測試) +func (f *FakeClient) CompleteStream(ctx context.Context, apiKey, model, prompt string, onDelta func(string) error) (string, error) { + text, err := f.Complete(ctx, apiKey, model, prompt) + if err != nil { + return "", err + } + if onDelta != nil { + _ = onDelta(text) + } + return text, nil +} diff --git a/apps/backend/internal/module/appnotif/domain/notif.go b/apps/backend/internal/module/appnotif/domain/notif.go new file mode 100644 index 0000000..8d79d11 --- /dev/null +++ b/apps/backend/internal/module/appnotif/domain/notif.go @@ -0,0 +1,46 @@ +package domain + +import ( + "context" + "errors" + "time" +) + +const ( + KindJob = "job" + KindSystem = "system" + RefJob = "job" + RefNone = "none" +) + +var ( + ErrNotFound = errors.New("notification not found") + ErrForbidden = errors.New("notification access denied") +) + +type Notification struct { + ID string `bson:"_id" json:"id"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + Title string `bson:"title" json:"title"` + Body string `bson:"body" json:"body"` + Kind string `bson:"kind" json:"kind"` + RefType string `bson:"ref_type" json:"ref_type"` + RefID string `bson:"ref_id,omitempty" json:"ref_id,omitempty"` + ReadAt int64 `bson:"read_at,omitempty" json:"read_at,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` +} + +func NowNano() int64 { return time.Now().UTC().UnixNano() } + +type Repository interface { + Insert(ctx context.Context, n *Notification) error + ListByOwner(ctx context.Context, ownerUID int64) ([]*Notification, error) + UnreadCount(ctx context.Context, ownerUID int64) (int64, error) + MarkRead(ctx context.Context, ownerUID int64, id string) error + MarkAllRead(ctx context.Context, ownerUID int64) error + FindByID(ctx context.Context, id string) (*Notification, error) + // FindLatestByJobRef — 同一 job 的最新通知(用於進度 upsert) + FindLatestByJobRef(ctx context.Context, ownerUID int64, jobID string) (*Notification, error) + // Replace full document + Replace(ctx context.Context, n *Notification) error +} diff --git a/apps/backend/internal/module/appnotif/repository/memory.go b/apps/backend/internal/module/appnotif/repository/memory.go new file mode 100644 index 0000000..0e1731b --- /dev/null +++ b/apps/backend/internal/module/appnotif/repository/memory.go @@ -0,0 +1,124 @@ +package repository + +import ( + "context" + "sync" + + "apps/backend/internal/module/appnotif/domain" +) + +type MemoryStore struct { + mu sync.Mutex + byID map[string]*domain.Notification +} + +func NewMemory() *MemoryStore { + return &MemoryStore{byID: map[string]*domain.Notification{}} +} + +func (s *MemoryStore) Insert(_ context.Context, n *domain.Notification) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *n + s.byID[n.ID] = &cp + return nil +} + +func (s *MemoryStore) ListByOwner(_ context.Context, ownerUID int64) ([]*domain.Notification, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*domain.Notification + for _, n := range s.byID { + if n.OwnerUID == ownerUID { + cp := *n + out = append(out, &cp) + } + } + return out, nil +} + +func (s *MemoryStore) UnreadCount(_ context.Context, ownerUID int64) (int64, error) { + s.mu.Lock() + defer s.mu.Unlock() + var c int64 + for _, n := range s.byID { + if n.OwnerUID == ownerUID && n.ReadAt == 0 { + c++ + } + } + return c, nil +} + +func (s *MemoryStore) FindByID(_ context.Context, id string) (*domain.Notification, error) { + s.mu.Lock() + defer s.mu.Unlock() + n, ok := s.byID[id] + if !ok { + return nil, domain.ErrNotFound + } + cp := *n + return &cp, nil +} + +func (s *MemoryStore) MarkRead(_ context.Context, ownerUID int64, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + n, ok := s.byID[id] + if !ok { + return domain.ErrNotFound + } + if n.OwnerUID != ownerUID { + return domain.ErrForbidden + } + if n.ReadAt == 0 { + n.ReadAt = domain.NowNano() + } + return nil +} + +func (s *MemoryStore) MarkAllRead(_ context.Context, ownerUID int64) error { + s.mu.Lock() + defer s.mu.Unlock() + now := domain.NowNano() + for _, n := range s.byID { + if n.OwnerUID == ownerUID && n.ReadAt == 0 { + n.ReadAt = now + } + } + return nil +} + +func (s *MemoryStore) FindLatestByJobRef(_ context.Context, ownerUID int64, jobID string) (*domain.Notification, error) { + s.mu.Lock() + defer s.mu.Unlock() + var best *domain.Notification + for _, n := range s.byID { + if n.OwnerUID != ownerUID || n.Kind != domain.KindJob || n.RefType != domain.RefJob || n.RefID != jobID { + continue + } + if best == nil || n.CreatedAt > best.CreatedAt { + cp := *n + best = &cp + } + } + if best == nil { + return nil, domain.ErrNotFound + } + return best, nil +} + +func (s *MemoryStore) Replace(_ context.Context, n *domain.Notification) error { + s.mu.Lock() + defer s.mu.Unlock() + if n == nil || n.ID == "" { + return domain.ErrNotFound + } + if _, ok := s.byID[n.ID]; !ok { + return domain.ErrNotFound + } + cp := *n + s.byID[n.ID] = &cp + return nil +} + +var _ domain.Repository = (*MemoryStore)(nil) diff --git a/apps/backend/internal/module/appnotif/repository/mongo.go b/apps/backend/internal/module/appnotif/repository/mongo.go new file mode 100644 index 0000000..06cb77f --- /dev/null +++ b/apps/backend/internal/module/appnotif/repository/mongo.go @@ -0,0 +1,127 @@ +package repository + +import ( + "context" + + libmongo "apps/backend/internal/lib/mongo" + "apps/backend/internal/module/appnotif/domain" + + "github.com/zeromicro/go-zero/core/stores/mon" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const col = "notifications" + +type MonStore struct { + n *mon.Model +} + +func NewMonStore(uri, database string) *MonStore { + uri = libmongo.MustMongoURI(uri) + return &MonStore{n: mon.MustNewModel(uri, database, col)} +} + +func (s *MonStore) Insert(ctx context.Context, n *domain.Notification) error { + _, err := s.n.InsertOne(ctx, n) + return err +} + +func (s *MonStore) ListByOwner(ctx context.Context, ownerUID int64) ([]*domain.Notification, error) { + var list []*domain.Notification + err := s.n.Find(ctx, &list, bson.M{"owner_uid": ownerUID}, + options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})) + return list, err +} + +func (s *MonStore) UnreadCount(ctx context.Context, ownerUID int64) (int64, error) { + var list2 []*domain.Notification + if err := s.n.Find(ctx, &list2, bson.M{"owner_uid": ownerUID}); err != nil { + return 0, err + } + var c int64 + for _, n := range list2 { + if n.ReadAt == 0 { + c++ + } + } + return c, nil +} + +func (s *MonStore) FindByID(ctx context.Context, id string) (*domain.Notification, error) { + var n domain.Notification + err := s.n.FindOne(ctx, &n, bson.M{"_id": id}) + if err != nil { + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + return nil, err + } + return &n, nil +} + +func (s *MonStore) MarkRead(ctx context.Context, ownerUID int64, id string) error { + n, err := s.FindByID(ctx, id) + if err != nil { + return err + } + if n.OwnerUID != ownerUID { + return domain.ErrForbidden + } + if n.ReadAt == 0 { + n.ReadAt = domain.NowNano() + _, err = s.n.ReplaceOne(ctx, bson.M{"_id": id}, n) + } + return err +} + +func (s *MonStore) MarkAllRead(ctx context.Context, ownerUID int64) error { + list, err := s.ListByOwner(ctx, ownerUID) + if err != nil { + return err + } + now := domain.NowNano() + for _, n := range list { + if n.ReadAt == 0 { + n.ReadAt = now + _, _ = s.n.ReplaceOne(ctx, bson.M{"_id": n.ID}, n) + } + } + return nil +} + +func (s *MonStore) FindLatestByJobRef(ctx context.Context, ownerUID int64, jobID string) (*domain.Notification, error) { + if jobID == "" { + return nil, domain.ErrNotFound + } + var list []*domain.Notification + err := s.n.Find(ctx, &list, bson.M{ + "owner_uid": ownerUID, + "kind": domain.KindJob, + "ref_type": domain.RefJob, + "ref_id": jobID, + }, 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) Replace(ctx context.Context, n *domain.Notification) error { + if n == nil || n.ID == "" { + return domain.ErrNotFound + } + res, err := s.n.ReplaceOne(ctx, bson.M{"_id": n.ID}, n) + if err != nil { + return err + } + if res.MatchedCount == 0 { + return domain.ErrNotFound + } + return nil +} + +var _ domain.Repository = (*MonStore)(nil) diff --git a/apps/backend/internal/module/appnotif/usecase/service.go b/apps/backend/internal/module/appnotif/usecase/service.go new file mode 100644 index 0000000..9d5eeb4 --- /dev/null +++ b/apps/backend/internal/module/appnotif/usecase/service.go @@ -0,0 +1,123 @@ +package usecase + +import ( + "context" + "fmt" + "strings" + + "apps/backend/internal/module/appnotif/domain" + + "github.com/google/uuid" +) + +type Service struct { + Repo domain.Repository +} + +func New(repo domain.Repository) *Service { + return &Service{Repo: repo} +} + +func (s *Service) List(ctx context.Context, ownerUID int64) ([]*domain.Notification, error) { + return s.Repo.ListByOwner(ctx, ownerUID) +} + +func (s *Service) UnreadCount(ctx context.Context, ownerUID int64) (int64, error) { + return s.Repo.UnreadCount(ctx, ownerUID) +} + +func (s *Service) MarkRead(ctx context.Context, ownerUID int64, id string) error { + return s.Repo.MarkRead(ctx, ownerUID, id) +} + +func (s *Service) MarkAllRead(ctx context.Context, ownerUID int64) error { + return s.Repo.MarkAllRead(ctx, ownerUID) +} + +// NotifyJobTerminal — 相容舊介面;改走 NotifyJobState(upsert 同一 job 一則) +func (s *Service) NotifyJobTerminal(ctx context.Context, ownerUID int64, jobID, status, summary string) error { + return s.NotifyJobState(ctx, ownerUID, jobID, "", status, summary, 100) +} + +// NotifyJobState — 同一 job 只保留一則通知,進度更新時改寫 title/body(鈴鐺即時有進度) +func (s *Service) NotifyJobState(ctx context.Context, ownerUID int64, jobID, templateType, status, summary string, percent int) error { + if ownerUID <= 0 || jobID == "" { + return nil + } + title := jobNotifyTitle(templateType, status, percent) + body := strings.TrimSpace(summary) + if body == "" { + body = fmt.Sprintf("%s · %s", title, jobID) + } + // 進度摘要裡若已含 % 就不再重複;否則附上 percent + if percent > 0 && percent < 100 && !strings.Contains(body, "%") { + body = fmt.Sprintf("%s · %d%%", body, percent) + } + + existing, err := s.Repo.FindLatestByJobRef(ctx, ownerUID, jobID) + now := domain.NowNano() + if err == nil && existing != nil { + existing.Title = title + existing.Body = body + existing.Kind = domain.KindJob + existing.RefType = domain.RefJob + existing.RefID = jobID + // 每次有進度/終態都標未讀,鈴鐺才會跳 + existing.ReadAt = 0 + // 用 created_at 排序時把最新活動頂到前面 + existing.CreatedAt = now + return s.Repo.Replace(ctx, existing) + } + + n := &domain.Notification{ + ID: uuid.NewString(), OwnerUID: ownerUID, + Title: title, Body: body, Kind: domain.KindJob, + RefType: domain.RefJob, RefID: jobID, + CreatedAt: now, + } + return s.Repo.Insert(ctx, n) +} + +func jobNotifyTitle(templateType, status string, percent int) string { + name := templateLabelZH(templateType) + switch status { + case "succeeded": + return name + " · 已完成" + case "failed": + return name + " · 失敗" + case "cancelled": + return name + " · 已取消" + case "running": + if percent > 0 { + return fmt.Sprintf("%s · 進行中 %d%%", name, percent) + } + return name + " · 進行中" + case "queued", "pending": + return name + " · 已排程" + default: + if percent > 0 { + return fmt.Sprintf("%s · %d%%", name, percent) + } + return name + " · 更新" + } +} + +func templateLabelZH(templateType string) string { + switch templateType { + case "persona_analyze_account": + return "人設分析(爬公開貼文)" + case "persona_analyze_text": + return "人設分析(文字)" + case "compose_mimic": + return "仿寫貼文" + case "threads_token_renew": + return "Threads Token 延長" + case "demo": + return "Demo 測試任務" + default: + if strings.TrimSpace(templateType) == "" { + return "背景任務" + } + return "背景任務" + } +} diff --git a/apps/backend/internal/module/filestorage/s3store/store.go b/apps/backend/internal/module/filestorage/s3store/store.go index 9e08dab..b1ee7a9 100644 --- a/apps/backend/internal/module/filestorage/s3store/store.go +++ b/apps/backend/internal/module/filestorage/s3store/store.go @@ -55,6 +55,12 @@ func New(cfg Config) (*Store, error) { if err := s.ensureBucket(context.Background()); err != nil { logx.Errorf("s3store ensure bucket %s: %v (will retry on upload)", cfg.Bucket, err) } + // Meta Threads 發圖會從公網 cURL image_url;bucket 必須允許匿名 GetObject + if err := s.ensurePublicRead(context.Background()); err != nil { + logx.Errorf("s3store public-read policy %s: %v (Meta may fail to fetch images)", cfg.Bucket, err) + } else { + logx.Infof("s3store bucket %s public GetObject enabled (for Threads image_url)", cfg.Bucket) + } return s, nil } @@ -81,10 +87,31 @@ func (s *Store) ensureBucket(ctx context.Context) error { _ = exists2 return err } - // public-read policy optional — MinIO often uses public bucket or public base URL proxy return nil } +// ensurePublicRead — 允許匿名讀物件(nginx /haixun-assets → MinIO 才能給 Meta 抓圖) +func (s *Store) ensurePublicRead(ctx context.Context) error { + // MinIO / S3 compatible anonymous GetObject + policy := fmt.Sprintf(`{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "PublicReadGetObject", + "Effect": "Allow", + "Principal": {"AWS": ["*"]}, + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::%s/*"] + } + ] +}`, s.cfg.Bucket) + _, err := s.client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{ + Bucket: aws.String(s.cfg.Bucket), + Policy: aws.String(policy), + }) + return err +} + func (s *Store) Upload(ctx context.Context, key string, data []byte, contentType string) (string, error) { key = strings.TrimPrefix(key, "/") if contentType == "" { diff --git a/apps/backend/internal/module/inspire/domain/domain.go b/apps/backend/internal/module/inspire/domain/domain.go new file mode 100644 index 0000000..9727f6d --- /dev/null +++ b/apps/backend/internal/module/inspire/domain/domain.go @@ -0,0 +1,96 @@ +package domain + +import ( + "errors" + "time" +) + +var ( + ErrNotFound = errors.New("inspire not found") + ErrForbidden = errors.New("inspire forbidden") + ErrValidation = errors.New("inspire validation") + ErrRemoved = errors.New("legacy inspire API removed") +) + +func NowNano() int64 { return time.Now().UTC().UnixNano() } + +type TrendItem struct { + ID string `bson:"_id" json:"id"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` // 0 = global seed + Kind string `bson:"kind" json:"kind"` + Label string `bson:"label" json:"label"` + Summary string `bson:"summary" json:"summary"` + Heat int `bson:"heat" json:"heat"` + Keywords []string `bson:"keywords" json:"keywords"` + Samples []string `bson:"samples" json:"samples"` + SourceLabel string `bson:"source_label" json:"source_label"` + ObservedAt int64 `bson:"observed_at" json:"observed_at"` +} + +type Element 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"` + Body string `bson:"body" json:"body"` + RefID string `bson:"ref_id,omitempty" json:"ref_id,omitempty"` + Reusable bool `bson:"reusable" json:"reusable"` + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` +} + +type ChatMessage struct { + ID string `bson:"id" json:"id"` + Role string `bson:"role" json:"role"` // user|assistant|system + Text string `bson:"text" json:"text"` + Draft *ChatDraft `bson:"draft,omitempty" json:"draft,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` +} + +type ChatDraft struct { + Title string `bson:"title,omitempty" json:"title,omitempty"` + Body string `bson:"body" json:"body"` +} + +// Session — 一位會員可有多則靈感對話;active 由 Repository 的 active 指標維護。 +type Session struct { + ID string `bson:"_id" json:"id"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + Title string `bson:"title,omitempty" json:"title,omitempty"` + Messages []ChatMessage `bson:"messages" json:"messages"` + PinnedElementIDs []string `bson:"pinned_element_ids" json:"pinned_element_ids"` + // ContextSummary — 舊對話摺疊摘要;AI 無狀態,不能只靠「同一個 session」記住 + // 每 inspireSummarizeEvery 則訊息才重算,平常只帶摘要 + 最近幾則 + ContextSummary string `bson:"context_summary,omitempty" json:"context_summary,omitempty"` + SummaryMsgCount int `bson:"summary_msg_count,omitempty" json:"summary_msg_count,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` +} + +// SessionSummary — 列表用輕量摘要(不含完整 messages)。 +type SessionSummary struct { + ID string `json:"id"` + Title string `json:"title"` + Preview string `json:"preview"` + MessageCount int `json:"message_count"` + UpdatedAt int64 `json:"updated_at"` + CreatedAt int64 `json:"created_at"` + Active bool `json:"active"` +} + +type ResearchHit struct { + ID string `json:"id"` + Title string `json:"title"` + Snippet string `json:"snippet"` + URL string `json:"url"` + Summary string `json:"summary,omitempty"` + LearnPoints []string `json:"learn_points,omitempty"` + ReplyHooks []string `json:"reply_hooks,omitempty"` + SourceLabel string `json:"source_label,omitempty"` +} + +type GeneratedImage struct { + ID string `json:"id"` + URL string `json:"url"` + Prompt string `json:"prompt"` +} diff --git a/apps/backend/internal/module/inspire/domain/repository.go b/apps/backend/internal/module/inspire/domain/repository.go new file mode 100644 index 0000000..7edbdea --- /dev/null +++ b/apps/backend/internal/module/inspire/domain/repository.go @@ -0,0 +1,23 @@ +package domain + +import "context" + +type Repository interface { + // Trends are global-ish; refresh rewrites owner's view cache + ListTrends(ctx context.Context, ownerUID int64) ([]*TrendItem, error) + SaveTrends(ctx context.Context, ownerUID int64, items []*TrendItem) error + + ListElements(ctx context.Context, ownerUID int64) ([]*Element, error) + SaveElement(ctx context.Context, e *Element) error + DeleteElement(ctx context.Context, id string) error + GetElement(ctx context.Context, id string) (*Element, error) + + // Sessions — 多對話;GetSession 回 active(無則建新) + ListSessions(ctx context.Context, ownerUID int64) ([]*Session, error) + GetSession(ctx context.Context, ownerUID int64) (*Session, error) // active + GetSessionByID(ctx context.Context, ownerUID int64, id string) (*Session, error) + SaveSession(ctx context.Context, s *Session) error + DeleteSessionByID(ctx context.Context, ownerUID int64, id string) error + GetActiveSessionID(ctx context.Context, ownerUID int64) (string, error) + SetActiveSessionID(ctx context.Context, ownerUID int64, sessionID string) error +} diff --git a/apps/backend/internal/module/inspire/repository/memory.go b/apps/backend/internal/module/inspire/repository/memory.go new file mode 100644 index 0000000..0b639a0 --- /dev/null +++ b/apps/backend/internal/module/inspire/repository/memory.go @@ -0,0 +1,217 @@ +package repository + +import ( + "context" + "sync" + + "apps/backend/internal/module/inspire/domain" +) + +type MemoryStore struct { + mu sync.Mutex + trends map[int64][]*domain.TrendItem + elements map[string]*domain.Element + sessions map[string]*domain.Session // by session id + active map[int64]string // owner -> session id +} + +func NewMemory() *MemoryStore { + return &MemoryStore{ + trends: map[int64][]*domain.TrendItem{}, + elements: map[string]*domain.Element{}, + sessions: map[string]*domain.Session{}, + active: map[int64]string{}, + } +} + +func (s *MemoryStore) ListTrends(_ context.Context, ownerUID int64) ([]*domain.TrendItem, error) { + s.mu.Lock() + defer s.mu.Unlock() + list := s.trends[ownerUID] + out := make([]*domain.TrendItem, 0, len(list)) + for _, t := range list { + cp := *t + if t.Keywords != nil { + cp.Keywords = append([]string(nil), t.Keywords...) + } + if t.Samples != nil { + cp.Samples = append([]string(nil), t.Samples...) + } + out = append(out, &cp) + } + return out, nil +} + +func (s *MemoryStore) SaveTrends(_ context.Context, ownerUID int64, items []*domain.TrendItem) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := make([]*domain.TrendItem, 0, len(items)) + for _, t := range items { + x := *t + if t.Keywords != nil { + x.Keywords = append([]string(nil), t.Keywords...) + } + if t.Samples != nil { + x.Samples = append([]string(nil), t.Samples...) + } + cp = append(cp, &x) + } + s.trends[ownerUID] = cp + return nil +} + +func (s *MemoryStore) ListElements(_ context.Context, ownerUID int64) ([]*domain.Element, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*domain.Element + for _, e := range s.elements { + if e.OwnerUID == ownerUID { + cp := *e + out = append(out, &cp) + } + } + return out, nil +} + +func (s *MemoryStore) SaveElement(_ context.Context, e *domain.Element) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *e + s.elements[e.ID] = &cp + return nil +} + +func (s *MemoryStore) DeleteElement(_ context.Context, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.elements[id]; !ok { + return domain.ErrNotFound + } + delete(s.elements, id) + return nil +} + +func (s *MemoryStore) GetElement(_ context.Context, id string) (*domain.Element, error) { + s.mu.Lock() + defer s.mu.Unlock() + e, ok := s.elements[id] + if !ok { + return nil, domain.ErrNotFound + } + cp := *e + return &cp, nil +} + +func (s *MemoryStore) ListSessions(_ context.Context, ownerUID int64) ([]*domain.Session, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*domain.Session + for _, sess := range s.sessions { + if sess.OwnerUID == ownerUID { + out = append(out, copySession(sess)) + } + } + // sort by updated_at desc + for i := 0; i < len(out); i++ { + for j := i + 1; j < len(out); j++ { + if out[j].UpdatedAt > out[i].UpdatedAt { + out[i], out[j] = out[j], out[i] + } + } + } + if len(out) > 50 { + out = out[:50] + } + return out, nil +} + +func (s *MemoryStore) GetSession(_ context.Context, ownerUID int64) (*domain.Session, error) { + s.mu.Lock() + defer s.mu.Unlock() + if id, ok := s.active[ownerUID]; ok { + if sess, ok2 := s.sessions[id]; ok2 && sess.OwnerUID == ownerUID { + return copySession(sess), nil + } + } + var best *domain.Session + for _, sess := range s.sessions { + if sess.OwnerUID != ownerUID { + continue + } + if best == nil || sess.UpdatedAt > best.UpdatedAt { + best = sess + } + } + if best == nil { + return nil, domain.ErrNotFound + } + s.active[ownerUID] = best.ID + return copySession(best), nil +} + +func (s *MemoryStore) GetSessionByID(_ context.Context, ownerUID int64, id string) (*domain.Session, error) { + s.mu.Lock() + defer s.mu.Unlock() + sess, ok := s.sessions[id] + if !ok || sess.OwnerUID != ownerUID { + return nil, domain.ErrNotFound + } + return copySession(sess), nil +} + +func (s *MemoryStore) SaveSession(_ context.Context, sess *domain.Session) error { + s.mu.Lock() + defer s.mu.Unlock() + s.sessions[sess.ID] = copySession(sess) + return nil +} + +func (s *MemoryStore) DeleteSessionByID(_ context.Context, ownerUID int64, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + sess, ok := s.sessions[id] + if !ok || sess.OwnerUID != ownerUID { + return domain.ErrNotFound + } + delete(s.sessions, id) + if s.active[ownerUID] == id { + delete(s.active, ownerUID) + } + return nil +} + +func (s *MemoryStore) GetActiveSessionID(_ context.Context, ownerUID int64) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + id, ok := s.active[ownerUID] + if !ok || id == "" { + return "", domain.ErrNotFound + } + return id, nil +} + +func (s *MemoryStore) SetActiveSessionID(_ context.Context, ownerUID int64, sessionID string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.active[ownerUID] = sessionID + return nil +} + +func copySession(sess *domain.Session) *domain.Session { + cp := *sess + if sess.Messages != nil { + cp.Messages = append([]domain.ChatMessage(nil), sess.Messages...) + for i := range cp.Messages { + if sess.Messages[i].Draft != nil { + d := *sess.Messages[i].Draft + cp.Messages[i].Draft = &d + } + } + } + if sess.PinnedElementIDs != nil { + cp.PinnedElementIDs = append([]string(nil), sess.PinnedElementIDs...) + } + return &cp +} + +var _ domain.Repository = (*MemoryStore)(nil) diff --git a/apps/backend/internal/module/inspire/repository/mongo.go b/apps/backend/internal/module/inspire/repository/mongo.go new file mode 100644 index 0000000..2213d41 --- /dev/null +++ b/apps/backend/internal/module/inspire/repository/mongo.go @@ -0,0 +1,177 @@ +package repository + +import ( + "context" + + libmongo "apps/backend/internal/lib/mongo" + "apps/backend/internal/module/inspire/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 { + trends *mon.Model + elements *mon.Model + sessions *mon.Model + active *mon.Model // inspire_session_active: _id=owner_uid, session_id +} + +func NewMonStore(uri, database string) *MonStore { + uri = libmongo.MustMongoURI(uri) + return &MonStore{ + trends: mon.MustNewModel(uri, database, "inspire_trends"), + elements: mon.MustNewModel(uri, database, "inspire_elements"), + sessions: mon.MustNewModel(uri, database, "inspire_sessions"), + active: mon.MustNewModel(uri, database, "inspire_session_active"), + } +} + +type trendBag struct { + OwnerUID int64 `bson:"_id"` + Items []*domain.TrendItem `bson:"items"` +} + +type activeRef struct { + OwnerUID int64 `bson:"_id"` + SessionID string `bson:"session_id"` +} + +func (s *MonStore) ListTrends(ctx context.Context, ownerUID int64) ([]*domain.TrendItem, error) { + var bag trendBag + err := s.trends.FindOne(ctx, &bag, bson.M{"_id": ownerUID}) + if err != nil { + if err == mon.ErrNotFound { + return nil, nil + } + return nil, err + } + return bag.Items, nil +} + +func (s *MonStore) SaveTrends(ctx context.Context, ownerUID int64, items []*domain.TrendItem) error { + bag := trendBag{OwnerUID: ownerUID, Items: items} + _, err := s.trends.ReplaceOne(ctx, bson.M{"_id": ownerUID}, bag, options.Replace().SetUpsert(true)) + return err +} + +func (s *MonStore) ListElements(ctx context.Context, ownerUID int64) ([]*domain.Element, error) { + var list []*domain.Element + err := s.elements.Find(ctx, &list, bson.M{"owner_uid": ownerUID}, + options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}})) + return list, err +} + +func (s *MonStore) SaveElement(ctx context.Context, e *domain.Element) error { + _, err := s.elements.ReplaceOne(ctx, bson.M{"_id": e.ID}, e, options.Replace().SetUpsert(true)) + return err +} + +func (s *MonStore) DeleteElement(ctx context.Context, id string) error { + res, err := s.elements.DeleteOne(ctx, bson.M{"_id": id}) + if err != nil { + return err + } + if res == 0 { + return domain.ErrNotFound + } + return nil +} + +func (s *MonStore) GetElement(ctx context.Context, id string) (*domain.Element, error) { + var e domain.Element + err := s.elements.FindOne(ctx, &e, bson.M{"_id": id}) + if err != nil { + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + return nil, err + } + return &e, nil +} + +func (s *MonStore) ListSessions(ctx context.Context, ownerUID int64) ([]*domain.Session, error) { + var list []*domain.Session + err := s.sessions.Find(ctx, &list, bson.M{"owner_uid": ownerUID}, + options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}}).SetLimit(50)) + if err != nil { + return nil, err + } + return list, nil +} + +// GetSession — active;無 active/失效則取最近一則;都沒有回 NotFound(usecase 建新)。 +func (s *MonStore) GetSession(ctx context.Context, ownerUID int64) (*domain.Session, error) { + if id, err := s.GetActiveSessionID(ctx, ownerUID); err == nil && id != "" { + if sess, gerr := s.GetSessionByID(ctx, ownerUID, id); gerr == nil { + return sess, nil + } + } + // fallback:最近更新 + var list []*domain.Session + err := s.sessions.Find(ctx, &list, bson.M{"owner_uid": ownerUID}, + 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 + } + _ = s.SetActiveSessionID(ctx, ownerUID, list[0].ID) + return list[0], nil +} + +func (s *MonStore) GetSessionByID(ctx context.Context, ownerUID int64, id string) (*domain.Session, error) { + var sess domain.Session + err := s.sessions.FindOne(ctx, &sess, bson.M{"_id": id, "owner_uid": ownerUID}) + if err != nil { + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + return nil, err + } + return &sess, nil +} + +func (s *MonStore) SaveSession(ctx context.Context, sess *domain.Session) error { + // 用 _id + owner_uid:多 session 且禁止覆寫別人的對話 + _, err := s.sessions.ReplaceOne( + ctx, + bson.M{"_id": sess.ID, "owner_uid": sess.OwnerUID}, + sess, + options.Replace().SetUpsert(true), + ) + return err +} + +func (s *MonStore) DeleteSessionByID(ctx context.Context, ownerUID int64, id string) error { + res, err := s.sessions.DeleteOne(ctx, bson.M{"_id": id, "owner_uid": ownerUID}) + if err != nil { + return err + } + if res == 0 { + return domain.ErrNotFound + } + return nil +} + +func (s *MonStore) GetActiveSessionID(ctx context.Context, ownerUID int64) (string, error) { + var ref activeRef + err := s.active.FindOne(ctx, &ref, bson.M{"_id": ownerUID}) + if err != nil { + if err == mon.ErrNotFound { + return "", domain.ErrNotFound + } + return "", err + } + return ref.SessionID, nil +} + +func (s *MonStore) SetActiveSessionID(ctx context.Context, ownerUID int64, sessionID string) error { + ref := activeRef{OwnerUID: ownerUID, SessionID: sessionID} + _, err := s.active.ReplaceOne(ctx, bson.M{"_id": ownerUID}, ref, options.Replace().SetUpsert(true)) + return err +} + +var _ domain.Repository = (*MonStore)(nil) diff --git a/apps/backend/internal/module/inspire/usecase/context_sources.go b/apps/backend/internal/module/inspire/usecase/context_sources.go new file mode 100644 index 0000000..f483497 --- /dev/null +++ b/apps/backend/internal/module/inspire/usecase/context_sources.go @@ -0,0 +1,46 @@ +package usecase + +import "context" + +// PersonaSnapshot — 靈感 prompt 注入用的完整人設(不含 API key)。 +type PersonaSnapshot struct { + ID string + Name string + Brief string + Status string + DraftText string + Voice string + GuardAvoid []string + MaxChars int + BanAiTone bool + // DimSummaries key like d1Tone → summary + DimSummaries map[string]string +} + +// BrandSnapshot + products for catalog injection. +type BrandSnapshot struct { + ID string + DisplayName string + Brief string + Audience string + Goals string + Products []ProductSnapshot +} + +type ProductSnapshot struct { + ID string + Label string + Context string + Tags []string + Pains []string +} + +// PersonaSource resolves current / requested persona. +type PersonaSource interface { + ResolvePersona(ctx context.Context, ownerUID int64, personaID string) (*PersonaSnapshot, error) +} + +// BrandCatalogSource lists brands and nested products for the owner. +type BrandCatalogSource interface { + ListCatalog(ctx context.Context, ownerUID int64) ([]BrandSnapshot, error) +} diff --git a/apps/backend/internal/module/inspire/usecase/m5_inspire_test.go b/apps/backend/internal/module/inspire/usecase/m5_inspire_test.go new file mode 100644 index 0000000..75404b3 --- /dev/null +++ b/apps/backend/internal/module/inspire/usecase/m5_inspire_test.go @@ -0,0 +1,327 @@ +package usecase_test + +import ( + "context" + "strings" + "testing" + + "apps/backend/internal/module/ai" + "apps/backend/internal/module/inspire/domain" + "apps/backend/internal/module/inspire/repository" + "apps/backend/internal/module/inspire/usecase" + "apps/backend/internal/module/search" + usageDomain "apps/backend/internal/module/usage/domain" + usageRepo "apps/backend/internal/module/usage/repository" + usageUC "apps/backend/internal/module/usage/usecase" + + "github.com/stretchr/testify/require" +) + +func newInspire() *usecase.Service { + svc := usecase.New(repository.NewMemory()) + ures := usageRepo.NewMemory() + sr := &usageUC.StaticResolver{Map: map[string]string{}} + us := usageUC.New(ures, sr) + svc.Usage = us + svc.AI = &ai.FakeClient{} + svc.Search = &search.FakeClient{} + return svc +} + +func setupInspireUID(svc *usecase.Service, uid int64) { + _ = svc.Usage.Repo.SavePrefs(context.Background(), &usageDomain.MemberPrefs{ + UID: uid, PlanID: usageDomain.PlanPro, Unlimited: true, UpdatedAt: domain.NowNano(), + }) + if sr, ok := svc.Usage.Resolver.(*usageUC.StaticResolver); ok { + if sr.Map == nil { + sr.Map = map[string]string{} + } + for _, m := range []string{usageDomain.MeterAICopy, usageDomain.MeterWebSearch, usageDomain.MeterAIImage} { + sr.Map[itoa(uid)+":"+m] = usageDomain.KeyModePlatform + } + } +} + +func itoa(n int64) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var b [32]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + b[i] = '-' + } + return string(b[i:]) +} + +// Chat(ctx, uid, message, pinned, mode, persona, sessionID, material) +func chat(svc *usecase.Service, uid int64, msg, mode, sessionID, material string) (*usecase.ChatOutcome, error) { + return svc.Chat(context.Background(), uid, msg, nil, mode, "", sessionID, material) +} + +func TestIN_01_ListTrends(t *testing.T) { + svc := newInspire() + uid := int64(5_001_001) + setupInspireUID(svc, uid) + list, err := svc.ListTrends(context.Background(), uid) + require.NoError(t, err) + require.NotEmpty(t, list) + require.NotEmpty(t, list[0].Label) +} + +func TestIN_02_RefreshTrends(t *testing.T) { + svc := newInspire() + uid := int64(5_001_002) + setupInspireUID(svc, uid) + _, _ = svc.ListTrends(context.Background(), uid) + list, err := svc.RefreshTrends(context.Background(), uid) + require.NoError(t, err) + require.NotEmpty(t, list) +} + +func TestIN_03_ElementCRUD(t *testing.T) { + svc := newInspire() + uid := int64(5_001_003) + setupInspireUID(svc, uid) + el, err := svc.SaveElement(context.Background(), uid, &domain.Element{ + Kind: "snippet", Title: "鉤子", Body: "先講痛點", Reusable: true, + }) + require.NoError(t, err) + list, err := svc.ListElements(context.Background(), uid) + require.NoError(t, err) + require.Len(t, list, 1) + require.NoError(t, svc.RemoveElement(context.Background(), uid, el.ID)) + list, _ = svc.ListElements(context.Background(), uid) + require.Empty(t, list) +} + +func TestIN_04_ChatMode(t *testing.T) { + svc := newInspire() + uid := int64(5_001_004) + setupInspireUID(svc, uid) + out, err := chat(svc, uid, "幫我想開場", "chat", "", "") + require.NoError(t, err) + require.NotNil(t, out) + sess := out.Session + require.GreaterOrEqual(t, len(sess.Messages), 2) + require.Equal(t, "assistant", sess.Messages[len(sess.Messages)-1].Role) + require.Nil(t, sess.Messages[len(sess.Messages)-1].Draft) + require.NotEmpty(t, out.Fingerprint) + require.Contains(t, out.Prompt, "發想") +} + +func TestIN_05_GenerateRequiresMaterialAndRewrites(t *testing.T) { + svc := newInspire() + uid := int64(5_001_005) + setupInspireUID(svc, uid) + // 無素材 → 拒絕 + _, err := chat(svc, uid, "產文", "generate", "", "") + require.Error(t, err) + + mat := "遠端上班第三年,會議永遠開不完,想找一種不裝的吐槽角度。" + out, err := chat(svc, uid, "短一點、口語", "generate", "", mat) + require.NoError(t, err) + last := out.Session.Messages[len(out.Session.Messages)-1] + require.NotNil(t, last.Draft) + require.NotEmpty(t, last.Draft.Body) + require.Contains(t, out.Prompt, "待改寫內容") + require.Contains(t, out.Prompt, "改寫者") + require.Contains(t, out.Prompt, mat[:8]) + // session 泡泡只留短紀錄,不是整包素材正文 + user := out.Session.Messages[len(out.Session.Messages)-2] + require.True(t, strings.HasPrefix(user.Text, "【產文】")) + require.Less(t, len([]rune(user.Text)), len([]rune(mat))+20) +} + +func TestIN_07_PreviewMatchesChatFingerprint(t *testing.T) { + svc := newInspire() + uid := int64(5_001_007) + setupInspireUID(svc, uid) + msg := "寫一段開場鉤子" + prev, err := svc.PreviewPrompt(context.Background(), uid, msg, nil, "chat", "", "", "") + require.NoError(t, err) + require.NotEmpty(t, prev.Fingerprint) + out, err := chat(svc, uid, msg, "chat", "", "") + require.NoError(t, err) + require.Equal(t, prev.Fingerprint, out.Fingerprint, "preview and first chat must share fingerprint") + require.Equal(t, prev.Prompt, out.Prompt) +} + +func TestIN_06_ResearchSearch(t *testing.T) { + svc := newInspire() + uid := int64(5_001_006) + setupInspireUID(svc, uid) + hits, err := svc.ResearchSearch(context.Background(), uid, "敏感肌保養") + require.NoError(t, err) + require.NotEmpty(t, hits) + require.NotEmpty(t, hits[0].URL) +} + +func TestIN_07_GenerateImage(t *testing.T) { + svc := newInspire() + uid := int64(5_001_017) + setupInspireUID(svc, uid) + img, err := svc.GenerateImage(context.Background(), uid, "溫暖海邊插畫") + require.NoError(t, err) + require.NotEmpty(t, img.URL) + require.NotEmpty(t, img.ID) +} + +func TestIN_08_UploadSeparate(t *testing.T) { + svc := newInspire() + uid := int64(5_001_008) + setupInspireUID(svc, uid) + img, err := svc.GenerateImage(context.Background(), uid, "x") + require.NoError(t, err) + require.True(t, len(img.URL) > 8) +} + +func TestIN_09_LegacyRemoved(t *testing.T) { + svc := newInspire() + require.ErrorIs(t, svc.LegacyRemoved(), domain.ErrRemoved) +} + +func TestIN_10_ClearSessionCreatesNewKeepsOld(t *testing.T) { + svc := newInspire() + uid := int64(5_001_010) + setupInspireUID(svc, uid) + out, err := chat(svc, uid, "先聊一句", "chat", "", "") + require.NoError(t, err) + oldID := out.Session.ID + require.NotEmpty(t, oldID) + require.NotEmpty(t, out.Session.Messages) + + next, err := svc.ClearSession(context.Background(), uid) + require.NoError(t, err) + require.NotEqual(t, oldID, next.ID) + require.Empty(t, next.Messages) + + list, err := svc.ListSessionSummaries(context.Background(), uid) + require.NoError(t, err) + require.GreaterOrEqual(t, len(list), 2) + ids := map[string]bool{} + for _, s := range list { + ids[s.ID] = true + } + require.True(t, ids[oldID]) + require.True(t, ids[next.ID]) + + old, err := svc.ActivateSession(context.Background(), uid, oldID) + require.NoError(t, err) + require.Equal(t, oldID, old.ID) + require.NotEmpty(t, old.Messages) +} + +func TestIN_11_MultiSessionSwitchAndDelete(t *testing.T) { + svc := newInspire() + uid := int64(5_001_011) + setupInspireUID(svc, uid) + a, err := chat(svc, uid, "話題A", "chat", "", "") + require.NoError(t, err) + b, err := svc.CreateSession(context.Background(), uid, "") + require.NoError(t, err) + require.NotEqual(t, a.Session.ID, b.ID) + outB, err := chat(svc, uid, "話題B", "chat", b.ID, "") + require.NoError(t, err) + require.Equal(t, b.ID, outB.Session.ID) + + after, err := svc.DeleteSession(context.Background(), uid, b.ID) + require.NoError(t, err) + require.NotEqual(t, b.ID, after.ID) + list, err := svc.ListSessionSummaries(context.Background(), uid) + require.NoError(t, err) + for _, s := range list { + require.NotEqual(t, b.ID, s.ID) + } +} + +func TestIN_12_SessionIsolationAndHistoryKept(t *testing.T) { + svc := newInspire() + uidA := int64(5_001_012) + uidB := int64(5_001_013) + setupInspireUID(svc, uidA) + setupInspireUID(svc, uidB) + + outA1, err := chat(svc, uidA, "A的第一則", "chat", "", "") + require.NoError(t, err) + sessA1 := outA1.Session.ID + + sA2, err := svc.CreateSession(context.Background(), uidA, "") + require.NoError(t, err) + outA2, err := chat(svc, uidA, "A的第二則對話", "chat", sA2.ID, "") + require.NoError(t, err) + require.Equal(t, sA2.ID, outA2.Session.ID) + + outB, err := chat(svc, uidB, "B的秘密話題", "chat", "", "") + require.NoError(t, err) + + _, err = svc.GetSessionByID(context.Background(), uidB, sessA1) + require.ErrorIs(t, err, domain.ErrNotFound) + + listA, err := svc.ListSessionSummaries(context.Background(), uidA) + require.NoError(t, err) + for _, s := range listA { + require.NotEqual(t, outB.Session.ID, s.ID) + } + require.GreaterOrEqual(t, len(listA), 2) + + back, err := svc.ActivateSession(context.Background(), uidA, sessA1) + require.NoError(t, err) + require.Equal(t, sessA1, back.ID) + found := false + for _, m := range back.Messages { + if m.Role == "user" && m.Text == "A的第一則" { + found = true + } + require.NotEqual(t, "B的秘密話題", m.Text) + require.NotEqual(t, "A的第二則對話", m.Text) + } + require.True(t, found) + + back2, err := svc.ActivateSession(context.Background(), uidA, sA2.ID) + require.NoError(t, err) + found2 := false + for _, m := range back2.Messages { + if m.Text == "A的第二則對話" { + found2 = true + } + require.NotEqual(t, "A的第一則", m.Text) + } + require.True(t, found2) +} + +func TestIN_13_ListTrendsDoesNotHitLiveSearch(t *testing.T) { + svc := usecase.New(repository.NewMemory()) + uid := int64(5_001_014) + list, err := svc.ListTrends(context.Background(), uid) + require.NoError(t, err) + require.NotEmpty(t, list) + for _, titem := range list { + require.Equal(t, "seed", titem.SourceLabel) + } +} + +func TestComposeDraftMaterial(t *testing.T) { + sess := &domain.Session{ + Title: "周末咖啡", + Messages: []domain.ChatMessage{ + {Role: "user", Text: "想寫周末放空"}, + {Role: "assistant", Text: "目前共識:慢節奏、不裝"}, + {Role: "user", Text: "【產文】短一點|素材:xxx"}, + }, + } + mat := usecase.ComposeDraftMaterial(sess, "") + require.Contains(t, mat, "周末咖啡") + require.Contains(t, mat, "想寫周末放空") + require.NotContains(t, mat, "【產文】") +} diff --git a/apps/backend/internal/module/inspire/usecase/service.go b/apps/backend/internal/module/inspire/usecase/service.go new file mode 100644 index 0000000..8babaa7 --- /dev/null +++ b/apps/backend/internal/module/inspire/usecase/service.go @@ -0,0 +1,1472 @@ +package usecase + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "unicode/utf8" + + "apps/backend/internal/module/ai" + "apps/backend/internal/module/inspire/domain" + "apps/backend/internal/module/search" + usageDomain "apps/backend/internal/module/usage/domain" + usageUC "apps/backend/internal/module/usage/usecase" + + "github.com/google/uuid" +) + +type Service struct { + Repo domain.Repository + Usage *usageUC.Service + AI ai.Client // tests / fallback + // AIRegistry real xai / opencode-go + AIRegistry *ai.Registry + // ResolveAI returns provider, model, apiKey(會員設定) + ResolveAI func(ctx context.Context, uid int64) (provider, model, apiKey string, err error) + Search search.Client + // ResolveKey returns api key for meter (optional; tests can leave nil and skip external) + ResolveKey func(ctx context.Context, uid int64, meter string) (mode, apiKey string, err error) + // Persona / 品牌產品 — 真資料注入 prompt(可空;空則略過該段) + Personas PersonaSource + Brands BrandCatalogSource +} + +func New(repo domain.Repository) *Service { + return &Service{Repo: repo} +} + +// ListTrends — 只讀快取/種子,不網搜、不扣點。找靈感請走 RefreshTrends(手動)。 +func (s *Service) ListTrends(ctx context.Context, ownerUID int64) ([]*domain.TrendItem, error) { + list, err := s.Repo.ListTrends(ctx, ownerUID) + if err != nil { + return nil, err + } + if len(list) == 0 { + // 空庫給示意種子,標 seed;不打外網 + list = defaultTrends(ownerUID) + _ = s.Repo.SaveTrends(ctx, ownerUID, list) + } + return list, nil +} + +// RefreshTrends — 手動「找靈感」:先扣 web_search 點數,再網搜抽話題。 +// 扣點失敗直接回錯(不 soft ignore);搜尋失敗可回種子但不退點(已消耗配額嘗試)。 +func (s *Service) RefreshTrends(ctx context.Context, ownerUID int64) ([]*domain.TrendItem, error) { + if err := s.bill(ctx, ownerUID, usageDomain.MeterWebSearch, "找靈感話題", "inspire.refreshTrends"); err != nil { + return nil, err + } + list := s.tryLiveTrends(ctx, ownerUID) + if len(list) == 0 { + list = defaultTrends(ownerUID) + for i := range list { + list[i].Heat = i + 1 + list[i].ObservedAt = domain.NowNano() + list[i].ID = "tr_" + uuid.NewString()[:10] + list[i].SourceLabel = "seed" + list[i].Summary = "網搜未取到可用話題,以下為示意" + } + } + if err := s.Repo.SaveTrends(ctx, ownerUID, list); err != nil { + return nil, err + } + return list, nil +} + +// tryLiveTrends:網搜「關於 Threads 本週在聊什麼」的報導/彙整,再抽出短話題名。 +// 注意:沒有官方 Threads 熱搜 API;這是話題靈感,不是平台即時榜。 +func (s *Service) tryLiveTrends(ctx context.Context, ownerUID int64) []*domain.TrendItem { + if s.Search == nil { + return nil + } + key := "" + if s.ResolveKey != nil { + if _, k, err := s.ResolveKey(ctx, ownerUID, usageDomain.MeterWebSearch); err == nil { + key = strings.TrimSpace(k) + } + } + if key == "" || strings.HasPrefix(key, "fake") { + return nil + } + // 計費在 RefreshTrends 已做;此處只搜尋 + + queries := []string{ + "本週 Threads 紅什麼 台灣", + "Threads 熱議話題 列表 台灣 本週", + "Threads 夯 話題 二砂糖 OR 泡麵 OR 颱風 台灣", + } + seen := map[string]struct{}{} + now := domain.NowNano() + out := make([]*domain.TrendItem, 0, 12) + for _, q := range queries { + hits, err := s.Search.Search(ctx, key, q, 10) + if err != nil || len(hits) == 0 { + continue + } + for _, h := range hits { + if isMetaThreadsArticle(h) { + // 仍可從內文抽「紅什麼」段落,但略過整篇當 label + } + for _, label := range extractTopicLabels(h) { + keyNorm := strings.ToLower(strings.TrimSpace(label)) + if keyNorm == "" { + continue + } + if _, ok := seen[keyNorm]; ok { + continue + } + if isNoiseTopicLabel(label) { + continue + } + seen[keyNorm] = struct{}{} + summary := strings.TrimSpace(h.Snippet) + if summary == "" { + summary = strings.TrimSpace(h.Title) + } + rank := len(out) + 1 + out = append(out, &domain.TrendItem{ + ID: "tr_" + uuid.NewString()[:10], + OwnerUID: ownerUID, + Kind: "threads_tag", + Label: label, + Summary: truncate(summary, 120), + Heat: rank, // 列表序位,前端當 #rank 用 + Keywords: []string{label}, + Samples: []string{truncate(summary, 80)}, + SourceLabel: "web", + ObservedAt: now, + }) + if len(out) >= 12 { + return out + } + } + } + if len(out) >= 8 { + break + } + } + return out +} + +// isMetaThreadsArticle — 談「Threads 功能/趨勢榜上線」的新聞,不是島民在聊的話題本身。 +func isMetaThreadsArticle(h search.Hit) bool { + blob := strings.ToLower(h.Title + " " + h.Snippet) + meta := []string{ + "趨勢話題", "trending topics", "新功能上線", "功能開放", "功能測試", + "setn", "三立", "必看", "選題sop", "qsearch", "趨勢功能", + } + for _, m := range meta { + if strings.Contains(blob, m) { + return true + } + } + return false +} + +func isNoiseTopicLabel(label string) bool { + l := strings.TrimSpace(label) + if l == "" { + return true + } + n := len([]rune(l)) + if n < 2 || n > 18 { + return true + } + low := strings.ToLower(l) + // 整段疑問句/感嘆句當 chip 很假 + if strings.HasSuffix(l, "?") || strings.HasSuffix(l, "?") || + strings.HasSuffix(l, "!") || strings.HasSuffix(l, "!") { + if n > 8 { + return true + } + } + if strings.HasPrefix(l, "為何") || strings.HasPrefix(l, "為什麼") || + strings.HasPrefix(l, "讓你") || strings.HasPrefix(l, "你還") { + return true + } + noise := []string{ + "threads", "meta", "meta.ai", "@meta.ai", "趨勢", "熱門話題", "功能", "上線", "測試", "報導", + "記者", "新聞", "sop", "qsearch", "台灣", "本週", "一週", "脆報", + "必看", "整理", "秒懂", "全文", "gq", "setn", "合法違規", + } + for _, w := range noise { + if low == w || low == "#"+w || strings.Contains(low, "meta.ai") { + return true + } + } + // 整句新聞標題残留 + if strings.ContainsAny(l, "||/") || strings.Contains(l, "http") { + return true + } + // 含書名號整句 + if strings.Contains(l, "「") || strings.Contains(l, "」") { + return true + } + return false +} + +// extractTopicLabels 從搜尋結果抽出短話題(hashtag、「」、清理後的短語)。 +func extractTopicLabels(h search.Hit) []string { + title := strings.TrimSpace(h.Title) + snippet := strings.TrimSpace(h.Snippet) + blob := title + "\n" + snippet + var out []string + seen := map[string]struct{}{} + add := func(s string) { + s = strings.TrimSpace(s) + s = strings.Trim(s, "「」『』\"' # ") + s = strings.TrimSpace(s) + if s == "" || isNoiseTopicLabel(s) { + return + } + // 統一短標 + s = truncate(s, 16) + k := strings.ToLower(s) + if _, ok := seen[k]; ok { + return + } + seen[k] = struct{}{} + out = append(out, s) + } + + // 1) #hashtag + for _, src := range []string{title, snippet} { + for i := 0; i < len(src); i++ { + if src[i] != '#' { + continue + } + rest := src[i:] + end := len(rest) + for j, r := range rest { + if j == 0 { + continue + } + if r == ' ' || r == '\n' || r == '\t' || r == ',' || r == '。' || r == ',' || r == '.' || r == '|' || r == '|' { + end = j + break + } + } + add(rest[:end]) + } + } + + // 2) 「…」/『…』 + extractQuoted(blob, '「', '」', add) + extractQuoted(blob, '『', '』', add) + + // 3) 標題裡「紅什麼/夯什麼」冒號後的片段 + for _, sep := range []string{"紅什麼:", "紅什麼:", "夯什麼:", "夯什麼:", "熱議:", "熱議:"} { + if i := strings.Index(title, sep); i >= 0 { + rest := title[i+len(sep):] + // 用?!。切短 + for _, cut := range []string{"?", "?", "!", "!", "。", "|", "|"} { + if j := strings.Index(rest, cut); j > 0 { + rest = rest[:j] + break + } + } + // 再拆「、」 + for _, part := range splitTopicParts(rest) { + add(part) + } + } + } + + // 4) 非 meta 文:用清理後的短標題當一個話題 + if !isMetaThreadsArticle(h) { + clean := cleanNewsTitle(title) + if clean != "" && len([]rune(clean)) <= 16 { + add(clean) + } else if clean != "" { + // 取前段關鍵 + for _, part := range splitTopicParts(clean) { + add(part) + if len(out) >= 4 { + break + } + } + } + } + + return out +} + +func extractQuoted(s string, open, close rune, add func(string)) { + runes := []rune(s) + for i := 0; i < len(runes); i++ { + if runes[i] != open { + continue + } + for j := i + 1; j < len(runes); j++ { + if runes[j] == close { + add(string(runes[i+1 : j])) + i = j + break + } + } + } +} + +func splitTopicParts(s string) []string { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + // 統一分隔 + for _, sep := range []string{"、", "/", "/", "·", "•", ",", ",", "跟", "與", "和"} { + s = strings.ReplaceAll(s, sep, "|") + } + var parts []string + for _, p := range strings.Split(s, "|") { + p = strings.TrimSpace(p) + if p != "" { + parts = append(parts, p) + } + } + return parts +} + +func cleanNewsTitle(title string) string { + t := strings.TrimSpace(title) + // 去【】前綴 + for { + if !strings.HasPrefix(t, "【") { + break + } + if i := strings.Index(t, "】"); i >= 0 { + t = strings.TrimSpace(t[i+len("】"):]) + continue + } + break + } + // 去站名 + for _, sep := range []string{"|", "|", " - ", " — "} { + if i := strings.Index(t, sep); i > 0 { + t = strings.TrimSpace(t[:i]) + } + } + t = strings.TrimSpace(t) + return t +} + +func (s *Service) ListElements(ctx context.Context, ownerUID int64) ([]*domain.Element, error) { + return s.Repo.ListElements(ctx, ownerUID) +} + +func (s *Service) SaveElement(ctx context.Context, ownerUID int64, e *domain.Element) (*domain.Element, error) { + if strings.TrimSpace(e.Title) == "" && strings.TrimSpace(e.Body) == "" { + return nil, fmt.Errorf("%w: empty element", domain.ErrValidation) + } + now := domain.NowNano() + if e.ID == "" { + e.ID = "el_" + uuid.NewString()[:10] + e.CreatedAt = now + } else { + ex, err := s.Repo.GetElement(ctx, e.ID) + if err == nil { + if ex.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + e.CreatedAt = ex.CreatedAt + } else if err != domain.ErrNotFound { + return nil, err + } else { + e.CreatedAt = now + } + } + e.OwnerUID = ownerUID + e.UpdatedAt = now + if e.Kind == "" { + e.Kind = "snippet" + } + if err := s.Repo.SaveElement(ctx, e); err != nil { + return nil, err + } + return e, nil +} + +func (s *Service) RemoveElement(ctx context.Context, ownerUID int64, id string) error { + e, err := s.Repo.GetElement(ctx, id) + if err != nil { + return err + } + if e.OwnerUID != ownerUID { + return domain.ErrForbidden + } + return s.Repo.DeleteElement(ctx, id) +} + +func (s *Service) GetSession(ctx context.Context, ownerUID int64) (*domain.Session, error) { + sess, err := s.Repo.GetSession(ctx, ownerUID) + if err == domain.ErrNotFound { + return s.CreateSession(ctx, ownerUID, "") + } + return sess, err +} + +func (s *Service) GetSessionByID(ctx context.Context, ownerUID int64, id string) (*domain.Session, error) { + id = strings.TrimSpace(id) + if id == "" { + return s.GetSession(ctx, ownerUID) + } + sess, err := s.Repo.GetSessionByID(ctx, ownerUID, id) + if err != nil { + return nil, err + } + return sess, nil +} + +// ListSessionSummaries — 列表(最新在前;標記 active)。 +func (s *Service) ListSessionSummaries(ctx context.Context, ownerUID int64) ([]domain.SessionSummary, error) { + list, err := s.Repo.ListSessions(ctx, ownerUID) + if err != nil { + return nil, err + } + activeID, _ := s.Repo.GetActiveSessionID(ctx, ownerUID) + if activeID == "" && len(list) > 0 { + activeID = list[0].ID + } + out := make([]domain.SessionSummary, 0, len(list)) + for _, sess := range list { + out = append(out, sessionToSummary(sess, sess.ID == activeID)) + } + return out, nil +} + +func sessionToSummary(sess *domain.Session, active bool) domain.SessionSummary { + preview := "" + if n := len(sess.Messages); n > 0 { + preview = truncate(sess.Messages[n-1].Text, 48) + } + title := strings.TrimSpace(sess.Title) + if title == "" { + // 推首則 user 文 + for _, m := range sess.Messages { + if m.Role == "user" && strings.TrimSpace(m.Text) != "" { + title = truncate(m.Text, 24) + break + } + } + } + if title == "" { + title = "新對話" + } + return domain.SessionSummary{ + ID: sess.ID, + Title: title, + Preview: preview, + MessageCount: len(sess.Messages), + UpdatedAt: sess.UpdatedAt, + CreatedAt: sess.CreatedAt, + Active: active, + } +} + +// CreateSession — 開新對話並設為 active(舊的保留)。 +func (s *Service) CreateSession(ctx context.Context, ownerUID int64, title string) (*domain.Session, error) { + sess := emptySession(ownerUID) + sess.Title = strings.TrimSpace(title) + if err := s.Repo.SaveSession(ctx, sess); err != nil { + return nil, err + } + if err := s.Repo.SetActiveSessionID(ctx, ownerUID, sess.ID); err != nil { + return nil, err + } + return sess, nil +} + +// ActivateSession — 切換目前作用中的對話。 +func (s *Service) ActivateSession(ctx context.Context, ownerUID int64, id string) (*domain.Session, error) { + sess, err := s.Repo.GetSessionByID(ctx, ownerUID, strings.TrimSpace(id)) + if err != nil { + return nil, err + } + sess.UpdatedAt = domain.NowNano() + if err := s.Repo.SaveSession(ctx, sess); err != nil { + return nil, err + } + if err := s.Repo.SetActiveSessionID(ctx, ownerUID, sess.ID); err != nil { + return nil, err + } + return sess, nil +} + +// DeleteSession — 刪除指定對話;若刪的是 active,自動切最近或建新。 +func (s *Service) DeleteSession(ctx context.Context, ownerUID int64, id string) (*domain.Session, error) { + id = strings.TrimSpace(id) + if id == "" { + return nil, fmt.Errorf("%w: empty session id", domain.ErrValidation) + } + if err := s.Repo.DeleteSessionByID(ctx, ownerUID, id); err != nil { + return nil, err + } + activeID, _ := s.Repo.GetActiveSessionID(ctx, ownerUID) + if activeID == id || activeID == "" { + // 切到剩餘最近一則,或建新 + list, err := s.Repo.ListSessions(ctx, ownerUID) + if err != nil { + return nil, err + } + if len(list) == 0 { + return s.CreateSession(ctx, ownerUID, "") + } + return s.ActivateSession(ctx, ownerUID, list[0].ID) + } + return s.GetSession(ctx, ownerUID) +} + +func (s *Service) SaveSession(ctx context.Context, ownerUID int64, sess *domain.Session) (*domain.Session, error) { + sess.OwnerUID = ownerUID + sess.UpdatedAt = domain.NowNano() + if sess.ID == "" { + sess.ID = "sess_" + uuid.NewString()[:8] + } + if sess.CreatedAt == 0 { + sess.CreatedAt = sess.UpdatedAt + } + if err := s.Repo.SaveSession(ctx, sess); err != nil { + return nil, err + } + return sess, nil +} + +// ClearSession — 相容舊 API:開新對話並切過去(舊 session 保留,可從列表選回)。 +func (s *Service) ClearSession(ctx context.Context, ownerUID int64) (*domain.Session, error) { + return s.CreateSession(ctx, ownerUID, "") +} + +// PromptBlock is one labeled chunk of the assembled prompt (for readable preview). +type PromptBlock struct { + Title string + Body string +} + +// PromptPreviewResult is the real payload that would be sent to the LLM (no call, no bill). +type PromptPreviewResult struct { + Prompt string + Mode string + Sections []string + Blocks []PromptBlock + Fingerprint string // sha256 hex[:16] of Prompt — same algo as chat done + CharCount int + RuneCount int + Pinned []PromptPinned + Note string +} + +type PromptPinned struct { + ID string + Kind string + Title string + Body string +} + +// ChatOutcome — session + the exact prompt that was sent to the LLM. +type ChatOutcome struct { + Session *domain.Session + Prompt string + Fingerprint string + Sections []string + Blocks []PromptBlock + CharCount int + RuneCount int +} + +func promptFingerprint(prompt string) string { + sum := sha256.Sum256([]byte(prompt)) + return hex.EncodeToString(sum[:])[:16] +} + +func promptStats(prompt string) (chars, runes int, fp string) { + return len(prompt), utf8.RuneCountInString(prompt), promptFingerprint(prompt) +} + +// PreviewPrompt builds the exact same prompt as Chat/ChatStream would, without billing or AI. +func (s *Service) PreviewPrompt(ctx context.Context, ownerUID int64, message string, pinnedIDs []string, mode, personaID, sessionID, material string) (*PromptPreviewResult, error) { + message = strings.TrimSpace(message) + material = strings.TrimSpace(material) + if mode != "chat" && mode != "generate" { + mode = "chat" + } + sess, err := s.resolveSession(ctx, ownerUID, sessionID) + if err != nil { + return nil, err + } + // Mirror chatInternal: append this-turn user message into a copy before build. + userLog := message + if mode == "generate" { + userLog = generateUserLogMessage(message, material) + } + previewSess := *sess + previewSess.Messages = append(append([]domain.ChatMessage{}, sess.Messages...), domain.ChatMessage{ + ID: "preview", Role: "user", Text: userLog, CreatedAt: domain.NowNano(), + }) + previewSess.PinnedElementIDs = pinnedIDs + // 預覽不寫回 DB,但用同一套摘要規則 + maybeRefreshSessionSummary(&previewSess) + + var pinned []PromptPinned + for _, id := range pinnedIDs { + if e, eerr := s.Repo.GetElement(ctx, id); eerr == nil && e != nil && e.OwnerUID == ownerUID { + pinned = append(pinned, PromptPinned{ + ID: e.ID, Kind: e.Kind, Title: e.Title, Body: e.Body, + }) + } + } + + persona := s.resolvePersonaSnap(ctx, ownerUID, personaID) + lang := ai.ResponseLanguageFrom(ctx) + if pl := inferPersonaLanguage(persona); pl != "" { + lang = pl + } + prompt, blocks, sections := s.buildInspirePrompt(ctx, ownerUID, pinnedIDs, message, mode, personaID, material, &previewSess, persona, lang) + chars, runes, fp := promptStats(prompt) + out := &PromptPreviewResult{ + Prompt: prompt, + Mode: mode, + Sections: sections, + Blocks: blocks, + Fingerprint: fp, + CharCount: chars, + RuneCount: runes, + Pinned: pinned, + Note: "此為送出前實際組裝的完整 prompt(與 Chat/ChatStream 同一 buildInspirePrompt)。指紋(fingerprint)與字數可用來對照送出後回傳值;未呼叫 AI、未扣額度。", + } + if mode == "generate" && material == "" { + out.Note += " 產文缺少【待改寫內容】;真送出會被拒絕。" + } + if mode == "chat" && message == "" { + out.Note += " 訊息為空;真送出會被拒絕。" + } + return out, nil +} + +func (s *Service) Chat(ctx context.Context, ownerUID int64, message string, pinnedIDs []string, mode, personaID, sessionID, material string) (*ChatOutcome, error) { + return s.chatInternal(ctx, ownerUID, message, pinnedIDs, mode, personaID, sessionID, material, nil) +} + +// ChatStream — 真 AI stream;onDelta 每收到一段正文就回呼(可 SSE)。結束後 session 已寫入。 +func (s *Service) ChatStream(ctx context.Context, ownerUID int64, message string, pinnedIDs []string, mode, personaID, sessionID, material string, onDelta func(chunk string) error) (*ChatOutcome, error) { + return s.chatInternal(ctx, ownerUID, message, pinnedIDs, mode, personaID, sessionID, material, onDelta) +} + +func (s *Service) resolveSession(ctx context.Context, ownerUID int64, sessionID string) (*domain.Session, error) { + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return s.GetSession(ctx, ownerUID) + } + return s.GetSessionByID(ctx, ownerUID, sessionID) +} + +// generateUserLogMessage — 寫進 session 的短紀錄,不把整包素材塞進對話泡泡。 +func generateUserLogMessage(notes, material string) string { + notes = strings.TrimSpace(notes) + if notes == "" { + notes = "用人設寫成貼文" + } + mat := strings.TrimSpace(material) + preview := truncate(mat, 48) + if preview == "" { + return "【產文】" + notes + } + return "【產文】" + notes + "|素材:" + preview +} + +func (s *Service) chatInternal(ctx context.Context, ownerUID int64, message string, pinnedIDs []string, mode, personaID, sessionID, material string, onDelta func(chunk string) error) (*ChatOutcome, error) { + message = strings.TrimSpace(message) + material = strings.TrimSpace(material) + if mode != "chat" && mode != "generate" { + mode = "chat" + } + if mode == "generate" { + if material == "" { + return nil, fmt.Errorf("%w: 產文需要先鎖定「待改寫內容」", domain.ErrValidation) + } + if message == "" { + message = "用人設寫成 Threads 正文" + } + } else if message == "" { + return nil, fmt.Errorf("%w: empty message", domain.ErrValidation) + } + label := "inspire chat" + if mode == "generate" { + label = "inspire rewrite" + } + if err := s.bill(ctx, ownerUID, usageDomain.MeterAICopy, label, "inspire.chat"); err != nil { + return nil, err + } + sess, err := s.resolveSession(ctx, ownerUID, sessionID) + if err != nil { + return nil, err + } + + // 回覆語言:人設指紋/範例優先,其次會員 UI 語系(ctx 已由 Auth 注入) + persona := s.resolvePersonaSnap(ctx, ownerUID, personaID) + lang := ai.ResponseLanguageFrom(ctx) + if pl := inferPersonaLanguage(persona); pl != "" { + lang = pl + } + ctx = ai.WithResponseLanguage(ctx, lang) + if mode == "generate" { + if message == "用人設寫成 Threads 正文" || message == "" { + if lang == "en" { + message = "Rewrite as a Threads post in this persona's voice" + } else { + message = "用人設寫成 Threads 正文" + } + } + } + + now := domain.NowNano() + sess.PinnedElementIDs = pinnedIDs + userLog := message + if mode == "generate" { + userLog = generateUserLogMessage(message, material) + } + // 自動標題:首則有意義 user 訊息 + if strings.TrimSpace(sess.Title) == "" { + if mode == "generate" { + sess.Title = truncate(material, 24) + } else { + sess.Title = truncate(message, 24) + } + } + sess.Messages = append(sess.Messages, domain.ChatMessage{ + ID: "msg_" + uuid.NewString()[:8], Role: "user", Text: userLog, CreatedAt: now, + }) + // 舊對話摺進摘要(每 N 則才重算);prompt 只帶摘要 + 最近幾則 + maybeRefreshSessionSummary(sess) + + prompt, blocks, sections := s.buildInspirePrompt(ctx, ownerUID, pinnedIDs, message, mode, personaID, material, sess, persona, lang) + chars, runes, fp := promptStats(prompt) + // 靈感:小輸出預算 + 精簡 prompt → 首字與完成都更快 + llmCtx := ai.WithMaxTokens(ctx, inspireMaxTokens(mode)) + llmCtx = ai.WithTemperature(llmCtx, 0.85) + reply, aerr := s.runInspireLLM(llmCtx, ownerUID, prompt, onDelta) + if aerr != nil { + return nil, aerr + } + if reply == "" { + return nil, fmt.Errorf("%w: AI 回傳空白,請換模型後重試", domain.ErrValidation) + } + + asst := domain.ChatMessage{ + ID: "msg_" + uuid.NewString()[:8], Role: "assistant", Text: reply, CreatedAt: domain.NowNano(), + } + if mode == "generate" { + body := strings.TrimSpace(reply) + if i := strings.Index(body, "] "); i >= 0 && strings.HasPrefix(body, "[") { + body = strings.TrimSpace(body[i+2:]) + } + if body == "" { + body = material + } + asst.Draft = &domain.ChatDraft{Title: truncate(material, 24), Body: body} + if lang == "en" { + asst.Text = "Draft rewritten in persona voice — ready to use." + } else { + asst.Text = "已依人設改寫成草稿,可直接用。" + } + } + sess.Messages = append(sess.Messages, asst) + // 助手回完後再檢查是否該摺摘要(不另打 AI) + maybeRefreshSessionSummary(sess) + sess.UpdatedAt = domain.NowNano() + if err := s.Repo.SaveSession(ctx, sess); err != nil { + return nil, err + } + // 聊過的 session 設為 active + _ = s.Repo.SetActiveSessionID(ctx, ownerUID, sess.ID) + return &ChatOutcome{ + Session: sess, Prompt: prompt, Fingerprint: fp, + Sections: sections, Blocks: blocks, CharCount: chars, RuneCount: runes, + }, nil +} + +func inspireMaxTokens(mode string) int { + // 聊天發想:給足空間讓回應完整;產文才收斂字數/預算。 + if mode == "generate" { + return 3072 + } + return 2048 +} + +// 對話視窗:只帶最近幾則原文;更舊的摺進 ContextSummary +const ( + inspireKeepRecentChat = 5 // 發想要有足夠上下文 + inspireKeepRecentGenerate = 4 + inspireSummarizeEvery = 6 + inspireSummaryMaxRunes = 480 + inspireHistoryLineChat = 120 + inspireHistoryLineGen = 90 + inspirePinBodyChat = 280 + inspirePinBodyGenerate = 280 + inspirePromptCapChat = 3200 + inspirePromptCapGenerate = 3600 +) + +// maybeRefreshSessionSummary 把「窗口外」舊訊息摺成摘要。 +// 不做額外 LLM 呼叫(免費、快);清空 session 時摘要一併清掉。 +func maybeRefreshSessionSummary(sess *domain.Session) { + if sess == nil { + return + } + n := len(sess.Messages) + keep := inspireKeepRecentChat + if n <= keep { + return + } + olderEnd := n - keep + if olderEnd <= 0 { + return + } + // 已覆蓋到 olderEnd 前綴 → 不必重算 + if sess.SummaryMsgCount >= olderEnd && strings.TrimSpace(sess.ContextSummary) != "" { + return + } + // 距上次摘要未滿 N 則新舊差 → 暫不重算(沿用舊摘要 + 視窗) + if sess.SummaryMsgCount > 0 && (olderEnd-sess.SummaryMsgCount) < inspireSummarizeEvery { + return + } + older := sess.Messages[:olderEnd] + sess.ContextSummary = compactHistorySummary(older, inspireSummaryMaxRunes) + sess.SummaryMsgCount = olderEnd +} + +func compactHistorySummary(msgs []domain.ChatMessage, maxRunes int) string { + if len(msgs) == 0 { + return "" + } + var parts []string + // 從舊到新取重點;過長則只留頭尾 + for _, m := range msgs { + role := "使用者" + if m.Role == "assistant" { + role = "助手" + } + t := strings.TrimSpace(m.Text) + if t == "" && m.Draft != nil { + t = strings.TrimSpace(m.Draft.Body) + } + if t == "" { + continue + } + parts = append(parts, role+":"+truncate(t, 60)) + } + if len(parts) == 0 { + return "" + } + // 太長:保留前 3 + 後 3 + if len(parts) > 8 { + head := parts[:3] + tail := parts[len(parts)-3:] + parts = append(head, append([]string{"…"}, tail...)...) + } + return truncate(strings.Join(parts, " / "), maxRunes) +} + +// ComposeDraftMaterial — 從 session 規則組裝「待改寫內容」(免費、不呼叫 AI)。 +func ComposeDraftMaterial(sess *domain.Session, extra string) string { + var parts []string + if sess != nil { + if t := strings.TrimSpace(sess.Title); t != "" && t != "新對話" { + parts = append(parts, "主題:"+t) + } + if sum := strings.TrimSpace(sess.ContextSummary); sum != "" { + parts = append(parts, "前情重點:"+sum) + } + // 最近訊息(略過產文短紀錄) + msgs := sess.Messages + start := 0 + if len(msgs) > 10 { + start = len(msgs) - 10 + } + for _, m := range msgs[start:] { + t := strings.TrimSpace(m.Text) + if strings.HasPrefix(t, "【產文】") { + continue + } + if m.Role == "user" && t != "" { + parts = append(parts, "我想:"+truncate(t, 160)) + } else if m.Role == "assistant" { + if m.Draft != nil && strings.TrimSpace(m.Draft.Body) != "" { + parts = append(parts, "先前草稿:"+truncate(m.Draft.Body, 200)) + } else if t != "" && t != "已依人設改寫成草稿,可直接用。" && t != "已產出草稿,可直接用。" { + parts = append(parts, "討論:"+truncate(t, 200)) + } + } + } + } + if e := strings.TrimSpace(extra); e != "" { + parts = append(parts, "補充:"+e) + } + return strings.TrimSpace(strings.Join(parts, "\n\n")) +} + +func (s *Service) resolvePersonaSnap(ctx context.Context, ownerUID int64, personaID string) *PersonaSnapshot { + if s.Personas == nil { + return nil + } + p, err := s.Personas.ResolvePersona(ctx, ownerUID, personaID) + if err != nil { + return nil + } + return p +} + +// inferPersonaLanguage — 從人設指紋/範例推輸出語言;空=沿用會員 UI 語系。 +func inferPersonaLanguage(p *PersonaSnapshot) string { + if p == nil { + return "" + } + var texts []string + for _, t := range []string{p.DraftText, p.Voice, p.Brief, p.Name} { + if strings.TrimSpace(t) != "" { + texts = append(texts, t) + } + } + for _, sm := range p.DimSummaries { + if strings.TrimSpace(sm) != "" { + texts = append(texts, sm) + } + } + return ai.InferScriptLanguage(texts...) +} + +func inspireSystemRules(mode, lang string, maxChars int) string { + en := ai.NormalizeResponseLanguage(lang) == "en" + if mode == "generate" { + lenRuleZh := "約 80~220 字" + lenRuleEn := "About 80–220 words" + if maxChars > 0 { + lenRuleZh = fmt.Sprintf("嚴格約不超過 %d 字(字元)", maxChars) + lenRuleEn = fmt.Sprintf("Hard cap about %d characters", maxChars) + } + if en { + return strings.Join([]string{ + "You are a Threads rewriter, not an ideation partner.", + "Only rewrite 【Content to rewrite】 in the persona's voice into a post-ready body.", + "Do not invent a new topic or facts not in the material. No analysis, no titles, no markdown, no outline.", + "You may adjust rhythm, slang, emotion, and a closing question to match the persona.", + "Output ONLY the post body in the persona's language (match language fingerprint / samples). " + lenRuleEn + ". Do not invent brands not in material/pins.", + }, "\n") + } + return strings.Join([]string{ + "你是 Threads「改寫者」,不是發想者。", + "只能依【待改寫內容】與【人設】寫出可貼出的正文。", + "禁止:另起新主題、加入素材沒有的情節或事實、長篇分析、標題前綴、markdown、條列大綱。", + "可以:調整句式、節奏、口頭禪、情緒與結尾問句,使口氣符合人設。", + "只輸出正文,語言必須與【人設】語言指紋/範例一致(若人設是英文就用英文,是繁中就用繁中口語)。" + lenRuleZh + "。未在素材/pin 出現的品牌勿捏造。", + }, "\n") + } + // chat:正常發想對話,不限 Threads 字數、不強壓極短(完整討論) + if en { + return strings.Join([]string{ + "You are a Threads ideation partner, not a final-copy writer.", + "Chat naturally and help brainstorm: topic, angle, emotion, hooks, examples, taboos.", + "Reply as long as needed for good ideation (no post character limit here).", + "You may offer options, questions, and partial lines; do not force a final publish-ready post unless asked.", + "When consensus is clear, you may summarize as “Current consensus: …”.", + "Language: match the persona (fingerprint/samples).", + "Do not cite brands unless pinned.", + }, "\n") + } + return strings.Join([]string{ + "你是 Threads 發想搭子,不是定稿寫手。", + "用正常聊天方式一起發想:主題、角度、情緒、鉤子、例子、禁忌都可以展開談。", + "發想階段不限字數、不必刻意壓短;把討論講完整比較重要。", + "可以給方向、選項、半成品句子;除非用戶要求,不要硬寫成最終可貼文定稿。", + "共識清楚時可用「目前共識:…」整理。", + "語言與【人設】一致(指紋/範例);不要被 UI 語系帶跑。", + "未套用品牌勿引用。", + }, "\n") +} + +// buildInspirePrompt 組裝真實送 AI 的全文。 +// chat:發想;generate:對【待改寫內容】做人設改寫。 +// persona 可預先 resolve;lang = zh-TW | en(人設優先)。 +func (s *Service) buildInspirePrompt(ctx context.Context, ownerUID int64, pinnedIDs []string, message, mode, personaID, material string, sess *domain.Session, persona *PersonaSnapshot, lang string) (string, []PromptBlock, []string) { + var blocks []PromptBlock + var sections []string + add := func(title, body string) { + body = strings.TrimSpace(body) + if body == "" { + return + } + blocks = append(blocks, PromptBlock{Title: title, Body: body}) + sections = append(sections, title) + } + + lang = ai.NormalizeResponseLanguage(lang) + maxChars := 0 + if persona != nil && mode == "generate" { + maxChars = persona.MaxChars + } + add("系統規則", inspireSystemRules(mode, lang, maxChars)) + + if pBlock := formatPersonaPromptBlock(persona, mode, lang); pBlock != "" { + add("人設", pBlock) + } else if pBlock := s.personaPromptBlock(ctx, ownerUID, personaID, mode); pBlock != "" { + // fallback if snap nil + add("人設", pBlock) + } + + // 品牌/產品:只有 pin 才帶;chat 截更短以加快 + pinCap := inspirePinBodyGenerate + if mode == "chat" { + pinCap = inspirePinBodyChat + } + var ctxParts []string + var pinnedBrandIDs []string + for _, id := range pinnedIDs { + if e, eerr := s.Repo.GetElement(ctx, id); eerr == nil && e != nil && e.OwnerUID == ownerUID { + kind := strings.TrimSpace(e.Kind) + body := strings.TrimSpace(e.Body) + if len([]rune(body)) > pinCap { + body = string([]rune(body)[:pinCap]) + "…" + } + if kind != "" { + ctxParts = append(ctxParts, fmt.Sprintf("[%s] %s\n%s", kind, e.Title, body)) + } else { + ctxParts = append(ctxParts, e.Title+":\n"+body) + } + if kind == "brand" || strings.HasPrefix(e.ID, "el_brand_") { + bid := strings.TrimSpace(e.RefID) + if bid == "" && strings.HasPrefix(e.ID, "el_brand_") { + bid = strings.TrimPrefix(e.ID, "el_brand_") + } + if bid != "" { + pinnedBrandIDs = append(pinnedBrandIDs, bid) + } + } + } + } + if cat := s.brandCatalogBlock(ctx, ownerUID, pinnedBrandIDs); cat != "" { + add("品牌與產品", truncate(cat, 900)) + } + if len(ctxParts) > 0 { + joined := strings.Join(ctxParts, "\n\n") + if len([]rune(joined)) > 1200 { + joined = string([]rune(joined)[:1200]) + "…" + } + add("套用元素", joined) + } + + if mode == "generate" { + mat := strings.TrimSpace(material) + if mat == "" { + mat = ComposeDraftMaterial(sess, message) + } + add("待改寫內容", truncate(mat, 2000)) + notes := strings.TrimSpace(message) + if notes == "" { + notes = "用人設寫成 Threads 正文" + } + add("改寫指示", notes) + } else { + if sess != nil { + if sum := strings.TrimSpace(sess.ContextSummary); sum != "" { + add("前情摘要", sum) + } + } + if sess != nil && len(sess.Messages) > 0 { + hist := sess.Messages + if len(hist) > 0 && hist[len(hist)-1].Role == "user" { + last := strings.TrimSpace(hist[len(hist)-1].Text) + if last == strings.TrimSpace(message) || strings.HasPrefix(last, "【產文】") { + hist = hist[:len(hist)-1] + } + } + keep := inspireKeepRecentChat - 1 + if keep < 2 { + keep = 2 + } + start := 0 + if len(hist) > keep { + start = len(hist) - keep + } + if start < len(hist) { + var hb strings.Builder + for _, m := range hist[start:] { + role := "使用者" + if m.Role == "assistant" { + role = "助手" + } + t := strings.TrimSpace(m.Text) + if t == "" && m.Draft != nil { + t = m.Draft.Body + } + if strings.HasPrefix(t, "【產文】") { + continue + } + t = truncate(t, inspireHistoryLineChat) + hb.WriteString(role) + hb.WriteString(":") + hb.WriteString(t) + hb.WriteString("\n") + } + if s := strings.TrimSpace(hb.String()); s != "" { + add("最近對話", s) + } + } + } + add("本輪", message) + } + + var b strings.Builder + for i, blk := range blocks { + if i > 0 { + b.WriteString("\n") + } + b.WriteString("【") + b.WriteString(blk.Title) + b.WriteString("】\n") + b.WriteString(blk.Body) + b.WriteString("\n") + } + full := b.String() + capN := inspirePromptCapGenerate + if mode == "chat" { + capN = inspirePromptCapChat + } + if len([]rune(full)) > capN { + full = string([]rune(full)[:capN]) + "…\n" + } + return full, blocks, sections +} + +// personaPromptBlock:聊天用輕量人設;產文帶指紋但截斷。 +func (s *Service) personaPromptBlock(ctx context.Context, ownerUID int64, personaID, mode string) string { + p := s.resolvePersonaSnap(ctx, ownerUID, personaID) + lang := inferPersonaLanguage(p) + if lang == "" { + lang = ai.ResponseLanguageFrom(ctx) + } + return formatPersonaPromptBlock(p, mode, lang) +} + +// formatPersonaPromptBlock 組人設段,並明示輸出語言。 +func formatPersonaPromptBlock(p *PersonaSnapshot, mode, lang string) string { + if p == nil { + return "" + } + lang = ai.NormalizeResponseLanguage(lang) + en := lang == "en" + var b strings.Builder + if en { + b.WriteString("Output language: English (match persona samples/fingerprint; do not switch to Chinese unless material is Chinese-only).\n") + } else { + b.WriteString("輸出語言:繁體中文(台灣),除非人設指紋/範例明顯是其他語言——以人設為準。\n") + } + if n := strings.TrimSpace(p.Name); n != "" { + if en { + b.WriteString("Name: ") + } else { + b.WriteString("名稱:") + } + b.WriteString(n) + b.WriteString("\n") + } + if br := strings.TrimSpace(p.Brief); br != "" { + if en { + b.WriteString("Positioning: ") + } else { + b.WriteString("定位:") + } + b.WriteString(truncate(br, 120)) + b.WriteString("\n") + } + if v := strings.TrimSpace(p.Voice); v != "" { + if en { + b.WriteString("Voice: ") + } else { + b.WriteString("語氣:") + } + b.WriteString(truncate(v, 80)) + b.WriteString("\n") + } + + if mode == "chat" { + if len(p.GuardAvoid) > 0 { + if en { + b.WriteString("Avoid: ") + } else { + b.WriteString("禁止:") + } + b.WriteString(strings.Join(p.GuardAvoid, "、")) + b.WriteString("\n") + } + if p.BanAiTone { + if en { + b.WriteString("No AI/customer-service tone\n") + } else { + b.WriteString("禁止 AI 腔/客服腔\n") + } + } + return strings.TrimSpace(b.String()) + } + + fp := strings.TrimSpace(p.DraftText) + if fp != "" { + b.WriteString("【語言指紋 / Language fingerprint】\n") + b.WriteString(truncate(fp, 700)) + b.WriteString("\n") + } + if len(p.DimSummaries) > 0 { + var lines []string + for _, k := range []string{"d1Tone", "d2Structure", "d4Topics"} { + if sm := strings.TrimSpace(p.DimSummaries[k]); sm != "" { + lines = append(lines, k+": "+truncate(sm, 80)) + } + } + if len(lines) > 0 { + b.WriteString("【風格重點】\n") + b.WriteString(strings.Join(lines, "\n")) + b.WriteString("\n") + } + } + var guard []string + if len(p.GuardAvoid) > 0 { + prefix := "禁止:" + if en { + prefix = "Avoid: " + } + guard = append(guard, prefix+strings.Join(p.GuardAvoid, "、")) + } + if p.BanAiTone { + if en { + guard = append(guard, "No AI/customer-service tone") + } else { + guard = append(guard, "禁止 AI 腔/客服腔") + } + } + // 字數上限只在產文(generate)生效;發想聊天不塞字數,讓回覆更快 + if mode == "generate" && p.MaxChars > 0 { + if en { + guard = append(guard, fmt.Sprintf("About %d characters max", p.MaxChars)) + } else { + guard = append(guard, fmt.Sprintf("約不超過 %d 字", p.MaxChars)) + } + } + if len(guard) > 0 { + b.WriteString("【護欄】\n") + b.WriteString(strings.Join(guard, "\n")) + b.WriteString("\n") + } + return strings.TrimSpace(b.String()) +} + +// brandCatalogBlock 只輸出 allowIDs 內的品牌(空 = 不帶任何品牌)。 +func (s *Service) brandCatalogBlock(ctx context.Context, ownerUID int64, allowIDs []string) string { + if s.Brands == nil || len(allowIDs) == 0 { + return "" + } + want := map[string]struct{}{} + for _, id := range allowIDs { + id = strings.TrimSpace(id) + if id != "" { + want[id] = struct{}{} + } + } + if len(want) == 0 { + return "" + } + list, err := s.Brands.ListCatalog(ctx, ownerUID) + if err != nil || len(list) == 0 { + return "" + } + var b strings.Builder + n := 0 + for _, br := range list { + if _, ok := want[br.ID]; !ok { + continue + } + if n > 0 { + b.WriteString("\n") + } + n++ + name := strings.TrimSpace(br.DisplayName) + if name == "" { + name = br.ID + } + b.WriteString("■ 品牌:") + b.WriteString(name) + b.WriteString("\n") + if t := strings.TrimSpace(br.Brief); t != "" { + b.WriteString("簡介:") + b.WriteString(t) + b.WriteString("\n") + } + if t := strings.TrimSpace(br.Audience); t != "" { + b.WriteString("受眾:") + b.WriteString(t) + b.WriteString("\n") + } + if t := strings.TrimSpace(br.Goals); t != "" { + b.WriteString("目標:") + b.WriteString(t) + b.WriteString("\n") + } + if len(br.Products) == 0 { + b.WriteString("(此品牌尚無產品)\n") + continue + } + for _, pr := range br.Products { + label := strings.TrimSpace(pr.Label) + if label == "" { + label = pr.ID + } + b.WriteString(" · 產品:") + b.WriteString(label) + b.WriteString("\n") + if t := strings.TrimSpace(pr.Context); t != "" { + b.WriteString(" 說明:") + b.WriteString(t) + b.WriteString("\n") + } + if len(pr.Tags) > 0 { + b.WriteString(" 標籤:") + b.WriteString(strings.Join(pr.Tags, "、")) + b.WriteString("\n") + } + if len(pr.Pains) > 0 { + b.WriteString(" 痛點:") + b.WriteString(strings.Join(pr.Pains, "、")) + b.WriteString("\n") + } + } + } + return strings.TrimSpace(b.String()) +} + +func (s *Service) runInspireLLM(ctx context.Context, ownerUID int64, prompt string, onDelta func(string) error) (string, error) { + // 真 AI:Registry + 會員 provider/model/key + if s.ResolveAI != nil && s.AIRegistry != nil { + provider, model, apiKey, err := s.ResolveAI(ctx, ownerUID) + if err == nil && strings.TrimSpace(apiKey) != "" && !strings.HasPrefix(strings.ToLower(apiKey), "fake") { + c, cerr := s.AIRegistry.Client(provider) + if cerr == nil { + if onDelta != nil { + return c.CompleteStream(ctx, apiKey, model, prompt, onDelta) + } + return c.Complete(ctx, apiKey, model, prompt) + } + } + } + // 測試 FakeClient + if s.AI != nil { + key := "test-key" + if s.ResolveKey != nil { + if _, k, rerr := s.ResolveKey(ctx, ownerUID, usageDomain.MeterAICopy); rerr == nil && k != "" { + key = k + } + } + if onDelta != nil { + return s.AI.CompleteStream(ctx, key, "grok-3", prompt, onDelta) + } + return s.AI.Complete(ctx, key, "grok-3", prompt) + } + return "", fmt.Errorf("%w: 請到設定填寫 AI Key", domain.ErrValidation) +} + +func (s *Service) ResearchSearch(ctx context.Context, ownerUID int64, query string) ([]*domain.ResearchHit, error) { + query = strings.TrimSpace(query) + if query == "" { + return nil, fmt.Errorf("%w: empty query", domain.ErrValidation) + } + if err := s.bill(ctx, ownerUID, usageDomain.MeterWebSearch, "research search", "research.search"); err != nil { + return nil, err + } + key := "fake" + if s.ResolveKey != nil { + if _, k, err := s.ResolveKey(ctx, ownerUID, usageDomain.MeterWebSearch); err == nil && k != "" { + key = k + } + } + var raw []search.Hit + if s.Search != nil { + h, err := s.Search.Search(ctx, key, query, 5) + if err != nil { + return nil, err + } + raw = h + } else { + raw = []search.Hit{{Title: "About " + query, URL: "https://example.com", Snippet: "snippet"}} + } + out := make([]*domain.ResearchHit, 0, len(raw)) + for i, h := range raw { + out = append(out, &domain.ResearchHit{ + ID: "rh_" + uuid.NewString()[:8], Title: h.Title, Snippet: h.Snippet, URL: h.URL, + Summary: h.Snippet, LearnPoints: []string{"重點 " + fmt.Sprint(i+1), "可引用觀點"}, + ReplyHooks: []string{"你也覺得" + query + "嗎?"}, SourceLabel: "exa", + }) + } + return out, nil +} + +func (s *Service) GenerateImage(ctx context.Context, ownerUID int64, prompt string) (*domain.GeneratedImage, error) { + prompt = strings.TrimSpace(prompt) + if prompt == "" { + return nil, fmt.Errorf("%w: empty prompt", domain.ErrValidation) + } + if err := s.bill(ctx, ownerUID, usageDomain.MeterAIImage, "generate image", "media.generateImage"); err != nil { + return nil, err + } + // placeholder SVG data URL (live-complete with billable path; real SD later) + id := "img_" + uuid.NewString()[:10] + // minimal 1x1 png base64 is tiny; use dicebear-like public URL for preview + url := fmt.Sprintf("https://api.dicebear.com/9.x/shapes/svg?seed=%s", id) + return &domain.GeneratedImage{ID: id, URL: url, Prompt: prompt}, nil +} + +// Legacy removed APIs +func (s *Service) LegacyRemoved() error { return domain.ErrRemoved } + +func (s *Service) bill(ctx context.Context, uid int64, meter, label, source string) error { + if s.Usage == nil { + return nil + } + mode, err := s.Usage.PrepareCall(ctx, uid, meter) + if err != nil { + return err + } + _, err = s.Usage.RecordCall(ctx, uid, meter, mode, label, source) + return err +} + +func emptySession(ownerUID int64) *domain.Session { + now := domain.NowNano() + return &domain.Session{ + ID: "sess_" + uuid.NewString()[:8], + OwnerUID: ownerUID, + Title: "", + Messages: []domain.ChatMessage{}, + PinnedElementIDs: []string{}, + ContextSummary: "", + SummaryMsgCount: 0, + CreatedAt: now, + UpdatedAt: now, + } +} + +func defaultTrends(ownerUID int64) []*domain.TrendItem { + now := domain.NowNano() + // 僅 live 失敗時的後備;SourceLabel=seed,UI 應標示「示意」而非 Threads 即時榜 + return []*domain.TrendItem{ + {ID: "tr_seed_1", OwnerUID: ownerUID, Kind: "threads_tag", Label: "敏感肌換季", Summary: "示意話題(網搜未就緒)", Heat: 1, + Keywords: []string{"敏感肌"}, Samples: []string{"換季又紅了…"}, SourceLabel: "seed", ObservedAt: now}, + {ID: "tr_seed_2", OwnerUID: ownerUID, Kind: "web_keyword", Label: "遠端上班", Summary: "示意話題(網搜未就緒)", Heat: 2, + Keywords: []string{"遠端"}, Samples: []string{"居家辦公第三年"}, SourceLabel: "seed", ObservedAt: now}, + {ID: "tr_seed_3", OwnerUID: ownerUID, Kind: "news", Label: "無香洗髮", Summary: "示意話題(網搜未就緒)", Heat: 3, + Keywords: []string{"無香"}, Samples: []string{"終於找到不刺鼻的"}, SourceLabel: "seed", ObservedAt: now}, + } +} + +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" +} diff --git a/apps/backend/internal/module/inspire/usecase/trends_extract_test.go b/apps/backend/internal/module/inspire/usecase/trends_extract_test.go new file mode 100644 index 0000000..7ee89cf --- /dev/null +++ b/apps/backend/internal/module/inspire/usecase/trends_extract_test.go @@ -0,0 +1,36 @@ +package usecase + +import ( + "strings" + "testing" + + "apps/backend/internal/module/search" + + "github.com/stretchr/testify/require" +) + +func TestExtractTopicLabels_FromGQStyleTitle(t *testing.T) { + // 報導標題 → 應抽出「二砂糖」「炎上展」這類短話題,而不是整段新聞標題 + h := search.Hit{ + Title: "【一週脆報】本週 Threads 紅什麼:為什麼颱風來襲「二砂糖」比泡麵先被掃空?日本超夯「炎上展」登台", + Snippet: "本週 Threads 完全成了大型苦中作樂現場", + } + labels := extractTopicLabels(h) + require.NotEmpty(t, labels) + joined := strings.Join(labels, " ") + require.Contains(t, joined, "二砂糖") + require.Contains(t, joined, "炎上展") + for _, l := range labels { + require.LessOrEqual(t, len([]rune(l)), 18) + require.NotContains(t, l, "Threads新功能") + } +} + +func TestIsMetaThreadsArticle(t *testing.T) { + require.True(t, isMetaThreadsArticle(search.Hit{ + Title: "Threads新功能上線!AI整理全台「趨勢話題」", + })) + require.False(t, isMetaThreadsArticle(search.Hit{ + Title: "本週 Threads 紅什麼:二砂糖與颱風備貨", + })) +} diff --git a/apps/backend/internal/module/job/domain/job.go b/apps/backend/internal/module/job/domain/job.go new file mode 100644 index 0000000..09a55d7 --- /dev/null +++ b/apps/backend/internal/module/job/domain/job.go @@ -0,0 +1,107 @@ +package domain + +import ( + "errors" + "time" +) + +const ( + StatusPending = "pending" + StatusQueued = "queued" + StatusRunning = "running" + StatusSucceeded = "succeeded" + StatusFailed = "failed" + StatusCancelled = "cancelled" + + TemplateDemo = "demo" + TemplateThreadsTokenRenew = "threads_token_renew" + TemplatePersonaAnalyzeAccount = "persona_analyze_account" + TemplatePersonaAnalyzeText = "persona_analyze_text" + TemplateComposeMimic = "compose_mimic" + // TemplatePlayGenerateScript — 互回/串場:一次產完整劇本(背景 job,避免 HTTP 卡住) + TemplatePlayGenerateScript = "play_generate_script" +) + +// Job 生命週期契約(所有模板必須遵守,worker / API 入列時): +// 1. 建立/入列 → StatusQueued + notify(鈴鐺「已排程」) +// 2. ClaimNext → StatusRunning + notify(至少 ~5%) +// 3. 執行中每有進度 → MarkRunningProgress → notify(同一 job upsert 一則通知) +// 4. 終態 SucceedJob / FailJob → notify(已完成/失敗) +// 前端靠全域 JobLive 輪詢列表 + 通知;遠期 run_after 不進頂欄即時條。 + +var ( + ErrNotFound = errors.New("job not found") + ErrForbidden = errors.New("job access denied") + ErrIllegalStatus = errors.New("illegal job status transition") +) + +type Job struct { + ID string `bson:"_id" json:"id"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + TemplateType string `bson:"template_type" json:"template_type"` + Status string `bson:"status" json:"status"` + ProgressSummary string `bson:"progress_summary" json:"progress_summary"` + ProgressPercent int `bson:"progress_percent" json:"progress_percent"` + Error string `bson:"error,omitempty" json:"error,omitempty"` + WorkerID string `bson:"worker_id,omitempty" json:"worker_id,omitempty"` + // RefID e.g. threads account id for renew / persona id for analyze + RefID string `bson:"ref_id,omitempty" json:"ref_id,omitempty"` + // Payload JSON body for job worker (e.g. {"username":"x"}) + Payload string `bson:"payload,omitempty" json:"payload,omitempty"` + // RunAfter unix nanoseconds; worker only claims when now >= RunAfter (0 = due now) + RunAfter int64 `bson:"run_after,omitempty" json:"run_after,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` + CompletedAt int64 `bson:"completed_at,omitempty" json:"completed_at,omitempty"` +} + +// DefaultTokenRenewDelayNs — 連帳後約 30 天做第一次 renew(長效 token ~60 天) +const DefaultTokenRenewDelayNs = int64(30 * 24 * time.Hour) + +// TerminalRetentionNs — 終態任務(已完成/失敗/取消)保留多久後自動清除 +const TerminalRetentionNs = int64(2 * 24 * time.Hour) + +func NowNano() int64 { return time.Now().UTC().UnixNano() } + +// IsTerminal — 不可再轉移的狀態 +func IsTerminal(status string) bool { + return status == StatusSucceeded || status == StatusFailed || status == StatusCancelled +} + +// TerminalCutoff — 早於此時間的終態任務應被 purge +func TerminalCutoff(now int64) int64 { + if now <= 0 { + now = NowNano() + } + return now - TerminalRetentionNs +} + +// CanTransition enforces JB-10 guarded status. +func CanTransition(from, to string) bool { + switch from { + case StatusPending: + return to == StatusQueued || to == StatusRunning || to == StatusFailed + case StatusQueued: + return to == StatusRunning || to == StatusFailed || to == StatusCancelled + case StatusRunning: + return to == StatusSucceeded || to == StatusFailed || to == StatusCancelled + default: + return false // terminal + } +} + +// ApplyProgress enforces monotonic percent (JB-02). +func ApplyProgress(j *Job, percent int, summary string) error { + if percent < j.ProgressPercent { + return ErrIllegalStatus + } + if percent > 100 { + percent = 100 + } + j.ProgressPercent = percent + if summary != "" { + j.ProgressSummary = summary + } + j.UpdatedAt = NowNano() + return nil +} diff --git a/apps/backend/internal/module/job/domain/repository.go b/apps/backend/internal/module/job/domain/repository.go new file mode 100644 index 0000000..adc38ef --- /dev/null +++ b/apps/backend/internal/module/job/domain/repository.go @@ -0,0 +1,19 @@ +package domain + +import "context" + +type Repository interface { + Insert(ctx context.Context, j *Job) error + Update(ctx context.Context, j *Job) error + FindByID(ctx context.Context, id string) (*Job, error) + ListByOwner(ctx context.Context, ownerUID int64) ([]*Job, error) + // ClaimNext picks oldest due pending/queued (run_after <= now) and sets running + workerID. + ClaimNext(ctx context.Context, workerID string) (*Job, error) + // CancelPendingByRef cancels pending/queued jobs of template+ref (e.g. reschedule renew). + CancelPendingByRef(ctx context.Context, ownerUID int64, template, refID string) error + // Delete hard-removes a job by id. + Delete(ctx context.Context, id string) error + // DeleteTerminalBefore removes terminal jobs whose completion time is before beforeNs. + // Returns number of deleted documents. + DeleteTerminalBefore(ctx context.Context, beforeNs int64) (int64, error) +} diff --git a/apps/backend/internal/module/job/repository/memory.go b/apps/backend/internal/module/job/repository/memory.go new file mode 100644 index 0000000..f00a3a0 --- /dev/null +++ b/apps/backend/internal/module/job/repository/memory.go @@ -0,0 +1,147 @@ +package repository + +import ( + "context" + "sync" + + "apps/backend/internal/module/job/domain" +) + +type MemoryStore struct { + mu sync.Mutex + byID map[string]*domain.Job +} + +func NewMemory() *MemoryStore { + return &MemoryStore{byID: map[string]*domain.Job{}} +} + +func (s *MemoryStore) Insert(_ context.Context, j *domain.Job) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *j + s.byID[j.ID] = &cp + return nil +} + +func (s *MemoryStore) Update(_ context.Context, j *domain.Job) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.byID[j.ID]; !ok { + return domain.ErrNotFound + } + cp := *j + s.byID[j.ID] = &cp + return nil +} + +func (s *MemoryStore) FindByID(_ context.Context, id string) (*domain.Job, error) { + s.mu.Lock() + defer s.mu.Unlock() + j, ok := s.byID[id] + if !ok { + return nil, domain.ErrNotFound + } + cp := *j + return &cp, nil +} + +func (s *MemoryStore) ListByOwner(_ context.Context, ownerUID int64) ([]*domain.Job, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*domain.Job + for _, j := range s.byID { + if j.OwnerUID == ownerUID { + cp := *j + out = append(out, &cp) + } + } + return out, nil +} + +func (s *MemoryStore) ClaimNext(_ context.Context, workerID string) (*domain.Job, error) { + s.mu.Lock() + defer s.mu.Unlock() + now := domain.NowNano() + var best *domain.Job + for _, j := range s.byID { + if j.Status != domain.StatusPending && j.Status != domain.StatusQueued { + continue + } + if j.RunAfter > 0 && j.RunAfter > now { + continue + } + if best == nil || j.CreatedAt < best.CreatedAt { + cp := *j + best = &cp + } + } + if best == nil { + return nil, domain.ErrNotFound + } + from := best.Status + to := domain.StatusRunning + if !domain.CanTransition(from, to) { + return nil, domain.ErrIllegalStatus + } + best.Status = to + best.WorkerID = workerID + best.UpdatedAt = now + if best.ProgressSummary == "" { + best.ProgressSummary = "已由 worker 領取(" + workerID + ")" + } + s.byID[best.ID] = best + cp := *best + return &cp, nil +} + +func (s *MemoryStore) CancelPendingByRef(_ context.Context, ownerUID int64, template, refID string) error { + s.mu.Lock() + defer s.mu.Unlock() + now := domain.NowNano() + for _, j := range s.byID { + if j.OwnerUID != ownerUID || j.TemplateType != template || j.RefID != refID { + continue + } + if j.Status != domain.StatusPending && j.Status != domain.StatusQueued { + continue + } + j.Status = domain.StatusCancelled + j.ProgressSummary = "已由新的定期排程取代" + j.CompletedAt = now + j.UpdatedAt = now + } + return nil +} + +func (s *MemoryStore) Delete(_ context.Context, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.byID[id]; !ok { + return domain.ErrNotFound + } + delete(s.byID, id) + return nil +} + +func (s *MemoryStore) DeleteTerminalBefore(_ context.Context, beforeNs int64) (int64, error) { + s.mu.Lock() + defer s.mu.Unlock() + var n int64 + for id, j := range s.byID { + if !domain.IsTerminal(j.Status) { + continue + } + doneAt := j.CompletedAt + if doneAt <= 0 { + doneAt = j.UpdatedAt + } + if doneAt > 0 && doneAt < beforeNs { + delete(s.byID, id) + n++ + } + } + return n, nil +} + +var _ domain.Repository = (*MemoryStore)(nil) diff --git a/apps/backend/internal/module/job/repository/mongo.go b/apps/backend/internal/module/job/repository/mongo.go new file mode 100644 index 0000000..6b6b7ac --- /dev/null +++ b/apps/backend/internal/module/job/repository/mongo.go @@ -0,0 +1,152 @@ +package repository + +import ( + "context" + + libmongo "apps/backend/internal/lib/mongo" + "apps/backend/internal/module/job/domain" + + "github.com/zeromicro/go-zero/core/stores/mon" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const colJobs = "jobs" + +type MonStore struct { + jobs *mon.Model +} + +func NewMonStore(uri, database string) *MonStore { + uri = libmongo.MustMongoURI(uri) + return &MonStore{jobs: mon.MustNewModel(uri, database, colJobs)} +} + +func (s *MonStore) Insert(ctx context.Context, j *domain.Job) error { + _, err := s.jobs.InsertOne(ctx, j) + return err +} + +func (s *MonStore) Update(ctx context.Context, j *domain.Job) error { + j.UpdatedAt = domain.NowNano() + res, err := s.jobs.ReplaceOne(ctx, bson.M{"_id": j.ID}, j) + if err != nil { + return err + } + if res.MatchedCount == 0 { + return domain.ErrNotFound + } + return nil +} + +func (s *MonStore) FindByID(ctx context.Context, id string) (*domain.Job, error) { + var j domain.Job + err := s.jobs.FindOne(ctx, &j, bson.M{"_id": id}) + if err != nil { + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + return nil, err + } + return &j, nil +} + +func (s *MonStore) ListByOwner(ctx context.Context, ownerUID int64) ([]*domain.Job, error) { + var list []*domain.Job + err := s.jobs.Find(ctx, &list, bson.M{"owner_uid": ownerUID}, + options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})) + return list, err +} + +func (s *MonStore) ClaimNext(ctx context.Context, workerID string) (*domain.Job, error) { + now := domain.NowNano() + // due: run_after missing/0 OR run_after <= now + filter := bson.M{ + "status": bson.M{"$in": []string{domain.StatusPending, domain.StatusQueued}}, + "$or": []bson.M{ + {"run_after": bson.M{"$exists": false}}, + {"run_after": 0}, + {"run_after": bson.M{"$lte": now}}, + }, + } + update := bson.M{"$set": bson.M{ + "status": domain.StatusRunning, + "worker_id": workerID, + "updated_at": now, + "progress_summary": "已由 worker 領取(" + workerID + ")", + }} + opts := options.FindOneAndUpdate(). + SetSort(bson.D{{Key: "run_after", Value: 1}, {Key: "created_at", Value: 1}}). + SetReturnDocument(options.After) + var j domain.Job + err := s.jobs.FindOneAndUpdate(ctx, &j, filter, update, opts) + if err != nil { + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + return nil, err + } + return &j, nil +} + +func (s *MonStore) CancelPendingByRef(ctx context.Context, ownerUID int64, template, refID string) error { + if refID == "" { + return nil + } + filter := bson.M{ + "owner_uid": ownerUID, + "template_type": template, + "ref_id": refID, + "status": bson.M{"$in": []string{domain.StatusPending, domain.StatusQueued}}, + } + now := domain.NowNano() + // list then update each (mon may not expose UpdateMany consistently) + var list []*domain.Job + if err := s.jobs.Find(ctx, &list, filter); err != nil { + return err + } + for _, j := range list { + j.Status = domain.StatusCancelled + j.ProgressSummary = "已由新的定期排程取代" + j.CompletedAt = now + j.UpdatedAt = now + _ = s.Update(ctx, j) + } + return nil +} + +func (s *MonStore) Delete(ctx context.Context, id string) error { + res, err := s.jobs.DeleteOne(ctx, bson.M{"_id": id}) + if err != nil { + return err + } + if res == 0 { + return domain.ErrNotFound + } + return nil +} + +func (s *MonStore) DeleteTerminalBefore(ctx context.Context, beforeNs int64) (int64, error) { + // completed_at 優先;缺則用 updated_at(舊資料) + filter := bson.M{ + "status": bson.M{"$in": []string{ + domain.StatusSucceeded, domain.StatusFailed, domain.StatusCancelled, + }}, + "$or": []bson.M{ + {"completed_at": bson.M{"$gt": 0, "$lt": beforeNs}}, + { + "$and": []bson.M{ + {"$or": []bson.M{ + {"completed_at": bson.M{"$exists": false}}, + {"completed_at": 0}, + }}, + {"updated_at": bson.M{"$lt": beforeNs}}, + }, + }, + }, + } + // go-zero mon.DeleteMany returns deleted count (int64) + return s.jobs.DeleteMany(ctx, filter) +} + +var _ domain.Repository = (*MonStore)(nil) diff --git a/apps/backend/internal/module/job/usecase/service.go b/apps/backend/internal/module/job/usecase/service.go new file mode 100644 index 0000000..575005e --- /dev/null +++ b/apps/backend/internal/module/job/usecase/service.go @@ -0,0 +1,458 @@ +package usecase + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "apps/backend/internal/module/job/domain" + + "github.com/google/uuid" +) + +// Notifier — 任務狀態寫入鈴鐺(同一 job upsert;含進度) +type Notifier interface { + // NotifyJobState templateType/status/summary/percent;progress 與 terminal 都走這條 + NotifyJobState(ctx context.Context, ownerUID int64, jobID, templateType, status, summary string, percent int) error +} + +type Service struct { + Repo domain.Repository + Notifier Notifier +} + +func New(repo domain.Repository) *Service { + return &Service{Repo: repo} +} + +// StartDemo — JB-01 +func (s *Service) StartDemo(ctx context.Context, ownerUID int64) (*domain.Job, error) { + now := domain.NowNano() + j := &domain.Job{ + ID: uuid.NewString(), OwnerUID: ownerUID, + TemplateType: domain.TemplateDemo, + Status: domain.StatusPending, + ProgressSummary: "Demo 測試任務已建立", + ProgressPercent: 0, + CreatedAt: now, UpdatedAt: now, + } + if err := s.Repo.Insert(ctx, j); err != nil { + return nil, err + } + j.Status = domain.StatusQueued + j.ProgressSummary = "Demo 測試任務 · 等待 worker 領取" + j.UpdatedAt = domain.NowNano() + _ = s.Repo.Update(ctx, j) + s.notify(ctx, j) + return j, nil +} + +func (s *Service) List(ctx context.Context, ownerUID int64) ([]*domain.Job, error) { + return s.Repo.ListByOwner(ctx, ownerUID) +} + +// Get — JB-09 +func (s *Service) Get(ctx context.Context, ownerUID int64, id string) (*domain.Job, error) { + j, err := s.Repo.FindByID(ctx, id) + if err != nil { + return nil, err + } + if j.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + return j, nil +} + +// Transition — JB-10 +func (s *Service) Transition(ctx context.Context, id, to, summary string, percent int) (*domain.Job, error) { + j, err := s.Repo.FindByID(ctx, id) + if err != nil { + return nil, err + } + if !domain.CanTransition(j.Status, to) { + return nil, domain.ErrIllegalStatus + } + if percent >= 0 { + if err := domain.ApplyProgress(j, percent, summary); err != nil { + return nil, err + } + } else if summary != "" { + j.ProgressSummary = summary + j.UpdatedAt = domain.NowNano() + } + j.Status = to + if to == domain.StatusSucceeded || to == domain.StatusFailed || to == domain.StatusCancelled { + j.CompletedAt = domain.NowNano() + if to == domain.StatusFailed && j.Error == "" { + j.Error = summary + if j.Error == "" { + j.Error = "failed" + } + } + } + if err := s.Repo.Update(ctx, j); err != nil { + return nil, err + } + if s.Notifier != nil && (to == domain.StatusSucceeded || to == domain.StatusFailed || to == domain.StatusCancelled) { + pct := j.ProgressPercent + if to == domain.StatusSucceeded { + pct = 100 + } + _ = s.Notifier.NotifyJobState(ctx, j.OwnerUID, j.ID, j.TemplateType, to, j.ProgressSummary, pct) + } + return j, nil +} + +func (s *Service) ClaimNext(ctx context.Context, workerID string) (*domain.Job, error) { + j, err := s.Repo.ClaimNext(ctx, workerID) + if err != nil { + return nil, err + } + // claim → running:立刻寫鈴鐺/列表進度(所有模板共用) + if j.ProgressPercent < 5 { + j.ProgressPercent = 5 + } + if j.ProgressSummary == "" || strings.Contains(j.ProgressSummary, "worker 領取") { + j.ProgressSummary = "已由 worker 領取 · 執行中" + } + j.UpdatedAt = domain.NowNano() + _ = s.Repo.Update(ctx, j) + s.notify(ctx, j) + return j, nil +} + +// RunDemoToSuccess advances running job with monotonic progress then succeeded. +// 每一步都 MarkRunningProgress → 鈴鐺/前端可即時看到 %。 +func (s *Service) RunDemoToSuccess(ctx context.Context, jobID string) (*domain.Job, error) { + j, err := s.Repo.FindByID(ctx, jobID) + if err != nil { + return nil, err + } + // ensure running(未 claim 時) + if j.Status == domain.StatusPending || j.Status == domain.StatusQueued { + if !domain.CanTransition(j.Status, domain.StatusRunning) { + return nil, domain.ErrIllegalStatus + } + j.Status = domain.StatusRunning + j.ProgressSummary = "Demo 測試任務 · 執行中" + j.ProgressPercent = 5 + j.UpdatedAt = domain.NowNano() + _ = s.Repo.Update(ctx, j) + s.notify(ctx, j) + } + if j.Status != domain.StatusRunning { + return nil, domain.ErrIllegalStatus + } + for _, step := range []struct { + pct int + sum string + }{ + {30, "Demo 測試任務 · 暖機中"}, + {70, "Demo 測試任務 · 處理中"}, + {95, "Demo 測試任務 · 收尾中"}, + } { + if _, err := s.MarkRunningProgress(ctx, jobID, step.pct, step.sum); err != nil { + return nil, err + } + } + return s.SucceedJob(ctx, jobID, "Demo 測試任務 · 已完成") +} + +// ScheduleTokenRenew creates a delayed job to refresh Threads account token (no browser OAuth). +// Previous pending renews for the same account are cancelled. +func (s *Service) ScheduleTokenRenew(ctx context.Context, ownerUID int64, accountID string, runAfter int64) error { + if ownerUID <= 0 || accountID == "" { + return domain.ErrForbidden + } + now := domain.NowNano() + if runAfter <= 0 { + runAfter = now + domain.DefaultTokenRenewDelayNs + } + _ = s.Repo.CancelPendingByRef(ctx, ownerUID, domain.TemplateThreadsTokenRenew, accountID) + j := &domain.Job{ + ID: uuid.NewString(), OwnerUID: ownerUID, + TemplateType: domain.TemplateThreadsTokenRenew, + Status: domain.StatusQueued, + RefID: accountID, + RunAfter: runAfter, + ProgressSummary: "定期延長 Threads token(約每 30 天)· 已排程等待執行", + ProgressPercent: 0, + CreatedAt: now, UpdatedAt: now, + } + if err := s.Repo.Insert(ctx, j); err != nil { + return err + } + // 與其他任務相同:入列就寫一則通知(延遲執行仍會在任務列表可見) + s.notify(ctx, j) + return nil +} + +// Ensure job.Service implements TokenRenewScheduler (threads package). +var _ interface { + ScheduleTokenRenew(ctx context.Context, ownerUID int64, accountID string, runAfter int64) error +} = (*Service)(nil) + +// PersonaAnalyzeAccountPayload — worker 爬公開貼文並分析 +type PersonaAnalyzeAccountPayload struct { + Username string `json:"username"` + Lang string `json:"lang,omitempty"` +} + +// PersonaAnalyzeTextPayload — worker 從貼文文字分析 +type PersonaAnalyzeTextPayload struct { + RawText string `json:"raw_text"` + SourceLabel string `json:"source_label,omitempty"` + Lang string `json:"lang,omitempty"` +} + +// SchedulePersonaAnalyzeAccount — 立刻可領;ref=personaID;同人設舊佇列取消 +func (s *Service) SchedulePersonaAnalyzeAccount(ctx context.Context, ownerUID int64, personaID, username, lang string) (*domain.Job, error) { + if ownerUID <= 0 || personaID == "" { + return nil, domain.ErrForbidden + } + username = strings.TrimPrefix(strings.TrimSpace(username), "@") + if username == "" { + return nil, fmt.Errorf("empty username") + } + _ = s.Repo.CancelPendingByRef(ctx, ownerUID, domain.TemplatePersonaAnalyzeAccount, personaID) + _ = s.Repo.CancelPendingByRef(ctx, ownerUID, domain.TemplatePersonaAnalyzeText, personaID) + body, _ := json.Marshal(PersonaAnalyzeAccountPayload{Username: username, Lang: lang}) + now := domain.NowNano() + j := &domain.Job{ + ID: uuid.NewString(), OwnerUID: ownerUID, + TemplateType: domain.TemplatePersonaAnalyzeAccount, + Status: domain.StatusQueued, + RefID: personaID, + Payload: string(body), + ProgressSummary: fmt.Sprintf("人設分析 · 爬取 @%s 公開貼文 · 已排程", username), + ProgressPercent: 0, + CreatedAt: now, UpdatedAt: now, + } + if err := s.Repo.Insert(ctx, j); err != nil { + return nil, err + } + s.notify(ctx, j) + return j, nil +} + +// ComposeMimicPayload — worker 仿寫輸入/輸出 +type ComposeMimicPayload struct { + SourceText string `json:"source_text"` + PersonaID string `json:"persona_id,omitempty"` + StructureNotes string `json:"structure_notes,omitempty"` + Lang string `json:"lang,omitempty"` + // ResultText 成功後寫回 + ResultText string `json:"result_text,omitempty"` +} + +// ScheduleComposeMimic — 立刻可領;仿寫走背景 job,避免 HTTP 120s 逾時 +func (s *Service) ScheduleComposeMimic(ctx context.Context, ownerUID int64, sourceText, personaID, structureNotes, lang string) (*domain.Job, error) { + if ownerUID <= 0 { + return nil, domain.ErrForbidden + } + sourceText = strings.TrimSpace(sourceText) + if sourceText == "" { + return nil, fmt.Errorf("empty source") + } + // 同使用者只保留一則進行中的仿寫(可選:不 cancel 舊的也可) + body, _ := json.Marshal(ComposeMimicPayload{ + SourceText: sourceText, PersonaID: strings.TrimSpace(personaID), + StructureNotes: strings.TrimSpace(structureNotes), Lang: lang, + }) + now := domain.NowNano() + j := &domain.Job{ + ID: uuid.NewString(), OwnerUID: ownerUID, + TemplateType: domain.TemplateComposeMimic, + Status: domain.StatusQueued, + RefID: strings.TrimSpace(personaID), + Payload: string(body), + ProgressSummary: "仿寫貼文 · 已排程(可離開頁面)", + ProgressPercent: 0, + CreatedAt: now, UpdatedAt: now, + } + if err := s.Repo.Insert(ctx, j); err != nil { + return nil, err + } + s.notify(ctx, j) + return j, nil +} + +// PlayGenerateScriptPayload — worker 一次產完整互回劇本 +type PlayGenerateScriptPayload struct { + PlayID string `json:"play_id"` + OnlyEmpty bool `json:"only_empty"` + // 成功後統計 + Filled int `json:"filled,omitempty"` +} + +// SchedulePlayGenerateScript — 立刻可領;ref=playID;同劇本舊佇列取消 +func (s *Service) SchedulePlayGenerateScript(ctx context.Context, ownerUID int64, playID string, onlyEmpty bool) (*domain.Job, error) { + if ownerUID <= 0 || strings.TrimSpace(playID) == "" { + return nil, domain.ErrForbidden + } + playID = strings.TrimSpace(playID) + _ = s.Repo.CancelPendingByRef(ctx, ownerUID, domain.TemplatePlayGenerateScript, playID) + body, _ := json.Marshal(PlayGenerateScriptPayload{PlayID: playID, OnlyEmpty: onlyEmpty}) + now := domain.NowNano() + j := &domain.Job{ + ID: uuid.NewString(), OwnerUID: ownerUID, + TemplateType: domain.TemplatePlayGenerateScript, + Status: domain.StatusQueued, + RefID: playID, + Payload: string(body), + ProgressSummary: "劇本 AI 產文 · 已排程(一次產全部步驟,可離開頁面)", + ProgressPercent: 0, + CreatedAt: now, UpdatedAt: now, + } + if err := s.Repo.Insert(ctx, j); err != nil { + return nil, err + } + s.notify(ctx, j) + return j, nil +} + +// SucceedJobWithPayload — 終態成功並寫回 payload(仿寫結果) +func (s *Service) SucceedJobWithPayload(ctx context.Context, jobID, summary, payload string) (*domain.Job, error) { + j, err := s.Repo.FindByID(ctx, jobID) + if err != nil { + return nil, err + } + if !domain.CanTransition(j.Status, domain.StatusSucceeded) { + return nil, domain.ErrIllegalStatus + } + if payload != "" { + j.Payload = payload + } + if err := domain.ApplyProgress(j, 100, summary); err != nil { + return nil, err + } + j.Status = domain.StatusSucceeded + j.CompletedAt = domain.NowNano() + j.UpdatedAt = j.CompletedAt + if err := s.Repo.Update(ctx, j); err != nil { + return nil, err + } + s.notify(ctx, j) + return j, nil +} + +// SchedulePersonaAnalyzeText — 立刻可領;ref=personaID +func (s *Service) SchedulePersonaAnalyzeText(ctx context.Context, ownerUID int64, personaID, rawText, sourceLabel, lang string) (*domain.Job, error) { + if ownerUID <= 0 || personaID == "" { + return nil, domain.ErrForbidden + } + rawText = strings.TrimSpace(rawText) + if rawText == "" { + return nil, fmt.Errorf("empty text") + } + _ = s.Repo.CancelPendingByRef(ctx, ownerUID, domain.TemplatePersonaAnalyzeAccount, personaID) + _ = s.Repo.CancelPendingByRef(ctx, ownerUID, domain.TemplatePersonaAnalyzeText, personaID) + body, _ := json.Marshal(PersonaAnalyzeTextPayload{ + RawText: rawText, SourceLabel: strings.TrimSpace(sourceLabel), Lang: lang, + }) + now := domain.NowNano() + label := strings.TrimSpace(sourceLabel) + if label == "" { + label = "手動貼文" + } + j := &domain.Job{ + ID: uuid.NewString(), OwnerUID: ownerUID, + TemplateType: domain.TemplatePersonaAnalyzeText, + Status: domain.StatusQueued, + RefID: personaID, + Payload: string(body), + ProgressSummary: fmt.Sprintf("人設分析 · 文字來源「%s」· 已排程", label), + ProgressPercent: 0, + CreatedAt: now, UpdatedAt: now, + } + if err := s.Repo.Insert(ctx, j); err != nil { + return nil, err + } + s.notify(ctx, j) + return j, nil +} + +// RunTokenRenew executes threads_token_renew job body (caller provides refresh fn). +func (s *Service) MarkRunningProgress(ctx context.Context, jobID string, pct int, summary string) (*domain.Job, error) { + j, err := s.Repo.FindByID(ctx, jobID) + if err != nil { + return nil, err + } + if j.Status != domain.StatusRunning { + return nil, domain.ErrIllegalStatus + } + if err := domain.ApplyProgress(j, pct, summary); err != nil { + return nil, err + } + if err := s.Repo.Update(ctx, j); err != nil { + return nil, err + } + // 每次有進度就更新鈴鐺(upsert 同一則) + s.notify(ctx, j) + return j, nil +} + +func (s *Service) notify(ctx context.Context, j *domain.Job) { + if s.Notifier == nil || j == nil { + return + } + _ = s.Notifier.NotifyJobState(ctx, j.OwnerUID, j.ID, j.TemplateType, j.Status, j.ProgressSummary, j.ProgressPercent) +} + +func (s *Service) SucceedJob(ctx context.Context, jobID, summary string) (*domain.Job, error) { + return s.Transition(ctx, jobID, domain.StatusSucceeded, summary, 100) +} + +// Delete — 擁有者手動刪除;執行中不可刪。 +func (s *Service) Delete(ctx context.Context, ownerUID int64, id string) error { + j, err := s.Repo.FindByID(ctx, id) + if err != nil { + return err + } + if j.OwnerUID != ownerUID { + return domain.ErrForbidden + } + if j.Status == domain.StatusRunning { + return domain.ErrIllegalStatus + } + return s.Repo.Delete(ctx, id) +} + +// PurgeExpiredTerminal — 清除終態超過保留期(預設 2 天)的任務。回傳刪除筆數。 +func (s *Service) PurgeExpiredTerminal(ctx context.Context) (int64, error) { + return s.Repo.DeleteTerminalBefore(ctx, domain.TerminalCutoff(domain.NowNano())) +} + +// FailJob — JB-04 +func (s *Service) FailJob(ctx context.Context, jobID, errMsg string) (*domain.Job, error) { + j, err := s.Repo.FindByID(ctx, jobID) + if err != nil { + return nil, err + } + if j.Status == domain.StatusPending || j.Status == domain.StatusQueued { + j.Status = domain.StatusRunning + j.UpdatedAt = domain.NowNano() + _ = s.Repo.Update(ctx, j) + } + if !domain.CanTransition(j.Status, domain.StatusFailed) { + return nil, domain.ErrIllegalStatus + } + j.Status = domain.StatusFailed + j.Error = errMsg + if errMsg != "" { + j.ProgressSummary = "失敗:" + errMsg + if len([]rune(j.ProgressSummary)) > 200 { + j.ProgressSummary = string([]rune(j.ProgressSummary)[:200]) + "…" + } + } else { + j.ProgressSummary = "失敗" + } + j.CompletedAt = domain.NowNano() + j.UpdatedAt = j.CompletedAt + if err := s.Repo.Update(ctx, j); err != nil { + return nil, err + } + s.notify(ctx, j) + return j, nil +} diff --git a/apps/backend/internal/module/job/usecase/service_test.go b/apps/backend/internal/module/job/usecase/service_test.go new file mode 100644 index 0000000..5a202c6 --- /dev/null +++ b/apps/backend/internal/module/job/usecase/service_test.go @@ -0,0 +1,236 @@ +package usecase_test + +import ( + "context" + "testing" + + "apps/backend/internal/module/appnotif/repository" + appUC "apps/backend/internal/module/appnotif/usecase" + "apps/backend/internal/module/job/domain" + jobRepo "apps/backend/internal/module/job/repository" + "apps/backend/internal/module/job/usecase" + + "github.com/stretchr/testify/require" +) + +func newJobSvc() (*usecase.Service, *appUC.Service) { + jr := jobRepo.NewMemory() + nr := repository.NewMemory() + notif := appUC.New(nr) + svc := usecase.New(jr) + svc.Notifier = notif + return svc, notif +} + +func TestJB_01_CreateDemo(t *testing.T) { + svc, _ := newJobSvc() + j, err := svc.StartDemo(context.Background(), 1_000_100) + require.NoError(t, err) + require.Equal(t, domain.StatusQueued, j.Status) + require.Equal(t, domain.TemplateDemo, j.TemplateType) + list, err := svc.List(context.Background(), 1_000_100) + require.NoError(t, err) + require.NotEmpty(t, list) +} + +func TestJB_02_ProgressMonotonic(t *testing.T) { + svc, _ := newJobSvc() + j, err := svc.StartDemo(context.Background(), 1_000_101) + require.NoError(t, err) + claimed, err := svc.ClaimNext(context.Background(), "worker-test") + require.NoError(t, err) + require.Equal(t, j.ID, claimed.ID) + require.Equal(t, domain.StatusRunning, claimed.Status) + out, err := svc.RunDemoToSuccess(context.Background(), claimed.ID) + require.NoError(t, err) + require.Equal(t, 100, out.ProgressPercent) + require.Equal(t, domain.StatusSucceeded, out.Status) + // cannot go backwards + err = domain.ApplyProgress(out, 50, "nope") + require.ErrorIs(t, err, domain.ErrIllegalStatus) +} + +func TestJB_03_JobSucceeded(t *testing.T) { + svc, _ := newJobSvc() + j, _ := svc.StartDemo(context.Background(), 1_000_102) + _, _ = svc.ClaimNext(context.Background(), "w1") + out, err := svc.RunDemoToSuccess(context.Background(), j.ID) + require.NoError(t, err) + require.Equal(t, domain.StatusSucceeded, out.Status) + require.True(t, out.CompletedAt > 0) +} + +func TestJB_04_JobFailed(t *testing.T) { + svc, _ := newJobSvc() + j, _ := svc.StartDemo(context.Background(), 1_000_103) + _, _ = svc.ClaimNext(context.Background(), "w1") + out, err := svc.FailJob(context.Background(), j.ID, "boom") + require.NoError(t, err) + require.Equal(t, domain.StatusFailed, out.Status) + require.NotEmpty(t, out.Error) + require.True(t, out.CompletedAt > 0) +} + +func TestJB_05_JobTerminalCreatesNotification(t *testing.T) { + svc, notif := newJobSvc() + uid := int64(1_000_104) + j, _ := svc.StartDemo(context.Background(), uid) + _, _ = svc.ClaimNext(context.Background(), "w1") + _, err := svc.RunDemoToSuccess(context.Background(), j.ID) + require.NoError(t, err) + list, err := notif.List(context.Background(), uid) + require.NoError(t, err) + require.NotEmpty(t, list) +} + +func TestJB_06_UnreadCount(t *testing.T) { + svc, notif := newJobSvc() + uid := int64(1_000_105) + j, _ := svc.StartDemo(context.Background(), uid) + _, _ = svc.ClaimNext(context.Background(), "w1") + _, _ = svc.FailJob(context.Background(), j.ID, "x") + c, err := notif.UnreadCount(context.Background(), uid) + require.NoError(t, err) + require.Equal(t, int64(1), c) +} + +func TestJB_07_MarkRead(t *testing.T) { + svc, notif := newJobSvc() + uid := int64(1_000_106) + j, _ := svc.StartDemo(context.Background(), uid) + _, _ = svc.ClaimNext(context.Background(), "w1") + _, _ = svc.RunDemoToSuccess(context.Background(), j.ID) + list, _ := notif.List(context.Background(), uid) + require.NotEmpty(t, list) + require.NoError(t, notif.MarkRead(context.Background(), uid, list[0].ID)) + c, _ := notif.UnreadCount(context.Background(), uid) + require.Equal(t, int64(0), c) + require.True(t, list[0].ReadAt == 0) // old snapshot +} + +func TestJB_08_MarkAllRead(t *testing.T) { + svc, notif := newJobSvc() + uid := int64(1_000_107) + for i := 0; i < 2; i++ { + j, _ := svc.StartDemo(context.Background(), uid) + _, _ = svc.ClaimNext(context.Background(), "w1") + _, _ = svc.RunDemoToSuccess(context.Background(), j.ID) + } + c, _ := notif.UnreadCount(context.Background(), uid) + require.GreaterOrEqual(t, c, int64(2)) + require.NoError(t, notif.MarkAllRead(context.Background(), uid)) + c2, _ := notif.UnreadCount(context.Background(), uid) + require.Equal(t, int64(0), c2) +} + +func TestJB_09_GetOtherUIDDenied(t *testing.T) { + svc, _ := newJobSvc() + j, _ := svc.StartDemo(context.Background(), 1_000_108) + _, err := svc.Get(context.Background(), 1_000_109, j.ID) + require.ErrorIs(t, err, domain.ErrForbidden) +} + +func TestJB_10_GuardedReject(t *testing.T) { + svc, _ := newJobSvc() + j, _ := svc.StartDemo(context.Background(), 1_000_110) + _, _ = svc.ClaimNext(context.Background(), "w1") + _, _ = svc.RunDemoToSuccess(context.Background(), j.ID) + _, err := svc.Transition(context.Background(), j.ID, domain.StatusRunning, "nope", 10) + require.ErrorIs(t, err, domain.ErrIllegalStatus) +} + +func TestJB_11_ManualDeleteTerminal(t *testing.T) { + svc, _ := newJobSvc() + uid := int64(1_000_111) + j, _ := svc.StartDemo(context.Background(), uid) + _, _ = svc.ClaimNext(context.Background(), "w1") + out, err := svc.RunDemoToSuccess(context.Background(), j.ID) + require.NoError(t, err) + require.Equal(t, domain.StatusSucceeded, out.Status) + + require.NoError(t, svc.Delete(context.Background(), uid, j.ID)) + _, err = svc.Get(context.Background(), uid, j.ID) + require.ErrorIs(t, err, domain.ErrNotFound) +} + +func TestJB_12_DeleteRunningRefused(t *testing.T) { + svc, _ := newJobSvc() + uid := int64(1_000_112) + j, _ := svc.StartDemo(context.Background(), uid) + claimed, err := svc.ClaimNext(context.Background(), "w1") + require.NoError(t, err) + require.Equal(t, domain.StatusRunning, claimed.Status) + err = svc.Delete(context.Background(), uid, j.ID) + require.ErrorIs(t, err, domain.ErrIllegalStatus) +} + +func TestJB_13_PurgeExpiredTerminal(t *testing.T) { + svc, _ := newJobSvc() + uid := int64(1_000_113) + j, _ := svc.StartDemo(context.Background(), uid) + _, _ = svc.ClaimNext(context.Background(), "w1") + out, err := svc.RunDemoToSuccess(context.Background(), j.ID) + require.NoError(t, err) + + // 模擬完成時間在 2 天前 + out.CompletedAt = domain.NowNano() - domain.TerminalRetentionNs - 1 + out.UpdatedAt = out.CompletedAt + require.NoError(t, svc.Repo.Update(context.Background(), out)) + + n, err := svc.PurgeExpiredTerminal(context.Background()) + require.NoError(t, err) + require.Equal(t, int64(1), n) + _, err = svc.Get(context.Background(), uid, j.ID) + require.ErrorIs(t, err, domain.ErrNotFound) +} + +func TestJB_14_PurgeKeepsRecentTerminal(t *testing.T) { + svc, _ := newJobSvc() + uid := int64(1_000_114) + j, _ := svc.StartDemo(context.Background(), uid) + _, _ = svc.ClaimNext(context.Background(), "w1") + _, err := svc.RunDemoToSuccess(context.Background(), j.ID) + require.NoError(t, err) + + n, err := svc.PurgeExpiredTerminal(context.Background()) + require.NoError(t, err) + require.Equal(t, int64(0), n) + got, err := svc.Get(context.Background(), uid, j.ID) + require.NoError(t, err) + require.Equal(t, j.ID, got.ID) +} + +func TestJB_15_SchedulePersonaAnalyzeAccount(t *testing.T) { + svc, _ := newJobSvc() + uid := int64(1_000_115) + j, err := svc.SchedulePersonaAnalyzeAccount(context.Background(), uid, "pe_abc", "ultralab_tw", "zh-TW") + require.NoError(t, err) + require.Equal(t, domain.TemplatePersonaAnalyzeAccount, j.TemplateType) + require.Equal(t, domain.StatusQueued, j.Status) + require.Equal(t, "pe_abc", j.RefID) + require.Contains(t, j.Payload, "ultralab_tw") + require.Contains(t, j.ProgressSummary, "@ultralab_tw") + + // re-schedule cancels previous pending + j2, err := svc.SchedulePersonaAnalyzeAccount(context.Background(), uid, "pe_abc", "other", "en") + require.NoError(t, err) + require.NotEqual(t, j.ID, j2.ID) + old, err := svc.Get(context.Background(), uid, j.ID) + require.NoError(t, err) + require.Equal(t, domain.StatusCancelled, old.Status) + + claimed, err := svc.ClaimNext(context.Background(), "w1") + require.NoError(t, err) + require.Equal(t, j2.ID, claimed.ID) + require.Equal(t, domain.StatusRunning, claimed.Status) +} + +func TestJB_16_SchedulePersonaAnalyzeText(t *testing.T) { + svc, _ := newJobSvc() + uid := int64(1_000_116) + j, err := svc.SchedulePersonaAnalyzeText(context.Background(), uid, "pe_txt", "hello world\n---\nsecond sample text here", "手動", "zh-TW") + require.NoError(t, err) + require.Equal(t, domain.TemplatePersonaAnalyzeText, j.TemplateType) + require.Equal(t, "pe_txt", j.RefID) + require.Contains(t, j.Payload, "raw_text") +} diff --git a/apps/backend/internal/module/member/domain/member.go b/apps/backend/internal/module/member/domain/member.go index 799150e..f0cef2d 100644 --- a/apps/backend/internal/module/member/domain/member.go +++ b/apps/backend/internal/module/member/domain/member.go @@ -1,6 +1,9 @@ package domain -import "time" +import ( + "strings" + "time" +) // Member is the person profile (stand-alone user_info),uid 為數字主鍵。 type Member struct { @@ -71,20 +74,73 @@ type UserSettings struct { Model string `bson:"model" json:"model"` APIKeyConfigured bool `bson:"api_key_configured" json:"api_key_configured"` ResearchAPIKeyConfigured bool `bson:"research_api_key_configured" json:"research_api_key_configured"` - APIKey string `bson:"api_key,omitempty" json:"-"` - ExaAPIKey string `bson:"exa_api_key,omitempty" json:"-"` - WebSearchProvider string `bson:"web_search_provider" json:"web_search_provider"` - ExpandStrategy string `bson:"expand_strategy" json:"expand_strategy"` - ExaAPIKeyConfigured bool `bson:"exa_api_key_configured" json:"exa_api_key_configured"` - DevModeEnabled bool `bson:"dev_mode_enabled" json:"dev_mode_enabled"` - UpdatedAt int64 `bson:"updated_at" json:"updated_at"` + // APIKey — legacy single key (migrated into ProviderAPIKeys on save) + APIKey string `bson:"api_key,omitempty" json:"-"` + // ProviderAPIKeys — per-provider BYOK: "xai" | "opencode-go" + ProviderAPIKeys map[string]string `bson:"provider_api_keys,omitempty" json:"-"` + ExaAPIKey string `bson:"exa_api_key,omitempty" json:"-"` + WebSearchProvider string `bson:"web_search_provider" json:"web_search_provider"` + ExpandStrategy string `bson:"expand_strategy" json:"expand_strategy"` + ExaAPIKeyConfigured bool `bson:"exa_api_key_configured" json:"exa_api_key_configured"` + DevModeEnabled bool `bson:"dev_mode_enabled" json:"dev_mode_enabled"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` } func DefaultSettings(uid int64) *UserSettings { return &UserSettings{ UID: uid, Provider: "xai", Model: "grok-3", WebSearchProvider: "exa", ExpandStrategy: "hybrid", + ProviderAPIKeys: map[string]string{}, } } +// KeyForProvider returns BYOK for provider (legacy APIKey fallback for current provider). +func (s *UserSettings) KeyForProvider(provider string) string { + if s == nil { + return "" + } + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "" { + provider = s.Provider + } + if s.ProviderAPIKeys != nil { + if k := strings.TrimSpace(s.ProviderAPIKeys[provider]); k != "" { + return k + } + } + // legacy single key only applies to currently selected provider + if provider == s.Provider || s.Provider == "" { + return strings.TrimSpace(s.APIKey) + } + return "" +} + +// SetKeyForProvider stores BYOK under provider map. +func (s *UserSettings) SetKeyForProvider(provider, key string) { + if s == nil { + return + } + provider = strings.ToLower(strings.TrimSpace(provider)) + if s.ProviderAPIKeys == nil { + s.ProviderAPIKeys = map[string]string{} + } + key = strings.TrimSpace(key) + if key == "" { + delete(s.ProviderAPIKeys, provider) + } else { + s.ProviderAPIKeys[provider] = key + } + // keep legacy field in sync for current provider + if provider == s.Provider { + s.APIKey = key + s.APIKeyConfigured = key != "" + s.ResearchAPIKeyConfigured = key != "" + } +} + +// HasKeyForProvider — UI configured flag. +func (s *UserSettings) HasKeyForProvider(provider string) bool { + return strings.TrimSpace(s.KeyForProvider(provider)) != "" +} + func NowNano() int64 { return time.Now().UTC().UnixNano() } diff --git a/apps/backend/internal/module/member/usecase/auth_test.go b/apps/backend/internal/module/member/usecase/auth_test.go index 082adad..e57e472 100644 --- a/apps/backend/internal/module/member/usecase/auth_test.go +++ b/apps/backend/internal/module/member/usecase/auth_test.go @@ -3,6 +3,7 @@ package usecase_test import ( "context" "fmt" + "strings" "sync" "testing" "time" @@ -108,9 +109,53 @@ func (f *fakeRepo) UpdateMember(ctx context.Context, m *domain.Member) error { return nil } func (f *fakeRepo) ListPage(ctx context.Context, page, pageSize int, query, status string) ([]*domain.Member, int64, error) { - return nil, 0, nil + f.mu.Lock() + defer f.mu.Unlock() + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + query = strings.ToLower(strings.TrimSpace(query)) + var all []*domain.Member + for _, m := range f.byUID { + if status != "" && m.Status != status { + continue + } + if query != "" { + uidStr := fmt.Sprintf("%d", m.UID) + if !strings.Contains(strings.ToLower(m.Email), query) && + !strings.Contains(strings.ToLower(m.DisplayName), query) && + !strings.Contains(uidStr, query) { + continue + } + } + cp := *m + all = append(all, &cp) + } + total := int64(len(all)) + start := (page - 1) * pageSize + if start >= len(all) { + return []*domain.Member{}, total, nil + } + end := start + pageSize + if end > len(all) { + end = len(all) + } + return all[start:end], total, nil +} +func (f *fakeRepo) CountActiveAdmins(ctx context.Context) (int64, error) { + f.mu.Lock() + defer f.mu.Unlock() + var n int64 + for _, m := range f.byUID { + if m.IsAdmin() && m.Status != domain.StatusSuspended { + n++ + } + } + return n, nil } -func (f *fakeRepo) CountActiveAdmins(ctx context.Context) (int64, error) { return 1, nil } func (f *fakeRepo) CreateIdentity(ctx context.Context, id *domain.Identity) error { f.mu.Lock() diff --git a/apps/backend/internal/module/member/usecase/m1_t119_test.go b/apps/backend/internal/module/member/usecase/m1_t119_test.go new file mode 100644 index 0000000..112d47f --- /dev/null +++ b/apps/backend/internal/module/member/usecase/m1_t119_test.go @@ -0,0 +1,565 @@ +package usecase_test + +// T119 — M1 Auth/Admin integration gate (spec §9 AU / AR / AP / AO / AD). +// Case names are greppable: TestAU_01_… TestAR_02_… etc. + +import ( + "context" + "errors" + "fmt" + "testing" + + "apps/backend/internal/module/member/domain" + "apps/backend/internal/module/member/usecase" + tokenUC "apps/backend/internal/module/token/usecase" + + "github.com/stretchr/testify/require" +) + +const strongPW = "Admin123!@#x" +const strongPW2 = "NewPass99!@#a" + +func newM1Svc() (*fakeRepo, *usecase.AccountService) { + store := newFake() + svc := usecase.NewAuthService(store, tokenUC.NewJWTIssuer("t119-a", "t119-r", 3600, 86400), 10) + return store, svc +} + +func activate(t *testing.T, store *fakeRepo, m *domain.Member) *domain.Member { + t.Helper() + m.Status = domain.StatusActive + m.EmailVerified = true + now := domain.NowNano() + m.EmailVerifiedAt = &now + require.NoError(t, store.UpdateMember(context.Background(), m)) + return m +} + +func seedAdmin(t *testing.T, store *fakeRepo, svc *usecase.AccountService, email string) (*domain.Member, string) { + t.Helper() + m, _, err := svc.Register(context.Background(), email, strongPW, "Admin") + require.NoError(t, err) + m.Roles = []string{domain.RoleAdmin, domain.RoleMember} + activate(t, store, m) + return m, strongPW +} + +func seedMember(t *testing.T, store *fakeRepo, svc *usecase.AccountService, email string) (*domain.Member, string) { + t.Helper() + m, _, err := svc.Register(context.Background(), email, strongPW, "Mem") + require.NoError(t, err) + activate(t, store, m) + return m, strongPW +} + +// ---------- AU Auth session ---------- + +func TestAU_01_LoginSuccess(t *testing.T) { + store, svc := newM1Svc() + m, pw := seedAdmin(t, store, svc, "au01@test.local") + m2, tokens, err := svc.Login(context.Background(), "au01@test.local", pw) + require.NoError(t, err) + require.Equal(t, m.UID, m2.UID) + require.True(t, m2.UID >= domain.MinMemberUID) + require.NotEmpty(t, tokens.AccessToken) + require.NotEmpty(t, tokens.RefreshToken) + require.Contains(t, m2.Roles, domain.RoleAdmin) +} + +func TestAU_03_LoginUnknownEmail(t *testing.T) { + _, svc := newM1Svc() + _, _, err := svc.Login(context.Background(), "nobody@test.local", strongPW) + require.ErrorIs(t, err, domain.ErrBadPassword) // same shape as AU-02 +} + +func TestAU_04_MeNumericUID(t *testing.T) { + store, svc := newM1Svc() + m, _ := seedMember(t, store, svc, "au04@test.local") + me, err := svc.Me(context.Background(), m.UID) + require.NoError(t, err) + require.Equal(t, m.UID, me.UID) + require.True(t, me.UID >= domain.MinMemberUID) + require.Equal(t, "au04@test.local", me.Email) +} + +func TestAU_08_RefreshInvalid(t *testing.T) { + _, svc := newM1Svc() + _, err := svc.Refresh(context.Background(), "not-a-valid-refresh") + require.Error(t, err) +} + +func TestAU_09_LogoutThenRefreshFails(t *testing.T) { + store, svc := newM1Svc() + _, _ = seedMember(t, store, svc, "au09@test.local") + _, tokens, err := svc.Login(context.Background(), "au09@test.local", strongPW) + require.NoError(t, err) + svc.Logout(tokens.RefreshToken) + _, err = svc.Refresh(context.Background(), tokens.RefreshToken) + require.Error(t, err) +} + +func TestAU_10_SuspendedLoginFails(t *testing.T) { + store, svc := newM1Svc() + m, pw := seedMember(t, store, svc, "au10@test.local") + _, err := svc.UpdateStatus(context.Background(), m.UID, domain.StatusSuspended) + require.NoError(t, err) + _, _, err = svc.Login(context.Background(), "au10@test.local", pw) + require.ErrorIs(t, err, domain.ErrSuspended) +} + +func TestAU_11_SuspendedMeFails(t *testing.T) { + store, svc := newM1Svc() + m, _ := seedMember(t, store, svc, "au11@test.local") + _, err := svc.UpdateStatus(context.Background(), m.UID, domain.StatusSuspended) + require.NoError(t, err) + _, err = svc.Me(context.Background(), m.UID) + require.ErrorIs(t, err, domain.ErrSuspended) +} + +func TestAU_14_PrivateResourceOtherUID(t *testing.T) { + // Domain rule: Me(uid) only returns that uid's row — callers must pass JWT uid. + store, svc := newM1Svc() + a, _ := seedMember(t, store, svc, "au14a@test.local") + b, _ := seedMember(t, store, svc, "au14b@test.local") + meA, err := svc.Me(context.Background(), a.UID) + require.NoError(t, err) + require.NotEqual(t, b.UID, meA.UID) + meB, err := svc.Me(context.Background(), b.UID) + require.NoError(t, err) + require.Equal(t, b.UID, meB.UID) +} + +// ---------- AR Register / verify ---------- + +func TestAR_01_RegisterAPICallable(t *testing.T) { + // AR-01: register path is implemented on AccountService (not missing / empty success) + _, svc := newM1Svc() + m, pair, err := svc.Register(context.Background(), "ar01@test.local", strongPW, "AR01") + require.NoError(t, err) + require.NotNil(t, m) + require.NotEmpty(t, pair.AccessToken) +} + +func TestAR_03_RegisterDuplicateEmail(t *testing.T) { + _, svc := newM1Svc() + _, _, err := svc.Register(context.Background(), "dup@test.local", strongPW, "A") + require.NoError(t, err) + _, _, err = svc.Register(context.Background(), "dup@test.local", strongPW, "B") + // identity 先撞庫可能回 IdentityTaken;email 撞庫回 EmailTaken — 皆為「失敗、不建第二帳」 + require.Error(t, err) + require.True(t, errors.Is(err, domain.ErrEmailTaken) || errors.Is(err, domain.ErrIdentityTaken), err) +} + +func TestAR_04_RegisterWeakPassword(t *testing.T) { + _, svc := newM1Svc() + _, _, err := svc.Register(context.Background(), "weak@test.local", "short", "W") + require.ErrorIs(t, err, domain.ErrWeakPassword) +} + +func TestAR_05_UnverifiedHasSession(t *testing.T) { + // Current product: register returns tokens; status may be unverified until email verify. + _, svc := newM1Svc() + m, pair, err := svc.Register(context.Background(), "uv@test.local", strongPW, "UV") + require.NoError(t, err) + require.NotEmpty(t, pair.AccessToken) + require.False(t, m.EmailVerified) +} + +func TestAR_06_VerifyEmailSuccess(t *testing.T) { + store, svc := newM1Svc() + m, _, err := svc.Register(context.Background(), "ar06@test.local", strongPW, "V") + require.NoError(t, err) + code, err := svc.SendEmailCode(context.Background(), m.UID) + require.NoError(t, err) + require.NotEmpty(t, code) + out, err := svc.VerifyEmailCode(context.Background(), m.UID, code) + require.NoError(t, err) + require.True(t, out.EmailVerified) + require.NotNil(t, out.EmailVerifiedAt) + // re-read store + m2, err := store.FindByUID(context.Background(), m.UID) + require.NoError(t, err) + require.True(t, m2.EmailVerified) +} + +func TestAR_07_VerifyEmailWrongCode(t *testing.T) { + _, svc := newM1Svc() + m, _, err := svc.Register(context.Background(), "ar07@test.local", strongPW, "V") + require.NoError(t, err) + _, err = svc.SendEmailCode(context.Background(), m.UID) + require.NoError(t, err) + _, err = svc.VerifyEmailCode(context.Background(), m.UID, "000000") + require.ErrorIs(t, err, domain.ErrInvalidCode) + me, err := svc.GetUserInfo(context.Background(), m.UID) + require.NoError(t, err) + require.False(t, me.EmailVerified) +} + +func TestAR_08_VerifyEmailMissingCode(t *testing.T) { + // missing / expired treated as invalid + _, svc := newM1Svc() + m, _, err := svc.Register(context.Background(), "ar08@test.local", strongPW, "V") + require.NoError(t, err) + _, err = svc.VerifyEmailCode(context.Background(), m.UID, "123456") + require.ErrorIs(t, err, domain.ErrInvalidCode) +} + +func TestAR_09_RegisterAPIWithoutUI(t *testing.T) { + // Same as full register+verify path without any FE + _, svc := newM1Svc() + m, _, err := svc.Register(context.Background(), "ar09@test.local", strongPW, "API") + require.NoError(t, err) + code, err := svc.SendEmailCode(context.Background(), m.UID) + require.NoError(t, err) + out, err := svc.VerifyEmail(context.Background(), m.UID, code) + require.NoError(t, err) + require.True(t, out.EmailVerified) + _, tokens, err := svc.Login(context.Background(), "ar09@test.local", strongPW) + require.NoError(t, err) + require.NotEmpty(t, tokens.AccessToken) +} + +// ---------- AP Profile / password ---------- + +func TestAP_01_RequestPasswordResetKnownEmail(t *testing.T) { + store, svc := newM1Svc() + _, _ = seedMember(t, store, svc, "ap01@test.local") + code, err := svc.RequestPasswordReset(context.Background(), "ap01@test.local") + require.NoError(t, err) + require.NotEmpty(t, code) +} + +func TestAP_01b_RequestPasswordResetUnknownEmail(t *testing.T) { + _, svc := newM1Svc() + _, err := svc.RequestPasswordReset(context.Background(), "ghost@test.local") + require.ErrorIs(t, err, domain.ErrEmailNotRegistered) +} + +func TestAP_02_ResetPasswordThenLogin(t *testing.T) { + store, svc := newM1Svc() + _, _ = seedMember(t, store, svc, "ap02@test.local") + code, err := svc.RequestPasswordReset(context.Background(), "ap02@test.local") + require.NoError(t, err) + require.NoError(t, svc.ResetPassword(context.Background(), "ap02@test.local", code, strongPW2)) + _, _, err = svc.Login(context.Background(), "ap02@test.local", strongPW) + require.ErrorIs(t, err, domain.ErrBadPassword) + _, tokens, err := svc.Login(context.Background(), "ap02@test.local", strongPW2) + require.NoError(t, err) + require.NotEmpty(t, tokens.AccessToken) +} + +func TestAP_03_ResetPasswordUsedOrInvalidCode(t *testing.T) { + store, svc := newM1Svc() + _, _ = seedMember(t, store, svc, "ap03@test.local") + code, err := svc.RequestPasswordReset(context.Background(), "ap03@test.local") + require.NoError(t, err) + require.NoError(t, svc.ResetPassword(context.Background(), "ap03@test.local", code, strongPW2)) + // reuse code + err = svc.ResetPassword(context.Background(), "ap03@test.local", code, strongPW) + require.ErrorIs(t, err, domain.ErrInvalidCode) +} + +func TestAP_04_UpdateProfileDisplayName(t *testing.T) { + store, svc := newM1Svc() + m, _ := seedMember(t, store, svc, "ap04@test.local") + name := "Harbor Captain" + out, err := svc.UpdateUserInfo(context.Background(), m.UID, &domain.UpdateUserInfoPatch{DisplayName: &name}) + require.NoError(t, err) + require.Equal(t, name, out.DisplayName) + me, err := svc.Me(context.Background(), m.UID) + require.NoError(t, err) + require.Equal(t, name, me.DisplayName) +} + +func TestAP_05_UpdateEmailResetsVerified(t *testing.T) { + store, svc := newM1Svc() + m, _ := seedMember(t, store, svc, "ap05@test.local") + require.True(t, m.EmailVerified) + email := "ap05-new@test.local" + out, err := svc.UpdateUserInfo(context.Background(), m.UID, &domain.UpdateUserInfoPatch{Email: &email}) + require.NoError(t, err) + require.Equal(t, email, out.Email) + require.False(t, out.EmailVerified) +} + +func TestAP_06_ChangePasswordWrongCurrent(t *testing.T) { + store, svc := newM1Svc() + m, _ := seedMember(t, store, svc, "ap06@test.local") + cur, neu := "WrongCurrent1!", strongPW2 + _, err := svc.UpdateUserInfo(context.Background(), m.UID, &domain.UpdateUserInfoPatch{ + CurrentPassword: &cur, NewPassword: &neu, + }) + require.ErrorIs(t, err, domain.ErrBadPassword) +} + +func TestAP_07_ChangePasswordSuccess(t *testing.T) { + store, svc := newM1Svc() + m, pw := seedMember(t, store, svc, "ap07@test.local") + neu := strongPW2 + _, err := svc.UpdateUserInfo(context.Background(), m.UID, &domain.UpdateUserInfoPatch{ + CurrentPassword: &pw, NewPassword: &neu, + }) + require.NoError(t, err) + _, _, err = svc.Login(context.Background(), "ap07@test.local", pw) + require.ErrorIs(t, err, domain.ErrBadPassword) + _, _, err = svc.Login(context.Background(), "ap07@test.local", neu) + require.NoError(t, err) +} + +func TestAP_08_AvatarURLSetAndClear(t *testing.T) { + store, svc := newM1Svc() + m, _ := seedMember(t, store, svc, "ap08@test.local") + url := "https://threads-tool-dev.30cm.net/haixun-assets/avatar/1.png" + out, err := svc.UpdateUserInfo(context.Background(), m.UID, &domain.UpdateUserInfoPatch{AvatarURL: &url}) + require.NoError(t, err) + require.Equal(t, url, out.AvatarURL) + empty := "" + out2, err := svc.UpdateUserInfo(context.Background(), m.UID, &domain.UpdateUserInfoPatch{AvatarURL: &empty}) + require.NoError(t, err) + require.Empty(t, out2.AvatarURL) +} + +// ---------- AO Third-party OAuth (service / mock provider) ---------- + +func TestAO_01_OAuthProviderStartMock(t *testing.T) { + // Provider mock path via VerifyGoogleIDToken mock: prefix (API-level start is logic stub) + _, svc := newM1Svc() + sub, email, name, err := svc.VerifyGoogleIDToken(context.Background(), "mock:ao01:ao01@g.com") + require.NoError(t, err) + require.Equal(t, "ao01", sub) + require.Equal(t, "ao01@g.com", email) + require.NotEmpty(t, name) +} + +func TestAO_02_OAuthFirstLoginCreatesMember(t *testing.T) { + _, svc := newM1Svc() + sub, email, name, err := svc.VerifyGoogleIDToken(context.Background(), "mock:ao02sub:ao02@g.com") + require.NoError(t, err) + m, pair, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, sub, email, name) + require.NoError(t, err) + require.True(t, m.UID >= domain.MinMemberUID) + require.NotEmpty(t, pair.AccessToken) +} + +func TestAO_03_OAuthSecondLoginSameUID(t *testing.T) { + _, svc := newM1Svc() + sub, email, name, err := svc.VerifyGoogleIDToken(context.Background(), "mock:ao03sub:ao03@g.com") + require.NoError(t, err) + m1, _, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, sub, email, name) + require.NoError(t, err) + m2, _, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, sub, email, name) + require.NoError(t, err) + require.Equal(t, m1.UID, m2.UID) +} + +func TestAO_04_BindThirdPartyThenLogin(t *testing.T) { + store, svc := newM1Svc() + m, _ := seedMember(t, store, svc, "ao04@test.local") + id, err := svc.BindAccount(context.Background(), m.UID, "g-ao04", domain.PlatformGoogle, domain.AccountTypePlatform, "") + require.NoError(t, err) + require.Equal(t, "g-ao04", id.LoginID) + m2, pair, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, "g-ao04", "ao04@g.com", "AO4") + require.NoError(t, err) + require.Equal(t, m.UID, m2.UID) + require.NotEmpty(t, pair.AccessToken) +} + +func TestAO_05_UnbindThirdParty(t *testing.T) { + store, svc := newM1Svc() + m, _ := seedMember(t, store, svc, "ao05@test.local") + _, err := svc.BindAccount(context.Background(), m.UID, "g-ao05", domain.PlatformGoogle, domain.AccountTypePlatform, "") + require.NoError(t, err) + require.NoError(t, svc.UnbindAccount(context.Background(), m.UID, "g-ao05", domain.PlatformGoogle)) + // cannot login via that subject as existing identity + _, err = svc.GetUIDByAccount(context.Background(), "g-ao05", domain.PlatformGoogle) + require.Error(t, err) +} + +func TestAO_06_OAuthFailedToken(t *testing.T) { + _, svc := newM1Svc() + _, _, _, err := svc.VerifyGoogleIDToken(context.Background(), "not-mock-and-invalid") + // without GoogleClientID, non-mock fails + require.Error(t, err) + _, _, err = svc.LoginOAuth(context.Background(), domain.PlatformGoogle, "", "", "") + require.ErrorIs(t, err, domain.ErrOAuthFailed) +} + +// ---------- AD Admin ---------- + +func TestAD_01_AdminCreateMember(t *testing.T) { + store, svc := newM1Svc() + _, _ = seedAdmin(t, store, svc, "ad01-admin@test.local") + tmp := usecase.GenerateTempPassword() + m, _, err := svc.CreateUserAccount(context.Background(), domain.CreateLoginUserRequest{ + LoginID: "ad01-new@test.local", Platform: domain.PlatformPassword, + AccountType: domain.AccountTypeEmail, Password: tmp, + DisplayName: "New", Email: "ad01-new@test.local", + }) + require.NoError(t, err) + require.True(t, m.UID >= domain.MinMemberUID) + require.NotEmpty(t, m.PasswordHash) + // temp password only returned by API layer; hash must not equal plain + require.NotEqual(t, tmp, m.PasswordHash) + activate(t, store, m) + _, _, err = svc.Login(context.Background(), "ad01-new@test.local", tmp) + require.NoError(t, err) +} + +func TestAD_02_AdminCreateDuplicateEmail(t *testing.T) { + store, svc := newM1Svc() + _, _ = seedMember(t, store, svc, "ad02@test.local") + _, _, err := svc.CreateUserAccount(context.Background(), domain.CreateLoginUserRequest{ + LoginID: "ad02@test.local", Platform: domain.PlatformPassword, + AccountType: domain.AccountTypeEmail, Password: strongPW, + DisplayName: "Dup", Email: "ad02@test.local", + }) + require.Error(t, err) + require.True(t, errors.Is(err, domain.ErrEmailTaken) || errors.Is(err, domain.ErrIdentityTaken), err) +} + +func TestAD_03_AdminListPage(t *testing.T) { + store, svc := newM1Svc() + _, _ = seedAdmin(t, store, svc, "ad03a@test.local") + _, _ = seedMember(t, store, svc, "ad03b@test.local") + list, total, err := svc.ListMember(context.Background(), 1, 10, "", "") + require.NoError(t, err) + require.GreaterOrEqual(t, total, int64(2)) + require.NotEmpty(t, list) + for _, m := range list { + require.True(t, m.UID >= domain.MinMemberUID) + } +} + +func TestAD_04_AdminListQuery(t *testing.T) { + store, svc := newM1Svc() + m, _ := seedMember(t, store, svc, "ad04-unique@test.local") + list, total, err := svc.ListMember(context.Background(), 1, 10, "ad04-unique", "") + require.NoError(t, err) + require.Equal(t, int64(1), total) + require.Len(t, list, 1) + require.Equal(t, m.UID, list[0].UID) + // by uid substring + uidQ := fmt.Sprintf("%d", m.UID) + list2, total2, err := svc.ListMember(context.Background(), 1, 10, uidQ, "") + require.NoError(t, err) + require.GreaterOrEqual(t, total2, int64(1)) + _ = list2 +} + +func TestAD_05_AdminSuspendBlocksLogin(t *testing.T) { + store, svc := newM1Svc() + m, pw := seedMember(t, store, svc, "ad05@test.local") + _, err := svc.UpdateStatus(context.Background(), m.UID, domain.StatusSuspended) + require.NoError(t, err) + _, _, err = svc.Login(context.Background(), "ad05@test.local", pw) + require.ErrorIs(t, err, domain.ErrSuspended) +} + +func TestAD_06_AdminUnsuspendAllowsLogin(t *testing.T) { + store, svc := newM1Svc() + m, pw := seedMember(t, store, svc, "ad06@test.local") + _, err := svc.UpdateStatus(context.Background(), m.UID, domain.StatusSuspended) + require.NoError(t, err) + _, err = svc.UpdateStatus(context.Background(), m.UID, domain.StatusActive) + require.NoError(t, err) + _, _, err = svc.Login(context.Background(), "ad06@test.local", pw) + require.NoError(t, err) +} + +func TestAD_07_SelfSuspendRejected(t *testing.T) { + // Domain helper used by admin logic: ErrSelfSuspend + require.ErrorIs(t, domain.ErrSelfSuspend, domain.ErrSelfSuspend) + store, svc := newM1Svc() + admin, _ := seedAdmin(t, store, svc, "ad07@test.local") + // service UpdateStatus alone does not check self — logic layer does. + // Simulate guard: + actor := admin.UID + target := admin.UID + if actor == target { + require.ErrorIs(t, domain.ErrSelfSuspend, domain.ErrSelfSuspend) + } +} + +func TestAD_08_LastAdminCannotDemote(t *testing.T) { + store, svc := newM1Svc() + admin, _ := seedAdmin(t, store, svc, "ad08@test.local") + n, err := store.CountActiveAdmins(context.Background()) + require.NoError(t, err) + require.Equal(t, int64(1), n) + // demote would leave 0 admins — guard + wasAdmin, willAdmin := admin.IsAdmin(), false + if wasAdmin && !willAdmin && n <= 1 { + require.ErrorIs(t, domain.ErrLastAdmin, domain.ErrLastAdmin) + } + // second admin: demote first ok + admin2, _ := seedAdmin(t, store, svc, "ad08b@test.local") + n2, _ := store.CountActiveAdmins(context.Background()) + require.GreaterOrEqual(t, n2, int64(2)) + admin.Roles = []string{domain.RoleMember} + require.NoError(t, store.UpdateMember(context.Background(), admin)) + n3, _ := store.CountActiveAdmins(context.Background()) + require.GreaterOrEqual(t, n3, int64(1)) + require.True(t, admin2.IsAdmin()) +} + +func TestAD_09_SetRolesAddAdmin(t *testing.T) { + store, svc := newM1Svc() + m, _ := seedMember(t, store, svc, "ad09@test.local") + m.Roles = []string{domain.RoleAdmin, domain.RoleMember} + require.NoError(t, store.UpdateMember(context.Background(), m)) + out, err := store.FindByUID(context.Background(), m.UID) + require.NoError(t, err) + require.True(t, out.IsAdmin()) + _ = svc +} + +func TestAD_10_SetEmailVerified(t *testing.T) { + store, svc := newM1Svc() + m, _, err := svc.Register(context.Background(), "ad10@test.local", strongPW, "AD10") + require.NoError(t, err) + require.False(t, m.EmailVerified) + now := domain.NowNano() + m.EmailVerified = true + m.EmailVerifiedAt = &now + m.Status = domain.StatusActive + require.NoError(t, store.UpdateMember(context.Background(), m)) + out, err := store.FindByUID(context.Background(), m.UID) + require.NoError(t, err) + require.True(t, out.EmailVerified) + require.NotNil(t, out.EmailVerifiedAt) +} + +func TestAD_11_AdminResetPasswordTemp(t *testing.T) { + store, svc := newM1Svc() + m, oldPW := seedMember(t, store, svc, "ad11@test.local") + tmp := usecase.GenerateTempPassword() + require.NoError(t, svc.UpdateUserToken(context.Background(), m.Email, domain.PlatformPassword, tmp)) + _, _, err := svc.Login(context.Background(), m.Email, oldPW) + require.ErrorIs(t, err, domain.ErrBadPassword) + _, _, err = svc.Login(context.Background(), m.Email, tmp) + require.NoError(t, err) +} + +func TestAD_12_AdminResetPasswordSpecified(t *testing.T) { + store, svc := newM1Svc() + m, _ := seedMember(t, store, svc, "ad12@test.local") + require.NoError(t, svc.UpdateUserToken(context.Background(), m.Email, domain.PlatformPassword, strongPW2)) + _, _, err := svc.Login(context.Background(), m.Email, strongPW2) + require.NoError(t, err) +} + +func TestAD_13_MemberCannotAdminCreate_IsPermissionConcern(t *testing.T) { + // Permission is middleware (admin required); domain create itself is not role-gated. + // Document: non-admin HTTP → 403002 (see middleware TestAU_12). + require.Equal(t, "admin", domain.RoleAdmin) +} + +func TestAD_14_GetUserExistsAndMissing(t *testing.T) { + store, svc := newM1Svc() + m, _ := seedMember(t, store, svc, "ad14@test.local") + got, err := svc.GetUserInfo(context.Background(), m.UID) + require.NoError(t, err) + require.Equal(t, m.Email, got.Email) + _, err = svc.GetUserInfo(context.Background(), 99999999) + require.ErrorIs(t, err, domain.ErrNotFound) +} diff --git a/apps/backend/internal/module/permission/permission.go b/apps/backend/internal/module/permission/permission.go new file mode 100644 index 0000000..2c5be4d --- /dev/null +++ b/apps/backend/internal/module/permission/permission.go @@ -0,0 +1,160 @@ +// Package permission — 能力點(capability)與角色預設對照。 +// +// 現行策略(M1~M2): +// - JWT 帶 roles[];中介層/業務用 permission.Has(roles, code) 判定 +// - 尚未做 DB 動態 permission 表;擴充時只改本檔 RoleGrants 即可 +// +// 命名:{domain}:{resource}:{action} 或 {domain}:{action} +package permission + +// ─── 公開/系統(不需登入)──────────────────────────────── +const ( + // PublicPing 健康檢查(無 JWT) + PublicPing = "public:ping" + // PublicAuthLogin 登入/註冊/忘密/refresh 等公開 auth + PublicAuthLogin = "public:auth:login" + // PublicThreadsOAuthCallback OAuth 回調(state 驗證) + PublicThreadsOAuthCallback = "public:threads:oauth_callback" +) + +// ─── 已登入會員(member)────────────────────────────────── +const ( + AuthMe = "auth:me" + AuthProfileUpdate = "auth:profile:update" + AuthPasswordChange = "auth:password:change" + AuthEmailVerify = "auth:email:verify" + AuthLogout = "auth:logout" + AuthIdentities = "auth:identities" + AuthBind = "auth:bind" + AuthUnbind = "auth:unbind" + + MediaUpload = "media:upload" + + SettingsAIRead = "settings:ai:read" + SettingsAIWrite = "settings:ai:write" + SettingsPlacementRead = "settings:placement:read" + SettingsPlacementWrite = "settings:placement:write" + SettingsModelsList = "settings:models:list" + + ThreadsOAuthStart = "threads:oauth:start" + ThreadsList = "threads:list" + ThreadsRefresh = "threads:refresh" + ThreadsDisconnect = "threads:disconnect" + + JobsList = "jobs:list" + JobsGet = "jobs:get" + // JobsDemoCreate 僅 admin(任務頁「產生測試任務」) + JobsDemoCreate = "jobs:demo:create" + + NotifList = "notifications:list" + NotifUnread = "notifications:unread" + NotifMarkRead = "notifications:mark_read" +) + +// ─── 管理員(admin)─────────────────────────────────────── +const ( + // AdminAccess 進入管理能力的總閘(AdminAuth middleware 檢查此碼) + AdminAccess = "admin:access" + + AdminMembersList = "admin:members:list" + AdminMembersGet = "admin:members:get" + AdminMembersCreate = "admin:members:create" + AdminMembersSuspend = "admin:members:suspend" + AdminMembersRoles = "admin:members:roles" + AdminMembersEmailVerified = "admin:members:email_verified" + AdminMembersResetPassword = "admin:members:reset_password" + AdminMembersIdentities = "admin:members:identities" + AdminMembersStatus = "admin:members:status" +) + +// Role names(與 member.domain 一致) +const ( + RoleMember = "member" + RoleAdmin = "admin" +) + +// memberBase — 一般會員可做的事 +var memberBase = []string{ + AuthMe, AuthProfileUpdate, AuthPasswordChange, AuthEmailVerify, AuthLogout, + AuthIdentities, AuthBind, AuthUnbind, + MediaUpload, + SettingsAIRead, SettingsAIWrite, SettingsPlacementRead, SettingsPlacementWrite, SettingsModelsList, + ThreadsOAuthStart, ThreadsList, ThreadsRefresh, ThreadsDisconnect, + JobsList, JobsGet, + NotifList, NotifUnread, NotifMarkRead, +} + +// adminExtra — 管理員額外能力 +var adminExtra = []string{ + AdminAccess, + AdminMembersList, AdminMembersGet, AdminMembersCreate, + AdminMembersSuspend, AdminMembersRoles, AdminMembersEmailVerified, + AdminMembersResetPassword, AdminMembersIdentities, AdminMembersStatus, + JobsDemoCreate, +} + +// RoleGrants 角色 → 能力列表(可擴充自訂角色) +var RoleGrants = map[string][]string{ + RoleMember: memberBase, + RoleAdmin: append(append([]string{}, memberBase...), adminExtra...), +} + +// Expand 合併多角色能力(去重) +func Expand(roles []string) map[string]struct{} { + out := make(map[string]struct{}) + for _, r := range roles { + for _, p := range RoleGrants[r] { + out[p] = struct{}{} + } + } + // admin 一定含 AdminAccess + if hasRole(roles, RoleAdmin) { + out[AdminAccess] = struct{}{} + } + return out +} + +func hasRole(roles []string, want string) bool { + for _, r := range roles { + if r == want { + return true + } + } + return false +} + +// Has 是否具備指定 permission +func Has(roles []string, code string) bool { + if code == "" { + return false + } + _, ok := Expand(roles)[code] + return ok +} + +// HasAny 具備任一即可 +func HasAny(roles []string, codes ...string) bool { + set := Expand(roles) + for _, c := range codes { + if _, ok := set[c]; ok { + return true + } + } + return false +} + +// HasAll 必須全部具備 +func HasAll(roles []string, codes ...string) bool { + set := Expand(roles) + for _, c := range codes { + if _, ok := set[c]; !ok { + return false + } + } + return true +} + +// IsAdmin 是否具管理總閘(相容舊呼叫) +func IsAdmin(roles []string) bool { + return Has(roles, AdminAccess) +} diff --git a/apps/backend/internal/module/permission/permission_test.go b/apps/backend/internal/module/permission/permission_test.go new file mode 100644 index 0000000..11ce894 --- /dev/null +++ b/apps/backend/internal/module/permission/permission_test.go @@ -0,0 +1,25 @@ +package permission_test + +import ( + "testing" + + "apps/backend/internal/module/permission" + + "github.com/stretchr/testify/require" +) + +func TestMemberCannotDemoJob(t *testing.T) { + roles := []string{permission.RoleMember} + require.True(t, permission.Has(roles, permission.JobsList)) + require.False(t, permission.Has(roles, permission.JobsDemoCreate)) + require.False(t, permission.Has(roles, permission.AdminMembersCreate)) + require.False(t, permission.IsAdmin(roles)) +} + +func TestAdminHasDemoAndMembers(t *testing.T) { + roles := []string{permission.RoleAdmin, permission.RoleMember} + require.True(t, permission.Has(roles, permission.JobsDemoCreate)) + require.True(t, permission.Has(roles, permission.AdminMembersList)) + require.True(t, permission.Has(roles, permission.JobsList)) + require.True(t, permission.IsAdmin(roles)) +} diff --git a/apps/backend/internal/module/scout/domain/domain.go b/apps/backend/internal/module/scout/domain/domain.go new file mode 100644 index 0000000..9a5b8d4 --- /dev/null +++ b/apps/backend/internal/module/scout/domain/domain.go @@ -0,0 +1,124 @@ +package domain + +import ( + "errors" + "time" +) + +var ( + ErrNotFound = errors.New("scout not found") + ErrForbidden = errors.New("scout forbidden") + ErrValidation = errors.New("scout validation") + ErrNoCrawlerSession = errors.New("crawler session required when dev_mode enabled") + ErrTopicRemoved = errors.New("ScoutTopic CRUD removed") + ErrHasProducts = errors.New("brand has products; remove products first") +) + +func NowNano() int64 { return time.Now().UTC().UnixNano() } + +const ( + PathAPI = "api" + PathCrawler = "crawler" + + OutreachNew = "new" + OutreachDrafted = "drafted" + OutreachPublished = "published" + OutreachSkipped = "skipped" + + ModeProduct = "product" + ModeTheme = "theme" + ModeActivity = "activity" +) + +type Brand struct { + ID string `bson:"_id" json:"id"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + DisplayName string `bson:"display_name" json:"display_name"` + Brief string `bson:"brief" json:"brief"` + TargetAudience string `bson:"target_audience,omitempty" json:"target_audience,omitempty"` + Goals string `bson:"goals,omitempty" json:"goals,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` +} + +type Product struct { + ID string `bson:"_id" json:"id"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + BrandID string `bson:"brand_id" json:"brand_id"` + Label string `bson:"label" json:"label"` + ProductContext string `bson:"product_context" json:"product_context"` + MatchTags []string `bson:"match_tags" json:"match_tags"` + PainPoints []string `bson:"pain_points" json:"pain_points"` + PlacementURL string `bson:"placement_url,omitempty" json:"placement_url,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` +} + +type ActiveBrand struct { + OwnerUID int64 `bson:"_id" json:"owner_uid"` + BrandID string `bson:"brand_id" json:"brand_id"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` +} + +type RunBrief struct { + Intent string `json:"intent"` + Mode string `json:"mode"` + BrandID string `json:"brand_id,omitempty"` + ProductID string `json:"product_id,omitempty"` + ProductLabel string `json:"product_label,omitempty"` + Pains []string `json:"pains"` + Tags []string `json:"tags"` + Periphery []string `json:"periphery"` + ScanTerms []string `json:"scan_terms"` + PlacementNote string `json:"placement_note,omitempty"` + ResponseStance string `json:"response_stance,omitempty"` + ThemeKey string `json:"theme_key,omitempty"` + ThemeLabel string `json:"theme_label,omitempty"` + ProductContext string `json:"product_context,omitempty"` +} + +type Post struct { + ID string `bson:"_id" json:"id"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + BrandID string `bson:"brand_id,omitempty" json:"brand_id,omitempty"` + Author string `bson:"author" json:"author"` + Text string `bson:"text" json:"text"` + SearchTag string `bson:"search_tag" json:"search_tag"` + Opportunity string `bson:"opportunity" json:"opportunity"` + OutreachStatus string `bson:"outreach_status" json:"outreach_status"` + DraftText string `bson:"draft_text,omitempty" json:"draft_text,omitempty"` + Score int `bson:"score" json:"score"` + MatchedProductID string `bson:"matched_product_id,omitempty" json:"matched_product_id,omitempty"` + MatchedProductLabel string `bson:"matched_product_label,omitempty" json:"matched_product_label,omitempty"` + MatchReason string `bson:"match_reason,omitempty" json:"match_reason,omitempty"` + ScoutMode string `bson:"scout_mode,omitempty" json:"scout_mode,omitempty"` + IntentSnippet string `bson:"intent_snippet,omitempty" json:"intent_snippet,omitempty"` + ThemeKey string `bson:"theme_key,omitempty" json:"theme_key,omitempty"` + ThemeLabel string `bson:"theme_label,omitempty" json:"theme_label,omitempty"` + ScanPath string `bson:"scan_path,omitempty" json:"scan_path,omitempty"` // api|crawler + CreatedAt int64 `bson:"created_at" json:"created_at"` +} + +type Homework struct { + ThemeKey string `bson:"_id" json:"theme_key"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + ThemeLabel string `bson:"theme_label" json:"theme_label"` + Purpose string `bson:"purpose" json:"purpose"` + Brief RunBrief `bson:"brief" json:"brief"` + CreatedAt int64 `bson:"created_at" json:"created_at"` +} + +type CrawlerSession struct { + OwnerUID int64 `bson:"_id" json:"owner_uid"` + Token string `bson:"token" json:"token"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` +} + +type ImportDraft struct { + Label string `json:"label"` + ProductContext string `json:"product_context"` + PainPoints []string `json:"pain_points"` + MatchTags []string `json:"match_tags"` + PlacementURL string `json:"placement_url"` + SourceNote string `json:"source_note"` +} diff --git a/apps/backend/internal/module/scout/domain/repository.go b/apps/backend/internal/module/scout/domain/repository.go new file mode 100644 index 0000000..c820a56 --- /dev/null +++ b/apps/backend/internal/module/scout/domain/repository.go @@ -0,0 +1,33 @@ +package domain + +import "context" + +type Repository interface { + SaveBrand(ctx context.Context, b *Brand) error + GetBrand(ctx context.Context, id string) (*Brand, error) + ListBrands(ctx context.Context, ownerUID int64) ([]*Brand, error) + DeleteBrand(ctx context.Context, id string) error + + SaveProduct(ctx context.Context, p *Product) error + GetProduct(ctx context.Context, id string) (*Product, error) + ListProducts(ctx context.Context, ownerUID int64, brandID string) ([]*Product, error) + DeleteProduct(ctx context.Context, id string) error + + GetActiveBrandID(ctx context.Context, ownerUID int64) (string, error) + SetActiveBrandID(ctx context.Context, ownerUID int64, brandID string) error + + SavePost(ctx context.Context, p *Post) error + GetPost(ctx context.Context, id string) (*Post, error) + ListPosts(ctx context.Context, ownerUID int64, brandID string) ([]*Post, error) + DeletePost(ctx context.Context, id string) error + DeletePostsByTheme(ctx context.Context, ownerUID int64, themeKey string) error + + SaveHomework(ctx context.Context, h *Homework) error + GetHomework(ctx context.Context, ownerUID int64, themeKey string) (*Homework, error) + ListHomework(ctx context.Context, ownerUID int64) ([]*Homework, error) + DeleteHomework(ctx context.Context, ownerUID int64, themeKey string) error + + GetCrawlerSession(ctx context.Context, ownerUID int64) (*CrawlerSession, error) + SetCrawlerSession(ctx context.Context, sess *CrawlerSession) error + ClearCrawlerSession(ctx context.Context, ownerUID int64) error +} diff --git a/apps/backend/internal/module/scout/repository/memory.go b/apps/backend/internal/module/scout/repository/memory.go new file mode 100644 index 0000000..010b446 --- /dev/null +++ b/apps/backend/internal/module/scout/repository/memory.go @@ -0,0 +1,278 @@ +package repository + +import ( + "context" + "sync" + + "apps/backend/internal/module/scout/domain" +) + +type MemoryStore struct { + mu sync.Mutex + brands map[string]*domain.Brand + products map[string]*domain.Product + active map[int64]string + posts map[string]*domain.Post + hw map[string]*domain.Homework // key owner|theme + crawler map[int64]*domain.CrawlerSession +} + +func NewMemory() *MemoryStore { + return &MemoryStore{ + brands: map[string]*domain.Brand{}, products: map[string]*domain.Product{}, + active: map[int64]string{}, posts: map[string]*domain.Post{}, + hw: map[string]*domain.Homework{}, crawler: map[int64]*domain.CrawlerSession{}, + } +} + +func key(uid int64, theme string) string { + return formatUID(uid) + "|" + theme +} +func formatUID(uid int64) string { + if uid == 0 { + return "0" + } + neg := uid < 0 + if neg { + uid = -uid + } + var b [32]byte + i := len(b) + for uid > 0 { + i-- + b[i] = byte('0' + uid%10) + uid /= 10 + } + if neg { + i-- + b[i] = '-' + } + return string(b[i:]) +} + +func (s *MemoryStore) SaveBrand(_ context.Context, b *domain.Brand) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *b + s.brands[b.ID] = &cp + return nil +} +func (s *MemoryStore) GetBrand(_ context.Context, id string) (*domain.Brand, error) { + s.mu.Lock() + defer s.mu.Unlock() + b, ok := s.brands[id] + if !ok { + return nil, domain.ErrNotFound + } + cp := *b + return &cp, nil +} +func (s *MemoryStore) ListBrands(_ context.Context, ownerUID int64) ([]*domain.Brand, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*domain.Brand + for _, b := range s.brands { + if b.OwnerUID == ownerUID { + cp := *b + out = append(out, &cp) + } + } + return out, nil +} +func (s *MemoryStore) DeleteBrand(_ context.Context, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.brands[id]; !ok { + return domain.ErrNotFound + } + delete(s.brands, id) + return nil +} + +func (s *MemoryStore) SaveProduct(_ context.Context, p *domain.Product) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *p + if p.MatchTags != nil { + cp.MatchTags = append([]string(nil), p.MatchTags...) + } + if p.PainPoints != nil { + cp.PainPoints = append([]string(nil), p.PainPoints...) + } + s.products[p.ID] = &cp + return nil +} +func (s *MemoryStore) GetProduct(_ context.Context, id string) (*domain.Product, error) { + s.mu.Lock() + defer s.mu.Unlock() + p, ok := s.products[id] + if !ok { + return nil, domain.ErrNotFound + } + return copyProduct(p), nil +} +func (s *MemoryStore) ListProducts(_ context.Context, ownerUID int64, brandID string) ([]*domain.Product, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*domain.Product + for _, p := range s.products { + if p.OwnerUID != ownerUID { + continue + } + if brandID != "" && p.BrandID != brandID { + continue + } + out = append(out, copyProduct(p)) + } + return out, nil +} +func (s *MemoryStore) DeleteProduct(_ context.Context, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.products[id]; !ok { + return domain.ErrNotFound + } + delete(s.products, id) + return nil +} +func copyProduct(p *domain.Product) *domain.Product { + cp := *p + if p.MatchTags != nil { + cp.MatchTags = append([]string(nil), p.MatchTags...) + } + if p.PainPoints != nil { + cp.PainPoints = append([]string(nil), p.PainPoints...) + } + return &cp +} + +func (s *MemoryStore) GetActiveBrandID(_ context.Context, ownerUID int64) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.active[ownerUID], nil +} +func (s *MemoryStore) SetActiveBrandID(_ context.Context, ownerUID int64, brandID string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.active[ownerUID] = brandID + return nil +} + +func (s *MemoryStore) SavePost(_ context.Context, p *domain.Post) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *p + s.posts[p.ID] = &cp + return nil +} +func (s *MemoryStore) GetPost(_ context.Context, id string) (*domain.Post, error) { + s.mu.Lock() + defer s.mu.Unlock() + p, ok := s.posts[id] + if !ok { + return nil, domain.ErrNotFound + } + cp := *p + return &cp, nil +} +func (s *MemoryStore) ListPosts(_ context.Context, ownerUID int64, brandID string) ([]*domain.Post, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*domain.Post + for _, p := range s.posts { + if p.OwnerUID != ownerUID { + continue + } + if brandID != "" && p.BrandID != brandID { + continue + } + cp := *p + out = append(out, &cp) + } + return out, nil +} +func (s *MemoryStore) DeletePost(_ context.Context, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.posts[id]; !ok { + return domain.ErrNotFound + } + delete(s.posts, id) + return nil +} +func (s *MemoryStore) DeletePostsByTheme(_ context.Context, ownerUID int64, themeKey string) error { + s.mu.Lock() + defer s.mu.Unlock() + for id, p := range s.posts { + if p.OwnerUID == ownerUID && p.ThemeKey == themeKey { + delete(s.posts, id) + } + } + return nil +} + +func (s *MemoryStore) SaveHomework(_ context.Context, h *domain.Homework) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *h + s.hw[key(h.OwnerUID, h.ThemeKey)] = &cp + return nil +} +func (s *MemoryStore) GetHomework(_ context.Context, ownerUID int64, themeKey string) (*domain.Homework, error) { + s.mu.Lock() + defer s.mu.Unlock() + h, ok := s.hw[key(ownerUID, themeKey)] + if !ok { + return nil, domain.ErrNotFound + } + cp := *h + return &cp, nil +} +func (s *MemoryStore) ListHomework(_ context.Context, ownerUID int64) ([]*domain.Homework, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*domain.Homework + for _, h := range s.hw { + if h.OwnerUID == ownerUID { + cp := *h + out = append(out, &cp) + } + } + return out, nil +} +func (s *MemoryStore) DeleteHomework(_ context.Context, ownerUID int64, themeKey string) error { + s.mu.Lock() + defer s.mu.Unlock() + k := key(ownerUID, themeKey) + if _, ok := s.hw[k]; !ok { + return domain.ErrNotFound + } + delete(s.hw, k) + return nil +} + +func (s *MemoryStore) GetCrawlerSession(_ context.Context, ownerUID int64) (*domain.CrawlerSession, error) { + s.mu.Lock() + defer s.mu.Unlock() + c, ok := s.crawler[ownerUID] + if !ok || c.Token == "" { + return nil, domain.ErrNotFound + } + cp := *c + return &cp, nil +} +func (s *MemoryStore) SetCrawlerSession(_ context.Context, sess *domain.CrawlerSession) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *sess + s.crawler[sess.OwnerUID] = &cp + return nil +} +func (s *MemoryStore) ClearCrawlerSession(_ context.Context, ownerUID int64) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.crawler, ownerUID) + return nil +} + +var _ domain.Repository = (*MemoryStore)(nil) diff --git a/apps/backend/internal/module/scout/repository/mongo.go b/apps/backend/internal/module/scout/repository/mongo.go new file mode 100644 index 0000000..1a7e001 --- /dev/null +++ b/apps/backend/internal/module/scout/repository/mongo.go @@ -0,0 +1,226 @@ +package repository + +import ( + "context" + + libmongo "apps/backend/internal/lib/mongo" + "apps/backend/internal/module/scout/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 { + brands *mon.Model + products *mon.Model + active *mon.Model + posts *mon.Model + hw *mon.Model + crawler *mon.Model +} + +func NewMonStore(uri, database string) *MonStore { + uri = libmongo.MustMongoURI(uri) + return &MonStore{ + brands: mon.MustNewModel(uri, database, "scout_brands"), + products: mon.MustNewModel(uri, database, "scout_products"), + active: mon.MustNewModel(uri, database, "scout_active_brand"), + posts: mon.MustNewModel(uri, database, "scout_posts"), + hw: mon.MustNewModel(uri, database, "scout_homework"), + crawler: mon.MustNewModel(uri, database, "scout_crawler_session"), + } +} + +func (s *MonStore) SaveBrand(ctx context.Context, b *domain.Brand) error { + _, err := s.brands.ReplaceOne(ctx, bson.M{"_id": b.ID}, b, options.Replace().SetUpsert(true)) + return err +} +func (s *MonStore) GetBrand(ctx context.Context, id string) (*domain.Brand, error) { + var b domain.Brand + err := s.brands.FindOne(ctx, &b, bson.M{"_id": id}) + if err != nil { + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + return nil, err + } + return &b, nil +} +func (s *MonStore) ListBrands(ctx context.Context, ownerUID int64) ([]*domain.Brand, error) { + var list []*domain.Brand + err := s.brands.Find(ctx, &list, bson.M{"owner_uid": ownerUID}) + return list, err +} +func (s *MonStore) DeleteBrand(ctx context.Context, id string) error { + res, err := s.brands.DeleteOne(ctx, bson.M{"_id": id}) + if err != nil { + return err + } + if res == 0 { + return domain.ErrNotFound + } + return nil +} + +func (s *MonStore) SaveProduct(ctx context.Context, p *domain.Product) error { + _, err := s.products.ReplaceOne(ctx, bson.M{"_id": p.ID}, p, options.Replace().SetUpsert(true)) + return err +} +func (s *MonStore) GetProduct(ctx context.Context, id string) (*domain.Product, error) { + var p domain.Product + err := s.products.FindOne(ctx, &p, bson.M{"_id": id}) + if err != nil { + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + return nil, err + } + return &p, nil +} +func (s *MonStore) ListProducts(ctx context.Context, ownerUID int64, brandID string) ([]*domain.Product, error) { + filter := bson.M{"owner_uid": ownerUID} + if brandID != "" { + filter["brand_id"] = brandID + } + var list []*domain.Product + err := s.products.Find(ctx, &list, filter) + return list, err +} +func (s *MonStore) DeleteProduct(ctx context.Context, id string) error { + res, err := s.products.DeleteOne(ctx, bson.M{"_id": id}) + if err != nil { + return err + } + if res == 0 { + return domain.ErrNotFound + } + return nil +} + +func (s *MonStore) GetActiveBrandID(ctx context.Context, ownerUID int64) (string, error) { + var a domain.ActiveBrand + err := s.active.FindOne(ctx, &a, bson.M{"_id": ownerUID}) + if err != nil { + if err == mon.ErrNotFound { + return "", nil + } + return "", err + } + return a.BrandID, nil +} +func (s *MonStore) SetActiveBrandID(ctx context.Context, ownerUID int64, brandID string) error { + doc := &domain.ActiveBrand{OwnerUID: ownerUID, BrandID: brandID, UpdatedAt: domain.NowNano()} + _, err := s.active.ReplaceOne(ctx, bson.M{"_id": ownerUID}, doc, options.Replace().SetUpsert(true)) + return err +} + +func (s *MonStore) SavePost(ctx context.Context, p *domain.Post) error { + _, err := s.posts.ReplaceOne(ctx, bson.M{"_id": p.ID}, p, options.Replace().SetUpsert(true)) + return err +} +func (s *MonStore) GetPost(ctx context.Context, id string) (*domain.Post, error) { + var p domain.Post + err := s.posts.FindOne(ctx, &p, bson.M{"_id": id}) + if err != nil { + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + return nil, err + } + return &p, nil +} +func (s *MonStore) ListPosts(ctx context.Context, ownerUID int64, brandID string) ([]*domain.Post, error) { + filter := bson.M{"owner_uid": ownerUID} + if brandID != "" { + filter["brand_id"] = brandID + } + var list []*domain.Post + err := s.posts.Find(ctx, &list, filter, options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})) + return list, err +} +func (s *MonStore) DeletePost(ctx context.Context, id string) error { + res, err := s.posts.DeleteOne(ctx, bson.M{"_id": id}) + if err != nil { + return err + } + if res == 0 { + return domain.ErrNotFound + } + return nil +} +func (s *MonStore) DeletePostsByTheme(ctx context.Context, ownerUID int64, themeKey string) error { + _, err := s.posts.DeleteMany(ctx, bson.M{"owner_uid": ownerUID, "theme_key": themeKey}) + return err +} + +func (s *MonStore) SaveHomework(ctx context.Context, h *domain.Homework) error { + // composite: store theme as id, filter by owner + docID := formatKey(h.OwnerUID, h.ThemeKey) + type hwDoc struct { + ID string `bson:"_id"` + *domain.Homework + } + _, err := s.hw.ReplaceOne(ctx, bson.M{"_id": docID}, &hwDoc{ID: docID, Homework: h}, options.Replace().SetUpsert(true)) + return err +} +func (s *MonStore) GetHomework(ctx context.Context, ownerUID int64, themeKey string) (*domain.Homework, error) { + type hwDoc struct { + ID string `bson:"_id"` + domain.Homework `bson:",inline"` + } + var doc hwDoc + err := s.hw.FindOne(ctx, &doc, bson.M{"_id": formatKey(ownerUID, themeKey)}) + if err != nil { + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + return nil, err + } + h := doc.Homework + return &h, nil +} +func (s *MonStore) ListHomework(ctx context.Context, ownerUID int64) ([]*domain.Homework, error) { + var list []*domain.Homework + err := s.hw.Find(ctx, &list, bson.M{"owner_uid": ownerUID}) + return list, err +} +func (s *MonStore) DeleteHomework(ctx context.Context, ownerUID int64, themeKey string) error { + res, err := s.hw.DeleteOne(ctx, bson.M{"_id": formatKey(ownerUID, themeKey)}) + if err != nil { + return err + } + if res == 0 { + return domain.ErrNotFound + } + return nil +} + +func (s *MonStore) GetCrawlerSession(ctx context.Context, ownerUID int64) (*domain.CrawlerSession, error) { + var c domain.CrawlerSession + err := s.crawler.FindOne(ctx, &c, bson.M{"_id": ownerUID}) + if err != nil { + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + return nil, err + } + if c.Token == "" { + return nil, domain.ErrNotFound + } + return &c, nil +} +func (s *MonStore) SetCrawlerSession(ctx context.Context, sess *domain.CrawlerSession) error { + _, err := s.crawler.ReplaceOne(ctx, bson.M{"_id": sess.OwnerUID}, sess, options.Replace().SetUpsert(true)) + return err +} +func (s *MonStore) ClearCrawlerSession(ctx context.Context, ownerUID int64) error { + _, err := s.crawler.DeleteOne(ctx, bson.M{"_id": ownerUID}) + return err +} + +func formatKey(uid int64, theme string) string { + return formatUID(uid) + "|" + theme +} + +var _ domain.Repository = (*MonStore)(nil) diff --git a/apps/backend/internal/module/scout/usecase/m5_scout_test.go b/apps/backend/internal/module/scout/usecase/m5_scout_test.go new file mode 100644 index 0000000..fb5f251 --- /dev/null +++ b/apps/backend/internal/module/scout/usecase/m5_scout_test.go @@ -0,0 +1,201 @@ +package usecase_test + +import ( + "context" + "testing" + + "apps/backend/internal/module/scout/domain" + "apps/backend/internal/module/scout/repository" + "apps/backend/internal/module/scout/usecase" + studioPublish "apps/backend/internal/module/studio/publish" + + "github.com/stretchr/testify/require" +) + +type staticDevMode struct { + on bool +} + +func (s *staticDevMode) DevModeEnabled(_ context.Context, _ int64) (bool, error) { + return s.on, nil +} + +func newScout() (*usecase.Service, *studioPublish.FakeTransport, *staticDevMode) { + tp := studioPublish.NewFake() + dev := &staticDevMode{} + svc := usecase.New(repository.NewMemory()) + svc.Transport = tp + svc.Settings = dev + return svc, tp, dev +} + +func TestSC_01_CreateBrandProduct(t *testing.T) { + svc, _, _ := newScout() + uid := int64(5_002_001) + b, err := svc.CreateBrand(context.Background(), uid, "海港", "敏感肌") + require.NoError(t, err) + p, err := svc.SaveProduct(context.Background(), uid, &domain.Product{ + BrandID: b.ID, Label: "無香洗髮", PainPoints: []string{"刺鼻"}, MatchTags: []string{"洗髮"}, + }) + require.NoError(t, err) + list, err := svc.ListProducts(context.Background(), uid, b.ID) + require.NoError(t, err) + require.Len(t, list, 1) + require.Equal(t, p.ID, list[0].ID) +} + +func TestSC_02_DeleteBrandWithProductsRefused(t *testing.T) { + svc, _, _ := newScout() + uid := int64(5_002_002) + b, _ := svc.CreateBrand(context.Background(), uid, "B", "") + _, _ = svc.SaveProduct(context.Background(), uid, &domain.Product{BrandID: b.ID, Label: "P"}) + err := svc.RemoveBrand(context.Background(), uid, b.ID) + require.ErrorIs(t, err, domain.ErrHasProducts) +} + +func TestSC_03_PrepareBrief(t *testing.T) { + svc, _, _ := newScout() + uid := int64(5_002_003) + brief, err := svc.PrepareBrief(context.Background(), uid, "敏感肌保養", "", "", "value", false) + require.NoError(t, err) + require.NotEmpty(t, brief.ScanTerms) + require.Equal(t, "敏感肌保養", brief.Intent) +} + +func TestSC_04_PrepareBriefEmptyIntent(t *testing.T) { + svc, _, _ := newScout() + _, err := svc.PrepareBrief(context.Background(), 1, " ", "", "", "", false) + require.Error(t, err) +} + +func TestSC_05_ApiPath(t *testing.T) { + svc, _, dev := newScout() + uid := int64(5_002_005) + dev.on = false + brief, _ := svc.PrepareBrief(context.Background(), uid, "遠端辦公", "", "", "value", false) + posts, err := svc.RunScanFromBrief(context.Background(), uid, brief) + require.NoError(t, err) + require.NotEmpty(t, posts) + require.Equal(t, domain.PathAPI, posts[0].ScanPath) +} + +func TestSC_06_CrawlerPath(t *testing.T) { + svc, _, dev := newScout() + uid := int64(5_002_006) + dev.on = true + require.NoError(t, svc.SetCrawlerSession(context.Background(), uid, "ext-session-1")) + brief, _ := svc.PrepareBrief(context.Background(), uid, "成分黨", "", "", "value", false) + posts, err := svc.RunScanFromBrief(context.Background(), uid, brief) + require.NoError(t, err) + require.Equal(t, domain.PathCrawler, posts[0].ScanPath) +} + +func TestSC_07_NoSession(t *testing.T) { + svc, _, dev := newScout() + uid := int64(5_002_007) + dev.on = true + brief, _ := svc.PrepareBrief(context.Background(), uid, "無 session", "", "", "value", false) + _, err := svc.RunScanFromBrief(context.Background(), uid, brief) + require.ErrorIs(t, err, domain.ErrNoCrawlerSession) +} + +func TestSC_08_ListPostsAfterScan(t *testing.T) { + svc, _, _ := newScout() + uid := int64(5_002_008) + brief, _ := svc.PrepareBrief(context.Background(), uid, "list test", "", "", "value", false) + _, _ = svc.RunScanFromBrief(context.Background(), uid, brief) + list, err := svc.ListPosts(context.Background(), uid, "") + require.NoError(t, err) + require.NotEmpty(t, list) + require.True(t, list[0].CreatedAt > 0) +} + +func TestSC_09_DraftOutreach(t *testing.T) { + svc, _, _ := newScout() + uid := int64(5_002_009) + brief, _ := svc.PrepareBrief(context.Background(), uid, "draft", "", "", "value", false) + posts, _ := svc.RunScanFromBrief(context.Background(), uid, brief) + p, err := svc.DraftOutreach(context.Background(), uid, posts[0].ID, "") + require.NoError(t, err) + require.Equal(t, domain.OutreachDrafted, p.OutreachStatus) + require.NotEmpty(t, p.DraftText) +} + +func TestSC_10_SkipAndMark(t *testing.T) { + svc, _, _ := newScout() + uid := int64(5_002_010) + brief, _ := svc.PrepareBrief(context.Background(), uid, "skip", "", "", "value", false) + posts, _ := svc.RunScanFromBrief(context.Background(), uid, brief) + p, err := svc.SkipOutreach(context.Background(), uid, posts[0].ID) + require.NoError(t, err) + require.Equal(t, domain.OutreachSkipped, p.OutreachStatus) + if len(posts) > 1 { + p2, err := svc.MarkPublished(context.Background(), uid, posts[1].ID) + require.NoError(t, err) + require.Equal(t, domain.OutreachPublished, p2.OutreachStatus) + } +} + +func TestSC_11_SendOutreach(t *testing.T) { + svc, tp, _ := newScout() + uid := int64(5_002_011) + brief, _ := svc.PrepareBrief(context.Background(), uid, "send", "", "", "value", false) + posts, _ := svc.RunScanFromBrief(context.Background(), uid, brief) + p, err := svc.SendOutreach(context.Background(), uid, posts[0].ID, "你好呀", "acc1") + require.NoError(t, err) + require.Equal(t, domain.OutreachPublished, p.OutreachStatus) + require.Equal(t, 1, tp.CallCount()) + // fail path + tp.Fail = true + brief2, _ := svc.PrepareBrief(context.Background(), uid, "sendfail", "", "", "value", false) + posts2, _ := svc.RunScanFromBrief(context.Background(), uid, brief2) + _, err = svc.SendOutreach(context.Background(), uid, posts2[0].ID, "nope", "") + require.Error(t, err) + got, _ := svc.ListPosts(context.Background(), uid, "") + for _, x := range got { + if x.ID == posts2[0].ID { + require.NotEqual(t, domain.OutreachPublished, x.OutreachStatus) + } + } +} + +func TestSC_12_RemovePostTheme(t *testing.T) { + svc, _, _ := newScout() + uid := int64(5_002_012) + brief, _ := svc.PrepareBrief(context.Background(), uid, "rm", "", "", "value", false) + posts, _ := svc.RunScanFromBrief(context.Background(), uid, brief) + require.NoError(t, svc.RemovePost(context.Background(), uid, posts[0].ID)) + require.NoError(t, svc.RemoveTheme(context.Background(), uid, brief.ThemeKey)) + list, _ := svc.ListPosts(context.Background(), uid, "") + require.Empty(t, list) +} + +func TestSC_13_Homework(t *testing.T) { + svc, _, _ := newScout() + uid := int64(5_002_013) + brief, _ := svc.PrepareBrief(context.Background(), uid, "hw", "", "", "value", false) + h, err := svc.SaveHomework(context.Background(), uid, &domain.Homework{ + ThemeKey: brief.ThemeKey, ThemeLabel: brief.ThemeLabel, Purpose: "value", Brief: *brief, + }) + require.NoError(t, err) + list, err := svc.ListHomework(context.Background(), uid) + require.NoError(t, err) + require.Len(t, list, 1) + got, err := svc.GetHomework(context.Background(), uid, h.ThemeKey) + require.NoError(t, err) + require.Equal(t, brief.Intent, got.Brief.Intent) + require.NoError(t, svc.RemoveHomework(context.Background(), uid, h.ThemeKey)) +} + +func TestSC_14_ImportProductFromUrl(t *testing.T) { + svc, _, _ := newScout() + d, err := svc.ImportProductFromURL(context.Background(), "https://shop.example.com/item/1") + require.NoError(t, err) + require.NotEmpty(t, d.Label) + require.Equal(t, "https://shop.example.com/item/1", d.PlacementURL) +} + +func TestSC_TopicRemoved(t *testing.T) { + svc, _, _ := newScout() + require.ErrorIs(t, svc.TopicRemoved(), domain.ErrTopicRemoved) +} diff --git a/apps/backend/internal/module/scout/usecase/service.go b/apps/backend/internal/module/scout/usecase/service.go new file mode 100644 index 0000000..90f85eb --- /dev/null +++ b/apps/backend/internal/module/scout/usecase/service.go @@ -0,0 +1,488 @@ +package usecase + +import ( + "context" + "fmt" + "net/url" + "strings" + + "apps/backend/internal/module/ai" + "apps/backend/internal/module/scout/domain" + studioDomain "apps/backend/internal/module/studio/domain" + studioPublish "apps/backend/internal/module/studio/publish" + threadsDomain "apps/backend/internal/module/threads/domain" + + "github.com/google/uuid" +) + +// SettingsReader for dev_mode +type SettingsReader interface { + DevModeEnabled(ctx context.Context, uid int64) (bool, error) +} + +// AccountLookup for send outreach +type AccountLookup interface { + Get(ctx context.Context, id string) (*threadsDomain.Account, error) + AccessToken(ctx context.Context, a *threadsDomain.Account) (string, error) +} + +type Service struct { + Repo domain.Repository + Settings SettingsReader + Transport studioPublish.Transport + Accounts AccountLookup + AI ai.Client +} + +func New(repo domain.Repository) *Service { + return &Service{Repo: repo} +} + +func (s *Service) ListBrands(ctx context.Context, ownerUID int64) ([]*domain.Brand, error) { + return s.Repo.ListBrands(ctx, ownerUID) +} + +func (s *Service) GetBrand(ctx context.Context, ownerUID int64, id string) (*domain.Brand, error) { + b, err := s.Repo.GetBrand(ctx, id) + if err != nil { + return nil, err + } + if b.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + return b, nil +} + +func (s *Service) CreateBrand(ctx context.Context, ownerUID int64, name, brief string) (*domain.Brand, error) { + now := domain.NowNano() + if name == "" { + name = "未命名品牌" + } + b := &domain.Brand{ + ID: "br_" + uuid.NewString()[:10], OwnerUID: ownerUID, + DisplayName: name, Brief: brief, CreatedAt: now, UpdatedAt: now, + } + if err := s.Repo.SaveBrand(ctx, b); err != nil { + return nil, err + } + aid, _ := s.Repo.GetActiveBrandID(ctx, ownerUID) + if aid == "" { + _ = s.Repo.SetActiveBrandID(ctx, ownerUID, b.ID) + } + return b, nil +} + +func (s *Service) SaveBrand(ctx context.Context, ownerUID int64, b *domain.Brand) (*domain.Brand, error) { + now := domain.NowNano() + if b.ID == "" { + return s.CreateBrand(ctx, ownerUID, b.DisplayName, b.Brief) + } + ex, err := s.GetBrand(ctx, ownerUID, b.ID) + if err != nil { + return nil, err + } + b.OwnerUID = ownerUID + b.CreatedAt = ex.CreatedAt + b.UpdatedAt = now + if err := s.Repo.SaveBrand(ctx, b); err != nil { + return nil, err + } + return b, nil +} + +func (s *Service) RemoveBrand(ctx context.Context, ownerUID int64, id string) error { + if _, err := s.GetBrand(ctx, ownerUID, id); err != nil { + return err + } + prods, _ := s.Repo.ListProducts(ctx, ownerUID, id) + if len(prods) > 0 { + return domain.ErrHasProducts + } + if err := s.Repo.DeleteBrand(ctx, id); err != nil { + return err + } + aid, _ := s.Repo.GetActiveBrandID(ctx, ownerUID) + if aid == id { + list, _ := s.Repo.ListBrands(ctx, ownerUID) + next := "" + if len(list) > 0 { + next = list[0].ID + } + _ = s.Repo.SetActiveBrandID(ctx, ownerUID, next) + } + return nil +} + +func (s *Service) GetActiveBrandID(ctx context.Context, ownerUID int64) (string, error) { + return s.Repo.GetActiveBrandID(ctx, ownerUID) +} + +func (s *Service) SetActiveBrandID(ctx context.Context, ownerUID int64, id string) error { + if id != "" { + if _, err := s.GetBrand(ctx, ownerUID, id); err != nil { + return err + } + } + return s.Repo.SetActiveBrandID(ctx, ownerUID, id) +} + +func (s *Service) ListProducts(ctx context.Context, ownerUID int64, brandID string) ([]*domain.Product, error) { + return s.Repo.ListProducts(ctx, ownerUID, brandID) +} + +func (s *Service) ListAllProducts(ctx context.Context, ownerUID int64) ([]*domain.Product, error) { + return s.Repo.ListProducts(ctx, ownerUID, "") +} + +func (s *Service) GetProduct(ctx context.Context, ownerUID int64, id string) (*domain.Product, error) { + p, err := s.Repo.GetProduct(ctx, id) + if err != nil { + return nil, err + } + if p.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + return p, nil +} + +func (s *Service) SaveProduct(ctx context.Context, ownerUID int64, p *domain.Product) (*domain.Product, error) { + now := domain.NowNano() + if p.BrandID == "" { + return nil, fmt.Errorf("%w: brand_id required", domain.ErrValidation) + } + if _, err := s.GetBrand(ctx, ownerUID, p.BrandID); err != nil { + return nil, err + } + if p.ID == "" { + p.ID = "prd_" + uuid.NewString()[:10] + p.CreatedAt = now + } else { + ex, err := s.GetProduct(ctx, ownerUID, p.ID) + if err == nil { + p.CreatedAt = ex.CreatedAt + } else if err != domain.ErrNotFound { + return nil, err + } else { + p.CreatedAt = now + } + } + p.OwnerUID = ownerUID + p.UpdatedAt = now + if p.Label == "" { + p.Label = "未命名產品" + } + if err := s.Repo.SaveProduct(ctx, p); err != nil { + return nil, err + } + return p, nil +} + +func (s *Service) RemoveProduct(ctx context.Context, ownerUID int64, id string) error { + if _, err := s.GetProduct(ctx, ownerUID, id); err != nil { + return err + } + return s.Repo.DeleteProduct(ctx, id) +} + +func (s *Service) ImportProductFromURL(_ context.Context, raw string) (*domain.ImportDraft, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("%w: empty url", domain.ErrValidation) + } + u, err := url.Parse(raw) + if err != nil || (u.Host == "" && !strings.HasPrefix(raw, "http")) { + return nil, fmt.Errorf("%w: bad url", domain.ErrValidation) + } + host := "" + if u != nil { + host = u.Host + } + label := "匯入商品" + if host != "" { + label = host + " 商品" + } + return &domain.ImportDraft{ + Label: label, + ProductContext: "從 " + raw + " 推估的產品情境(live 可接真爬頁)", + PainPoints: []string{"找不到適合的", "價格猶豫", "不確定是否適合自己"}, + MatchTags: []string{"好物", "推薦", "踩雷"}, + PlacementURL: raw, + SourceNote: "importProductFromUrl · " + host, + }, nil +} + +func (s *Service) PrepareBrief(ctx context.Context, ownerUID int64, intent, brandID, productID, purpose string, deep bool) (*domain.RunBrief, error) { + _ = deep + intent = strings.TrimSpace(intent) + if intent == "" { + return nil, fmt.Errorf("%w: intent required", domain.ErrValidation) + } + mode := domain.ModeTheme + if purpose == "activity" { + mode = domain.ModeActivity + } + brief := &domain.RunBrief{ + Intent: intent, Mode: mode, BrandID: brandID, ProductID: productID, + Pains: []string{}, Tags: []string{}, Periphery: []string{}, ScanTerms: []string{}, + } + if productID != "" { + p, err := s.GetProduct(ctx, ownerUID, productID) + if err == nil { + mode = domain.ModeProduct + if purpose == "activity" { + mode = domain.ModeActivity + } + brief.Mode = mode + brief.ProductLabel = p.Label + brief.ProductContext = p.ProductContext + brief.Pains = append([]string(nil), p.PainPoints...) + brief.Tags = append([]string(nil), p.MatchTags...) + brief.BrandID = p.BrandID + brief.PlacementNote = "軟性經驗分享,避免硬廣" + } + } + if len(brief.Pains) == 0 { + brief.Pains = []string{intent, "相關困擾"} + } + if len(brief.Tags) == 0 { + brief.Tags = tokenize(intent) + } + brief.Periphery = []string{"使用情境", "替代方案", "成分/規格"} + seen := map[string]bool{} + for _, t := range append(brief.Pains, brief.Tags...) { + t = strings.TrimSpace(t) + if t == "" || seen[t] { + continue + } + seen[t] = true + brief.ScanTerms = append(brief.ScanTerms, t) + } + brief.ThemeLabel = truncate(intent, 36) + brief.ThemeKey = mode + "|" + productID + "|" + truncate(intent, 48) + brief.ResponseStance = "先共鳴再給建議" + return brief, nil +} + +func (s *Service) RunScanFromBrief(ctx context.Context, ownerUID int64, brief *domain.RunBrief) ([]*domain.Post, error) { + if brief == nil { + return nil, fmt.Errorf("%w: nil brief", domain.ErrValidation) + } + if len(brief.ScanTerms) == 0 { + return nil, fmt.Errorf("%w: need scan_terms", domain.ErrValidation) + } + path := domain.PathAPI + devMode := false + if s.Settings != nil { + if d, err := s.Settings.DevModeEnabled(ctx, ownerUID); err == nil { + devMode = d + } + } + if devMode { + sess, err := s.Repo.GetCrawlerSession(ctx, ownerUID) + if err != nil || sess == nil || sess.Token == "" { + return nil, domain.ErrNoCrawlerSession + } + path = domain.PathCrawler + } + now := domain.NowNano() + var out []*domain.Post + authors := []string{"itchy_days", "new_mom_tw", "remote_worker", "curious_one"} + limit := len(brief.ScanTerms) + if limit > 5 { + limit = 5 + } + for i := 0; i < limit; i++ { + term := brief.ScanTerms[i] + p := &domain.Post{ + ID: "sp_" + uuid.NewString()[:10], OwnerUID: ownerUID, BrandID: brief.BrandID, + Author: authors[i%len(authors)], Text: fmt.Sprintf("有人懂「%s」嗎?最近超煩…", term), + SearchTag: term, Opportunity: "可接話分享經驗", OutreachStatus: domain.OutreachNew, + Score: 55 + (i*11)%40, MatchedProductID: brief.ProductID, MatchedProductLabel: brief.ProductLabel, + MatchReason: "命中掃描詞 " + term, ScoutMode: brief.Mode, IntentSnippet: brief.Intent, + ThemeKey: brief.ThemeKey, ThemeLabel: brief.ThemeLabel, ScanPath: path, + CreatedAt: now - int64(i)*1000, + } + if err := s.Repo.SavePost(ctx, p); err != nil { + return nil, err + } + out = append(out, p) + } + return out, nil +} + +func (s *Service) ListPosts(ctx context.Context, ownerUID int64, brandID string) ([]*domain.Post, error) { + return s.Repo.ListPosts(ctx, ownerUID, brandID) +} + +func (s *Service) DraftOutreach(ctx context.Context, ownerUID int64, postID, personaID string) (*domain.Post, error) { + _ = personaID + p, err := s.getPostOwned(ctx, ownerUID, postID) + if err != nil { + return nil, err + } + draft := "嗨 @" + p.Author + ",看到你提到「" + p.SearchTag + "」,我也遇過類似情況…" + if s.AI != nil { + if out, aerr := s.AI.Complete(ctx, "fake", "grok-3", "outreach: "+p.Text); aerr == nil && out != "" { + draft = out + } + } + p.DraftText = draft + p.OutreachStatus = domain.OutreachDrafted + if err := s.Repo.SavePost(ctx, p); err != nil { + return nil, err + } + return p, nil +} + +func (s *Service) SkipOutreach(ctx context.Context, ownerUID int64, postID string) (*domain.Post, error) { + p, err := s.getPostOwned(ctx, ownerUID, postID) + if err != nil { + return nil, err + } + p.OutreachStatus = domain.OutreachSkipped + if err := s.Repo.SavePost(ctx, p); err != nil { + return nil, err + } + return p, nil +} + +func (s *Service) MarkPublished(ctx context.Context, ownerUID int64, postID string) (*domain.Post, error) { + p, err := s.getPostOwned(ctx, ownerUID, postID) + if err != nil { + return nil, err + } + p.OutreachStatus = domain.OutreachPublished + if err := s.Repo.SavePost(ctx, p); err != nil { + return nil, err + } + return p, nil +} + +func (s *Service) SendOutreach(ctx context.Context, ownerUID int64, postID, text, accountID string) (*domain.Post, error) { + p, err := s.getPostOwned(ctx, ownerUID, postID) + if err != nil { + return nil, err + } + text = strings.TrimSpace(text) + if text == "" { + text = p.DraftText + } + if text == "" { + return nil, fmt.Errorf("%w: empty outreach text", domain.ErrValidation) + } + if s.Transport != nil { + token := "fake" + if s.Accounts != nil && accountID != "" { + if acc, aerr := s.Accounts.Get(ctx, accountID); aerr == nil && acc != nil && acc.OwnerUID == ownerUID { + if t, terr := s.Accounts.AccessToken(ctx, acc); terr == nil && t != "" { + token = t + } + } + } + if _, perr := s.Transport.Publish(ctx, studioDomain.PublishRequest{ + AccessToken: token, AccountID: accountID, Text: text, + }); perr != nil { + return nil, perr + } + } + p.DraftText = text + p.OutreachStatus = domain.OutreachPublished + if err := s.Repo.SavePost(ctx, p); err != nil { + return nil, err + } + return p, nil +} + +func (s *Service) RemovePost(ctx context.Context, ownerUID int64, postID string) error { + if _, err := s.getPostOwned(ctx, ownerUID, postID); err != nil { + return err + } + return s.Repo.DeletePost(ctx, postID) +} + +func (s *Service) RemoveTheme(ctx context.Context, ownerUID int64, themeKey string) error { + return s.Repo.DeletePostsByTheme(ctx, ownerUID, themeKey) +} + +func (s *Service) ListHomework(ctx context.Context, ownerUID int64) ([]*domain.Homework, error) { + return s.Repo.ListHomework(ctx, ownerUID) +} + +func (s *Service) SaveHomework(ctx context.Context, ownerUID int64, h *domain.Homework) (*domain.Homework, error) { + if h.ThemeKey == "" { + return nil, fmt.Errorf("%w: theme_key required", domain.ErrValidation) + } + h.OwnerUID = ownerUID + if h.CreatedAt == 0 { + h.CreatedAt = domain.NowNano() + } + if err := s.Repo.SaveHomework(ctx, h); err != nil { + return nil, err + } + return h, nil +} + +func (s *Service) GetHomework(ctx context.Context, ownerUID int64, themeKey string) (*domain.Homework, error) { + return s.Repo.GetHomework(ctx, ownerUID, themeKey) +} + +func (s *Service) RemoveHomework(ctx context.Context, ownerUID int64, themeKey string) error { + return s.Repo.DeleteHomework(ctx, ownerUID, themeKey) +} + +func (s *Service) SetCrawlerSession(ctx context.Context, ownerUID int64, token string) error { + return s.Repo.SetCrawlerSession(ctx, &domain.CrawlerSession{ + OwnerUID: ownerUID, Token: token, UpdatedAt: domain.NowNano(), + }) +} + +// GetCrawlerSessionToken returns Playwright storageState JSON for browser crawl (may be empty). +func (s *Service) GetCrawlerSessionToken(ctx context.Context, ownerUID int64) (string, error) { + sess, err := s.Repo.GetCrawlerSession(ctx, ownerUID) + if err != nil { + return "", err + } + if sess == nil { + return "", nil + } + return sess.Token, nil +} + +func (s *Service) ClearCrawlerSession(ctx context.Context, ownerUID int64) error { + return s.Repo.ClearCrawlerSession(ctx, ownerUID) +} + +// TopicRemoved — SC: ScoutTopic CRUD not implemented +func (s *Service) TopicRemoved() error { return domain.ErrTopicRemoved } + +func (s *Service) getPostOwned(ctx context.Context, ownerUID int64, id string) (*domain.Post, error) { + p, err := s.Repo.GetPost(ctx, id) + if err != nil { + return nil, err + } + if p.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + return p, nil +} + +func tokenize(s string) []string { + parts := strings.FieldsFunc(s, func(r rune) bool { + return r == ' ' || r == '、' || r == ',' || r == '/' + }) + if len(parts) == 0 { + return []string{s} + } + if len(parts) > 4 { + parts = parts[:4] + } + return parts +} + +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) +} diff --git a/apps/backend/internal/module/search/exa.go b/apps/backend/internal/module/search/exa.go new file mode 100644 index 0000000..a9e36a0 --- /dev/null +++ b/apps/backend/internal/module/search/exa.go @@ -0,0 +1,195 @@ +package search + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const defaultBaseURL = "https://api.exa.ai/search" + +type Hit struct { + Title string `json:"title"` + URL string `json:"url"` + Snippet string `json:"snippet"` +} + +type Client interface { + Search(ctx context.Context, apiKey, query string, limit int) ([]Hit, error) +} + +// FakeClient for tests / no platform key. +type FakeClient struct { + Fail bool +} + +func (f *FakeClient) Search(_ context.Context, apiKey, query string, limit int) ([]Hit, error) { + if f.Fail || apiKey == "" { + return nil, fmt.Errorf("search failed: no key") + } + if limit <= 0 { + limit = 3 + } + var hits []Hit + for i := 0; i < limit; i++ { + hits = append(hits, Hit{ + Title: fmt.Sprintf("Result %d for %s", i+1, strings.TrimSpace(query)), + URL: fmt.Sprintf("https://example.com/r/%d", i+1), + Snippet: "fake exa snippet", + }) + } + return hits, nil +} + +// ExaClient calls api.exa.ai (real web search). +type ExaClient struct { + BaseURL string + HTTP *http.Client + // IncludeDomains optional host filter (e.g. threads.net). + IncludeDomains []string +} + +// NewExa creates a ready HTTP client. +func NewExa() *ExaClient { + return &ExaClient{ + BaseURL: defaultBaseURL, + HTTP: &http.Client{Timeout: 25 * time.Second}, + } +} + +// NewExaThreads prefers results from Threads domains. +func NewExaThreads() *ExaClient { + c := NewExa() + c.IncludeDomains = []string{"threads.net", "threads.com"} + return c +} + +func (c *ExaClient) Search(ctx context.Context, apiKey, query string, limit int) ([]Hit, error) { + apiKey = strings.TrimSpace(apiKey) + query = strings.TrimSpace(query) + if apiKey == "" || strings.HasPrefix(apiKey, "fake") { + return nil, fmt.Errorf("search failed: no key") + } + if query == "" { + return nil, fmt.Errorf("search query required") + } + if limit <= 0 { + limit = 5 + } + if limit > 20 { + limit = 20 + } + base := c.BaseURL + if base == "" { + base = defaultBaseURL + } + httpClient := c.HTTP + if httpClient == nil { + httpClient = &http.Client{Timeout: 25 * time.Second} + } + + body := map[string]any{ + "query": query, + "type": "auto", + "numResults": limit, + "userLocation": "TW", + "contents": map[string]any{ + "highlights": true, + "text": map[string]any{"maxCharacters": 400}, + }, + } + if len(c.IncludeDomains) > 0 { + body["includeDomains"] = c.IncludeDomains + } + + payload, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, base, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", apiKey) + + res, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) + if err != nil { + return nil, err + } + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("exa status %d: %s", res.StatusCode, truncateRunes(string(raw), 120)) + } + + var parsed struct { + Results []struct { + Title string `json:"title"` + URL string `json:"url"` + Highlights []string `json:"highlights"` + Text string `json:"text"` + Author string `json:"author"` + } `json:"results"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, err + } + + out := make([]Hit, 0, len(parsed.Results)) + for _, item := range parsed.Results { + u := strings.TrimSpace(item.URL) + if u == "" { + continue + } + snippet := firstNonEmpty(item.Highlights...) + if snippet == "" { + snippet = strings.TrimSpace(item.Text) + } + if snippet == "" { + snippet = strings.TrimSpace(item.Title) + } + title := strings.TrimSpace(item.Title) + if title == "" { + title = strings.TrimSpace(item.Author) + } + if title == "" { + title = u + } + out = append(out, Hit{ + Title: title, + URL: u, + Snippet: truncateRunes(snippet, 200), + }) + if len(out) >= limit { + break + } + } + return out, nil +} + +func firstNonEmpty(ss ...string) string { + for _, s := range ss { + if t := strings.TrimSpace(s); t != "" { + return t + } + } + return "" +} + +func truncateRunes(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" +} diff --git a/apps/backend/internal/module/search/exa_test.go b/apps/backend/internal/module/search/exa_test.go new file mode 100644 index 0000000..3f8c4c9 --- /dev/null +++ b/apps/backend/internal/module/search/exa_test.go @@ -0,0 +1,24 @@ +package search_test + +import ( + "context" + "testing" + + "apps/backend/internal/module/search" + + "github.com/stretchr/testify/require" +) + +func TestST_10_PlatformSearch(t *testing.T) { + c := &search.FakeClient{} + hits, err := c.Search(context.Background(), "plat-exa", "threads", 2) + require.NoError(t, err) + require.Len(t, hits, 2) +} + +func TestST_11_ByokSearch(t *testing.T) { + c := &search.FakeClient{} + hits, err := c.Search(context.Background(), "member-exa", "query", 1) + require.NoError(t, err) + require.NotEmpty(t, hits) +} diff --git a/apps/backend/internal/module/studio/domain/domain.go b/apps/backend/internal/module/studio/domain/domain.go new file mode 100644 index 0000000..73f6db1 --- /dev/null +++ b/apps/backend/internal/module/studio/domain/domain.go @@ -0,0 +1,288 @@ +package domain + +import ( + "errors" + "time" +) + +var ( + ErrNotFound = errors.New("studio not found") + ErrForbidden = errors.New("studio forbidden") + ErrValidation = errors.New("studio validation") + ErrNoAccount = errors.New("no usable threads account") + ErrIllegalStatus = errors.New("illegal outbox status") + ErrNotDue = errors.New("step not due") + ErrRootBlocked = errors.New("root not published; reply blocked") + ErrBadURL = errors.New("invalid external url") + ErrFormulaRemove = errors.New("generateFromFormula removed") +) + +const ( + PersonaEmpty = "empty" + PersonaAnalyzing = "analyzing" + PersonaReady = "ready" + + PlayDraft = "draft" + PlayReady = "ready" + PlayScheduling = "scheduling" + PlayActive = "active" + PlayCompleted = "completed" + PlayPartial = "partial_failed" + PlayArchived = "archived" + + StepRoot = "root" + StepReply = "reply" + + OBScheduling = "scheduling" + OBActive = "active" + OBCompleted = "completed" + OBPartial = "partial_failed" + OBCancelled = "cancelled" + + StepDraft = "draft" + StepScheduled = "scheduled" + StepPublishing = "publishing" + StepPublished = "published" + StepFailed = "failed" + StepCancelled = "cancelled" + StepBlocked = "blocked" + + MentionPending = "pending" + MentionReplied = "replied" + MentionSkipped = "skipped" +) + +func NowNano() int64 { return time.Now().UTC().UnixNano() } + +// Persona — PE domain +type Persona struct { + ID string `bson:"_id" json:"id"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + Name string `bson:"name" json:"name"` + Brief string `bson:"brief" json:"brief"` + Status string `bson:"status" json:"status"` + Style PersonaStyle `bson:"style" json:"style"` + Guard PersonaGuard `bson:"guard" json:"guard"` + Voice string `bson:"voice,omitempty" json:"voice,omitempty"` + Notes string `bson:"notes,omitempty" json:"notes,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` +} + +type PersonaStyle struct { + Draft PersonaDraft `bson:"draft" json:"draft"` + DraftText string `bson:"draft_text" json:"draft_text"` + Source string `bson:"source" json:"source"` // manual|benchmark|seed + BenchmarkUsername string `bson:"benchmark_username,omitempty" json:"benchmark_username,omitempty"` + SourceLabel string `bson:"source_label,omitempty" json:"source_label,omitempty"` + SampleCount int `bson:"sample_count" json:"sample_count"` + AnalyzedAt int64 `bson:"analyzed_at,omitempty" json:"analyzed_at,omitempty"` + SamplePreviews []string `bson:"sample_previews,omitempty" json:"sample_previews,omitempty"` + // Dimensions — 8D 風格摘要(分析後必填,前端「分析」頁卡片) + Dimensions map[string]StyleDimension `bson:"dimensions,omitempty" json:"dimensions,omitempty"` +} + +// StyleDimension — 單一 8D 維度 +type StyleDimension struct { + Summary string `bson:"summary" json:"summary"` + Evidence []string `bson:"evidence,omitempty" json:"evidence,omitempty"` +} + +type PersonaDraft struct { + Identity string `bson:"identity" json:"identity"` + Tone string `bson:"tone" json:"tone"` + Audience string `bson:"audience" json:"audience"` + Hooks string `bson:"hooks" json:"hooks"` + LanguageFingerprint string `bson:"language_fingerprint" json:"language_fingerprint"` + Rhythm string `bson:"rhythm" json:"rhythm"` + Punctuation string `bson:"punctuation" json:"punctuation"` + ContentPatterns string `bson:"content_patterns" json:"content_patterns"` + KnowledgeTranslation string `bson:"knowledge_translation" json:"knowledge_translation"` + CtaStyle string `bson:"cta_style" json:"cta_style"` + Examples string `bson:"examples" json:"examples"` + Avoid string `bson:"avoid" json:"avoid"` +} + +type PersonaGuard struct { + Avoid []string `bson:"avoid" json:"avoid"` + MaxChars int `bson:"max_chars" json:"max_chars"` + BanAiTone bool `bson:"ban_ai_tone" json:"ban_ai_tone"` +} + +// ActivePersona stores per-owner active id +type ActivePersona struct { + OwnerUID int64 `bson:"_id" json:"owner_uid"` + PersonaID string `bson:"persona_id" json:"persona_id"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` +} + +// Play — PL domain +type ExternalTarget struct { + URL string `bson:"url" json:"url"` + RawURL string `bson:"raw_url,omitempty" json:"raw_url,omitempty"` + Shortcode string `bson:"shortcode,omitempty" json:"shortcode,omitempty"` + AuthorUsername string `bson:"author_username,omitempty" json:"author_username,omitempty"` + TextPreview string `bson:"text_preview,omitempty" json:"text_preview,omitempty"` + MediaID string `bson:"media_id,omitempty" json:"media_id,omitempty"` + ResolvedAt int64 `bson:"resolved_at,omitempty" json:"resolved_at,omitempty"` +} + +type PlayStep struct { + ID string `bson:"id" json:"id"` + SortOrder int `bson:"sort_order" json:"sort_order"` + Kind string `bson:"kind" json:"kind"` + AccountID string `bson:"account_id" json:"account_id"` + Text string `bson:"text" json:"text"` + DelayFromPreviousSec int `bson:"delay_from_previous_sec" json:"delay_from_previous_sec"` + PersonaID string `bson:"persona_id,omitempty" json:"persona_id,omitempty"` + BrandID string `bson:"brand_id,omitempty" json:"brand_id,omitempty"` + ImageURLs []string `bson:"image_urls,omitempty" json:"image_urls,omitempty"` +} + +type Play struct { + ID string `bson:"_id" json:"id"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + Title string `bson:"title" json:"title"` + Topic string `bson:"topic" json:"topic"` + Status string `bson:"status" json:"status"` + LeadAccountID string `bson:"lead_account_id" json:"lead_account_id"` + CastAccountIDs []string `bson:"cast_account_ids" json:"cast_account_ids"` + TargetOwnPostID string `bson:"target_own_post_id,omitempty" json:"target_own_post_id,omitempty"` + TargetExternal *ExternalTarget `bson:"target_external,omitempty" json:"target_external,omitempty"` + Steps []PlayStep `bson:"steps" json:"steps"` + ScheduleStartAt int64 `bson:"schedule_start_at" json:"schedule_start_at"` + IntervalMinutes int `bson:"interval_minutes,omitempty" json:"interval_minutes,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` +} + +// Outbox +type OutboxStep struct { + ID string `bson:"id" json:"id"` + StepID string `bson:"step_id" json:"step_id"` + SortOrder int `bson:"sort_order" json:"sort_order"` + Kind string `bson:"kind" json:"kind"` + AccountID string `bson:"account_id" json:"account_id"` + Text string `bson:"text" json:"text"` + // TopicTag — 主貼可用的 Threads 話題標籤 + TopicTag string `bson:"topic_tag,omitempty" json:"topic_tag,omitempty"` + // ImageURLs — 公開 https 圖網址(Meta image_url 可抓;勿存 data URL) + ImageURLs []string `bson:"image_urls,omitempty" json:"image_urls,omitempty"` + Status string `bson:"status" json:"status"` + Error string `bson:"error,omitempty" json:"error,omitempty"` + ScheduledAt int64 `bson:"scheduled_at,omitempty" json:"scheduled_at,omitempty"` + PublishedAt int64 `bson:"published_at,omitempty" json:"published_at,omitempty"` + // MediaID after successful publish (for reply chain) + MediaID string `bson:"media_id,omitempty" json:"media_id,omitempty"` + // ReplyTo is parent media id (external or previous root) + ReplyTo string `bson:"reply_to,omitempty" json:"reply_to,omitempty"` +} + +type OutboxBundle struct { + ID string `bson:"_id" json:"id"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + PlayID string `bson:"play_id" json:"play_id"` + Title string `bson:"title" json:"title"` + Status string `bson:"status" json:"status"` + Steps []OutboxStep `bson:"steps" json:"steps"` + // Root reply target for under-post schemes + ReplyToMediaID string `bson:"reply_to_media_id,omitempty" json:"reply_to_media_id,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` +} + +// OwnPost / Mention +type OwnPostReply struct { + ID string `bson:"id" json:"id"` + Username string `bson:"username" json:"username"` + Text string `bson:"text" json:"text"` + CreatedAt int64 `bson:"created_at" json:"created_at"` + LikeCount int `bson:"like_count,omitempty" json:"like_count,omitempty"` + ReplyStatus string `bson:"reply_status,omitempty" json:"reply_status,omitempty"` + RepliedBy string `bson:"replied_by,omitempty" json:"replied_by,omitempty"` + RepliedAt int64 `bson:"replied_at,omitempty" json:"replied_at,omitempty"` + ParentReplyID string `bson:"parent_reply_id,omitempty" json:"parent_reply_id,omitempty"` + IsMine bool `bson:"is_mine,omitempty" json:"is_mine,omitempty"` +} + +type OwnPost struct { + ID string `bson:"_id" json:"id"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + AccountID string `bson:"account_id" json:"account_id"` + MediaID string `bson:"media_id,omitempty" json:"media_id,omitempty"` + Text string `bson:"text" json:"text"` + MediaType string `bson:"media_type" json:"media_type"` + MediaURL string `bson:"media_url,omitempty" json:"media_url,omitempty"` + ThumbnailURL string `bson:"thumbnail_url,omitempty" json:"thumbnail_url,omitempty"` + Permalink string `bson:"permalink,omitempty" json:"permalink,omitempty"` + Shortcode string `bson:"shortcode,omitempty" json:"shortcode,omitempty"` + TopicTag string `bson:"topic_tag,omitempty" json:"topic_tag,omitempty"` + LikeCount int `bson:"like_count" json:"like_count"` + ReplyCount int `bson:"reply_count" json:"reply_count"` + RepostCount int `bson:"repost_count" json:"repost_count"` + QuoteCount int `bson:"quote_count" json:"quote_count"` + ViewCount int `bson:"view_count" json:"view_count"` + ShareCount int `bson:"share_count" json:"share_count"` + InsightsStatus string `bson:"insights_status,omitempty" json:"insights_status,omitempty"` + FormulaSummary string `bson:"formula_summary,omitempty" json:"formula_summary,omitempty"` + Insight string `bson:"insight,omitempty" json:"insight,omitempty"` + FormulaDetail string `bson:"formula_detail,omitempty" json:"formula_detail,omitempty"` + Replies []OwnPostReply `bson:"replies" json:"replies"` + PublishedAt int64 `bson:"published_at" json:"published_at"` +} + +type Mention struct { + ID string `bson:"_id" json:"id"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + AccountID string `bson:"account_id" json:"account_id"` + MediaID string `bson:"media_id,omitempty" json:"media_id,omitempty"` // Threads media id(回覆 reply_to) + FromUsername string `bson:"from_username" json:"from_username"` + Text string `bson:"text" json:"text"` + ContextSnippet string `bson:"context_snippet" json:"context_snippet"` + Permalink string `bson:"permalink,omitempty" json:"permalink,omitempty"` + RootPostID string `bson:"root_post_id,omitempty" json:"root_post_id,omitempty"` + ParentID string `bson:"parent_id,omitempty" json:"parent_id,omitempty"` + Status string `bson:"status" json:"status"` + DraftText string `bson:"draft_text,omitempty" json:"draft_text,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` +} + +type SyncMeta struct { + OwnerUID int64 `bson:"_id" json:"owner_uid"` + LastSyncedAt int64 `bson:"last_synced_at" json:"last_synced_at"` +} + +type ViralAnalysis struct { + Hooks string `json:"hooks"` + Structure string `json:"structure"` + Emotion string `json:"emotion"` + Summary string `json:"summary"` + CTA string `json:"cta,omitempty"` + Copyable string `json:"copyable,omitempty"` // 可複製要點 + Risks string `json:"risks,omitempty"` // 風險/禁忌 +} + +// PersonaPreview — 試產主貼 + 回文(真 LLM + 可選新聞話題) +type PersonaPreview struct { + Topic string `json:"topic"` + TopicSource string `json:"topic_source"` // news | manual | fallback + PostText string `json:"post_text"` + ReplyText string `json:"reply_text"` + Notes string `json:"notes,omitempty"` +} + +// PublishRequest for transport (must be called for real publish). +type PublishRequest struct { + AccessToken string + AccountID string + Text string + ReplyTo string // empty = root post + // TopicTag — Threads 話題標籤(API topic_tag;1~50 字,不可含 . &) + TopicTag string + // ImageURLs — 公開可抓的 http(s) 圖;空=純文字 TEXT + ImageURLs []string +} + +type PublishResult struct { + MediaID string +} diff --git a/apps/backend/internal/module/studio/domain/repository.go b/apps/backend/internal/module/studio/domain/repository.go new file mode 100644 index 0000000..5c1c779 --- /dev/null +++ b/apps/backend/internal/module/studio/domain/repository.go @@ -0,0 +1,41 @@ +package domain + +import "context" + +// Repository persists studio domain entities (memory or mon). +type Repository interface { + // Persona + SavePersona(ctx context.Context, p *Persona) error + GetPersona(ctx context.Context, id string) (*Persona, error) + ListPersonas(ctx context.Context, ownerUID int64) ([]*Persona, error) + DeletePersona(ctx context.Context, id string) error + GetActivePersonaID(ctx context.Context, ownerUID int64) (string, error) + SetActivePersonaID(ctx context.Context, ownerUID int64, personaID string) error + + // Play + SavePlay(ctx context.Context, p *Play) error + GetPlay(ctx context.Context, id string) (*Play, error) + ListPlays(ctx context.Context, ownerUID int64) ([]*Play, error) + DeletePlay(ctx context.Context, id string) error + + // Outbox + SaveOutbox(ctx context.Context, b *OutboxBundle) error + GetOutbox(ctx context.Context, id string) (*OutboxBundle, error) + ListOutbox(ctx context.Context, ownerUID int64) ([]*OutboxBundle, error) + DeleteOutbox(ctx context.Context, id string) error + // ListDueSteps returns bundles that may have due scheduled steps (all owners for worker). + ListAllOutbox(ctx context.Context) ([]*OutboxBundle, error) + + // OwnPosts + SaveOwnPost(ctx context.Context, p *OwnPost) error + GetOwnPost(ctx context.Context, id string) (*OwnPost, error) + ListOwnPosts(ctx context.Context, ownerUID int64, accountID string) ([]*OwnPost, error) + DeleteOwnPost(ctx context.Context, id string) error + GetSyncMeta(ctx context.Context, ownerUID int64) (*SyncMeta, error) + SetSyncMeta(ctx context.Context, m *SyncMeta) error + + // Mentions + SaveMention(ctx context.Context, m *Mention) error + GetMention(ctx context.Context, id string) (*Mention, error) + ListMentions(ctx context.Context, ownerUID int64, accountID string) ([]*Mention, error) +} diff --git a/apps/backend/internal/module/studio/publish/fake.go b/apps/backend/internal/module/studio/publish/fake.go new file mode 100644 index 0000000..8b0a21e --- /dev/null +++ b/apps/backend/internal/module/studio/publish/fake.go @@ -0,0 +1,67 @@ +package publish + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + + "apps/backend/internal/module/studio/domain" + + "github.com/google/uuid" +) + +// FakeTransport records every Publish call — OB-02 greppable proof. +type FakeTransport struct { + mu sync.Mutex + Calls []domain.PublishRequest + Fail bool + FailMsg string + // FailOnNth fails the Nth call (1-based); 0 = never + FailOnNth int + n int64 +} + +func NewFake() *FakeTransport { + return &FakeTransport{} +} + +func (f *FakeTransport) Publish(ctx context.Context, req domain.PublishRequest) (*domain.PublishResult, error) { + _ = ctx + n := atomic.AddInt64(&f.n, 1) + f.mu.Lock() + f.Calls = append(f.Calls, req) + fail := f.Fail + failNth := f.FailOnNth + msg := f.FailMsg + f.mu.Unlock() + if fail || (failNth > 0 && int(n) == failNth) { + if msg == "" { + msg = "threads publish failed" + } + return nil, fmt.Errorf("%s", msg) + } + return &domain.PublishResult{MediaID: "media_" + uuid.NewString()[:8]}, nil +} + +func (f *FakeTransport) CallCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.Calls) +} + +func (f *FakeTransport) Reset() { + f.mu.Lock() + defer f.mu.Unlock() + f.Calls = nil + atomic.StoreInt64(&f.n, 0) + f.Fail = false + f.FailOnNth = 0 +} + +// Transport interface used by studio service (context.Context). +type Transport interface { + Publish(ctx context.Context, req domain.PublishRequest) (*domain.PublishResult, error) +} + +var _ Transport = (*FakeTransport)(nil) diff --git a/apps/backend/internal/module/studio/publish/meta.go b/apps/backend/internal/module/studio/publish/meta.go new file mode 100644 index 0000000..5da32c0 --- /dev/null +++ b/apps/backend/internal/module/studio/publish/meta.go @@ -0,0 +1,347 @@ +package publish + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "apps/backend/internal/module/studio/domain" + + "github.com/zeromicro/go-zero/core/logx" +) + +// MetaTransport — 真 Threads 發文/回覆(container → publish)。 +// 支援 TEXT / 單圖 IMAGE / 多圖 CAROUSEL(image_url 須為 Meta 可抓的公開 https)。 +type MetaTransport struct { + GraphBase string + HTTPClient *http.Client +} + +func NewMeta(graphBase string) *MetaTransport { + if graphBase == "" { + graphBase = "https://graph.threads.net" + } + return &MetaTransport{ + GraphBase: strings.TrimRight(graphBase, "/"), + HTTPClient: &http.Client{Timeout: 90 * time.Second}, + } +} + +func (t *MetaTransport) Publish(ctx context.Context, req domain.PublishRequest) (*domain.PublishResult, error) { + token := strings.TrimSpace(req.AccessToken) + if token == "" || strings.HasPrefix(token, "fake-") { + return nil, fmt.Errorf("Threads token 無效,請重新連帳") + } + text := strings.TrimSpace(req.Text) + images := publicImageURLs(req.ImageURLs) + if text == "" && len(images) == 0 { + return nil, fmt.Errorf("內容不可為空") + } + // Meta 會從公網 cURL image_url;先 HEAD 自檢,避免只得到 code 1 unknown + for i, img := range images { + if err := t.preflightImageURL(ctx, img); err != nil { + return nil, fmt.Errorf("第 %d 張圖 Meta 無法抓取:%w", i+1, err) + } + } + + createURL := t.GraphBase + "/v1.0/me/threads" + replyTo := strings.TrimSpace(req.ReplyTo) + topicTag := "" + if replyTo == "" { + topicTag = normalizeTopicTag(req.TopicTag) + } + + var containerID string + var wait time.Duration + var err error + + switch { + case len(images) == 0: + // 純文字 + form := url.Values{} + form.Set("media_type", "TEXT") + form.Set("text", text) + form.Set("access_token", token) + if replyTo != "" { + form.Set("reply_to_id", replyTo) + } + if topicTag != "" { + form.Set("topic_tag", topicTag) + } + containerID, err = t.createContainer(ctx, createURL, form) + wait = 8 * time.Second + case len(images) == 1: + // 單圖 + form := url.Values{} + form.Set("media_type", "IMAGE") + form.Set("image_url", images[0]) + form.Set("access_token", token) + if text != "" { + form.Set("text", text) + } + if replyTo != "" { + form.Set("reply_to_id", replyTo) + } + if topicTag != "" { + form.Set("topic_tag", topicTag) + } + containerID, err = t.createContainer(ctx, createURL, form) + wait = 30 * time.Second + default: + // 多圖 carousel:先建 is_carousel_item 子容器,再建 CAROUSEL + childIDs := make([]string, 0, len(images)) + for i, img := range images { + // Threads carousel 最多 20,這裡保守 10 + if i >= 10 { + break + } + cf := url.Values{} + cf.Set("media_type", "IMAGE") + cf.Set("image_url", img) + cf.Set("is_carousel_item", "true") + cf.Set("access_token", token) + cid, cerr := t.createContainer(ctx, createURL, cf) + if cerr != nil { + return nil, fmt.Errorf("Threads 建立 carousel 子圖失敗:%w", cerr) + } + // 子項需 FINISHED 才可組 carousel + if werr := t.waitContainerReady(ctx, token, cid, 30*time.Second); werr != nil { + return nil, fmt.Errorf("Threads 子圖未就緒:%w", werr) + } + childIDs = append(childIDs, cid) + } + form := url.Values{} + form.Set("media_type", "CAROUSEL") + form.Set("children", strings.Join(childIDs, ",")) + form.Set("access_token", token) + if text != "" { + form.Set("text", text) + } + if replyTo != "" { + form.Set("reply_to_id", replyTo) + } + if topicTag != "" { + form.Set("topic_tag", topicTag) + } + containerID, err = t.createContainer(ctx, createURL, form) + wait = 30 * time.Second + } + if err != nil { + return nil, err + } + + if werr := t.waitContainerReady(ctx, token, containerID, wait); werr != nil { + // 超時仍嘗試 publish;硬錯誤則直接失敗 + if wait > 0 && strings.Contains(werr.Error(), "container") { + // ERROR/EXPIRED 會回 error message + if !strings.Contains(werr.Error(), "timeout") { + return nil, fmt.Errorf("Threads 媒體處理失敗:%w", werr) + } + } + } + + // publish + pForm := url.Values{} + pForm.Set("creation_id", containerID) + pForm.Set("access_token", token) + pubURL := t.GraphBase + "/v1.0/me/threads_publish" + pBody, pStatus, err := t.postForm(ctx, pubURL, pForm) + if err != nil { + return nil, fmt.Errorf("Threads 發佈失敗:%w", err) + } + if pStatus >= 300 { + return nil, fmt.Errorf("Threads 發佈失敗:%s", parseGraphError(pBody, pStatus)) + } + var published struct { + ID string `json:"id"` + } + if err := json.Unmarshal(pBody, &published); err != nil || published.ID == "" { + return nil, fmt.Errorf("Threads 發佈失敗:無法解析 media id(%s)", truncate(string(pBody), 180)) + } + logx.Infof("threads publish ok media_id=%s images=%d reply_to=%s", published.ID, len(images), req.ReplyTo) + return &domain.PublishResult{MediaID: published.ID}, nil +} + +func publicImageURLs(raw []string) []string { + out := make([]string, 0, len(raw)) + seen := make(map[string]struct{}, len(raw)) + for _, u := range raw { + u = strings.TrimSpace(u) + if u == "" { + continue + } + // data URL 無法給 Meta 抓,跳過(前端應先上傳) + if strings.HasPrefix(u, "data:") { + continue + } + if strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "http://") { + if _, ok := seen[u]; ok { + continue + } + seen[u] = struct{}{} + out = append(out, u) + } + } + return out +} + +// preflightImageURL — 確認公開 URL 對匿名 GET 可讀(Meta crawler 同樣匿名) +func (t *MetaTransport) preflightImageURL(ctx context.Context, imageURL string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodHead, imageURL, nil) + if err != nil { + return err + } + // 部分 CDN 對 HEAD 不友善,失敗再 GET 前幾個 byte + res, err := t.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("無法連線 %s:%w", truncate(imageURL, 80), err) + } + _ = res.Body.Close() + if res.StatusCode == http.StatusMethodNotAllowed || res.StatusCode == http.StatusForbidden || res.StatusCode >= 400 { + // fallback GET + gReq, gerr := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) + if gerr != nil { + return gerr + } + gReq.Header.Set("Range", "bytes=0-1023") + gRes, gerr := t.HTTPClient.Do(gReq) + if gerr != nil { + return fmt.Errorf("無法連線 %s:%w", truncate(imageURL, 80), gerr) + } + _, _ = io.Copy(io.Discard, io.LimitReader(gRes.Body, 2048)) + _ = gRes.Body.Close() + if gRes.StatusCode >= 300 { + return fmt.Errorf("HTTP %d(請確認 MinIO/nginx 允許公開讀取 image_url)", gRes.StatusCode) + } + return nil + } + if res.StatusCode >= 300 { + return fmt.Errorf("HTTP %d(請確認物件儲存允許公開讀取)", res.StatusCode) + } + return nil +} + +func (t *MetaTransport) createContainer(ctx context.Context, createURL string, form url.Values) (string, error) { + cBody, cStatus, err := t.postForm(ctx, createURL, form) + if err != nil { + return "", fmt.Errorf("Threads 建立容器失敗:%w", err) + } + if cStatus >= 300 { + return "", fmt.Errorf("Threads 建立容器失敗:%s", parseGraphError(cBody, cStatus)) + } + var created struct { + ID string `json:"id"` + } + if err := json.Unmarshal(cBody, &created); err != nil || created.ID == "" { + return "", fmt.Errorf("Threads 建立容器失敗:無法解析 container id(%s)", truncate(string(cBody), 180)) + } + return created.ID, nil +} + +// normalizeTopicTag — Threads API:1~50 字,不可含 . & +func normalizeTopicTag(raw string) string { + s := strings.TrimSpace(raw) + s = strings.TrimPrefix(s, "#") + s = strings.TrimSpace(s) + if s == "" { + return "" + } + s = strings.ReplaceAll(s, ".", "") + s = strings.ReplaceAll(s, "&", "") + s = strings.TrimSpace(s) + r := []rune(s) + if len(r) > 50 { + s = string(r[:50]) + } + if s == "" { + return "" + } + return s +} + +func (t *MetaTransport) postForm(ctx context.Context, fullURL string, form url.Values) ([]byte, int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, 0, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + res, err := t.HTTPClient.Do(req) + if err != nil { + return nil, 0, err + } + defer res.Body.Close() + body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20)) + return body, res.StatusCode, nil +} + +func (t *MetaTransport) waitContainerReady(ctx context.Context, token, containerID string, maxWait time.Duration) error { + deadline := time.Now().Add(maxWait) + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + q := url.Values{} + q.Set("fields", "status,error_message") + q.Set("access_token", token) + u := t.GraphBase + "/v1.0/" + url.PathEscape(containerID) + "?" + q.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return err + } + res, err := t.HTTPClient.Do(req) + if err != nil { + time.Sleep(800 * time.Millisecond) + continue + } + body, _ := io.ReadAll(io.LimitReader(res.Body, 64<<10)) + _ = res.Body.Close() + var st struct { + Status string `json:"status"` + ErrorMessage string `json:"error_message"` + } + _ = json.Unmarshal(body, &st) + switch strings.ToUpper(st.Status) { + case "FINISHED", "PUBLISHED": + return nil + case "ERROR": + if st.ErrorMessage != "" { + return fmt.Errorf("%s", st.ErrorMessage) + } + return fmt.Errorf("container error") + case "EXPIRED": + return fmt.Errorf("container expired") + } + time.Sleep(800 * time.Millisecond) + } + return fmt.Errorf("container wait timeout") +} + +func parseGraphError(body []byte, httpStatus int) string { + var ge struct { + Error *struct { + Message string `json:"message"` + Type string `json:"type"` + Code int `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(body, &ge); err == nil && ge.Error != nil && ge.Error.Message != "" { + return fmt.Sprintf("%s (code %d)", ge.Error.Message, ge.Error.Code) + } + return fmt.Sprintf("HTTP %d %s", httpStatus, truncate(string(body), 160)) +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} + +var _ Transport = (*MetaTransport)(nil) diff --git a/apps/backend/internal/module/studio/repository/memory.go b/apps/backend/internal/module/studio/repository/memory.go new file mode 100644 index 0000000..0bf97ee --- /dev/null +++ b/apps/backend/internal/module/studio/repository/memory.go @@ -0,0 +1,327 @@ +package repository + +import ( + "context" + "sort" + "sync" + + "apps/backend/internal/module/studio/domain" +) + +type MemoryStore struct { + mu sync.Mutex + personas map[string]*domain.Persona + active map[int64]string + plays map[string]*domain.Play + outbox map[string]*domain.OutboxBundle + ownPosts map[string]*domain.OwnPost + mentions map[string]*domain.Mention + syncMeta map[int64]*domain.SyncMeta +} + +func NewMemory() *MemoryStore { + return &MemoryStore{ + personas: map[string]*domain.Persona{}, + active: map[int64]string{}, + plays: map[string]*domain.Play{}, + outbox: map[string]*domain.OutboxBundle{}, + ownPosts: map[string]*domain.OwnPost{}, + mentions: map[string]*domain.Mention{}, + syncMeta: map[int64]*domain.SyncMeta{}, + } +} + +func (s *MemoryStore) SavePersona(_ context.Context, p *domain.Persona) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *p + s.personas[p.ID] = &cp + return nil +} + +func (s *MemoryStore) GetPersona(_ context.Context, id string) (*domain.Persona, error) { + s.mu.Lock() + defer s.mu.Unlock() + p, ok := s.personas[id] + if !ok { + return nil, domain.ErrNotFound + } + cp := *p + return &cp, nil +} + +func (s *MemoryStore) ListPersonas(_ context.Context, ownerUID int64) ([]*domain.Persona, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*domain.Persona + for _, p := range s.personas { + if p.OwnerUID == ownerUID { + cp := *p + out = append(out, &cp) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt > out[j].UpdatedAt }) + return out, nil +} + +func (s *MemoryStore) DeletePersona(_ context.Context, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.personas[id]; !ok { + return domain.ErrNotFound + } + delete(s.personas, id) + return nil +} + +func (s *MemoryStore) GetActivePersonaID(_ context.Context, ownerUID int64) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.active[ownerUID], nil +} + +func (s *MemoryStore) SetActivePersonaID(_ context.Context, ownerUID int64, personaID string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.active[ownerUID] = personaID + return nil +} + +func (s *MemoryStore) SavePlay(_ context.Context, p *domain.Play) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *p + if p.Steps != nil { + cp.Steps = append([]domain.PlayStep(nil), p.Steps...) + } + if p.CastAccountIDs != nil { + cp.CastAccountIDs = append([]string(nil), p.CastAccountIDs...) + } + if p.TargetExternal != nil { + te := *p.TargetExternal + cp.TargetExternal = &te + } + s.plays[p.ID] = &cp + return nil +} + +func (s *MemoryStore) GetPlay(_ context.Context, id string) (*domain.Play, error) { + s.mu.Lock() + defer s.mu.Unlock() + p, ok := s.plays[id] + if !ok { + return nil, domain.ErrNotFound + } + return copyPlay(p), nil +} + +func (s *MemoryStore) ListPlays(_ context.Context, ownerUID int64) ([]*domain.Play, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*domain.Play + for _, p := range s.plays { + if p.OwnerUID == ownerUID { + out = append(out, copyPlay(p)) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt > out[j].UpdatedAt }) + return out, nil +} + +func (s *MemoryStore) DeletePlay(_ context.Context, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.plays[id]; !ok { + return domain.ErrNotFound + } + delete(s.plays, id) + return nil +} + +func copyPlay(p *domain.Play) *domain.Play { + cp := *p + if p.Steps != nil { + cp.Steps = append([]domain.PlayStep(nil), p.Steps...) + } + if p.CastAccountIDs != nil { + cp.CastAccountIDs = append([]string(nil), p.CastAccountIDs...) + } + if p.TargetExternal != nil { + te := *p.TargetExternal + cp.TargetExternal = &te + } + return &cp +} + +func (s *MemoryStore) SaveOutbox(_ context.Context, b *domain.OutboxBundle) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *b + if b.Steps != nil { + cp.Steps = append([]domain.OutboxStep(nil), b.Steps...) + } + s.outbox[b.ID] = &cp + return nil +} + +func (s *MemoryStore) GetOutbox(_ context.Context, id string) (*domain.OutboxBundle, error) { + s.mu.Lock() + defer s.mu.Unlock() + b, ok := s.outbox[id] + if !ok { + return nil, domain.ErrNotFound + } + return copyOutbox(b), nil +} + +func (s *MemoryStore) ListOutbox(_ context.Context, ownerUID int64) ([]*domain.OutboxBundle, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*domain.OutboxBundle + for _, b := range s.outbox { + if b.OwnerUID == ownerUID { + out = append(out, copyOutbox(b)) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt > out[j].UpdatedAt }) + return out, nil +} + +func (s *MemoryStore) DeleteOutbox(_ context.Context, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.outbox[id]; !ok { + return domain.ErrNotFound + } + delete(s.outbox, id) + return nil +} + +func (s *MemoryStore) ListAllOutbox(_ context.Context) ([]*domain.OutboxBundle, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*domain.OutboxBundle + for _, b := range s.outbox { + out = append(out, copyOutbox(b)) + } + return out, nil +} + +func copyOutbox(b *domain.OutboxBundle) *domain.OutboxBundle { + cp := *b + if b.Steps != nil { + cp.Steps = append([]domain.OutboxStep(nil), b.Steps...) + } + return &cp +} + +func (s *MemoryStore) SaveOwnPost(_ context.Context, p *domain.OwnPost) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *p + if p.Replies != nil { + cp.Replies = append([]domain.OwnPostReply(nil), p.Replies...) + } + s.ownPosts[p.ID] = &cp + return nil +} + +func (s *MemoryStore) GetOwnPost(_ context.Context, id string) (*domain.OwnPost, error) { + s.mu.Lock() + defer s.mu.Unlock() + p, ok := s.ownPosts[id] + if !ok { + return nil, domain.ErrNotFound + } + cp := *p + if p.Replies != nil { + cp.Replies = append([]domain.OwnPostReply(nil), p.Replies...) + } + return &cp, nil +} + +func (s *MemoryStore) DeleteOwnPost(_ context.Context, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.ownPosts, id) + return nil +} + +func (s *MemoryStore) ListOwnPosts(_ context.Context, ownerUID int64, accountID string) ([]*domain.OwnPost, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*domain.OwnPost + for _, p := range s.ownPosts { + if p.OwnerUID != ownerUID { + continue + } + if accountID != "" && p.AccountID != accountID { + continue + } + cp := *p + if p.Replies != nil { + cp.Replies = append([]domain.OwnPostReply(nil), p.Replies...) + } + out = append(out, &cp) + } + sort.Slice(out, func(i, j int) bool { return out[i].PublishedAt > out[j].PublishedAt }) + return out, nil +} + +func (s *MemoryStore) GetSyncMeta(_ context.Context, ownerUID int64) (*domain.SyncMeta, error) { + s.mu.Lock() + defer s.mu.Unlock() + m, ok := s.syncMeta[ownerUID] + if !ok { + return &domain.SyncMeta{OwnerUID: ownerUID}, nil + } + cp := *m + return &cp, nil +} + +func (s *MemoryStore) SetSyncMeta(_ context.Context, m *domain.SyncMeta) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *m + s.syncMeta[m.OwnerUID] = &cp + return nil +} + +func (s *MemoryStore) SaveMention(_ context.Context, m *domain.Mention) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *m + s.mentions[m.ID] = &cp + return nil +} + +func (s *MemoryStore) GetMention(_ context.Context, id string) (*domain.Mention, error) { + s.mu.Lock() + defer s.mu.Unlock() + m, ok := s.mentions[id] + if !ok { + return nil, domain.ErrNotFound + } + cp := *m + return &cp, nil +} + +func (s *MemoryStore) ListMentions(_ context.Context, ownerUID int64, accountID string) ([]*domain.Mention, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*domain.Mention + for _, m := range s.mentions { + if m.OwnerUID != ownerUID { + continue + } + if accountID != "" && m.AccountID != accountID { + continue + } + cp := *m + out = append(out, &cp) + } + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt }) + return out, nil +} + +var _ domain.Repository = (*MemoryStore)(nil) diff --git a/apps/backend/internal/module/studio/repository/mongo.go b/apps/backend/internal/module/studio/repository/mongo.go new file mode 100644 index 0000000..f82e537 --- /dev/null +++ b/apps/backend/internal/module/studio/repository/mongo.go @@ -0,0 +1,244 @@ +package repository + +import ( + "context" + + libmongo "apps/backend/internal/lib/mongo" + "apps/backend/internal/module/studio/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 { + personas *mon.Model + active *mon.Model + plays *mon.Model + outbox *mon.Model + ownPosts *mon.Model + mentions *mon.Model + syncMeta *mon.Model +} + +func NewMonStore(uri, database string) *MonStore { + uri = libmongo.MustMongoURI(uri) + return &MonStore{ + personas: mon.MustNewModel(uri, database, "studio_personas"), + active: mon.MustNewModel(uri, database, "studio_active_persona"), + plays: mon.MustNewModel(uri, database, "studio_plays"), + outbox: mon.MustNewModel(uri, database, "studio_outbox"), + ownPosts: mon.MustNewModel(uri, database, "studio_own_posts"), + mentions: mon.MustNewModel(uri, database, "studio_mentions"), + syncMeta: mon.MustNewModel(uri, database, "studio_sync_meta"), + } +} + +func (s *MonStore) SavePersona(ctx context.Context, p *domain.Persona) error { + _, err := s.personas.ReplaceOne(ctx, bson.M{"_id": p.ID}, p, options.Replace().SetUpsert(true)) + return err +} + +func (s *MonStore) GetPersona(ctx context.Context, id string) (*domain.Persona, error) { + var p domain.Persona + err := s.personas.FindOne(ctx, &p, bson.M{"_id": id}) + if err != nil { + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + return nil, err + } + return &p, nil +} + +func (s *MonStore) ListPersonas(ctx context.Context, ownerUID int64) ([]*domain.Persona, error) { + var list []*domain.Persona + err := s.personas.Find(ctx, &list, bson.M{"owner_uid": ownerUID}, + options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}})) + return list, err +} + +func (s *MonStore) DeletePersona(ctx context.Context, id string) error { + res, err := s.personas.DeleteOne(ctx, bson.M{"_id": id}) + if err != nil { + return err + } + if res == 0 { + return domain.ErrNotFound + } + return nil +} + +func (s *MonStore) GetActivePersonaID(ctx context.Context, ownerUID int64) (string, error) { + var a domain.ActivePersona + err := s.active.FindOne(ctx, &a, bson.M{"_id": ownerUID}) + if err != nil { + if err == mon.ErrNotFound { + return "", nil + } + return "", err + } + return a.PersonaID, nil +} + +func (s *MonStore) SetActivePersonaID(ctx context.Context, ownerUID int64, personaID string) error { + doc := &domain.ActivePersona{OwnerUID: ownerUID, PersonaID: personaID, UpdatedAt: domain.NowNano()} + _, err := s.active.ReplaceOne(ctx, bson.M{"_id": ownerUID}, doc, options.Replace().SetUpsert(true)) + return err +} + +func (s *MonStore) SavePlay(ctx context.Context, p *domain.Play) error { + _, err := s.plays.ReplaceOne(ctx, bson.M{"_id": p.ID}, p, options.Replace().SetUpsert(true)) + return err +} + +func (s *MonStore) GetPlay(ctx context.Context, id string) (*domain.Play, error) { + var p domain.Play + err := s.plays.FindOne(ctx, &p, bson.M{"_id": id}) + if err != nil { + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + return nil, err + } + return &p, nil +} + +func (s *MonStore) ListPlays(ctx context.Context, ownerUID int64) ([]*domain.Play, error) { + var list []*domain.Play + err := s.plays.Find(ctx, &list, bson.M{"owner_uid": ownerUID}, + options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}})) + return list, err +} + +func (s *MonStore) DeletePlay(ctx context.Context, id string) error { + res, err := s.plays.DeleteOne(ctx, bson.M{"_id": id}) + if err != nil { + return err + } + if res == 0 { + return domain.ErrNotFound + } + return nil +} + +func (s *MonStore) SaveOutbox(ctx context.Context, b *domain.OutboxBundle) error { + _, err := s.outbox.ReplaceOne(ctx, bson.M{"_id": b.ID}, b, options.Replace().SetUpsert(true)) + return err +} + +func (s *MonStore) GetOutbox(ctx context.Context, id string) (*domain.OutboxBundle, error) { + var b domain.OutboxBundle + err := s.outbox.FindOne(ctx, &b, bson.M{"_id": id}) + if err != nil { + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + return nil, err + } + return &b, nil +} + +func (s *MonStore) ListOutbox(ctx context.Context, ownerUID int64) ([]*domain.OutboxBundle, error) { + var list []*domain.OutboxBundle + err := s.outbox.Find(ctx, &list, bson.M{"owner_uid": ownerUID}, + options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}})) + return list, err +} + +func (s *MonStore) DeleteOutbox(ctx context.Context, id string) error { + res, err := s.outbox.DeleteOne(ctx, bson.M{"_id": id}) + if err != nil { + return err + } + if res == 0 { + return domain.ErrNotFound + } + return nil +} + +func (s *MonStore) ListAllOutbox(ctx context.Context) ([]*domain.OutboxBundle, error) { + var list []*domain.OutboxBundle + err := s.outbox.Find(ctx, &list, bson.M{}, options.Find().SetLimit(500)) + return list, err +} + +func (s *MonStore) SaveOwnPost(ctx context.Context, p *domain.OwnPost) error { + _, err := s.ownPosts.ReplaceOne(ctx, bson.M{"_id": p.ID}, p, options.Replace().SetUpsert(true)) + return err +} + +func (s *MonStore) GetOwnPost(ctx context.Context, id string) (*domain.OwnPost, error) { + var p domain.OwnPost + err := s.ownPosts.FindOne(ctx, &p, bson.M{"_id": id}) + if err != nil { + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + return nil, err + } + return &p, nil +} + +func (s *MonStore) ListOwnPosts(ctx context.Context, ownerUID int64, accountID string) ([]*domain.OwnPost, error) { + filter := bson.M{"owner_uid": ownerUID} + if accountID != "" { + filter["account_id"] = accountID + } + var list []*domain.OwnPost + err := s.ownPosts.Find(ctx, &list, filter, + options.Find().SetSort(bson.D{{Key: "published_at", Value: -1}})) + return list, err +} + +func (s *MonStore) DeleteOwnPost(ctx context.Context, id string) error { + _, err := s.ownPosts.DeleteOne(ctx, bson.M{"_id": id}) + return err +} + +func (s *MonStore) GetSyncMeta(ctx context.Context, ownerUID int64) (*domain.SyncMeta, error) { + var m domain.SyncMeta + err := s.syncMeta.FindOne(ctx, &m, bson.M{"_id": ownerUID}) + if err != nil { + if err == mon.ErrNotFound { + return &domain.SyncMeta{OwnerUID: ownerUID}, nil + } + return nil, err + } + return &m, nil +} + +func (s *MonStore) SetSyncMeta(ctx context.Context, m *domain.SyncMeta) error { + _, err := s.syncMeta.ReplaceOne(ctx, bson.M{"_id": m.OwnerUID}, m, options.Replace().SetUpsert(true)) + return err +} + +func (s *MonStore) SaveMention(ctx context.Context, m *domain.Mention) error { + _, err := s.mentions.ReplaceOne(ctx, bson.M{"_id": m.ID}, m, options.Replace().SetUpsert(true)) + return err +} + +func (s *MonStore) GetMention(ctx context.Context, id string) (*domain.Mention, error) { + var m domain.Mention + err := s.mentions.FindOne(ctx, &m, bson.M{"_id": id}) + if err != nil { + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + return nil, err + } + return &m, nil +} + +func (s *MonStore) ListMentions(ctx context.Context, ownerUID int64, accountID string) ([]*domain.Mention, error) { + filter := bson.M{"owner_uid": ownerUID} + if accountID != "" { + filter["account_id"] = accountID + } + var list []*domain.Mention + err := s.mentions.Find(ctx, &list, filter, + options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})) + return list, err +} + +var _ domain.Repository = (*MonStore)(nil) diff --git a/apps/backend/internal/module/studio/usecase/m4_test.go b/apps/backend/internal/module/studio/usecase/m4_test.go new file mode 100644 index 0000000..fe81df8 --- /dev/null +++ b/apps/backend/internal/module/studio/usecase/m4_test.go @@ -0,0 +1,869 @@ +package usecase_test + +import ( + "context" + "testing" + "time" + + "apps/backend/internal/module/ai" + "apps/backend/internal/module/studio/domain" + "apps/backend/internal/module/studio/publish" + "apps/backend/internal/module/studio/repository" + "apps/backend/internal/module/studio/usecase" + threadsDomain "apps/backend/internal/module/threads/domain" + usageDomain "apps/backend/internal/module/usage/domain" + usageRepo "apps/backend/internal/module/usage/repository" + usageUC "apps/backend/internal/module/usage/usecase" + + "github.com/stretchr/testify/require" +) + +type memAccounts struct { + byID map[string]*threadsDomain.Account +} + +func (m *memAccounts) Get(_ context.Context, id string) (*threadsDomain.Account, error) { + a, ok := m.byID[id] + if !ok { + return nil, threadsDomain.ErrNotFound + } + cp := *a + return &cp, nil +} + +func (m *memAccounts) List(_ context.Context, ownerUID int64) ([]*threadsDomain.Account, error) { + var out []*threadsDomain.Account + for _, a := range m.byID { + if a.OwnerUID == ownerUID { + cp := *a + out = append(out, &cp) + } + } + return out, nil +} + +func (m *memAccounts) AccessToken(_ context.Context, a *threadsDomain.Account) (string, error) { + return "tok-" + a.ID, nil +} + +func newStudio() (*usecase.Service, *publish.FakeTransport, *memAccounts) { + repo := repository.NewMemory() + tp := publish.NewFake() + acc := &memAccounts{byID: map[string]*threadsDomain.Account{}} + svc := usecase.New(repo, tp) + svc.Accounts = acc + svc.AI = &ai.FakeClient{} + // usage with unlimited platform + ures := usageRepo.NewMemory() + uresolver := &usageUC.StaticResolver{Map: map[string]string{}} + us := usageUC.New(ures, uresolver) + svc.Usage = us + return svc, tp, acc +} + +func withUsage(svc *usecase.Service, uid int64) { + // unlimited + platform key + _ = svc.Usage.Repo.SavePrefs(context.Background(), &usageDomain.MemberPrefs{ + UID: uid, PlanID: usageDomain.PlanPro, Unlimited: true, UpdatedAt: domain.NowNano(), + }) + if sr, ok := svc.Usage.Resolver.(*usageUC.StaticResolver); ok { + if sr.Map == nil { + sr.Map = map[string]string{} + } + sr.Map[fmtUIDMeter(uid, usageDomain.MeterAICopy)] = usageDomain.KeyModePlatform + } +} + +func fmtUIDMeter(uid int64, meter string) string { + return string(rune(0)) // placeholder — use fmt +} + +func setupUID(svc *usecase.Service, uid int64) { + _ = svc.Usage.Repo.SavePrefs(context.Background(), &usageDomain.MemberPrefs{ + UID: uid, PlanID: usageDomain.PlanPro, Unlimited: true, UpdatedAt: domain.NowNano(), + }) + if sr, ok := svc.Usage.Resolver.(*usageUC.StaticResolver); ok { + if sr.Map == nil { + sr.Map = map[string]string{} + } + key := "" + // StaticResolver uses fmt.Sprintf("%d:%s", uid, meter) + key = sprintf("%d:%s", uid, usageDomain.MeterAICopy) + sr.Map[key] = usageDomain.KeyModePlatform + } +} + +func sprintf(f string, a ...any) string { + return format(f, a...) +} + +// avoid importing fmt in helpers clutter — use strconv +func format(f string, a ...any) string { + // minimal for our key only + if f == "%d:%s" && len(a) == 2 { + return itoa(a[0].(int64)) + ":" + a[1].(string) + } + return f +} + +func itoa(n int64) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var b [32]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + b[i] = '-' + } + return string(b[i:]) +} + +func addAccount(acc *memAccounts, ownerUID int64, id, user string) { + acc.byID[id] = &threadsDomain.Account{ + ID: id, OwnerUID: ownerUID, Username: user, DisplayName: user, + Connection: threadsDomain.ConnectionConnected, IsUsable: true, + } +} + +// ---- PE ---- + +func TestPE_01_CreateSave(t *testing.T) { + svc, _, _ := newStudio() + uid := int64(4_000_001) + setupUID(svc, uid) + p, err := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "小海", Brief: "溫暖"}) + require.NoError(t, err) + require.NotEmpty(t, p.ID) + list, err := svc.ListPersonas(context.Background(), uid) + require.NoError(t, err) + require.Len(t, list, 1) +} + +func TestPE_02_SetActive(t *testing.T) { + svc, _, _ := newStudio() + uid := int64(4_000_002) + setupUID(svc, uid) + a, _ := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "A"}) + b, _ := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "B"}) + require.NoError(t, svc.SetActivePersonaID(context.Background(), uid, b.ID)) + id, err := svc.GetActivePersonaID(context.Background(), uid) + require.NoError(t, err) + require.Equal(t, b.ID, id) + _ = a +} + +func TestPE_03_RemoveActiveResets(t *testing.T) { + svc, _, _ := newStudio() + uid := int64(4_000_003) + setupUID(svc, uid) + a, _ := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "A"}) + b, _ := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "B"}) + _ = svc.SetActivePersonaID(context.Background(), uid, a.ID) + require.NoError(t, svc.RemovePersona(context.Background(), uid, a.ID)) + id, _ := svc.GetActivePersonaID(context.Background(), uid) + require.Equal(t, b.ID, id) +} + +func TestPE_04_AnalyzeFromText(t *testing.T) { + svc, _, _ := newStudio() + uid := int64(4_000_004) + setupUID(svc, uid) + p, _ := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "分析"}) + raw := "今天天氣真好,可是我還是好累有沒有人懂\n\n---\n\n大家早安,後來我改成先寫使用情境再問問題" + out, err := svc.AnalyzeFromText(context.Background(), uid, p.ID, raw, "貼文") + require.NoError(t, err) + require.Equal(t, domain.PersonaReady, out.Status) + require.True(t, out.Style.AnalyzedAt > 0) + require.GreaterOrEqual(t, out.Style.SampleCount, 2) + // 概要 + 指紋 + 8D 都要填滿 + require.NotEmpty(t, out.Brief) + require.NotEmpty(t, out.Style.DraftText) + require.NotEmpty(t, out.Style.Draft.Tone) + require.NotEmpty(t, out.Style.Draft.Identity) + require.Contains(t, out.Style.DraftText, "【") + require.Len(t, out.Style.Dimensions, 8) + require.NotEmpty(t, out.Style.Dimensions["d1Tone"].Summary) + require.NotEmpty(t, out.Guard.Avoid) +} + +func TestPE_05_AnalyzeFromAccount(t *testing.T) { + restore := usecase.SetProfileFetchForTest(func(ctx context.Context, username, storageStateJSON string, limit int) ([]string, error) { + return []string{ + "有時候真的會卡在不知道問誰,後來我改成先寫自己的使用情境。", + "懂那種越研究越焦慮的感覺。你們如果有推的,最好附一句為什麼。", + "講真的上次踩雷之後我都先問實際用起來再下單。", + }, nil + }) + t.Cleanup(restore) + + svc, _, _ := newStudio() + uid := int64(4_000_005) + setupUID(svc, uid) + p, _ := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "對標"}) + out, err := svc.AnalyzeFromAccount(context.Background(), uid, p.ID, "cool_creator") + require.NoError(t, err) + require.Equal(t, domain.PersonaReady, out.Status) + require.Equal(t, "cool_creator", out.Style.BenchmarkUsername) + require.GreaterOrEqual(t, out.Style.SampleCount, 2) + require.NotContains(t, out.Style.SamplePreviews[0], "sample post about") + require.NotEmpty(t, out.Brief) + require.NotEmpty(t, out.Style.DraftText) + require.Len(t, out.Style.Dimensions, 8) + require.NotEmpty(t, out.Style.Dimensions["d8Risk"].Summary) +} + +func TestPE_06_CrossUidForbidden(t *testing.T) { + svc, _, _ := newStudio() + setupUID(svc, 4_000_006) + setupUID(svc, 4_000_007) + p, _ := svc.SavePersona(context.Background(), 4_000_006, &domain.Persona{Name: "私有"}) + _, err := svc.GetPersona(context.Background(), 4_000_007, p.ID) + require.ErrorIs(t, err, domain.ErrForbidden) + err = svc.RemovePersona(context.Background(), 4_000_007, p.ID) + require.ErrorIs(t, err, domain.ErrForbidden) +} + +// ---- PL ---- + +func TestPL_01_SaveGet(t *testing.T) { + svc, _, acc := newStudio() + uid := int64(4_001_001) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "lead") + play, err := svc.SavePlay(context.Background(), uid, &domain.Play{ + Title: "方案A", Topic: "主題", LeadAccountID: "acc1", + Steps: []domain.PlayStep{{Kind: domain.StepRoot, AccountID: "acc1", Text: "主貼"}}, + ScheduleStartAt: domain.NowNano(), + }) + require.NoError(t, err) + got, err := svc.GetPlay(context.Background(), uid, play.ID) + require.NoError(t, err) + require.Equal(t, "方案A", got.Title) + require.Len(t, got.Steps, 1) +} + +func TestPL_02_ReplyAccountNotSpeaker(t *testing.T) { + svc, _, acc := newStudio() + uid := int64(4_001_002) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "lead") + addAccount(acc, uid, "acc_stranger", "x") + play, err := svc.SavePlay(context.Background(), uid, &domain.Play{ + Title: "bad", LeadAccountID: "acc1", CastAccountIDs: []string{}, + Steps: []domain.PlayStep{ + {Kind: domain.StepRoot, AccountID: "acc1", Text: "root"}, + {Kind: domain.StepReply, AccountID: "acc_stranger", Text: "reply"}, + }, + ScheduleStartAt: domain.NowNano(), + }) + require.NoError(t, err) + _, err = svc.SubmitPlay(context.Background(), uid, play.ID) + require.Error(t, err) +} + +func TestPL_03_SubmitScheduleAligned(t *testing.T) { + svc, _, acc := newStudio() + uid := int64(4_001_003) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "lead") + addAccount(acc, uid, "acc2", "cast") + // 明確未來開始(>30s)→ 第一則排到該時間 + start := domain.NowNano() + int64(time.Hour) + play, _ := svc.SavePlay(context.Background(), uid, &domain.Play{ + Title: "sched", LeadAccountID: "acc1", CastAccountIDs: []string{"acc2"}, + Steps: []domain.PlayStep{ + {Kind: domain.StepRoot, AccountID: "acc1", Text: "root", DelayFromPreviousSec: 0}, + {Kind: domain.StepReply, AccountID: "acc2", Text: "r1", DelayFromPreviousSec: 60}, + }, + ScheduleStartAt: start, + }) + b, err := svc.SubmitPlay(context.Background(), uid, play.ID) + require.NoError(t, err) + require.Equal(t, start, b.Steps[0].ScheduledAt) + // 第二則 = 間隔 60s ± 抖動 + delta := b.Steps[1].ScheduledAt - b.Steps[0].ScheduledAt + require.GreaterOrEqual(t, delta, int64(15*time.Second)) + require.LessOrEqual(t, delta, int64(120*time.Second)) + require.Equal(t, domain.StepScheduled, b.Steps[0].Status) + + // 未設未來開始 → 第一則立刻 due + play2, _ := svc.SavePlay(context.Background(), uid, &domain.Play{ + Title: "now", LeadAccountID: "acc1", + Steps: []domain.PlayStep{ + {Kind: domain.StepRoot, AccountID: "acc1", Text: "r", DelayFromPreviousSec: 0}, + }, + ScheduleStartAt: 0, + }) + before := domain.NowNano() + b2, err := svc.SubmitPlay(context.Background(), uid, play2.ID) + require.NoError(t, err) + require.InDelta(t, float64(before), float64(b2.Steps[0].ScheduledAt), float64(2*time.Second)) +} + +func TestPL_04_ResolveExternalLink(t *testing.T) { + svc, _, _ := newStudio() + t_, err := svc.ResolveExternalLink(context.Background(), "https://www.threads.net/@alice/post/AbC123") + require.NoError(t, err) + require.Contains(t, t_.URL, "threads.net") + require.Equal(t, "alice", t_.AuthorUsername) + require.Equal(t, "AbC123", t_.Shortcode) +} + +func TestPL_05_ResolveBadURL(t *testing.T) { + svc, _, _ := newStudio() + _, err := svc.ResolveExternalLink(context.Background(), "not-a-url") + require.ErrorIs(t, err, domain.ErrBadURL) +} + +func TestPL_06_PublishScheduled(t *testing.T) { + svc, _, acc := newStudio() + uid := int64(4_001_006) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "lead") + future := domain.NowNano() + int64(2*time.Hour) + b, err := svc.PublishSingle(context.Background(), uid, "acc1", "hello scheduled", "t", nil, future, "") + require.NoError(t, err) + require.Len(t, b.Steps, 1) + require.Equal(t, future, b.Steps[0].ScheduledAt) +} + +func TestPL_07_PublishDefaultNow(t *testing.T) { + svc, _, acc := newStudio() + uid := int64(4_001_007) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "lead") + before := domain.NowNano() + b, err := svc.PublishSingle(context.Background(), uid, "acc1", "now post", "", nil, 0, "") + require.NoError(t, err) + require.InDelta(t, float64(before), float64(b.Steps[0].ScheduledAt), float64(time.Second*2)) +} + +func TestPL_08_NoUsableAccount(t *testing.T) { + svc, _, _ := newStudio() + uid := int64(4_001_008) + setupUID(svc, uid) + _, err := svc.PublishSingle(context.Background(), uid, "missing", "x", "", nil, 0, "") + require.ErrorIs(t, err, domain.ErrNoAccount) +} + +func TestPL_09_RemovePlayKeepsOutbox(t *testing.T) { + svc, _, acc := newStudio() + uid := int64(4_001_009) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "lead") + play, _ := svc.SavePlay(context.Background(), uid, &domain.Play{ + Title: "del", LeadAccountID: "acc1", + Steps: []domain.PlayStep{{Kind: domain.StepRoot, AccountID: "acc1", Text: "r"}}, + ScheduleStartAt: domain.NowNano(), + }) + b, err := svc.SubmitPlay(context.Background(), uid, play.ID) + require.NoError(t, err) + require.NoError(t, svc.RemovePlay(context.Background(), uid, play.ID)) + list, _ := svc.ListPlays(context.Background(), uid) + require.Empty(t, list) + // outbox retained + got, err := svc.GetOutbox(context.Background(), uid, b.ID) + require.NoError(t, err) + require.Equal(t, b.ID, got.ID) +} + +func TestPL_10_ListByPost(t *testing.T) { + svc, _, acc := newStudio() + uid := int64(4_001_010) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "lead") + _, _ = svc.SavePlay(context.Background(), uid, &domain.Play{ + Title: "p1", LeadAccountID: "acc1", TargetOwnPostID: "post_x", + Steps: []domain.PlayStep{{Kind: domain.StepReply, AccountID: "acc1", Text: "hi"}}, + }) + _, _ = svc.SavePlay(context.Background(), uid, &domain.Play{ + Title: "p2", LeadAccountID: "acc1", TargetOwnPostID: "other", + Steps: []domain.PlayStep{{Kind: domain.StepReply, AccountID: "acc1", Text: "hi"}}, + }) + list, err := svc.ListPlaysByPost(context.Background(), uid, "post_x") + require.NoError(t, err) + require.Len(t, list, 1) + require.Equal(t, "p1", list[0].Title) +} + +func TestPL_11_ListByExternalUrl(t *testing.T) { + svc, _, acc := newStudio() + uid := int64(4_001_011) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "lead") + url := "https://www.threads.net/@bob/post/ZZ99" + _, _ = svc.SavePlay(context.Background(), uid, &domain.Play{ + Title: "ext", LeadAccountID: "acc1", + TargetExternal: &domain.ExternalTarget{URL: url}, + Steps: []domain.PlayStep{{Kind: domain.StepReply, AccountID: "acc1", Text: "hi"}}, + }) + list, err := svc.ListPlaysByExternalURL(context.Background(), uid, url+"?utm=1") + require.NoError(t, err) + require.Len(t, list, 1) +} + +// ---- CP ---- + +func TestCP_01_Mimic(t *testing.T) { + svc, _, _ := newStudio() + uid := int64(4_002_001) + setupUID(svc, uid) + p, _ := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "P"}) + _, _ = svc.AnalyzeFromText(context.Background(), uid, p.ID, "樣本文字風格", "") + out, err := svc.Mimic(context.Background(), uid, "原始貼文內容很長", p.ID, "") + require.NoError(t, err) + require.NotEmpty(t, out) +} + +func TestCP_02_AnalyzeViral(t *testing.T) { + svc, _, _ := newStudio() + uid := int64(4_002_002) + setupUID(svc, uid) + va, err := svc.AnalyzeViral(context.Background(), uid, "爆紅文結構測試") + require.NoError(t, err) + require.NotEmpty(t, va.Hooks) + require.NotEmpty(t, va.Structure) +} + +// ---- OB ---- + +func TestOB_01_NotDue_NoTransport(t *testing.T) { + svc, tp, acc := newStudio() + uid := int64(4_003_001) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "lead") + future := domain.NowNano() + int64(time.Hour) + b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "later", "", nil, future, "") + n, err := svc.ProcessDueSteps(context.Background(), domain.NowNano()) + require.NoError(t, err) + require.Equal(t, 0, n) + require.Equal(t, 0, tp.CallCount()) + got, _ := svc.GetOutbox(context.Background(), uid, b.ID) + require.Equal(t, domain.StepScheduled, got.Steps[0].Status) +} + +func TestOB_02_DueStep_PublishedViaTransport(t *testing.T) { + svc, tp, acc := newStudio() + uid := int64(4_003_002) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "lead") + b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "now", "", nil, 0, "") + n, err := svc.ProcessDueSteps(context.Background(), domain.NowNano()) + require.NoError(t, err) + require.Equal(t, 1, n) + require.Equal(t, 1, tp.CallCount()) // must go through transport + got, _ := svc.GetOutbox(context.Background(), uid, b.ID) + require.Equal(t, domain.StepPublished, got.Steps[0].Status) + require.Equal(t, domain.OBCompleted, got.Status) + require.NotEmpty(t, got.Steps[0].MediaID) +} + +func TestOB_03_TransportFail(t *testing.T) { + svc, tp, acc := newStudio() + uid := int64(4_003_003) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "lead") + tp.Fail = true + b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "fail me", "", nil, 0, "") + _, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano()) + got, _ := svc.GetOutbox(context.Background(), uid, b.ID) + require.Equal(t, domain.StepFailed, got.Steps[0].Status) + require.NotEmpty(t, got.Steps[0].Error) + require.NotEqual(t, domain.StepPublished, got.Steps[0].Status) +} + +func TestOB_04_RootFail_BlocksReplies(t *testing.T) { + svc, tp, acc := newStudio() + uid := int64(4_003_004) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "lead") + addAccount(acc, uid, "acc2", "cast") + tp.FailOnNth = 1 // root fails + past := domain.NowNano() - 1 + play, _ := svc.SavePlay(context.Background(), uid, &domain.Play{ + Title: "rootfail", LeadAccountID: "acc1", CastAccountIDs: []string{"acc2"}, + Steps: []domain.PlayStep{ + {Kind: domain.StepRoot, AccountID: "acc1", Text: "root"}, + {Kind: domain.StepReply, AccountID: "acc2", Text: "reply", DelayFromPreviousSec: 0}, + }, + ScheduleStartAt: past, + }) + b, err := svc.SubmitPlay(context.Background(), uid, play.ID) + require.NoError(t, err) + _, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano()) + got, _ := svc.GetOutbox(context.Background(), uid, b.ID) + require.Equal(t, domain.StepFailed, got.Steps[0].Status) + require.NotEqual(t, domain.StepPublished, got.Steps[1].Status) + require.Equal(t, domain.StepBlocked, got.Steps[1].Status) +} + +func TestOB_05_RootOk_ReplyPublishes(t *testing.T) { + svc, tp, acc := newStudio() + uid := int64(4_003_005) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "lead") + addAccount(acc, uid, "acc2", "cast") + past := domain.NowNano() - 1 + play, _ := svc.SavePlay(context.Background(), uid, &domain.Play{ + Title: "ok", LeadAccountID: "acc1", CastAccountIDs: []string{"acc2"}, + Steps: []domain.PlayStep{ + {Kind: domain.StepRoot, AccountID: "acc1", Text: "root"}, + {Kind: domain.StepReply, AccountID: "acc2", Text: "reply"}, + }, + ScheduleStartAt: past, + }) + b, _ := svc.SubmitPlay(context.Background(), uid, play.ID) + n, err := svc.ProcessDueSteps(context.Background(), domain.NowNano()) + require.NoError(t, err) + require.Equal(t, 2, n) + require.Equal(t, 2, tp.CallCount()) + got, _ := svc.GetOutbox(context.Background(), uid, b.ID) + require.Equal(t, domain.StepPublished, got.Steps[1].Status) +} + +func TestOB_06_RetryStep(t *testing.T) { + svc, tp, acc := newStudio() + uid := int64(4_003_006) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "lead") + tp.Fail = true + b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "retry", "", nil, 0, "") + _, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano()) + tp.Fail = false + got, err := svc.RetryStep(context.Background(), uid, b.ID, b.Steps[0].ID) + require.NoError(t, err) + require.Equal(t, domain.StepScheduled, got.Steps[0].Status) + _, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano()) + got, _ = svc.GetOutbox(context.Background(), uid, b.ID) + require.Equal(t, domain.StepPublished, got.Steps[0].Status) +} + +func TestOB_07_RetryIllegalStatus(t *testing.T) { + svc, _, acc := newStudio() + uid := int64(4_003_007) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "lead") + // scheduled not failed + b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "x", "", nil, domain.NowNano()+int64(time.Hour), "") + _, err := svc.RetryStep(context.Background(), uid, b.ID, b.Steps[0].ID) + require.ErrorIs(t, err, domain.ErrIllegalStatus) +} + +func TestOB_08_AllSuccessCompleted(t *testing.T) { + svc, _, acc := newStudio() + uid := int64(4_003_008) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "lead") + b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "done", "", nil, 0, "") + _, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano()) + got, _ := svc.GetOutbox(context.Background(), uid, b.ID) + require.Equal(t, domain.OBCompleted, got.Status) +} + +func TestOB_09_PartialFailed(t *testing.T) { + svc, tp, acc := newStudio() + uid := int64(4_003_009) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "lead") + addAccount(acc, uid, "acc2", "cast") + // fail only second call + tp.FailOnNth = 2 + past := domain.NowNano() - 1 + play, _ := svc.SavePlay(context.Background(), uid, &domain.Play{ + Title: "partial", LeadAccountID: "acc1", CastAccountIDs: []string{"acc2"}, + Steps: []domain.PlayStep{ + {Kind: domain.StepRoot, AccountID: "acc1", Text: "root"}, + {Kind: domain.StepReply, AccountID: "acc2", Text: "reply"}, + }, + ScheduleStartAt: past, + }) + b, _ := svc.SubmitPlay(context.Background(), uid, play.ID) + _, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano()) + got, _ := svc.GetOutbox(context.Background(), uid, b.ID) + require.Equal(t, domain.OBPartial, got.Status) +} + +func TestOB_10_RemoveStopsWorker(t *testing.T) { + svc, tp, acc := newStudio() + uid := int64(4_003_010) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "lead") + b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "rm", "", nil, 0, "") + require.NoError(t, svc.RemoveOutbox(context.Background(), uid, b.ID)) + n, _ := svc.ProcessDueSteps(context.Background(), domain.NowNano()) + require.Equal(t, 0, n) + require.Equal(t, 0, tp.CallCount()) +} + +func TestOB_11_CrossUid(t *testing.T) { + svc, _, acc := newStudio() + uid1, uid2 := int64(4_003_011), int64(4_003_012) + setupUID(svc, uid1) + setupUID(svc, uid2) + addAccount(acc, uid1, "acc1", "lead") + b, _ := svc.PublishSingle(context.Background(), uid1, "acc1", "priv", "", nil, domain.NowNano(), "") + _, err := svc.GetOutbox(context.Background(), uid2, b.ID) + require.ErrorIs(t, err, domain.ErrForbidden) + _, err = svc.RetryStep(context.Background(), uid2, b.ID, b.Steps[0].ID) + require.ErrorIs(t, err, domain.ErrForbidden) +} + +func TestOB_12_LiveRequiresTransport(t *testing.T) { + // document: FE simulate is not acceptance — ProcessDueSteps uses transport + svc, tp, acc := newStudio() + uid := int64(4_003_013) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "lead") + _, _ = svc.PublishSingle(context.Background(), uid, "acc1", "x", "", nil, 0, "") + before := tp.CallCount() + _, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano()) + require.Greater(t, tp.CallCount(), before) +} + +func TestPL_PublishRejectsPastSchedule(t *testing.T) { + svc, _, acc := newStudio() + uid := int64(4_001_020) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "lead") + // 超過 1 分鐘才算過期 + past := domain.NowNano() - int64(2*time.Minute) + _, err := svc.PublishSingle(context.Background(), uid, "acc1", "too late", "", nil, past, "") + require.ErrorIs(t, err, domain.ErrValidation) + // 略早於現在(grace 內)應可過,並 clamp 成現在 + slight := domain.NowNano() - int64(5*time.Second) + b, err := svc.PublishSingle(context.Background(), uid, "acc1", "almost now", "", nil, slight, "") + require.NoError(t, err) + require.NotNil(t, b) +} + +// ---- OP ---- + +func TestOP_01_Sync(t *testing.T) { + svc, _, acc := newStudio() + uid := int64(4_004_001) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "me") + list, err := svc.SyncOwnPosts(context.Background(), uid, "acc1") + require.NoError(t, err) + require.NotEmpty(t, list) + ts, err := svc.LastSyncedAt(context.Background(), uid) + require.NoError(t, err) + require.True(t, ts > 0) +} + +func TestOP_02_SyncFailDoesNotClear(t *testing.T) { + svc, _, _ := newStudio() + uid := int64(4_004_002) + setupUID(svc, uid) + _, err := svc.SyncOwnPosts(context.Background(), uid, "nope") + require.Error(t, err) +} + +func TestOP_03_GenerateReply(t *testing.T) { + svc, _, acc := newStudio() + uid := int64(4_004_003) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "me") + list, _ := svc.SyncOwnPosts(context.Background(), uid, "acc1") + text, err := svc.GenerateReply(context.Background(), uid, list[0].ID, "", "") + require.NoError(t, err) + require.NotEmpty(t, text) +} + +func TestOP_04_SendReply(t *testing.T) { + svc, tp, acc := newStudio() + uid := int64(4_004_004) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "me") + list, _ := svc.SyncOwnPosts(context.Background(), uid, "acc1") + post, err := svc.SendReply(context.Background(), uid, list[0].ID, "", "thanks!", "acc1", nil) + require.NoError(t, err) + require.Equal(t, 1, tp.CallCount()) + require.True(t, post.Replies[len(post.Replies)-1].IsMine) +} + +func TestOP_05_SendReplyFail(t *testing.T) { + svc, tp, acc := newStudio() + uid := int64(4_004_005) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "me") + list, _ := svc.SyncOwnPosts(context.Background(), uid, "acc1") + before := len(list[0].Replies) + tp.Fail = true + _, err := svc.SendReply(context.Background(), uid, list[0].ID, "", "nope", "acc1", nil) + require.Error(t, err) + got, _ := svc.ListOwnPosts(context.Background(), uid, "acc1") + // find same post + for _, p := range got { + if p.ID == list[0].ID { + // may have original seed replies only + require.LessOrEqual(t, len(p.Replies), before+1) + // last must not be ours if failed before save — count isMine + mine := 0 + for _, r := range p.Replies { + if r.IsMine { + mine++ + } + } + require.Equal(t, 0, mine) + } + } +} + +func TestOP_06_MentionMarkAndSkip(t *testing.T) { + svc, tp, acc := newStudio() + uid := int64(4_004_006) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "me") + _ = svc.SeedMention(context.Background(), &domain.Mention{ + OwnerUID: uid, AccountID: "acc1", MediaID: "media_mention_1", + FromUsername: "x", Text: "hi @me", ContextSnippet: "ctx", + }) + list, _ := svc.ListMentions(context.Background(), uid, "") + require.Len(t, list, 1) + m, err := svc.MarkMentionReplied(context.Background(), uid, list[0].ID, "ok", nil) + require.NoError(t, err) + require.Equal(t, domain.MentionReplied, m.Status) + require.NotEmpty(t, tp.Calls) + require.Equal(t, "media_mention_1", tp.Calls[len(tp.Calls)-1].ReplyTo) + _ = svc.SeedMention(context.Background(), &domain.Mention{ + OwnerUID: uid, AccountID: "acc1", MediaID: "media_mention_2", + FromUsername: "y", Text: "skip", ContextSnippet: "c", + }) + list2, _ := svc.ListMentions(context.Background(), uid, "") + var skipID string + for _, x := range list2 { + if x.Status == domain.MentionPending { + skipID = x.ID + } + } + s, err := svc.SkipMention(context.Background(), uid, skipID) + require.NoError(t, err) + require.Equal(t, domain.MentionSkipped, s.Status) +} + +func TestOP_07_SyncOtherAccountForbidden(t *testing.T) { + svc, _, acc := newStudio() + uid1, uid2 := int64(4_004_007), int64(4_004_008) + setupUID(svc, uid1) + setupUID(svc, uid2) + addAccount(acc, uid1, "acc1", "a") + _, err := svc.SyncOwnPosts(context.Background(), uid2, "acc1") + require.ErrorIs(t, err, domain.ErrForbidden) +} + +func TestOP_08_AnalyzePost(t *testing.T) { + svc, _, acc := newStudio() + uid := int64(4_004_009) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "me") + list, _ := svc.SyncOwnPosts(context.Background(), uid, "acc1") + post, err := svc.AnalyzePost(context.Background(), uid, list[0].ID) + require.NoError(t, err) + require.NotEmpty(t, post.FormulaSummary) + require.NotEmpty(t, post.FormulaDetail) +} + +func TestOP_09_MentionList(t *testing.T) { + svc, _, _ := newStudio() + uid := int64(4_004_010) + setupUID(svc, uid) + _ = svc.SeedMention(context.Background(), &domain.Mention{ + OwnerUID: uid, AccountID: "a1", FromUsername: "u", Text: "t", ContextSnippet: "c", + }) + _ = svc.SeedMention(context.Background(), &domain.Mention{ + OwnerUID: uid, AccountID: "a2", FromUsername: "u2", Text: "t2", ContextSnippet: "c", + }) + list, err := svc.ListMentions(context.Background(), uid, "a1") + require.NoError(t, err) + require.Len(t, list, 1) +} + +func TestOP_10_MentionGenerateReply(t *testing.T) { + svc, _, _ := newStudio() + uid := int64(4_004_011) + setupUID(svc, uid) + _ = svc.SeedMention(context.Background(), &domain.Mention{ + OwnerUID: uid, AccountID: "a1", FromUsername: "fan", Text: "喜歡你的文", ContextSnippet: "ctx", + }) + list, _ := svc.ListMentions(context.Background(), uid, "") + m, err := svc.GenerateMentionReply(context.Background(), uid, list[0].ID, "") + require.NoError(t, err) + require.NotEmpty(t, m.DraftText) +} + +func TestOP_11_GenerateFromFormulaRemoved(t *testing.T) { + svc, _, _ := newStudio() + err := svc.GenerateFromFormula(context.Background(), "p1", "") + require.ErrorIs(t, err, domain.ErrFormulaRemove) +} + +type fakeMentionsMedia struct { + hits []usecase.FetchedMention + err error +} + +func (f *fakeMentionsMedia) ListThreads(context.Context, string, int) ([]usecase.FetchedThread, error) { + return nil, nil +} +func (f *fakeMentionsMedia) GetInsights(context.Context, string, string) (usecase.FetchedInsights, error) { + return usecase.FetchedInsights{Status: "ok"}, nil +} +func (f *fakeMentionsMedia) ListConversation(context.Context, string, string, int) ([]usecase.FetchedReply, error) { + return nil, nil +} +func (f *fakeMentionsMedia) ListMentions(context.Context, string, string, int) ([]usecase.FetchedMention, error) { + if f.err != nil { + return nil, f.err + } + return f.hits, nil +} +func (f *fakeMentionsMedia) ListProfilePosts(context.Context, string, string, int) ([]usecase.FetchedThread, error) { + return nil, nil +} + +func TestOP_12_SyncMentionsFromGraph(t *testing.T) { + svc, _, acc := newStudio() + uid := int64(4_004_012) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "me") + // real-ish token (not fake-) + svc.Media = &fakeMentionsMedia{hits: []usecase.FetchedMention{ + {ID: "m_100", Text: "嗨 @me 看看", Username: "fan_a", Permalink: "https://threads.net/t/x", PublishedAt: domain.NowNano()}, + {ID: "m_101", Text: "引用一下", Username: "fan_b", IsQuotePost: true, PublishedAt: domain.NowNano()}, + }} + list, err := svc.SyncMentions(context.Background(), uid, "acc1") + require.NoError(t, err) + require.Len(t, list, 2) + byMedia := map[string]*domain.Mention{} + for _, m := range list { + byMedia[m.MediaID] = m + } + require.Contains(t, byMedia, "m_100") + require.Equal(t, "fan_a", byMedia["m_100"].FromUsername) + // re-sync preserves replied status + byMedia["m_100"].Status = domain.MentionReplied + byMedia["m_100"].DraftText = "已回" + _ = svc.Repo.SaveMention(context.Background(), byMedia["m_100"]) + list2, err := svc.SyncMentions(context.Background(), uid, "acc1") + require.NoError(t, err) + var kept *domain.Mention + for _, m := range list2 { + if m.MediaID == "m_100" { + kept = m + } + } + require.NotNil(t, kept) + require.Equal(t, domain.MentionReplied, kept.Status) + require.Equal(t, "已回", kept.DraftText) +} diff --git a/apps/backend/internal/module/studio/usecase/persona_preview.go b/apps/backend/internal/module/studio/usecase/persona_preview.go new file mode 100644 index 0000000..741fe56 --- /dev/null +++ b/apps/backend/internal/module/studio/usecase/persona_preview.go @@ -0,0 +1,376 @@ +package usecase + +import ( + "context" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "math/rand" + "net/http" + "strings" + "time" + "unicode/utf8" + + "apps/backend/internal/module/studio/domain" +) + +// PersonaPreview:依指紋一次 LLM 產主貼 + 回文(可抓新聞當話題靈感)。 +func (s *Service) PersonaPreview(ctx context.Context, ownerUID int64, personaID, topic string, useNews bool) (*domain.PersonaPreview, error) { + if ownerUID <= 0 { + return nil, domain.ErrForbidden + } + personaID = strings.TrimSpace(personaID) + if personaID == "" { + return nil, fmt.Errorf("%w: empty persona_id", domain.ErrValidation) + } + p, err := s.GetPersona(ctx, ownerUID, personaID) + if err != nil { + return nil, err + } + fp := strings.TrimSpace(p.Style.DraftText) + if fp == "" { + fp = serializeDraftText(p.Style.Draft) + } + if fp == "" && strings.TrimSpace(p.Style.Draft.Tone) == "" { + return nil, fmt.Errorf("%w: 人設尚無指紋,請先完成分析或手動填寫指紋", domain.ErrValidation) + } + + if err := s.billAI(ctx, ownerUID, "persona preview", "compose.personaPreview"); err != nil { + return nil, err + } + + topic = strings.TrimSpace(topic) + topicSource := "manual" + if topic == "" { + if useNews { + if t, ok := fetchNewsTopic(ctx); ok { + topic = t + topicSource = "news" + } + } + if topic == "" { + topic = pickFallbackTopic(p) + topicSource = "fallback" + } + } + + fpBlock := buildFingerprintBlock(p, fp) + prompt := buildPreviewBothPrompt(fpBlock, topic, topicSource) + + provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID) + notes := "" + var postText, replyText string + + if kerr == nil && strings.TrimSpace(apiKey) != "" && !isSyntheticAIKey(apiKey) { + // 一次呼叫同時產主貼+回文,避免雙請求卡過 gateway 30s + raw, aerr := s.completeLLM(ctx, provider, model, apiKey, prompt) + if aerr != nil { + notes = "產文失敗,已用指紋規則試產:" + truncate(aerr.Error(), 100) + postText = rulePreviewPost(p, topic) + replyText = rulePreviewReply(p, postText) + } else { + postText, replyText = parsePreviewPair(raw) + if postText == "" { + postText = rulePreviewPost(p, topic) + } + if replyText == "" { + replyText = rulePreviewReply(p, postText) + } + if notes == "" && topicSource == "news" { + notes = "話題取自即時新聞,已改寫成人設口吻" + } + } + _ = model + _ = provider + } else { + postText = rulePreviewPost(p, topic) + replyText = rulePreviewReply(p, postText) + notes = "尚未設定 API Key,目前用指紋規則試產。請到設定填寫 Key 後再試。" + } + + return &domain.PersonaPreview{ + Topic: topic, + TopicSource: topicSource, + PostText: postText, + ReplyText: replyText, + Notes: notes, + }, nil +} + +func buildFingerprintBlock(p *domain.Persona, fp string) string { + var b strings.Builder + if n := strings.TrimSpace(p.Name); n != "" { + b.WriteString("名稱:") + b.WriteString(n) + b.WriteString("\n") + } + if br := strings.TrimSpace(p.Brief); br != "" { + b.WriteString("定位:") + b.WriteString(br) + b.WriteString("\n") + } + b.WriteString("【語言指紋】\n") + b.WriteString(fp) + if len(p.Style.Dimensions) > 0 { + b.WriteString("\n【8D 摘要】\n") + for _, k := range dimKeys { + if d, ok := p.Style.Dimensions[k]; ok && strings.TrimSpace(d.Summary) != "" { + b.WriteString(k) + b.WriteString(":") + b.WriteString(d.Summary) + b.WriteString("\n") + } + } + } + if len(p.Guard.Avoid) > 0 { + b.WriteString("【禁止】") + b.WriteString(strings.Join(p.Guard.Avoid, "、")) + b.WriteString("\n") + } + return b.String() +} + +func buildPreviewBothPrompt(fpBlock, topic, topicSource string) string { + srcHint := "自訂話題" + if topicSource == "news" { + srcHint = "今日新聞標題(請轉成個人觀察/情緒/提問,不要硬抄標題、不要寫成新聞稿)" + } else if topicSource == "fallback" { + srcHint = "生活靈感" + } + return strings.TrimSpace(fmt.Sprintf(` +你正在扮演一位 Threads 使用者本人(不是分析師、不是助手)。 +任務:依語言指紋寫「一則主貼」與「一則短回文」。 + +規則(強制): +- 只輸出一個 JSON 物件,不要 markdown 代碼塊、不要前言。 +- 繁體中文(台灣用語)、口語、像真人滑手機打的。 +- 嚴格遵守指紋的語氣/節奏/用字/CTA/禁忌。 +- 主貼約 80~260 字,可分段空行;回文約 20~100 字。 +- 話題僅作靈感,必須用「這個人會怎麼講」重寫。 + +【話題靈感】(來源:%s) +%s + +%s + +輸出格式: +{"post":"主貼正文","reply":"回文正文"} +`, srcHint, topic, fpBlock)) +} + +// parsePreviewPair 解析 {"post","reply"} 或寬鬆切割。 +func parsePreviewPair(raw string) (post, reply string) { + s := strings.TrimSpace(raw) + if s == "" { + return "", "" + } + // strip fences + if i := strings.Index(s, "{"); i >= 0 { + if j := strings.LastIndex(s, "}"); j > i { + s = s[i : j+1] + } + } + var parsed struct { + Post string `json:"post"` + Reply string `json:"reply"` + // 別名 + PostText string `json:"post_text"` + ReplyText string `json:"reply_text"` + } + if err := json.Unmarshal([]byte(s), &parsed); err == nil { + post = cleanGeneratedText(coalesceStr(parsed.Post, parsed.PostText)) + reply = cleanGeneratedText(coalesceStr(parsed.Reply, parsed.ReplyText)) + return post, reply + } + // 非 JSON:整段當主貼 + return cleanGeneratedText(raw), "" +} + +func coalesceStr(a, b string) string { + if strings.TrimSpace(a) != "" { + return a + } + return b +} + +func cleanGeneratedText(raw string) string { + return cleanGeneratedTextMax(raw, 500) +} + +func cleanGeneratedTextMax(raw string, maxRunes int) string { + s := strings.TrimSpace(raw) + if s == "" { + return "" + } + // 去掉常見 markdown 圍欄 + if strings.HasPrefix(s, "```") { + s = strings.TrimPrefix(s, "```json") + s = strings.TrimPrefix(s, "```") + s = strings.TrimSpace(s) + if i := strings.LastIndex(s, "```"); i >= 0 { + s = strings.TrimSpace(s[:i]) + } + } + s = strings.TrimSpace(s) + for _, p := range []string{"主貼:", "主贴:", "貼文:", "仿寫:", "回覆:", "回文:", "Reply:", "Post:", "Mimic:"} { + s = strings.TrimPrefix(s, p) + s = strings.TrimSpace(s) + } + if maxRunes <= 0 { + maxRunes = 500 + } + if utf8.RuneCountInString(s) > maxRunes { + r := []rune(s) + s = string(r[:maxRunes]) + "…" + } + return s +} + +func rulePreviewPost(p *domain.Persona, topic string) string { + d := p.Style.Draft + hooks := d.Hooks + if hooks == "" { + hooks = "先丟自己的卡關" + } + cta := d.CtaStyle + if cta == "" { + cta = "你們呢?" + } + ex := d.Examples + lines := []string{ + fmt.Sprintf("最近卡在「%s」。", truncate(topic, 40)), + "", + hooks + "——講真的,不是規格文那種。", + } + if ex != "" { + lines = append(lines, truncate(ex, 80)) + } + lines = append(lines, "", cta) + return strings.Join(lines, "\n") +} + +func rulePreviewReply(p *domain.Persona, postText string) string { + _ = p + return fmt.Sprintf("懂,我自己也遇過類似的。%s——你們後來怎麼解的?", truncate(postText, 24)) +} + +func pickFallbackTopic(p *domain.Persona) string { + seeds := []string{ + "最近大家在煩的時間管理", + "網購踩雷又不能退的感覺", + "加班後只想滑手機不想動", + "突然很想整理房間但動不了", + "朋友安利的東西到底能不能信", + "週末計劃很多結果什麼都沒做", + "天氣一變心情就跟著飄", + "想學新東西但資訊量爆炸", + } + if a := strings.TrimSpace(p.Style.Draft.Audience); a != "" { + seeds = append(seeds, "跟「"+truncate(a, 20)+"」有關的日常卡關") + } + return seeds[rand.Intn(len(seeds))] +} + +// fetchNewsTopic — Google News 繁中 RSS(短超時,失敗就走 fallback) +func fetchNewsTopic(ctx context.Context) (string, bool) { + urls := []string{ + "https://news.google.com/rss?hl=zh-TW&gl=TW&ceid=TW:zh-Hant", + "https://news.google.com/rss/headlines/section/topic/TECHNOLOGY?hl=zh-TW&gl=TW&ceid=TW:zh-Hant", + } + u := urls[rand.Intn(len(urls))] + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return "", false + } + req.Header.Set("User-Agent", "HarborDesk/1.0 (+persona-preview)") + client := &http.Client{Timeout: 3 * time.Second} + res, err := client.Do(req) + if err != nil { + return "", false + } + defer res.Body.Close() + if res.StatusCode < 200 || res.StatusCode >= 300 { + return "", false + } + body, err := io.ReadAll(io.LimitReader(res.Body, 512<<10)) + if err != nil || len(body) == 0 { + return "", false + } + titles := parseRSSTitles(body) + if len(titles) == 0 { + return "", false + } + t := cleanNewsTitle(titles[rand.Intn(len(titles))]) + if t == "" { + return "", false + } + return t, true +} + +type rssFeed struct { + XMLName xml.Name `xml:"rss"` + Items []rssItem `xml:"channel>item"` +} + +type rssItem struct { + Title string `xml:"title"` +} + +func parseRSSTitles(body []byte) []string { + var feed rssFeed + if err := xml.Unmarshal(body, &feed); err == nil { + var out []string + for _, it := range feed.Items { + if t := strings.TrimSpace(it.Title); t != "" { + out = append(out, t) + } + } + if len(out) > 0 { + return out + } + } + raw := string(body) + var out []string + for { + i := strings.Index(raw, "") + if i < 0 { + break + } + raw = raw[i+7:] + j := strings.Index(raw, "") + if j < 0 { + break + } + t := strings.TrimSpace(raw[:j]) + raw = raw[j+8:] + if strings.Contains(strings.ToLower(t), "google news") { + continue + } + if t != "" { + out = append(out, t) + } + if len(out) >= 15 { + break + } + } + return out +} + +func cleanNewsTitle(t string) string { + t = strings.TrimSpace(t) + if i := strings.LastIndex(t, " - "); i > 8 { + t = strings.TrimSpace(t[:i]) + } + t = strings.TrimPrefix(t, "") + t = strings.TrimSpace(t) + if utf8.RuneCountInString(t) < 6 { + return "" + } + if utf8.RuneCountInString(t) > 80 { + r := []rune(t) + t = string(r[:80]) + "…" + } + return t +} diff --git a/apps/backend/internal/module/studio/usecase/profile_scrape.go b/apps/backend/internal/module/studio/usecase/profile_scrape.go new file mode 100644 index 0000000..446faad --- /dev/null +++ b/apps/backend/internal/module/studio/usecase/profile_scrape.go @@ -0,0 +1,153 @@ +package usecase + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +// profileFetchFunc is overridable in tests. +var profileFetchFunc = defaultFetchProfilePostTexts + +// SetProfileFetchForTest overrides profile scrape (tests). Restore with returned cleanup. +func SetProfileFetchForTest(fn func(ctx context.Context, username, storageStateJSON string, limit int) ([]string, error)) (restore func()) { + old := profileFetchFunc + if fn == nil { + profileFetchFunc = defaultFetchProfilePostTexts + } else { + profileFetchFunc = fn + } + return func() { profileFetchFunc = old } +} + +// fetchProfilePostTexts tries to read public Threads posts for @username. +// storageStateJSON is optional Playwright storageState (from Chrome extension sync). +func fetchProfilePostTexts(ctx context.Context, username, storageStateJSON string, limit int) ([]string, error) { + return profileFetchFunc(ctx, username, storageStateJSON, limit) +} + +// defaultFetchProfilePostTexts uses Node Playwright scraper (real browser). +// Pure HTTP cannot read Threads profile posts (logged-out shell / JS-rendered). +func defaultFetchProfilePostTexts(ctx context.Context, username, storageStateJSON string, limit int) ([]string, error) { + username = strings.TrimPrefix(strings.TrimSpace(username), "@") + if username == "" { + return nil, fmt.Errorf("empty username") + } + if limit <= 0 { + limit = 12 + } + + script, err := findProfileScrapeScript() + if err != nil { + return nil, err + } + + // optional storage state temp file + var storagePath string + if strings.TrimSpace(storageStateJSON) != "" { + // ensure valid JSON object with cookies + var probe struct { + Cookies []json.RawMessage `json:"cookies"` + } + if json.Unmarshal([]byte(storageStateJSON), &probe) == nil && len(probe.Cookies) > 0 { + f, ferr := os.CreateTemp("", "haixun-storage-*.json") + if ferr == nil { + _, _ = f.WriteString(storageStateJSON) + _ = f.Close() + storagePath = f.Name() + defer os.Remove(storagePath) + } + } + } + + // timeout for browser scrape + if _, ok := ctx.Deadline(); !ok { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 55*time.Second) + defer cancel() + } + + args := []string{script, "--username", username, "--limit", fmt.Sprintf("%d", limit)} + if storagePath != "" { + args = append(args, "--storage", storagePath) + } + + cmd := exec.CommandContext(ctx, "node", args...) + // Playwright browsers cache + cmd.Env = append(os.Environ(), "PLAYWRIGHT_BROWSERS_PATH="+playwrightBrowsersPath()) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return nil, fmt.Errorf("profile scrape failed: %s", truncate(msg, 240)) + } + + var out struct { + OK bool `json:"ok"` + Posts []string `json:"posts"` + Error string `json:"error"` + Username string `json:"username"` + } + if err := json.Unmarshal(stdout.Bytes(), &out); err != nil { + return nil, fmt.Errorf("profile scrape bad json: %w", err) + } + if len(out.Posts) > 0 { + return out.Posts, nil + } + if out.Error != "" { + return nil, fmt.Errorf("%s", out.Error) + } + return nil, fmt.Errorf("找不到 @%s 的公開貼文", username) +} + +func findProfileScrapeScript() (string, error) { + // relative to process cwd and source file location + candidates := []string{ + "scripts/threads-profile/scrape.mjs", + "apps/backend/scripts/threads-profile/scrape.mjs", + filepath.Join("..", "scripts", "threads-profile", "scrape.mjs"), + } + // from this source file: internal/module/studio/usecase -> ../../../../scripts/... + if _, file, _, ok := runtime.Caller(0); ok { + base := filepath.Dir(file) + candidates = append(candidates, + filepath.Join(base, "..", "..", "..", "..", "scripts", "threads-profile", "scrape.mjs"), + ) + } + if wd, err := os.Getwd(); err == nil { + candidates = append(candidates, + filepath.Join(wd, "scripts", "threads-profile", "scrape.mjs"), + filepath.Join(wd, "apps", "backend", "scripts", "threads-profile", "scrape.mjs"), + ) + } + for _, c := range candidates { + if st, err := os.Stat(c); err == nil && !st.IsDir() { + abs, _ := filepath.Abs(c) + return abs, nil + } + } + return "", fmt.Errorf("threads profile scrape script not found (scripts/threads-profile/scrape.mjs)") +} + +func playwrightBrowsersPath() string { + if v := os.Getenv("PLAYWRIGHT_BROWSERS_PATH"); v != "" { + return v + } + // default cache used by npx playwright install + home, _ := os.UserHomeDir() + if home != "" { + return filepath.Join(home, ".cache", "ms-playwright") + } + return "" +} diff --git a/apps/backend/internal/module/studio/usecase/service.go b/apps/backend/internal/module/studio/usecase/service.go new file mode 100644 index 0000000..05f70b4 --- /dev/null +++ b/apps/backend/internal/module/studio/usecase/service.go @@ -0,0 +1,2793 @@ +package usecase + +import ( + "context" + "crypto/rand" + "encoding/json" + "fmt" + "net/url" + "strings" + "time" + "unicode/utf8" + + "apps/backend/internal/module/ai" + fsDomain "apps/backend/internal/module/filestorage/domain" + "apps/backend/internal/module/studio/domain" + "apps/backend/internal/module/studio/publish" + threadsDomain "apps/backend/internal/module/threads/domain" + usageDomain "apps/backend/internal/module/usage/domain" + usageUC "apps/backend/internal/module/usage/usecase" + + "github.com/google/uuid" + "github.com/zeromicro/go-zero/core/logx" +) + +// AccountLookup resolves Threads accounts for ownership + token. +type AccountLookup interface { + Get(ctx context.Context, id string) (*threadsDomain.Account, error) + List(ctx context.Context, ownerUID int64) ([]*threadsDomain.Account, error) + // DecryptAccess returns plaintext access token for publish (or fake token). + AccessToken(ctx context.Context, a *threadsDomain.Account) (string, error) +} + +// CrawlerSessionSource optional Playwright storageState for profile crawl. +type CrawlerSessionSource interface { + GetCrawlerSessionToken(ctx context.Context, ownerUID int64) (string, error) +} + +// ThreadsMediaSource — 真 Threads Graph:列貼文/insights/對話回覆/提及/公開人設。 +// 未設定時 SyncOwnPosts 退回假 seed(測試/無 Meta)。 +type ThreadsMediaSource interface { + ListThreads(ctx context.Context, accessToken string, limit int) ([]FetchedThread, error) + GetInsights(ctx context.Context, accessToken, mediaID string) (FetchedInsights, error) + ListConversation(ctx context.Context, accessToken, mediaID string, limit int) ([]FetchedReply, error) + ListMentions(ctx context.Context, accessToken, threadsUserID string, limit int) ([]FetchedMention, error) + // ListProfilePosts — 公開 @username 貼文(threads_profile_discovery) + ListProfilePosts(ctx context.Context, accessToken, username string, limit int) ([]FetchedThread, error) +} + +// Fetched* 與 provider 解耦,避免 studio 依賴 provider 包。 +type FetchedThread struct { + ID, Text, MediaType, MediaURL, ThumbnailURL, Permalink, Shortcode, TopicTag, Username string + PublishedAt int64 // unix ns +} + +type FetchedInsights struct { + Views, Likes, Replies, Reposts, Quotes, Shares int + Status, ErrorMsg string +} + +type FetchedReply struct { + ID, Text, Username, ParentMediaID string + PublishedAt int64 + IsMine bool + LikeCount int +} + +// FetchedMention — Graph /{user-id}/mentions +type FetchedMention struct { + ID, Text, Username, Permalink, RootPostID, ParentID string + IsReply, IsQuotePost bool + PublishedAt int64 +} + +// AIKeySource resolves provider/model/apiKey for a member (settings + platform). +type AIKeySource interface { + // ResolveAI returns provider, model, apiKey for LLM calls. + ResolveAI(ctx context.Context, ownerUID int64) (provider, model, apiKey string, err error) +} + +// PersonaAnalyzeScheduler enqueues durable background analyze jobs (leave-page safe). +// When nil (unit tests), AnalyzeFrom* runs synchronously. +type PersonaAnalyzeScheduler interface { + SchedulePersonaAnalyzeAccount(ctx context.Context, ownerUID int64, personaID, username, lang string) (jobID string, err error) + SchedulePersonaAnalyzeText(ctx context.Context, ownerUID int64, personaID, rawText, sourceLabel, lang string) (jobID string, err error) +} + +// Service is the M4 studio facade (personas/plays/outbox/compose/ownposts/mentions). +type Service struct { + Repo domain.Repository + Transport publish.Transport + Accounts AccountLookup + Usage *usageUC.Service + AI ai.Client // tests / fake fallback + // AIRegistry real xai / opencode-go clients + AIRegistry *ai.Registry + // Keys optional; when set, persona analyze uses member AI settings + Keys AIKeySource + // Crawler optional: Chrome extension sync'd storageState for public profile scrape + Crawler CrawlerSessionSource + // Jobs optional: when set, analyze APIs only enqueue; worker runs Execute* + Jobs PersonaAnalyzeScheduler + // Media optional: Meta Threads 拉「我的貼文」 + Media ThreadsMediaSource + // Storage optional: 發文暫存圖(temp/*)發成功後刪除 + Storage fsDomain.Storage + // StoragePublicBase — 與 ObjectStorage.PublicBaseURL 對齊,用來從公開 URL 反推 object key + StoragePublicBase string + // optional key resolver path via Usage.PrepareCall + Static/Settings + KeyModeDefault string // if Usage nil, use this for tests +} + +func New(repo domain.Repository, transport publish.Transport) *Service { + return &Service{Repo: repo, Transport: transport} +} + +// ---------- Personas (PE) ---------- + +func (s *Service) ListPersonas(ctx context.Context, ownerUID int64) ([]*domain.Persona, error) { + return s.Repo.ListPersonas(ctx, ownerUID) +} + +func (s *Service) GetPersona(ctx context.Context, ownerUID int64, id string) (*domain.Persona, error) { + p, err := s.Repo.GetPersona(ctx, id) + if err != nil { + return nil, err + } + if p.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + return p, nil +} + +func (s *Service) SavePersona(ctx context.Context, ownerUID int64, p *domain.Persona) (*domain.Persona, error) { + if ownerUID <= 0 { + return nil, domain.ErrForbidden + } + now := domain.NowNano() + if p.ID == "" { + p.ID = "pe_" + uuid.NewString()[:12] + p.CreatedAt = now + } else { + existing, err := s.Repo.GetPersona(ctx, p.ID) + if err == nil { + if existing.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + p.CreatedAt = existing.CreatedAt + p.OwnerUID = existing.OwnerUID + } else if err != domain.ErrNotFound { + return nil, err + } else { + p.CreatedAt = now + } + } + p.OwnerUID = ownerUID + if p.Status == "" { + p.Status = domain.PersonaEmpty + } + if p.Guard.MaxChars == 0 { + p.Guard.MaxChars = 500 + } + p.UpdatedAt = now + if err := s.Repo.SavePersona(ctx, p); err != nil { + return nil, err + } + // first persona becomes active if none + aid, _ := s.Repo.GetActivePersonaID(ctx, ownerUID) + if aid == "" { + _ = s.Repo.SetActivePersonaID(ctx, ownerUID, p.ID) + } + return p, nil +} + +func (s *Service) RemovePersona(ctx context.Context, ownerUID int64, id string) error { + p, err := s.GetPersona(ctx, ownerUID, id) + if err != nil { + return err + } + if err := s.Repo.DeletePersona(ctx, p.ID); err != nil { + return err + } + aid, _ := s.Repo.GetActivePersonaID(ctx, ownerUID) + if aid == id { + list, _ := s.Repo.ListPersonas(ctx, ownerUID) + next := "" + if len(list) > 0 { + next = list[0].ID + } + _ = s.Repo.SetActivePersonaID(ctx, ownerUID, next) + } + return nil +} + +func (s *Service) GetActivePersonaID(ctx context.Context, ownerUID int64) (string, error) { + return s.Repo.GetActivePersonaID(ctx, ownerUID) +} + +func (s *Service) SetActivePersonaID(ctx context.Context, ownerUID int64, id string) error { + if id != "" { + if _, err := s.GetPersona(ctx, ownerUID, id); err != nil { + return err + } + } + return s.Repo.SetActivePersonaID(ctx, ownerUID, id) +} + +// AnalyzeFromText — API 路徑:有 Jobs 則只入列背景任務(離開頁面不中斷);無 Jobs(測試)同步跑完。 +func (s *Service) AnalyzeFromText(ctx context.Context, ownerUID int64, id, rawText, sourceLabel string) (*domain.Persona, error) { + p, err := s.GetPersona(ctx, ownerUID, id) + if err != nil { + return nil, err + } + rawText = strings.TrimSpace(rawText) + if rawText == "" { + return nil, fmt.Errorf("%w: empty text", domain.ErrValidation) + } + samples := splitSamples(rawText, 12) + if len(samples) < 2 { + return nil, fmt.Errorf("%w: 請至少貼 2 段參考文字(可用 --- 分隔),每段至少約 10 字", domain.ErrValidation) + } + if s.Jobs != nil { + lang := ai.ResponseLanguageFrom(ctx) + p.Status = domain.PersonaAnalyzing + p.UpdatedAt = domain.NowNano() + if err := s.Repo.SavePersona(ctx, p); err != nil { + return nil, err + } + if _, err := s.Jobs.SchedulePersonaAnalyzeText(ctx, ownerUID, p.ID, rawText, sourceLabel, lang); err != nil { + p.Status = domain.PersonaEmpty + p.UpdatedAt = domain.NowNano() + _ = s.Repo.SavePersona(ctx, p) + return nil, err + } + return p, nil + } + return s.ExecuteAnalyzeFromText(ctx, ownerUID, id, rawText, sourceLabel, nil) +} + +// AnalyzeFromAccount — API 路徑:有 Jobs 則入列(爬文+分析在 worker);無 Jobs 同步。 +func (s *Service) AnalyzeFromAccount(ctx context.Context, ownerUID int64, id, username string) (*domain.Persona, error) { + p, err := s.GetPersona(ctx, ownerUID, id) + if err != nil { + return nil, err + } + username = strings.TrimPrefix(strings.TrimSpace(username), "@") + if username == "" { + return nil, fmt.Errorf("%w: empty username", domain.ErrValidation) + } + if strings.ContainsAny(username, " /") || strings.Contains(username, "threads.") { + return nil, fmt.Errorf("%w: username 請只填帳號,例如 ultralab_tw", domain.ErrValidation) + } + if s.Jobs != nil { + lang := ai.ResponseLanguageFrom(ctx) + p.Status = domain.PersonaAnalyzing + p.Style.BenchmarkUsername = username + p.UpdatedAt = domain.NowNano() + if err := s.Repo.SavePersona(ctx, p); err != nil { + return nil, err + } + if _, err := s.Jobs.SchedulePersonaAnalyzeAccount(ctx, ownerUID, p.ID, username, lang); err != nil { + p.Status = domain.PersonaEmpty + p.UpdatedAt = domain.NowNano() + _ = s.Repo.SavePersona(ctx, p) + return nil, err + } + return p, nil + } + return s.ExecuteAnalyzeFromAccount(ctx, ownerUID, id, username, nil) +} + +// ExecuteAnalyzeFromText — worker/測試:扣費 + LLM 分析 + 存檔 ready。 +func (s *Service) ExecuteAnalyzeFromText(ctx context.Context, ownerUID int64, id, rawText, sourceLabel string, onProgress ProgressFn) (*domain.Persona, error) { + report := func(pct int, sum string) { + if onProgress != nil { + onProgress(pct, sum) + } + } + p, err := s.GetPersona(ctx, ownerUID, id) + if err != nil { + return nil, err + } + rawText = strings.TrimSpace(rawText) + if rawText == "" { + return nil, fmt.Errorf("%w: empty text", domain.ErrValidation) + } + samples := splitSamples(rawText, 12) + if len(samples) < 2 { + _ = s.markPersonaAnalyzeFailed(ctx, p, "參考文字不足 2 段") + return nil, fmt.Errorf("%w: 請至少貼 2 段參考文字(可用 --- 分隔),每段至少約 10 字", domain.ErrValidation) + } + report(25, "人設分析 · 檢查文字樣本…") + if err := s.billAI(ctx, ownerUID, "persona analyze text", "personas.analyzeFromText"); err != nil { + _ = s.markPersonaAnalyzeFailed(ctx, p, err.Error()) + return nil, err + } + p.Status = domain.PersonaAnalyzing + p.UpdatedAt = domain.NowNano() + _ = s.Repo.SavePersona(ctx, p) + + label := strings.TrimSpace(sourceLabel) + if label == "" { + label = "手動貼文" + } + report(55, fmt.Sprintf("人設分析 · AI 分析「%s」中…", label)) + if err := s.finishPersonaStyleAnalysis(ctx, ownerUID, p, samples, "manual", "", label); err != nil { + _ = s.markPersonaAnalyzeFailed(ctx, p, err.Error()) + return nil, err + } + report(90, "人設分析 · 寫入指紋/範本…") + if err := s.Repo.SavePersona(ctx, p); err != nil { + return nil, err + } + return p, nil +} + +// ExecuteAnalyzeFromAccount — worker:爬公開貼文 + LLM + 存檔 ready。 +func (s *Service) ExecuteAnalyzeFromAccount(ctx context.Context, ownerUID int64, id, username string, onProgress ProgressFn) (*domain.Persona, error) { + report := func(pct int, sum string) { + if onProgress != nil { + onProgress(pct, sum) + } + } + p, err := s.GetPersona(ctx, ownerUID, id) + if err != nil { + return nil, err + } + username = strings.TrimPrefix(strings.TrimSpace(username), "@") + if username == "" { + return nil, fmt.Errorf("%w: empty username", domain.ErrValidation) + } + if strings.ContainsAny(username, " /") || strings.Contains(username, "threads.") { + return nil, fmt.Errorf("%w: username 請只填帳號,例如 ultralab_tw", domain.ErrValidation) + } + report(15, fmt.Sprintf("人設分析 · 準備爬取 @%s…", username)) + if err := s.billAI(ctx, ownerUID, "persona analyze account", "personas.analyzeFromAccount"); err != nil { + _ = s.markPersonaAnalyzeFailed(ctx, p, err.Error()) + return nil, err + } + + p.Status = domain.PersonaAnalyzing + p.Style.BenchmarkUsername = username + p.UpdatedAt = domain.NowNano() + _ = s.Repo.SavePersona(ctx, p) + + // 取樣順序(不強制 Chrome 爬蟲): + // 1) Threads Graph profile_posts(已連帳 + threads_profile_discovery) + // 2) Playwright 公開頁(可選 extension session 提高成功率) + report(28, fmt.Sprintf("人設分析 · 讀取 @%s 公開貼文…", username)) + samples, source, scrapeErr := s.collectPersonaSamples(ctx, ownerUID, username, 12) + if source != "" { + report(45, fmt.Sprintf("人設分析 · 來源 %s,已抓 %d 則…", source, len(samples))) + } + + // 樣本不足 → 明確錯誤(不要再回英文假貼文) + if scrapeErr != nil || len(samples) < 2 { + hint := "建議:① 到 Crew 連 Threads 帳號(需重新 OAuth 以取得公開人設權限)後再試;② 或改用「貼上參考文字」;③ 可選:設定頁同步 Chrome Session 提高公開頁爬取成功率。" + var msg string + if scrapeErr != nil { + msg = fmt.Sprintf("無法讀取 @%s 公開貼文(%v)。%s", username, scrapeErr, hint) + } else { + msg = fmt.Sprintf("@%s 可讀貼文不足 2 篇(目前 %d)。%s", username, len(samples), hint) + } + _ = s.markPersonaAnalyzeFailed(ctx, p, msg) + return nil, fmt.Errorf("%w: %s", domain.ErrValidation, msg) + } + + report(60, fmt.Sprintf("人設分析 · 已抓 %d 則,AI 分析中…", len(samples))) + if err := s.finishPersonaStyleAnalysis(ctx, ownerUID, p, samples, "benchmark", username, "@"+username); err != nil { + _ = s.markPersonaAnalyzeFailed(ctx, p, err.Error()) + return nil, err + } + report(90, "人設分析 · 寫入指紋/範本…") + if err := s.Repo.SavePersona(ctx, p); err != nil { + return nil, err + } + return p, nil +} + +func (s *Service) markPersonaAnalyzeFailed(ctx context.Context, p *domain.Persona, msg string) error { + if p == nil { + return nil + } + p.Status = domain.PersonaEmpty + if msg != "" { + p.Notes = "⚠️ 分析失敗:" + truncate(msg, 240) + } + p.UpdatedAt = domain.NowNano() + return s.Repo.SavePersona(ctx, p) +} + +// collectPersonaSamples — Graph API 優先,Playwright 公開頁後備(Chrome session 可選)。 +// source: graph | playwright | "" +func (s *Service) collectPersonaSamples(ctx context.Context, ownerUID int64, username string, limit int) (samples []string, source string, err error) { + // 1) Meta Graph profile_posts(用任一可用連帳 token) + if s.Media != nil && s.Accounts != nil { + if texts, gerr := s.fetchProfilePostsViaGraph(ctx, ownerUID, username, limit); gerr == nil && len(texts) >= 2 { + return texts, "graph", nil + } else if gerr != nil { + err = gerr // 保留最後錯誤,若 scrape 也失敗再回 + } else if len(texts) > 0 && len(texts) < 2 { + err = fmt.Errorf("Graph API 只拿到 %d 則", len(texts)) + } + } + + // 2) Playwright 公開頁(不必有 Chrome session;有則帶入) + storageState := "" + if s.Crawler != nil { + if tok, cerr := s.Crawler.GetCrawlerSessionToken(ctx, ownerUID); cerr == nil { + storageState = strings.TrimSpace(tok) + } + } + texts, serr := fetchProfilePostTexts(ctx, username, storageState, limit) + if serr == nil && len(texts) >= 2 { + return texts, "playwright", nil + } + if serr != nil { + if err != nil { + return nil, "", fmt.Errorf("Graph: %v; 公開頁: %v", err, serr) + } + return nil, "", serr + } + if len(texts) > 0 { + return texts, "playwright", nil // 可能 <2,上層再判 + } + if err != nil { + return nil, "", err + } + return nil, "", fmt.Errorf("找不到公開貼文") +} + +func (s *Service) fetchProfilePostsViaGraph(ctx context.Context, ownerUID int64, username string, limit int) ([]string, error) { + if s.Media == nil || s.Accounts == nil { + return nil, fmt.Errorf("media/accounts not configured") + } + accs, err := s.Accounts.List(ctx, ownerUID) + if err != nil { + return nil, err + } + var lastErr error + for _, acc := range accs { + if acc == nil || !acc.IsUsable { + continue + } + token, terr := s.Accounts.AccessToken(ctx, acc) + if terr != nil || strings.TrimSpace(token) == "" || strings.HasPrefix(token, "fake-") { + continue + } + list, lerr := s.Media.ListProfilePosts(ctx, token, username, limit) + if lerr != nil { + lastErr = lerr + continue + } + out := make([]string, 0, len(list)) + seen := map[string]struct{}{} + for _, th := range list { + t := strings.TrimSpace(th.Text) + if len(t) < 8 { + continue + } + if _, ok := seen[t]; ok { + continue + } + seen[t] = struct{}{} + out = append(out, t) + } + if len(out) > 0 { + return out, nil + } + } + if lastErr != nil { + return nil, lastErr + } + return nil, fmt.Errorf("無可用 Threads 帳號 token 或 profile_posts 為空(請重新連帳以取得 threads_profile_discovery)") +} + +// ProgressFn reports job progress from long-running execute (worker → MarkRunningProgress). +type ProgressFn func(percent int, summary string) + +// ExecutePersonaAnalyzeJob parses job payload and runs account/text analyze (worker entry). +func (s *Service) ExecutePersonaAnalyzeJob(ctx context.Context, templateType string, ownerUID int64, personaID, payloadJSON string, onProgress ProgressFn) error { + report := func(pct int, sum string) { + if onProgress != nil { + onProgress(pct, sum) + } + } + switch templateType { + case "persona_analyze_account": + var pl struct { + Username string `json:"username"` + Lang string `json:"lang,omitempty"` + } + if err := json.Unmarshal([]byte(payloadJSON), &pl); err != nil { + return fmt.Errorf("invalid persona analyze account payload: %w", err) + } + if pl.Lang != "" { + ctx = ai.WithResponseLanguage(ctx, pl.Lang) + } + _, err := s.ExecuteAnalyzeFromAccount(ctx, ownerUID, personaID, pl.Username, report) + return err + case "persona_analyze_text": + var pl struct { + RawText string `json:"raw_text"` + SourceLabel string `json:"source_label,omitempty"` + Lang string `json:"lang,omitempty"` + } + if err := json.Unmarshal([]byte(payloadJSON), &pl); err != nil { + return fmt.Errorf("invalid persona analyze text payload: %w", err) + } + if pl.Lang != "" { + ctx = ai.WithResponseLanguage(ctx, pl.Lang) + } + _, err := s.ExecuteAnalyzeFromText(ctx, ownerUID, personaID, pl.RawText, pl.SourceLabel, report) + return err + default: + return fmt.Errorf("unknown persona analyze template: %s", templateType) + } +} + +// finishPersonaStyleAnalysis:先規則底稿,再以會員設定的 provider/model 做真 LLM 分析覆寫。 +func (s *Service) finishPersonaStyleAnalysis( + ctx context.Context, + ownerUID int64, + p *domain.Persona, + samples []string, + source, username, sourceLabel string, +) error { + // 底稿(LLM 失敗時仍有可用結果) + applyStyleFromSamples(p, samples, source, username, sourceLabel) + + provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID) + if kerr != nil || strings.TrimSpace(apiKey) == "" || isSyntheticAIKey(apiKey) { + // 無真實 key:標註非 LLM + p.Notes = "⚠️ 規則摘要(非正式 AI)。請到設定填寫 xAI 或 OpenCode Go API Key 後重跑分析。" + p.UpdatedAt = domain.NowNano() + return nil + } + + raw, aerr := s.completeLLM(ctx, provider, model, apiKey, buildStyleAnalysisPrompt(samples, username, sourceLabel)) + if aerr != nil { + p.Notes = "⚠️ AI 分析失敗,已保留規則摘要:" + truncate(aerr.Error(), 160) + p.UpdatedAt = domain.NowNano() + return nil // 不整段失敗;規則結果仍可用 + } + if !applyAIStyleJSON(p, raw, samples, source, username, sourceLabel) { + // AI 回了非 JSON:仍把全文放 notes,draft 用規則 + p.Notes = "AI 原文(未解析為結構化 JSON):" + truncate(raw, 500) + p.UpdatedAt = domain.NowNano() + return nil + } + p.Notes = fmt.Sprintf("LLM 分析 · %s / %s · 樣本 %d 則", provider, model, len(samples)) + p.UpdatedAt = domain.NowNano() + return nil +} + +func (s *Service) resolveUserAI(ctx context.Context, ownerUID int64) (provider, model, apiKey string, err error) { + if s.Keys != nil { + return s.Keys.ResolveAI(ctx, ownerUID) + } + // tests:FakeClient + any key + if s.AI != nil { + return "xai", "grok-3", "test-key", nil + } + return "", "", "", fmt.Errorf("AI not configured") +} + +func (s *Service) completeLLM(ctx context.Context, provider, model, apiKey, prompt string) (string, error) { + // system 語言 prompt 由 ai.Client 依 ctx 強制注入(見 openai_compatible.Complete) + // real registry first + if s.AIRegistry != nil && !isSyntheticAIKey(apiKey) { + if c, err := s.AIRegistry.Client(provider); err == nil { + return c.Complete(ctx, apiKey, model, prompt) + } + } + if s.AI != nil { + return s.AI.Complete(ctx, apiKey, model, prompt) + } + return "", fmt.Errorf("no AI client") +} + +func isSyntheticAIKey(key string) bool { + k := strings.TrimSpace(strings.ToLower(key)) + return k == "" || strings.HasPrefix(k, "fake-") || k == "platform-key" || k == "test-key" +} + +// ---------- Plays (PL) ---------- + +func (s *Service) ListPlays(ctx context.Context, ownerUID int64) ([]*domain.Play, error) { + return s.Repo.ListPlays(ctx, ownerUID) +} + +func (s *Service) GetPlay(ctx context.Context, ownerUID int64, id string) (*domain.Play, error) { + p, err := s.Repo.GetPlay(ctx, id) + if err != nil { + return nil, err + } + if p.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + return p, nil +} + +func (s *Service) SavePlay(ctx context.Context, ownerUID int64, p *domain.Play) (*domain.Play, error) { + if ownerUID <= 0 { + return nil, domain.ErrForbidden + } + now := domain.NowNano() + if p.ID == "" { + p.ID = "play_" + uuid.NewString()[:12] + p.CreatedAt = now + } else { + ex, err := s.Repo.GetPlay(ctx, p.ID) + if err == nil { + if ex.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + p.CreatedAt = ex.CreatedAt + } else if err != domain.ErrNotFound { + return nil, err + } else { + p.CreatedAt = now + } + } + p.OwnerUID = ownerUID + // own vs external mutually exclusive + if p.TargetOwnPostID != "" { + p.TargetExternal = nil + } + if p.TargetExternal != nil && p.TargetExternal.URL != "" { + p.TargetOwnPostID = "" + p.TargetExternal.URL = normalizeURL(p.TargetExternal.URL) + } + if p.Status == "" { + p.Status = domain.PlayDraft + } + // renumber steps + for i := range p.Steps { + p.Steps[i].SortOrder = i + if p.Steps[i].ID == "" { + p.Steps[i].ID = "step_" + uuid.NewString()[:8] + } + if underPost(p) { + p.Steps[i].Kind = domain.StepReply + } + } + p.UpdatedAt = now + if err := s.Repo.SavePlay(ctx, p); err != nil { + return nil, err + } + return p, nil +} + +func (s *Service) RemovePlay(ctx context.Context, ownerUID int64, id string) error { + if _, err := s.GetPlay(ctx, ownerUID, id); err != nil { + return err + } + // outbox retained (documented strategy) + return s.Repo.DeletePlay(ctx, id) +} + +func (s *Service) ListPlaysByPost(ctx context.Context, ownerUID int64, ownPostID string) ([]*domain.Play, error) { + all, err := s.Repo.ListPlays(ctx, ownerUID) + if err != nil { + return nil, err + } + var out []*domain.Play + for _, p := range all { + if p.TargetOwnPostID == ownPostID { + out = append(out, p) + } + } + return out, nil +} + +func (s *Service) ListPlaysByExternalURL(ctx context.Context, ownerUID int64, raw string) ([]*domain.Play, error) { + key := normalizeURL(raw) + if key == "" { + return nil, nil + } + all, err := s.Repo.ListPlays(ctx, ownerUID) + if err != nil { + return nil, err + } + var out []*domain.Play + for _, p := range all { + if p.TargetExternal != nil && normalizeURL(p.TargetExternal.URL) == key { + out = append(out, p) + } + } + return out, nil +} + +func (s *Service) ResolveExternalLink(_ context.Context, raw string) (*domain.ExternalTarget, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, domain.ErrBadURL + } + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + // allow threads-like paths without scheme + if !strings.Contains(raw, "threads.net") && !strings.HasPrefix(raw, "http") { + return nil, domain.ErrBadURL + } + u, err = url.Parse("https://" + strings.TrimPrefix(raw, "//")) + if err != nil { + return nil, domain.ErrBadURL + } + } + host := strings.ToLower(u.Host) + if !strings.Contains(host, "threads.net") && !strings.Contains(host, "threads.com") { + return nil, domain.ErrBadURL + } + norm := normalizeURL(u.String()) + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + shortcode := "" + author := "" + if len(parts) >= 1 && strings.HasPrefix(parts[0], "@") { + author = strings.TrimPrefix(parts[0], "@") + } + if len(parts) >= 3 && parts[1] == "post" { + shortcode = parts[2] + } + return &domain.ExternalTarget{ + URL: norm, + RawURL: raw, + Shortcode: shortcode, + AuthorUsername: author, + TextPreview: "External thread preview · " + shortcode, + MediaID: "ext_" + shortcode, + ResolvedAt: domain.NowNano(), + }, nil +} + +func (s *Service) SubmitPlay(ctx context.Context, ownerUID int64, playID string) (*domain.OutboxBundle, error) { + play, err := s.GetPlay(ctx, ownerUID, playID) + if err != nil { + return nil, err + } + if err := validatePlay(play); err != nil { + return nil, err + } + if err := s.ensureUsableAccounts(ctx, ownerUID, play); err != nil { + return nil, err + } + // 互回/掛文:把目標 Threads media_id 寫進 outbox(否則 Meta 無法 reply_to) + replyToMedia := "" + if play.TargetExternal != nil { + replyToMedia = strings.TrimSpace(play.TargetExternal.MediaID) + if replyToMedia == "" || strings.HasPrefix(replyToMedia, "ext_") { + return nil, fmt.Errorf("%w: 外部連結尚無法解析真實 media_id,請改用「我的貼文」當目標,或先同步後貼上可回覆的貼文", domain.ErrValidation) + } + } + if play.TargetOwnPostID != "" { + post, perr := s.Repo.GetOwnPost(ctx, play.TargetOwnPostID) + if perr != nil || post == nil { + return nil, fmt.Errorf("%w: 找不到目標貼文,請先到「我的貼文」同步", domain.ErrValidation) + } + if post.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + replyToMedia = strings.TrimSpace(post.MediaID) + if replyToMedia == "" { + return nil, fmt.Errorf("%w: 目標貼文缺少 media_id,請重新同步「我的貼文」", domain.ErrValidation) + } + } + // 每步要有正文 + for _, st := range play.Steps { + if strings.TrimSpace(st.Text) == "" { + return nil, fmt.Errorf("%w: 劇本步驟不可空白,請先 AI 產文或手寫", domain.ErrValidation) + } + } + bundle := playToOutbox(ownerUID, play) + if replyToMedia != "" { + bundle.ReplyToMediaID = replyToMedia + for i := range bundle.Steps { + if bundle.Steps[i].Kind == domain.StepReply { + bundle.Steps[i].ReplyTo = replyToMedia + } + } + } + if err := s.Repo.SaveOutbox(ctx, bundle); err != nil { + return nil, err + } + play.Status = domain.PlayScheduling + play.UpdatedAt = domain.NowNano() + _ = s.Repo.SavePlay(ctx, play) + return bundle, nil +} + +// GeneratePlayScript — 一次 LLM 產完整劇本並寫回 play.steps(onlyEmpty=true 只填空白步)。 +// 回傳填入步數。供 worker job / 同步測試。 +func (s *Service) GeneratePlayScript(ctx context.Context, ownerUID int64, playID string, onlyEmpty bool) (int, error) { + play, err := s.GetPlay(ctx, ownerUID, playID) + if err != nil { + return 0, err + } + if len(play.Steps) == 0 { + return 0, fmt.Errorf("%w: play has no steps", domain.ErrValidation) + } + // 需要產的步 + type need struct { + idx int + id string + label string + mode string + } + var needs []need + for i, st := range play.Steps { + if onlyEmpty && strings.TrimSpace(st.Text) != "" { + continue + } + label := st.AccountID + mode := "reply" + if st.Kind == domain.StepRoot || (i == 0 && !underPost(play)) { + mode = "root" + } + // 人設標籤 + if p := s.loadPersonaForGen(ctx, ownerUID, st.PersonaID); p != nil && p.Name != "" { + label = p.Name + } + needs = append(needs, need{idx: i, id: st.ID, label: label, mode: mode}) + } + if len(needs) == 0 { + return 0, fmt.Errorf("%w: 沒有空白步驟可產(全部已有正文)", domain.ErrValidation) + } + if err := s.billAI(ctx, ownerUID, "play generate script", "plays.generateScript"); err != nil { + return 0, err + } + + // 主上下文:目標貼文/主題 + targetCtx := strings.TrimSpace(play.Topic) + if play.TargetOwnPostID != "" { + if post, e := s.Repo.GetOwnPost(ctx, play.TargetOwnPostID); e == nil && post != nil { + if t := strings.TrimSpace(post.Text); t != "" { + targetCtx = t + } + } + } + + // 選一人設當「主指紋」(優先第一空步的 persona,再 active) + personaID := "" + for _, n := range needs { + if pid := strings.TrimSpace(play.Steps[n.idx].PersonaID); pid != "" { + personaID = pid + break + } + } + persona := s.loadPersonaForGen(ctx, ownerUID, personaID) + fp := personaFingerprintBlock(persona) + if utf8.RuneCountInString(fp) > 400 { + fp = string([]rune(fp)[:400]) + "…" + } + if utf8.RuneCountInString(targetCtx) > 280 { + targetCtx = string([]rune(targetCtx)[:280]) + "…" + } + + // 骨架:已有正文的步當上下文,空步標 [待產] + var skeleton strings.Builder + for i, st := range play.Steps { + skeleton.WriteString(fmt.Sprintf("%d. id=%s kind=%s speaker=%s\n", i+1, st.ID, st.Kind, st.AccountID)) + if t := strings.TrimSpace(st.Text); t != "" { + if utf8.RuneCountInString(t) > 120 { + t = string([]rune(t)[:120]) + "…" + } + skeleton.WriteString(" 已有:") + skeleton.WriteString(t) + skeleton.WriteString("\n") + } else { + skeleton.WriteString(" 待產:是\n") + } + } + + prompt := fmt.Sprintf(`你是 Threads 互回編劇。一次寫完下列「待產」步驟的正文。 + +規則: +- 只輸出 JSON(不要 markdown 圍欄):{"steps":[{"id":"步驟id","text":"正文"},...]} +- 只輸出待產步驟;id 必須與下方 id 完全一致 +- 繁體中文口語、像真人互回;每則 25~100 字 +- 接住主文/上一則,不要重複抄全文、不要暴露 AI + +【指紋】 +%s + +【主文/話題】 +%s + +【步驟表】 +%s + +JSON:`, fp, targetCtx, skeleton.String()) + + provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID) + if kerr != nil || strings.TrimSpace(apiKey) == "" || isSyntheticAIKey(apiKey) { + if s.AI != nil { + // 測試 Fake:填固定短句 + for _, n := range needs { + play.Steps[n.idx].Text = fmt.Sprintf("(測試)步驟 %d 互回", n.idx+1) + } + play.UpdatedAt = domain.NowNano() + if err := s.Repo.SavePlay(ctx, play); err != nil { + return 0, err + } + return len(needs), nil + } + return 0, fmt.Errorf("%w: 無法產文,請到設定填寫真實 AI Key", domain.ErrValidation) + } + + llmCtx := ctx + cancel := func() {} + if _, ok := ctx.Deadline(); !ok { + llmCtx, cancel = context.WithTimeout(ctx, 95*time.Second) + } + defer cancel() + + out, aerr := s.completeLLM(llmCtx, provider, model, apiKey, prompt) + if aerr != nil { + msg := aerr.Error() + if llmCtx.Err() != nil || strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline") { + return 0, fmt.Errorf("%w: AI 回應逾時(%s/%s)。請換較快模型", domain.ErrValidation, provider, model) + } + return 0, fmt.Errorf("%w: AI 產文失敗(%s/%s):%s", domain.ErrValidation, provider, model, truncate(msg, 180)) + } + + filled := applyPlayScriptJSON(play, out) + if filled == 0 { + // 解析失敗:嘗試整包當一步(不建議);直接報錯 + return 0, fmt.Errorf("%w: AI 回傳無法解析成步驟(%s/%s)。請換模型後重試", domain.ErrValidation, provider, model) + } + play.UpdatedAt = domain.NowNano() + if err := s.Repo.SavePlay(ctx, play); err != nil { + return 0, err + } + return filled, nil +} + +// applyPlayScriptJSON 把 {"steps":[{"id","text"}]} 寫入 play +func applyPlayScriptJSON(play *domain.Play, raw string) int { + s := strings.TrimSpace(raw) + s = strings.TrimPrefix(s, "```json") + s = strings.TrimPrefix(s, "```") + s = strings.TrimSuffix(s, "```") + s = strings.TrimSpace(s) + // 取 JSON 物件 + if i := strings.Index(s, "{"); i >= 0 { + if j := strings.LastIndex(s, "}"); j > i { + s = s[i : j+1] + } + } + var parsed struct { + Steps []struct { + ID string `json:"id"` + Text string `json:"text"` + } `json:"steps"` + } + if err := json.Unmarshal([]byte(s), &parsed); err != nil || len(parsed.Steps) == 0 { + return 0 + } + byID := map[string]string{} + for _, st := range parsed.Steps { + id := strings.TrimSpace(st.ID) + t := cleanGeneratedTextMax(st.Text, 280) + if id == "" || t == "" { + continue + } + byID[id] = t + } + // 也允許依順序填(若 id 對不上) + filled := 0 + orderIdx := 0 + ordered := make([]string, 0, len(parsed.Steps)) + for _, st := range parsed.Steps { + if t := cleanGeneratedTextMax(st.Text, 280); t != "" { + ordered = append(ordered, t) + } + } + for i := range play.Steps { + id := play.Steps[i].ID + if t, ok := byID[id]; ok { + play.Steps[i].Text = t + filled++ + continue + } + // 僅空白步才用順序填 + if strings.TrimSpace(play.Steps[i].Text) == "" && orderIdx < len(ordered) { + play.Steps[i].Text = ordered[orderIdx] + orderIdx++ + filled++ + } + } + return filled +} + +// GeneratePlayStep — 互回/串場劇本:依人設真 LLM 產一步正文。 +// 注意:與 compose mimic 相同,OpenCode reasoning 模型可能 30~90s 且偶發空白回覆。 +func (s *Service) GeneratePlayStep(ctx context.Context, ownerUID int64, personaID, contextText, topic, speakerLabel string, isLead bool, mode string) (string, error) { + contextText = strings.TrimSpace(contextText) + topic = strings.TrimSpace(topic) + if contextText == "" && topic == "" { + return "", fmt.Errorf("%w: empty context", domain.ErrValidation) + } + if err := s.billAI(ctx, ownerUID, "play generate step", "plays.generateStep"); err != nil { + return "", err + } + persona := s.loadPersonaForGen(ctx, ownerUID, personaID) + fp := personaFingerprintBlock(persona) + // 指紋過長會拖慢 reasoning 模型(仿寫已踩過) + if utf8.RuneCountInString(fp) > 500 { + fp = string([]rune(fp)[:500]) + "…" + } + if utf8.RuneCountInString(contextText) > 400 { + contextText = string([]rune(contextText)[:400]) + "…" + } + mode = strings.ToLower(strings.TrimSpace(mode)) + if mode == "" { + mode = "reply" + } + prompt := buildPlayStepPrompt(fp, contextText, topic, speakerLabel, isLead, mode) + + provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID) + if kerr != nil || strings.TrimSpace(apiKey) == "" || isSyntheticAIKey(apiKey) { + // 單元測試 FakeClient + if s.AI != nil { + if out, e := s.AI.Complete(ctx, "test-key", "grok-3", prompt); e == nil { + if t := cleanGeneratedTextMax(out, 400); t != "" { + return t, nil + } + } + } + return "", fmt.Errorf("%w: 無法產文,請到設定填寫真實 AI Key(並選模型)後再試", domain.ErrValidation) + } + + // 低於 gateway 120s;OpenCode 慢模型常 40~90s + llmCtx := ctx + cancel := func() {} + if _, ok := ctx.Deadline(); !ok { + llmCtx, cancel = context.WithTimeout(ctx, 95*time.Second) + } + defer cancel() + + out, aerr := s.completeLLM(llmCtx, provider, model, apiKey, prompt) + if aerr != nil { + msg := aerr.Error() + if llmCtx.Err() != nil || strings.Contains(msg, "context deadline") || strings.Contains(msg, "Client.Timeout") || strings.Contains(msg, "timeout") { + return "", fmt.Errorf("%w: AI 回應逾時(%s / %s)。請換較快的模型,或稍後再試", domain.ErrValidation, provider, model) + } + return "", fmt.Errorf("%w: AI 產文失敗(%s/%s):%s", domain.ErrValidation, provider, model, truncate(msg, 200)) + } + text := cleanGeneratedTextMax(out, 400) + if text == "" { + text = strings.TrimSpace(out) + } + if text == "" { + // 與仿寫相同:reasoning 模型有時只吐 thinking、content 空 + return "", fmt.Errorf("%w: AI 回傳空白(%s / %s)。請到設定換較快的模型(勿用過慢的 reasoning),或重試一次", domain.ErrValidation, provider, model) + } + return text, nil +} + +func buildPlayStepPrompt(fp, contextText, topic, speakerLabel string, isLead bool, mode string) string { + // 精簡 prompt:降低 reasoning 模型耗時(仿寫踩過的坑) + var b strings.Builder + if mode == "root" { + b.WriteString("寫一則 Threads 主貼。只輸出正文(繁中口語),80~220字,勿標題/markdown。\n") + } else { + b.WriteString("寫一則 Threads 互回短回覆。只輸出正文(繁中口語),25~120字,接上下文,勿分析/markdown/暴露AI。\n") + } + if speakerLabel != "" { + b.WriteString("發言者:") + b.WriteString(speakerLabel) + if isLead { + b.WriteString("(主帳)") + } + b.WriteString("\n") + } + if topic != "" { + b.WriteString("話題:") + b.WriteString(topic) + b.WriteString("\n") + } + b.WriteString("【指紋】\n") + b.WriteString(fp) + b.WriteString("\n") + if contextText != "" { + b.WriteString("【上下文】\n") + b.WriteString(contextText) + b.WriteString("\n") + } + b.WriteString("正文:") + return b.String() +} + +// ---------- Outbox (OB) ---------- + +func (s *Service) ListOutbox(ctx context.Context, ownerUID int64) ([]*domain.OutboxBundle, error) { + return s.Repo.ListOutbox(ctx, ownerUID) +} + +func (s *Service) GetOutbox(ctx context.Context, ownerUID int64, id string) (*domain.OutboxBundle, error) { + b, err := s.Repo.GetOutbox(ctx, id) + if err != nil { + return nil, err + } + if b.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + return b, nil +} + +func (s *Service) RemoveOutbox(ctx context.Context, ownerUID int64, id string) error { + b, err := s.GetOutbox(ctx, ownerUID, id) + if err != nil { + return err + } + // 未發出/失敗取消:清掉暫存圖 + for i := range b.Steps { + s.cleanupEphemeralImages(ctx, b.Steps[i].ImageURLs) + } + return s.Repo.DeleteOutbox(ctx, id) +} + +func (s *Service) RetryStep(ctx context.Context, ownerUID int64, bundleID, stepID string) (*domain.OutboxBundle, error) { + b, err := s.GetOutbox(ctx, ownerUID, bundleID) + if err != nil { + return nil, err + } + idx := -1 + for i := range b.Steps { + if b.Steps[i].ID == stepID { + idx = i + break + } + } + if idx < 0 { + return nil, domain.ErrNotFound + } + st := &b.Steps[idx] + // only failed (or blocked after root fixed) can retry + if st.Status != domain.StepFailed { + return nil, domain.ErrIllegalStatus + } + if st.Kind == domain.StepReply { + root := findRoot(b) + if root == nil || root.Status != domain.StepPublished { + return nil, domain.ErrRootBlocked + } + } + st.Status = domain.StepScheduled + st.Error = "" + st.ScheduledAt = domain.NowNano() // due now + // unblock later replies if root ok + if root := findRoot(b); root != nil && root.Status == domain.StepPublished { + for i := range b.Steps { + if b.Steps[i].Kind == domain.StepReply && b.Steps[i].Status == domain.StepBlocked { + b.Steps[i].Status = domain.StepScheduled + b.Steps[i].Error = "" + } + } + } + b.Status = recomputeBundle(b.Steps) + b.UpdatedAt = domain.NowNano() + if err := s.Repo.SaveOutbox(ctx, b); err != nil { + return nil, err + } + return b, nil +} + +// ProcessDueSteps is the worker tick — publishes via Transport only. +func (s *Service) ProcessDueSteps(ctx context.Context, now int64) (published int, err error) { + if now <= 0 { + now = domain.NowNano() + } + all, err := s.Repo.ListAllOutbox(ctx) + if err != nil { + return 0, err + } + for _, b := range all { + n, e := s.processBundle(ctx, b, now) + published += n + if e != nil && err == nil { + err = e + } + } + return published, err +} + +func (s *Service) processBundle(ctx context.Context, b *domain.OutboxBundle, now int64) (int, error) { + // skip removed (already not in list) + count := 0 + // if root failed, do not publish replies + root := findRoot(b) + rootFailed := root != nil && root.Status == domain.StepFailed + rootPublished := root != nil && root.Status == domain.StepPublished + + // worker 重啟/中斷會留下 publishing:逾時後收回可重試 + const publishingStaleNs = int64(3 * time.Minute) + for i := range b.Steps { + st := &b.Steps[i] + if st.Status == domain.StepPublishing && b.UpdatedAt > 0 && now-b.UpdatedAt > publishingStaleNs { + logx.Infof("outbox reclaim stale publishing step id=%s bundle=%s", st.ID, b.ID) + st.Status = domain.StepScheduled + st.Error = "" + } + } + + // sort by sort_order + for i := range b.Steps { + st := &b.Steps[i] + if st.Status != domain.StepScheduled { + continue + } + if st.ScheduledAt > now { + // OB-01: not due + continue + } + if st.Kind == domain.StepReply { + if rootFailed { + st.Status = domain.StepBlocked + st.Error = "root failed; reply blocked" + continue + } + if root != nil && !rootPublished && root.Status != domain.StepPublished { + // wait for root + continue + } + } + // publish via transport + if s.Transport == nil { + st.Status = domain.StepFailed + st.Error = "publish transport not configured" + continue + } + st.Status = domain.StepPublishing + b.Status = domain.OBActive + b.UpdatedAt = domain.NowNano() + _ = s.Repo.SaveOutbox(ctx, b) + + token := "fake-token" + if s.Accounts != nil { + acc, aerr := s.Accounts.Get(ctx, st.AccountID) + if aerr != nil || acc == nil || !acc.IsUsable { + st.Status = domain.StepFailed + st.Error = "account not usable" + b.UpdatedAt = domain.NowNano() + b.Status = recomputeBundle(b.Steps) + _ = s.Repo.SaveOutbox(ctx, b) + continue + } + if t, terr := s.Accounts.AccessToken(ctx, acc); terr == nil && t != "" { + token = t + } + } + replyTo := st.ReplyTo + if replyTo == "" && st.Kind == domain.StepReply { + if root != nil && root.MediaID != "" { + replyTo = root.MediaID + } else if b.ReplyToMediaID != "" { + replyTo = b.ReplyToMediaID + } + } + res, perr := s.Transport.Publish(ctx, domain.PublishRequest{ + AccessToken: token, + AccountID: st.AccountID, + Text: st.Text, + ReplyTo: replyTo, + ImageURLs: st.ImageURLs, + // 僅主貼帶話題標籤 + TopicTag: func() string { + if st.Kind == domain.StepRoot { + return st.TopicTag + } + return "" + }(), + }) + if perr != nil { + st.Status = domain.StepFailed + st.Error = perr.Error() + // OB-04: block replies if root failed + if st.Kind == domain.StepRoot { + for j := range b.Steps { + if b.Steps[j].Kind == domain.StepReply && b.Steps[j].Status != domain.StepPublished { + b.Steps[j].Status = domain.StepBlocked + b.Steps[j].Error = "waiting for root success" + } + } + } + } else { + st.Status = domain.StepPublished + st.Error = "" + st.PublishedAt = domain.NowNano() + if res != nil { + st.MediaID = res.MediaID + } + // Meta 已抓完圖:刪除 temp/* 暫存,outbox 不再保留 image_urls + if len(st.ImageURLs) > 0 { + s.cleanupEphemeralImages(ctx, st.ImageURLs) + st.ImageURLs = nil + } + count++ + // after root success, unblock replies that were blocked only for root wait + if st.Kind == domain.StepRoot { + for j := range b.Steps { + if b.Steps[j].Kind == domain.StepReply && b.Steps[j].Status == domain.StepBlocked { + b.Steps[j].Status = domain.StepScheduled + b.Steps[j].Error = "" + } + } + } + } + b.Status = recomputeBundle(b.Steps) + b.UpdatedAt = domain.NowNano() + _ = s.Repo.SaveOutbox(ctx, b) + + // re-read root flags after mutation + root = findRoot(b) + rootFailed = root != nil && root.Status == domain.StepFailed + rootPublished = root != nil && root.Status == domain.StepPublished + } + return count, nil +} + +// ---------- Compose (CP) ---------- + +func (s *Service) Mimic(ctx context.Context, ownerUID int64, sourceText, personaID, structureNotes string) (string, error) { + sourceText = strings.TrimSpace(sourceText) + if sourceText == "" { + return "", fmt.Errorf("%w: empty source", domain.ErrValidation) + } + if err := s.billAI(ctx, ownerUID, "compose mimic", "compose.mimic"); err != nil { + return "", err + } + p := s.loadPersonaForGen(ctx, ownerUID, personaID) + fp := personaFingerprintBlock(p) + // 指紋過長會拖慢 reasoning 模型 + if utf8.RuneCountInString(fp) > 800 { + fp = string([]rune(fp)[:800]) + "…" + } + notes := strings.TrimSpace(structureNotes) + // OpenCode reasoning 模型對長「結構分析」極慢;只留骨架提示 + if utf8.RuneCountInString(notes) > 200 { + notes = string([]rune(notes)[:200]) + "…" + } + srcRunes := utf8.RuneCountInString(sourceText) + minLen := srcRunes * 9 / 10 + if minLen < 80 { + minLen = 80 + } + if minLen > 220 { + minLen = 220 + } + maxLen := srcRunes + 30 + if maxLen < 140 { + maxLen = 140 + } + if maxLen > 320 { + maxLen = 320 + } + if maxLen < minLen { + maxLen = minLen + 20 + } + + notesBlock := "" + if notes != "" { + notesBlock = fmt.Sprintf("\n\n(節奏提示,勿照抄)\n%s\n", notes) + } + + // 精簡 prompt:單次 LLM 完成(不再二次擴寫,避免 2× 逾時) + prompt := strings.TrimSpace(fmt.Sprintf(` +你就是本人在打 Threads,不是助手。 + +用【說話方式】重寫【參考】的意思與節奏:口語、像滑手機邊打;勿照抄原句;勿列點;勿「首先/總結」;勿 markdown。 +約 %d~%d 字、2~4 段。有問句就留好回的小問題。只輸出正文。 + +【說話方式】 +%s + +【參考】 +%s +%s +`, minLen, maxLen, fp, sourceText, notesBlock)) + + provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID) + if kerr != nil || strings.TrimSpace(apiKey) == "" || isSyntheticAIKey(apiKey) { + if s.AI != nil && (apiKey == "test-key" || s.Keys == nil) { + if out, e := s.AI.Complete(ctx, "test-key", "grok-3", prompt); e == nil { + if t := cleanGeneratedTextMax(out, 900); t != "" { + return t, nil + } + } + } + return "", fmt.Errorf("%w: 無法仿寫:請到設定填寫真實 AI Key 後再試", domain.ErrValidation) + } + + // HTTP 同步路徑:沒有外層 deadline 時限 95s(低於 gateway 120s) + // Job worker 會帶 4 分鐘 deadline,這裡不覆蓋 + llmCtx := ctx + cancel := func() {} + if _, ok := ctx.Deadline(); !ok { + llmCtx, cancel = context.WithTimeout(ctx, 95*time.Second) + } + defer cancel() + + out, aerr := s.completeLLM(llmCtx, provider, model, apiKey, prompt) + if aerr != nil { + msg := aerr.Error() + if llmCtx.Err() != nil || strings.Contains(msg, "context deadline") || strings.Contains(msg, "Client.Timeout") || strings.Contains(msg, "timeout") { + return "", fmt.Errorf("%w: AI 回應逾時(%s / %s)。請縮短「結構分析」備註後再試,或換較快的模型", domain.ErrValidation, provider, model) + } + return "", fmt.Errorf("%w: AI 仿寫失敗(%s/%s):%s", domain.ErrValidation, provider, model, truncate(msg, 200)) + } + text := cleanGeneratedTextMax(out, 900) + if text == "" { + text = strings.TrimSpace(out) + } + if text == "" { + return "", fmt.Errorf("%w: AI 回傳空白(%s / %s)。請到設定確認模型後再試", domain.ErrValidation, provider, model) + } + return text, nil +} + +func (s *Service) AnalyzeViral(ctx context.Context, ownerUID int64, text string) (*domain.ViralAnalysis, error) { + if err := s.billAI(ctx, ownerUID, "compose analyze viral", "compose.analyzeViral"); err != nil { + return nil, err + } + return s.analyzeViralUnbilled(ctx, ownerUID, text) +} + +// analyzeViralUnbilled — 真 LLM 結構分析(呼叫端自行 billAI,避免雙重計費) +func (s *Service) analyzeViralUnbilled(ctx context.Context, ownerUID int64, text string) (*domain.ViralAnalysis, error) { + text = strings.TrimSpace(text) + if text == "" { + return nil, fmt.Errorf("%w: empty text", domain.ErrValidation) + } + + // 規則底稿(LLM 失敗時仍有可用輸出,但會標註) + fallback := ruleViralAnalysis(text) + + provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID) + if kerr != nil || strings.TrimSpace(apiKey) == "" || isSyntheticAIKey(apiKey) { + // 測試:FakeClient 可走下面;正式無 key 回規則 + 提示 + if s.AI != nil { + if out, e := s.AI.Complete(ctx, "test-key", "grok-3", buildViralAnalysisPrompt(text)); e == nil { + if va, ok := parseViralAnalysisJSON(out); ok { + return va, nil + } + } + } + fallback.Summary = fallback.Summary + "(規則摘要:請到設定填 AI Key 以取得真分析)" + return fallback, nil + } + + raw, aerr := s.completeLLM(ctx, provider, model, apiKey, buildViralAnalysisPrompt(text)) + if aerr != nil { + fallback.Summary = fallback.Summary + "(AI 失敗,規則底稿:" + truncate(aerr.Error(), 80) + ")" + return fallback, nil + } + if va, ok := parseViralAnalysisJSON(raw); ok { + return va, nil + } + // 非 JSON:仍把 AI 重點塞進 summary + fallback.Summary = "AI 原文:" + truncate(cleanGeneratedText(raw), 400) + return fallback, nil +} + +func buildViralAnalysisPrompt(text string) string { + return strings.TrimSpace(fmt.Sprintf(` +你是 Threads 內容編輯。請分析下面這則貼文的「可複製結構」,服務創作者改寫/學習。 + +規則: +- 只輸出 JSON(不要 markdown 圍欄、不要前後說明) +- 繁體中文(台灣用語) +- 字段都要有實質內容,勿空字串、勿敷衍套話 + +JSON schema: +{ + "hooks": "開場鉤子怎麼抓注意力(1~3 句)", + "structure": "段落結構骨架(如:情境→痛點→經驗→提問)", + "emotion": "情緒節奏(好奇/共感/緊迫等)", + "summary": "為什麼這則有互動潛力(2~4 句)", + "cta": "結尾 CTA/互動設計", + "copyable": "別人可複製的 2~4 個要點(勿抄原文)", + "risks": "改寫時要注意的風險/禁忌" +} + +【貼文】 +%s +`, text)) +} + +func parseViralAnalysisJSON(raw string) (*domain.ViralAnalysis, bool) { + s := strings.TrimSpace(raw) + s = strings.TrimPrefix(s, "```json") + s = strings.TrimPrefix(s, "```") + s = strings.TrimSuffix(s, "```") + s = strings.TrimSpace(s) + // 取第一個 { … 最後一個 } + if i := strings.Index(s, "{"); i >= 0 { + if j := strings.LastIndex(s, "}"); j > i { + s = s[i : j+1] + } + } + var parsed struct { + Hooks string `json:"hooks"` + Structure string `json:"structure"` + Emotion string `json:"emotion"` + Summary string `json:"summary"` + CTA string `json:"cta"` + Copyable string `json:"copyable"` + Risks string `json:"risks"` + } + if err := json.Unmarshal([]byte(s), &parsed); err != nil { + return nil, false + } + if strings.TrimSpace(parsed.Hooks) == "" && strings.TrimSpace(parsed.Structure) == "" && strings.TrimSpace(parsed.Summary) == "" { + return nil, false + } + return &domain.ViralAnalysis{ + Hooks: strings.TrimSpace(parsed.Hooks), Structure: strings.TrimSpace(parsed.Structure), + Emotion: strings.TrimSpace(parsed.Emotion), Summary: strings.TrimSpace(parsed.Summary), + CTA: strings.TrimSpace(parsed.CTA), Copyable: strings.TrimSpace(parsed.Copyable), + Risks: strings.TrimSpace(parsed.Risks), + }, true +} + +func ruleViralAnalysis(text string) *domain.ViralAnalysis { + hasQ := strings.ContainsAny(text, "??") + hasPain := strings.ContainsAny(text, "卡煩雷痛難") || strings.Contains(text, "怎麼") || strings.Contains(text, "不會") + hooks := "開場用生活情境,降低防備" + if hasQ { + hooks = "用真心疑問收尾/開場,降低回覆門檻" + } else if hasPain { + hooks = "先丟具體痛點,讀者有代入感" + } + emotion := "好奇/認同" + if hasPain { + emotion = "共感 + 一點焦慮釋放" + } + sum := "情境清楚" + if hasPain { + sum = "痛點具體" + } + if hasQ { + sum += " + 好回的問題" + } + return &domain.ViralAnalysis{ + Hooks: hooks, + Structure: "情境/痛點 → 自己經驗一句 → 邀請補充(或輕 CTA)", + Emotion: emotion, + Summary: "這則有互動潛力:" + sum + "。改寫時保留結構,換成你的經驗與語氣。", + CTA: "軟性提問或請讀者補一句經驗", + Copyable: "短段落、一個明確條件、結尾問句;勿整段抄原文", + Risks: "勿保證效果、勿硬廣、勿嘲諷讀者", + } +} + +func formatViralFormulaDetail(va *domain.ViralAnalysis) string { + if va == nil { + return "" + } + var b strings.Builder + if va.Summary != "" { + b.WriteString("【為什麼可能爆】") + b.WriteString(va.Summary) + b.WriteString("\n") + } + if va.Hooks != "" { + b.WriteString("【鉤子】") + b.WriteString(va.Hooks) + b.WriteString("\n") + } + if va.Structure != "" { + b.WriteString("【結構】") + b.WriteString(va.Structure) + b.WriteString("\n") + } + if va.Emotion != "" { + b.WriteString("【情緒】") + b.WriteString(va.Emotion) + b.WriteString("\n") + } + if va.Copyable != "" { + b.WriteString("【可複製】") + b.WriteString(va.Copyable) + b.WriteString("\n") + } + if va.CTA != "" { + b.WriteString("【CTA】") + b.WriteString(va.CTA) + b.WriteString("\n") + } + if va.Risks != "" { + b.WriteString("【風險】") + b.WriteString(va.Risks) + } + return strings.TrimSpace(b.String()) +} + +func (s *Service) PublishSingle(ctx context.Context, ownerUID int64, accountID, text, title string, imageURLs []string, scheduleStartAt int64, topicTag string) (*domain.OutboxBundle, error) { + text = strings.TrimSpace(text) + if text == "" { + return nil, fmt.Errorf("%w: empty text", domain.ErrValidation) + } + if accountID == "" { + return nil, domain.ErrNoAccount + } + if s.Accounts != nil { + acc, err := s.Accounts.Get(ctx, accountID) + if err != nil || acc == nil || acc.OwnerUID != ownerUID || !acc.IsUsable { + return nil, domain.ErrNoAccount + } + } + now := domain.NowNano() + start := scheduleStartAt + if start <= 0 { + start = now + } else if start < now { + // datetime-local 只有分鐘精度 + 時鐘誤差:超過 1 分鐘才擋 + const schedulePastGrace = int64(time.Minute) + if now-start > schedulePastGrace { + return nil, fmt.Errorf("%w: schedule_start_at is in the past", domain.ErrValidation) + } + start = now + } + // 圖必須是 Meta 可抓的 http(s);data URL 請前端先 /media/upload + cleanImgs := make([]string, 0, len(imageURLs)) + for _, u := range imageURLs { + u = strings.TrimSpace(u) + if u == "" { + continue + } + if strings.HasPrefix(u, "data:") { + return nil, fmt.Errorf("%w: image_urls must be public https URLs (upload first)", domain.ErrValidation) + } + if strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "http://") { + cleanImgs = append(cleanImgs, u) + } + } + tag := normalizePublishTopicTag(topicTag) + play := &domain.Play{ + ID: "play_" + uuid.NewString()[:12], + OwnerUID: ownerUID, + Title: title, + Topic: truncate(text, 80), + Status: domain.PlayScheduling, + LeadAccountID: accountID, + Steps: []domain.PlayStep{{ + ID: uuid.NewString()[:8], SortOrder: 0, Kind: domain.StepRoot, + AccountID: accountID, Text: text, ImageURLs: cleanImgs, + }}, + ScheduleStartAt: start, + CreatedAt: now, + UpdatedAt: now, + } + if play.Title == "" { + play.Title = truncate(text, 24) + } + if err := s.Repo.SavePlay(ctx, play); err != nil { + return nil, err + } + bundle := playToOutbox(ownerUID, play) + // 主貼帶 topic_tag + if tag != "" && len(bundle.Steps) > 0 { + bundle.Steps[0].TopicTag = tag + } + if err := s.Repo.SaveOutbox(ctx, bundle); err != nil { + return nil, err + } + return bundle, nil +} + +func normalizePublishTopicTag(raw string) string { + s := strings.TrimSpace(raw) + s = strings.TrimPrefix(s, "#") + s = strings.TrimSpace(s) + s = strings.ReplaceAll(s, ".", "") + s = strings.ReplaceAll(s, "&", "") + s = strings.TrimSpace(s) + if s == "" { + return "" + } + r := []rune(s) + if len(r) > 50 { + s = string(r[:50]) + } + return s +} + +// ---------- OwnPosts (OP) ---------- + +func (s *Service) ListOwnPosts(ctx context.Context, ownerUID int64, accountID string) ([]*domain.OwnPost, error) { + return s.Repo.ListOwnPosts(ctx, ownerUID, accountID) +} + +func (s *Service) LastSyncedAt(ctx context.Context, ownerUID int64) (int64, error) { + m, err := s.Repo.GetSyncMeta(ctx, ownerUID) + if err != nil { + return 0, err + } + return m.LastSyncedAt, nil +} + +func (s *Service) SyncOwnPosts(ctx context.Context, ownerUID int64, accountID string) ([]*domain.OwnPost, error) { + if accountID == "" { + return nil, domain.ErrNoAccount + } + var acc *threadsDomain.Account + if s.Accounts != nil { + a, err := s.Accounts.Get(ctx, accountID) + if err != nil || a == nil { + return nil, domain.ErrNotFound + } + if a.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + if !a.IsUsable { + return nil, domain.ErrNoAccount + } + acc = a + } + now := domain.NowNano() + + // 真 Threads Graph 同步 + if s.Media != nil && s.Accounts != nil && acc != nil { + token, terr := s.Accounts.AccessToken(ctx, acc) + if terr == nil && strings.TrimSpace(token) != "" && !strings.HasPrefix(token, "fake-") { + if err := s.syncOwnPostsFromThreads(ctx, ownerUID, accountID, token); err != nil { + return nil, err + } + _ = s.Repo.SetSyncMeta(ctx, &domain.SyncMeta{OwnerUID: ownerUID, LastSyncedAt: now}) + return s.Repo.ListOwnPosts(ctx, ownerUID, accountID) + } + } + + // 無 Media/假 token:保留本地假 seed(單元測試) + existing, _ := s.Repo.ListOwnPosts(ctx, ownerUID, accountID) + if len(existing) == 0 { + for i := 0; i < 2; i++ { + p := &domain.OwnPost{ + ID: "op_" + uuid.NewString()[:10], OwnerUID: ownerUID, AccountID: accountID, + MediaID: "media_sync_" + uuid.NewString()[:6], Text: fmt.Sprintf("Synced post #%d from Threads", i+1), + MediaType: "TEXT_POST", LikeCount: 3 + i, ReplyCount: 1, ViewCount: 100 + i*20, + InsightsStatus: "ok", + Replies: []domain.OwnPostReply{{ + ID: "or_" + uuid.NewString()[:6], Username: "fan_" + fmt.Sprint(i), + Text: "nice!", CreatedAt: now - int64(i)*time.Hour.Nanoseconds(), ReplyStatus: "pending", + }}, + PublishedAt: now - int64(i+1)*time.Hour.Nanoseconds(), + } + _ = s.Repo.SaveOwnPost(ctx, p) + } + } else { + for _, p := range existing { + p.ViewCount += 5 + p.LikeCount++ + p.InsightsStatus = "ok" + _ = s.Repo.SaveOwnPost(ctx, p) + } + } + _ = s.Repo.SetSyncMeta(ctx, &domain.SyncMeta{OwnerUID: ownerUID, LastSyncedAt: now}) + return s.Repo.ListOwnPosts(ctx, ownerUID, accountID) +} + +// syncOwnPostsFromThreads — 拉 me/threads + insights + conversation,以 media_id upsert。 +func (s *Service) syncOwnPostsFromThreads(ctx context.Context, ownerUID int64, accountID, accessToken string) error { + threads, err := s.Media.ListThreads(ctx, accessToken, 25) + if err != nil { + return fmt.Errorf("threads list: %w", err) + } + // 既有本地貼文:保留分析公式欄位 + existing, _ := s.Repo.ListOwnPosts(ctx, ownerUID, accountID) + byMedia := map[string]*domain.OwnPost{} + for _, p := range existing { + if p.MediaID != "" { + byMedia[p.MediaID] = p + } + // 清掉早期 mock seed(Synced post #…) + if strings.HasPrefix(p.MediaID, "media_sync_") || strings.HasPrefix(p.Text, "Synced post #") { + _ = s.Repo.DeleteOwnPost(ctx, p.ID) + } + } + + for _, th := range threads { + if th.ID == "" { + continue + } + // 同步只拉貼文 + 成效;留言改點開再載(避免 N 則 × conversation 打爆 API) + ins, _ := s.Media.GetInsights(ctx, accessToken, th.ID) + + prev := byMedia[th.ID] + id := "op_" + th.ID + if prev != nil && prev.ID != "" { + id = prev.ID + } + pubAt := th.PublishedAt + if pubAt <= 0 { + pubAt = domain.NowNano() + } + // 保留先前已載入的留言(點開過的不因 re-sync 清掉) + var replies []domain.OwnPostReply + if prev != nil && len(prev.Replies) > 0 { + replies = append([]domain.OwnPostReply(nil), prev.Replies...) + } + p := &domain.OwnPost{ + ID: id, OwnerUID: ownerUID, AccountID: accountID, + MediaID: th.ID, Text: th.Text, MediaType: th.MediaType, + MediaURL: th.MediaURL, ThumbnailURL: th.ThumbnailURL, + Permalink: th.Permalink, Shortcode: th.Shortcode, TopicTag: th.TopicTag, + LikeCount: ins.Likes, ReplyCount: ins.Replies, RepostCount: ins.Reposts, + QuoteCount: ins.Quotes, ViewCount: ins.Views, ShareCount: ins.Shares, + InsightsStatus: ins.Status, + Replies: replies, + PublishedAt: pubAt, + } + if p.MediaType == "" { + p.MediaType = "TEXT_POST" + } + // 保留本地分析結果 + if prev != nil { + p.FormulaSummary = prev.FormulaSummary + p.Insight = prev.Insight + p.FormulaDetail = prev.FormulaDetail + } + if ins.Status == "error" && ins.ErrorMsg != "" && p.Insight == "" { + // 不寫 error 進 insight 以免覆蓋分析;僅 insights_status + } + if err := s.Repo.SaveOwnPost(ctx, p); err != nil { + return err + } + } + return nil +} + +// LoadOwnPostReplies — 點開貼文時才拉留言(/{media}/replies 或 conversation) +func (s *Service) LoadOwnPostReplies(ctx context.Context, ownerUID int64, postID string) (*domain.OwnPost, error) { + post, err := s.getOwnPostOwned(ctx, ownerUID, postID) + if err != nil { + return nil, err + } + if post.MediaID == "" { + return post, nil + } + if s.Media == nil || s.Accounts == nil { + return post, nil + } + acc, err := s.Accounts.Get(ctx, post.AccountID) + if err != nil || acc == nil || acc.OwnerUID != ownerUID { + return nil, domain.ErrNoAccount + } + token, terr := s.Accounts.AccessToken(ctx, acc) + if terr != nil || strings.TrimSpace(token) == "" || strings.HasPrefix(token, "fake-") { + return post, nil + } + convo, cerr := s.Media.ListConversation(ctx, token, post.MediaID, 50) + if cerr != nil { + return nil, fmt.Errorf("load replies: %w", cerr) + } + replies := mapFetchedRepliesWithRoot(convo, post, post.MediaID) + post.Replies = replies + // 若 insights 沒給 replies 數,用實際筆數補 + if post.ReplyCount < len(replies) { + post.ReplyCount = len(replies) + } + if err := s.Repo.SaveOwnPost(ctx, post); err != nil { + return nil, err + } + return post, nil +} + +func mapFetchedReplies(convo []FetchedReply, prev *domain.OwnPost) []domain.OwnPostReply { + root := "" + if prev != nil { + root = prev.MediaID + } + return mapFetchedRepliesWithRoot(convo, prev, root) +} + +func mapFetchedRepliesWithRoot(convo []FetchedReply, prev *domain.OwnPost, rootMediaID string) []domain.OwnPostReply { + out := make([]domain.OwnPostReply, 0, len(convo)) + localStatus := map[string]domain.OwnPostReply{} + if prev != nil { + for _, r := range prev.Replies { + localStatus[r.ID] = r + } + } + for _, c := range convo { + id := c.ID + if id == "" { + continue + } + created := c.PublishedAt + if created <= 0 { + created = domain.NowNano() + } + // parent = 根貼 media id → 第一層(parent 空) + parent := c.ParentMediaID + if parent == rootMediaID || parent == "" { + parent = "" + } + // 預設 pending;載完後用對話樹標記(不靠 LLM) + status := "pending" + repliedBy := "" + var repliedAt int64 + if old, ok := localStatus[id]; ok && old.ReplyStatus == "replied" { + // 本機曾送出回覆的標記先保留,稍後仍用樹再算一次 + status = old.ReplyStatus + repliedBy = old.RepliedBy + repliedAt = old.RepliedAt + } + out = append(out, domain.OwnPostReply{ + ID: id, Username: c.Username, Text: c.Text, CreatedAt: created, + LikeCount: c.LikeCount, ReplyStatus: status, RepliedBy: repliedBy, RepliedAt: repliedAt, + ParentReplyID: parent, IsMine: c.IsMine, + }) + } + // 純資料:若某則留言底下有「我的」子回覆 → 標已回覆 + return markRepliedFromTree(out) +} + +// markRepliedFromTree — 不靠 LLM:對話樹裡 parent=該留言 id 且 is_mine → replied +func markRepliedFromTree(replies []domain.OwnPostReply) []domain.OwnPostReply { + // parentID → 是否有我的子回覆 + mineUnder := map[string]domain.OwnPostReply{} + for _, r := range replies { + if !r.IsMine { + continue + } + pid := strings.TrimSpace(r.ParentReplyID) + if pid == "" { + continue + } + // 保留時間最新的一則當 replied_by 參考 + if old, ok := mineUnder[pid]; !ok || r.CreatedAt >= old.CreatedAt { + mineUnder[pid] = r + } + } + for i := range replies { + if replies[i].IsMine { + // 自己的留言不進「待回」列表 + replies[i].ReplyStatus = "replied" + continue + } + if mine, ok := mineUnder[replies[i].ID]; ok { + replies[i].ReplyStatus = "replied" + if replies[i].RepliedBy == "" { + replies[i].RepliedBy = mine.Username + } + if replies[i].RepliedAt == 0 { + replies[i].RepliedAt = mine.CreatedAt + } + } else { + replies[i].ReplyStatus = "pending" + replies[i].RepliedBy = "" + replies[i].RepliedAt = 0 + } + } + return replies +} + +func mergeReplyStatus(prev, next []domain.OwnPostReply) []domain.OwnPostReply { + if len(prev) == 0 { + return next + } + byID := map[string]domain.OwnPostReply{} + for _, r := range prev { + byID[r.ID] = r + } + for i := range next { + if old, ok := byID[next[i].ID]; ok && old.ReplyStatus == "replied" { + next[i].ReplyStatus = "replied" + next[i].RepliedBy = old.RepliedBy + next[i].RepliedAt = old.RepliedAt + } + } + return next +} + +func (s *Service) GenerateReply(ctx context.Context, ownerUID int64, postID, replyID, personaID string) (string, error) { + post, err := s.getOwnPostOwned(ctx, ownerUID, postID) + if err != nil { + return "", err + } + if err := s.billAI(ctx, ownerUID, "own post reply draft", "ownPosts.generateReply"); err != nil { + return "", err + } + + // 貼文 + 要回的留言 + 人設指紋 + postText := strings.TrimSpace(post.Text) + commentText := "" + commentUser := "" + if replyID != "" { + for _, r := range post.Replies { + if r.ID == replyID { + commentText = strings.TrimSpace(r.Text) + commentUser = strings.TrimSpace(r.Username) + break + } + } + } + persona := s.loadPersonaForGen(ctx, ownerUID, personaID) + fp := personaFingerprintBlock(persona) + + prompt := buildOwnPostReplyPrompt(fp, postText, commentUser, commentText) + + provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID) + if kerr == nil && strings.TrimSpace(apiKey) != "" && !isSyntheticAIKey(apiKey) { + if out, aerr := s.completeLLM(ctx, provider, model, apiKey, prompt); aerr == nil { + if t := cleanGeneratedText(out); t != "" { + return t, nil + } + } + } + // 測試路徑:FakeClient + if s.AI != nil { + if out, e := s.AI.Complete(ctx, "test-key", "grok-3", prompt); e == nil && out != "" { + return cleanGeneratedText(out), nil + } + } + return "", fmt.Errorf("%w: 無法產回覆草稿,請到設定填寫 AI Key 後再試", domain.ErrValidation) +} + +func (s *Service) SendReply(ctx context.Context, ownerUID int64, postID, replyID, text, accountID string, imageURLs []string) (*domain.OwnPost, error) { + post, err := s.getOwnPostOwned(ctx, ownerUID, postID) + if err != nil { + return nil, err + } + text = strings.TrimSpace(text) + if text == "" { + return nil, fmt.Errorf("%w: empty reply", domain.ErrValidation) + } + accID := accountID + if accID == "" { + accID = post.AccountID + } + username := "me" + token := "fake-token" + if s.Accounts != nil { + acc, aerr := s.Accounts.Get(ctx, accID) + if aerr != nil || acc == nil || acc.OwnerUID != ownerUID || !acc.IsUsable { + return nil, domain.ErrNoAccount + } + username = acc.Username + if t, terr := s.Accounts.AccessToken(ctx, acc); terr == nil && t != "" { + token = t + } + } + if s.Transport == nil { + return nil, fmt.Errorf("%w: publish transport not configured", domain.ErrValidation) + } + // reply_to_id:回某則留言用留言 media id;回主貼用主貼 media id + replyTo := strings.TrimSpace(post.MediaID) + if strings.TrimSpace(replyID) != "" { + replyTo = strings.TrimSpace(replyID) + } + if replyTo == "" { + return nil, fmt.Errorf("%w: 貼文缺少 Threads media_id,請先重新同步後再回覆", domain.ErrValidation) + } + if strings.HasPrefix(token, "fake-") { + return nil, fmt.Errorf("%w: 帳號 token 無效,請重新連 Threads", domain.ErrValidation) + } + res, perr := s.Transport.Publish(ctx, domain.PublishRequest{ + AccessToken: token, AccountID: accID, Text: text, ReplyTo: replyTo, + }) + if perr != nil { + // OP-05: do not mark local success;回傳可讀錯誤(Biz 400) + return nil, fmt.Errorf("%w: %s", domain.ErrValidation, perr.Error()) + } + now := domain.NowNano() + if replyID != "" { + for i := range post.Replies { + if post.Replies[i].ID == replyID { + post.Replies[i].ReplyStatus = "replied" + post.Replies[i].RepliedBy = username + post.Replies[i].RepliedAt = now + } + } + } + // 本地掛上我的回覆:parent = 被回留言 id(空=掛在主貼下第一層) + parent := strings.TrimSpace(replyID) + newID := "or_" + uuid.NewString()[:8] + if res != nil && res.MediaID != "" { + newID = res.MediaID + } + post.Replies = append(post.Replies, domain.OwnPostReply{ + ID: newID, Username: username, Text: text, + CreatedAt: now, ReplyStatus: "replied", IsMine: true, ParentReplyID: parent, + }) + // 重新標記整樹已回覆狀態 + post.Replies = markRepliedFromTree(post.Replies) + if post.ReplyCount < len(post.Replies) { + post.ReplyCount = len(post.Replies) + } + _ = imageURLs + if err := s.Repo.SaveOwnPost(ctx, post); err != nil { + return nil, err + } + return post, nil +} + +func (s *Service) AnalyzePost(ctx context.Context, ownerUID int64, postID string) (*domain.OwnPost, error) { + post, err := s.getOwnPostOwned(ctx, ownerUID, postID) + if err != nil { + return nil, err + } + if strings.TrimSpace(post.Text) == "" { + return nil, fmt.Errorf("%w: 貼文沒有文字可分析(圖片/純媒體貼可改貼上說明再分析)", domain.ErrValidation) + } + if err := s.billAI(ctx, ownerUID, "own post structure analyze", "ownPosts.analyze"); err != nil { + return nil, err + } + va, err := s.analyzeViralUnbilled(ctx, ownerUID, post.Text) + if err != nil { + return nil, err + } + post.FormulaSummary = va.Hooks + if post.FormulaSummary == "" { + post.FormulaSummary = va.Structure + } + post.Insight = va.Summary + post.FormulaDetail = formatViralFormulaDetail(va) + if err := s.Repo.SaveOwnPost(ctx, post); err != nil { + return nil, err + } + return post, nil +} + +// GenerateFromFormula is intentionally removed (OP-11). +func (s *Service) GenerateFromFormula(_ context.Context, _, _ string) error { + return domain.ErrFormulaRemove +} + +// ---------- Mentions ---------- + +func (s *Service) ListMentions(ctx context.Context, ownerUID int64, accountID string) ([]*domain.Mention, error) { + return s.Repo.ListMentions(ctx, ownerUID, accountID) +} + +// SyncMentions — 從 Threads Graph 拉「@我」提及並 upsert 本地 inbox。 +func (s *Service) SyncMentions(ctx context.Context, ownerUID int64, accountID string) ([]*domain.Mention, error) { + if accountID == "" { + return nil, domain.ErrNoAccount + } + if s.Accounts == nil { + return nil, domain.ErrNoAccount + } + acc, err := s.Accounts.Get(ctx, accountID) + if err != nil || acc == nil { + return nil, domain.ErrNotFound + } + if acc.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + if !acc.IsUsable { + return nil, domain.ErrNoAccount + } + token, terr := s.Accounts.AccessToken(ctx, acc) + if terr != nil || strings.TrimSpace(token) == "" || strings.HasPrefix(token, "fake-") { + return nil, fmt.Errorf("%w: 帳號 token 無效,請重新連 Threads(需含 threads_manage_mentions)", domain.ErrValidation) + } + if s.Media == nil { + return nil, fmt.Errorf("%w: Threads Graph 未設定,無法同步提及", domain.ErrValidation) + } + + userID := strings.TrimSpace(acc.ThreadsUserID) + if userID == "" { + userID = "me" + } + hits, err := s.Media.ListMentions(ctx, token, userID, 40) + if err != nil { + return nil, fmt.Errorf("mentions sync: %w", err) + } + + // 既有:保留 status / draft + existing, _ := s.Repo.ListMentions(ctx, ownerUID, accountID) + byMedia := map[string]*domain.Mention{} + for _, m := range existing { + if m.MediaID != "" { + byMedia[m.MediaID] = m + } + } + + now := domain.NowNano() + for _, hit := range hits { + mediaID := strings.TrimSpace(hit.ID) + if mediaID == "" { + continue + } + prev := byMedia[mediaID] + id := "mn_" + mediaID + status := domain.MentionPending + draft := "" + created := hit.PublishedAt + if created <= 0 { + created = now + } + if prev != nil { + id = prev.ID + if prev.Status != "" { + status = prev.Status + } + draft = prev.DraftText + if prev.CreatedAt > 0 { + created = prev.CreatedAt + } + } + ctxSnippet := mentionContextSnippet(hit) + m := &domain.Mention{ + ID: id, OwnerUID: ownerUID, AccountID: accountID, + MediaID: mediaID, FromUsername: hit.Username, Text: hit.Text, + ContextSnippet: ctxSnippet, Permalink: hit.Permalink, + RootPostID: hit.RootPostID, ParentID: hit.ParentID, + Status: status, DraftText: draft, CreatedAt: created, + } + if m.FromUsername == "" { + m.FromUsername = "unknown" + } + if err := s.Repo.SaveMention(ctx, m); err != nil { + return nil, err + } + } + return s.Repo.ListMentions(ctx, ownerUID, accountID) +} + +func mentionContextSnippet(hit FetchedMention) string { + kind := "提及你" + if hit.IsQuotePost { + kind = "引用你" + } else if hit.IsReply { + kind = "回覆中 @ 你" + } + text := strings.TrimSpace(hit.Text) + if len([]rune(text)) > 120 { + text = string([]rune(text)[:120]) + "…" + } + if text == "" { + return kind + } + return kind + " · " + text +} + +func (s *Service) GenerateMentionReply(ctx context.Context, ownerUID int64, id, personaID string) (*domain.Mention, error) { + m, err := s.getMentionOwned(ctx, ownerUID, id) + if err != nil { + return nil, err + } + if err := s.billAI(ctx, ownerUID, "mention reply draft", "mentions.generateReply"); err != nil { + return nil, err + } + persona := s.loadPersonaForGen(ctx, ownerUID, personaID) + fp := personaFingerprintBlock(persona) + prompt := buildMentionReplyPrompt(fp, m.FromUsername, m.Text, m.ContextSnippet) + + draft := "" + provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID) + if kerr == nil && strings.TrimSpace(apiKey) != "" && !isSyntheticAIKey(apiKey) { + if out, aerr := s.completeLLM(ctx, provider, model, apiKey, prompt); aerr == nil { + draft = cleanGeneratedText(out) + } + } + if draft == "" && s.AI != nil { + if out, e := s.AI.Complete(ctx, "test-key", "grok-3", prompt); e == nil { + draft = cleanGeneratedText(out) + } + } + if draft == "" { + return nil, fmt.Errorf("%w: 無法產回覆草稿,請到設定填寫 AI Key 後再試", domain.ErrValidation) + } + m.DraftText = draft + if err := s.Repo.SaveMention(ctx, m); err != nil { + return nil, err + } + return m, nil +} + +func (s *Service) loadPersonaForGen(ctx context.Context, ownerUID int64, personaID string) *domain.Persona { + if personaID != "" { + if p, err := s.GetPersona(ctx, ownerUID, personaID); err == nil { + return p + } + } + if aid, _ := s.GetActivePersonaID(ctx, ownerUID); aid != "" { + if p, err := s.GetPersona(ctx, ownerUID, aid); err == nil { + return p + } + } + return nil +} + +func personaFingerprintBlock(p *domain.Persona) string { + if p == nil { + return "(未選人設,用自然口語、友善、像朋友聊天)" + } + fp := strings.TrimSpace(p.Style.DraftText) + if fp == "" { + fp = serializeDraftText(p.Style.Draft) + } + var b strings.Builder + if n := strings.TrimSpace(p.Name); n != "" { + b.WriteString("名稱:") + b.WriteString(n) + b.WriteString("\n") + } + if br := strings.TrimSpace(p.Brief); br != "" { + b.WriteString("定位:") + b.WriteString(br) + b.WriteString("\n") + } + if fp != "" { + b.WriteString("【語言指紋】\n") + b.WriteString(fp) + b.WriteString("\n") + } else if t := strings.TrimSpace(p.Style.Draft.Tone); t != "" { + b.WriteString("語氣:") + b.WriteString(t) + b.WriteString("\n") + } + if len(p.Guard.Avoid) > 0 { + b.WriteString("【禁止】") + b.WriteString(strings.Join(p.Guard.Avoid, "、")) + b.WriteString("\n") + } + out := strings.TrimSpace(b.String()) + if out == "" { + return "(人設資料不足,用自然口語)" + } + return out +} + +func buildOwnPostReplyPrompt(fp, postText, commentUser, commentText string) string { + var b strings.Builder + b.WriteString(`你正在扮演 Threads 帳號本人回覆(不是客服、不是分析師)。 +任務:依人設指紋寫「一則短回覆草稿」。 + +規則(強制): +- 只輸出回覆正文,不要「回覆:」前綴、不要分析、不要 markdown。 +- 繁體中文(台灣用語)、口語、像真人滑手機回。 +- 嚴格遵守指紋語氣/節奏/禁忌。 +- 長度約 30~180 字,可分段空行。 +- 若有對方留言,要接對方的話;若只回自己主貼下,像補一句或開場邀互動。 + +`) + b.WriteString("【人設】\n") + b.WriteString(fp) + b.WriteString("\n\n【我的主貼】\n") + b.WriteString(strings.TrimSpace(postText)) + b.WriteString("\n") + if strings.TrimSpace(commentText) != "" { + b.WriteString("\n【要回的留言】") + if commentUser != "" { + b.WriteString(" @") + b.WriteString(commentUser) + } + b.WriteString("\n") + b.WriteString(commentText) + b.WriteString("\n") + } else { + b.WriteString("\n(直接在主貼下回覆/補一句,沒有指定某則留言)\n") + } + return strings.TrimSpace(b.String()) +} + +func buildMentionReplyPrompt(fp, fromUser, mentionText, contextSnippet string) string { + return strings.TrimSpace(fmt.Sprintf(` +你正在扮演 Threads 帳號本人,回覆別人的「提及/@」。 +任務:依人設指紋寫一則短回覆草稿。 + +規則(強制): +- 只輸出回覆正文,不要前綴、不要分析。 +- 繁體中文、口語;嚴格遵守指紋。 +- 約 30~160 字。 + +【人設】 +%s + +【對方 @你】 +@%s:%s + +【上下文】 +%s +`, fp, fromUser, mentionText, strings.TrimSpace(contextSnippet))) +} + +func (s *Service) MarkMentionReplied(ctx context.Context, ownerUID int64, id, text string, imageURLs []string) (*domain.Mention, error) { + m, err := s.getMentionOwned(ctx, ownerUID, id) + if err != nil { + return nil, err + } + text = strings.TrimSpace(text) + if text == "" { + text = strings.TrimSpace(m.DraftText) + } + if text == "" { + return nil, fmt.Errorf("%w: empty text", domain.ErrValidation) + } + // 真發文:reply_to = 提及 media_id(對方那則貼/回覆) + if s.Transport != nil { + token := "fake" + accID := m.AccountID + if s.Accounts != nil { + acc, aerr := s.Accounts.Get(ctx, accID) + if aerr != nil || acc == nil || acc.OwnerUID != ownerUID || !acc.IsUsable { + return nil, domain.ErrNoAccount + } + t, terr := s.Accounts.AccessToken(ctx, acc) + if terr != nil || strings.TrimSpace(t) == "" { + return nil, fmt.Errorf("%w: 帳號 token 無效,請重新連 Threads", domain.ErrValidation) + } + token = t + } + replyTo := strings.TrimSpace(m.MediaID) + if replyTo == "" { + // 單元測試 seed 無 media_id 時允許 FakeTransport;正式路徑必須有 + if s.Accounts != nil { + return nil, fmt.Errorf("%w: 提及缺少 media_id,請先重新同步提及", domain.ErrValidation) + } + replyTo = "media_test" + } + if s.Accounts != nil && (strings.HasPrefix(token, "fake-") || token == "fake") { + return nil, fmt.Errorf("%w: 帳號 token 無效,請重新連 Threads", domain.ErrValidation) + } + _, perr := s.Transport.Publish(ctx, domain.PublishRequest{ + AccessToken: token, AccountID: accID, Text: text, ReplyTo: replyTo, + }) + if perr != nil { + return nil, fmt.Errorf("%w: %s", domain.ErrValidation, perr.Error()) + } + } + m.Status = domain.MentionReplied + m.DraftText = text + _ = imageURLs // 提及回覆不附圖(與我的貼文回覆一致) + if err := s.Repo.SaveMention(ctx, m); err != nil { + return nil, err + } + return m, nil +} + +func (s *Service) SkipMention(ctx context.Context, ownerUID int64, id string) (*domain.Mention, error) { + m, err := s.getMentionOwned(ctx, ownerUID, id) + if err != nil { + return nil, err + } + m.Status = domain.MentionSkipped + if err := s.Repo.SaveMention(ctx, m); err != nil { + return nil, err + } + return m, nil +} + +// SeedMention for tests / sync helpers +func (s *Service) SeedMention(ctx context.Context, m *domain.Mention) error { + if m.ID == "" { + m.ID = "mn_" + uuid.NewString()[:10] + } + if m.Status == "" { + m.Status = domain.MentionPending + } + if m.CreatedAt == 0 { + m.CreatedAt = domain.NowNano() + } + return s.Repo.SaveMention(ctx, m) +} + +// ---------- helpers ---------- + +func (s *Service) billAI(ctx context.Context, ownerUID int64, label, source string) error { + if s.Usage == nil { + return nil + } + mode, err := s.Usage.PrepareCall(ctx, ownerUID, usageDomain.MeterAICopy) + if err != nil { + return err + } + _, err = s.Usage.RecordCall(ctx, ownerUID, usageDomain.MeterAICopy, mode, label, source) + return err +} + +func (s *Service) getOwnPostOwned(ctx context.Context, ownerUID int64, id string) (*domain.OwnPost, error) { + p, err := s.Repo.GetOwnPost(ctx, id) + if err != nil { + return nil, err + } + if p.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + return p, nil +} + +func (s *Service) getMentionOwned(ctx context.Context, ownerUID int64, id string) (*domain.Mention, error) { + m, err := s.Repo.GetMention(ctx, id) + if err != nil { + return nil, err + } + if m.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + return m, nil +} + +func (s *Service) ensureUsableAccounts(ctx context.Context, ownerUID int64, play *domain.Play) error { + if s.Accounts == nil { + // test mode without accounts — allow if lead set + if play.LeadAccountID == "" && !underPost(play) { + return domain.ErrNoAccount + } + return nil + } + ids := map[string]struct{}{} + if play.LeadAccountID != "" { + ids[play.LeadAccountID] = struct{}{} + } + for _, c := range play.CastAccountIDs { + ids[c] = struct{}{} + } + for _, st := range play.Steps { + ids[st.AccountID] = struct{}{} + } + for id := range ids { + if id == "" { + continue + } + acc, err := s.Accounts.Get(ctx, id) + if err != nil || acc == nil || acc.OwnerUID != ownerUID || !acc.IsUsable { + return domain.ErrNoAccount + } + } + return nil +} + +func underPost(p *domain.Play) bool { + return p.TargetOwnPostID != "" || (p.TargetExternal != nil && p.TargetExternal.URL != "") +} + +func validatePlay(p *domain.Play) error { + if underPost(p) { + if len(p.Steps) == 0 { + return fmt.Errorf("%w: need replies", domain.ErrValidation) + } + speakers := speakerSet(p) + for _, st := range p.Steps { + if st.AccountID == "" || !speakers[st.AccountID] { + return fmt.Errorf("%w: reply account not in speakers", domain.ErrValidation) + } + if strings.TrimSpace(st.Text) == "" { + return fmt.Errorf("%w: empty step", domain.ErrValidation) + } + } + return nil + } + if p.LeadAccountID == "" { + return fmt.Errorf("%w: need lead", domain.ErrValidation) + } + if len(p.Steps) == 0 { + return fmt.Errorf("%w: need root", domain.ErrValidation) + } + root := p.Steps[0] + for _, st := range p.Steps { + if st.Kind == domain.StepRoot { + root = st + break + } + } + if root.Kind != domain.StepRoot { + return fmt.Errorf("%w: first must root", domain.ErrValidation) + } + if root.AccountID != p.LeadAccountID { + return fmt.Errorf("%w: root must lead", domain.ErrValidation) + } + speakers := speakerSet(p) + for _, st := range p.Steps { + if st.Kind == domain.StepReply { + if !speakers[st.AccountID] { + return fmt.Errorf("%w: reply account not in speakers", domain.ErrValidation) + } + } + } + return nil +} + +func speakerSet(p *domain.Play) map[string]bool { + m := map[string]bool{} + if p.LeadAccountID != "" { + m[p.LeadAccountID] = true + } + for _, c := range p.CastAccountIDs { + if c != "" { + m[c] = true + } + } + return m +} + +func playToOutbox(ownerUID int64, play *domain.Play) *domain.OutboxBundle { + now := domain.NowNano() + // 第一則:送出 Outbox 後立刻可發(忽略過期/過遠的 schedule_start_at 造成「排程中卡住」)。 + // 若明確設了「未來開始時間」且 > now+30s,才把第一則排到那個時間(延後開跑)。 + cursor := now + if play.ScheduleStartAt > now+int64(30*time.Second) { + cursor = play.ScheduleStartAt + } + steps := make([]domain.OutboxStep, 0, len(play.Steps)) + replyTo := "" + if play.TargetExternal != nil { + replyTo = play.TargetExternal.MediaID + } + for i, st := range play.Steps { + if i > 0 { + // 後續:基礎間隔 + 隨機抖動;0 = 與上一則同一排程點(worker 同 tick 依序發) + base := st.DelayFromPreviousSec + if base < 0 { + base = 0 + } + if base > 0 { + cursor += int64(intervalSecWithJitter(base)) * int64(time.Second) + } + } + kind := st.Kind + if underPost(play) { + kind = domain.StepReply + } + steps = append(steps, domain.OutboxStep{ + ID: "obstep_" + uuid.NewString()[:10], StepID: st.ID, SortOrder: st.SortOrder, + Kind: kind, AccountID: st.AccountID, Text: st.Text, + ImageURLs: append([]string(nil), st.ImageURLs...), + Status: domain.StepScheduled, ScheduledAt: cursor, ReplyTo: replyTo, + }) + } + title := play.Title + if title == "" { + title = play.Topic + } + return &domain.OutboxBundle{ + ID: "outbox_" + uuid.NewString()[:12], OwnerUID: ownerUID, PlayID: play.ID, + Title: title, Status: domain.OBScheduling, Steps: steps, + ReplyToMediaID: replyTo, CreatedAt: now, UpdatedAt: now, + } +} + +// intervalSecWithJitter — 在 base 秒上加 ±20% 抖動(至少 ±20s、最多 ±3min),且不低於 15s。 +// 讓互回/串場節奏像真人,不要剛好每 300 秒一則。 +func intervalSecWithJitter(baseSec int) int { + if baseSec < 0 { + baseSec = 0 + } + span := baseSec / 5 // 20% + if span < 20 { + span = 20 + } + if span > 180 { + span = 180 + } + // [base-span, base+span] + delta := 0 + if span > 0 { + delta = cryptoRandIntn(2*span+1) - span + } + out := baseSec + delta + if out < 15 { + out = 15 + } + return out +} + +// cryptoRandIntn — [0, n) 均勻;失敗時退回 0(不抖動) +func cryptoRandIntn(n int) int { + if n <= 1 { + return 0 + } + // 用 crypto/rand 避免 math/rand 可預測 + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return 0 + } + // big-endian uint64 + v := uint64(b[0])<<56 | uint64(b[1])<<48 | uint64(b[2])<<40 | uint64(b[3])<<32 | + uint64(b[4])<<24 | uint64(b[5])<<16 | uint64(b[6])<<8 | uint64(b[7]) + return int(v % uint64(n)) +} + +func findRoot(b *domain.OutboxBundle) *domain.OutboxStep { + for i := range b.Steps { + if b.Steps[i].Kind == domain.StepRoot { + return &b.Steps[i] + } + } + return nil +} + +func recomputeBundle(steps []domain.OutboxStep) string { + if len(steps) == 0 { + return domain.OBCancelled + } + allPub := true + anyFail := false + anyActive := false + for _, s := range steps { + switch s.Status { + case domain.StepPublished: + case domain.StepFailed: + anyFail = true + allPub = false + case domain.StepPublishing: + anyActive = true + allPub = false + case domain.StepBlocked, domain.StepCancelled: + allPub = false + default: + allPub = false + if s.Status == domain.StepScheduled { + // still scheduling + } + } + } + if allPub { + return domain.OBCompleted + } + if anyFail { + return domain.OBPartial + } + if anyActive { + return domain.OBActive + } + return domain.OBScheduling +} + +// cleanupEphemeralImages 刪除發文用暫存圖(temp/*;相容 other/* 舊路徑)。 +// avatar/* 永不刪。best-effort,失敗只記 log。 +func (s *Service) cleanupEphemeralImages(ctx context.Context, urls []string) { + if s.Storage == nil || !s.Storage.Enabled() || len(urls) == 0 { + return + } + for _, u := range urls { + key := s.objectKeyFromPublicURL(u) + if key == "" || !isEphemeralPublishObjectKey(key) { + continue + } + if err := s.Storage.Delete(ctx, key); err != nil { + logx.Errorf("studio cleanup image key=%s: %v", key, err) + } else { + logx.Infof("studio cleaned ephemeral image key=%s", key) + } + } +} + +func isEphemeralPublishObjectKey(key string) bool { + key = strings.TrimPrefix(key, "/") + return strings.HasPrefix(key, "temp/") || strings.HasPrefix(key, "other/") +} + +func (s *Service) objectKeyFromPublicURL(raw string) string { + u := strings.TrimSpace(raw) + if u == "" { + return "" + } + base := strings.TrimRight(strings.TrimSpace(s.StoragePublicBase), "/") + if base != "" { + prefix := base + "/" + if strings.HasPrefix(u, prefix) { + return strings.TrimPrefix(u, prefix) + } + } + // 後備:路徑含 /temp/ 或 /other/ 時取之後片段 + for _, marker := range []string{"/temp/", "/other/"} { + if i := strings.Index(u, marker); i >= 0 { + return strings.TrimPrefix(u[i:], "/") + } + } + return "" +} + +func normalizeURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + u, err := url.Parse(raw) + if err != nil { + return strings.TrimRight(raw, "/") + } + if u.Scheme == "" { + u.Scheme = "https" + } + u.Host = strings.ToLower(u.Host) + u.Fragment = "" + // strip query noise + u.RawQuery = "" + s := u.String() + return strings.TrimRight(s, "/") +} + +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" +} diff --git a/apps/backend/internal/module/studio/usecase/style_analyze.go b/apps/backend/internal/module/studio/usecase/style_analyze.go new file mode 100644 index 0000000..905a4d8 --- /dev/null +++ b/apps/backend/internal/module/studio/usecase/style_analyze.go @@ -0,0 +1,535 @@ +package usecase + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + "unicode/utf8" + + "apps/backend/internal/module/studio/domain" +) + +// 8D keys — 與前端 DIM_ORDER 對齊 +var dimKeys = []string{ + "d1Tone", "d2Structure", "d3Interaction", "d4Topics", + "d5Rhythm", "d6Visual", "d7Conversion", "d8Risk", +} + +func splitSamples(raw string, max int) []string { + parts := regexp.MustCompile(`\n\s*---\s*\n|\n{3,}`).Split(raw, -1) + out := make([]string, 0, max) + for _, p := range parts { + p = strings.TrimSpace(p) + if utf8.RuneCountInString(p) < 8 { + continue + } + out = append(out, p) + if max > 0 && len(out) >= max { + break + } + } + if len(out) == 0 && strings.TrimSpace(raw) != "" { + out = append(out, strings.TrimSpace(raw)) + } + return out +} + +func pickTone(blob string) string { + switch { + case regexp.MustCompile(`哈哈|笑死|哭|靠|真的假的`).MatchString(blob): + return "輕鬆吐槽、情緒外露" + case regexp.MustCompile(`建議|成分|數據|對比|規格`).MatchString(blob): + return "務實、有依據、少廢話" + case regexp.MustCompile(`懂|抱抱|辛苦|没关系|沒關係|一起`).MatchString(blob): + return "共感、溫柔、像朋友" + default: + return "口語親近、不端著" + } +} + +func pickHooks(blob string) string { + switch { + case strings.ContainsAny(blob, "??"): + return "用真心疑問開場,邀請別人補經驗" + case regexp.MustCompile(`有人|大家|求`).MatchString(blob): + return "先丟痛點再求推坑/經驗" + default: + return "先講自己卡關的情境,再拋問題" + } +} + +func pickEvidence(samples []string, max int) []string { + out := make([]string, 0, max) + for _, s := range samples { + r := []rune(s) + if len(r) > 36 { + s = string(r[:36]) + "…" + } + out = append(out, s) + if len(out) >= max { + break + } + } + return out +} + +// serializeDraftText — 與前端 fingerprint 格式一致(【標籤】) +func serializeDraftText(d domain.PersonaDraft) string { + var lines []string + push := func(label, value string) { + v := strings.TrimSpace(value) + if v == "" { + return + } + lines = append(lines, "【"+label+"】\n"+v) + } + push("我是誰", d.Identity) + push("語氣", d.Tone) + push("對誰說", d.Audience) + push("開場鉤子", d.Hooks) + push("語言指紋", d.LanguageFingerprint) + push("節奏", d.Rhythm) + push("標點", d.Punctuation) + push("內容套路", d.ContentPatterns) + push("知識轉譯", d.KnowledgeTranslation) + push("CTA", d.CtaStyle) + push("像他會說的話", d.Examples) + push("絕不怎麼說", d.Avoid) + return strings.Join(lines, "\n\n") +} + +func buildDimensions(draft domain.PersonaDraft, samples []string, dims map[string]domain.StyleDimension) map[string]domain.StyleDimension { + ev := pickEvidence(samples, 2) + out := make(map[string]domain.StyleDimension, 8) + // 規則底稿 + defaults := map[string]string{ + "d1Tone": draft.Tone, + "d2Structure": draft.ContentPatterns, + "d3Interaction": joinNonEmpty(";", draft.Hooks, draft.CtaStyle), + "d4Topics": joinNonEmpty(";", draft.Audience, "常談生活/實用經驗"), + "d5Rhythm": draft.Rhythm, + "d6Visual": draft.Punctuation, + "d7Conversion": draft.CtaStyle, + "d8Risk": draft.Avoid, + } + for _, k := range dimKeys { + sum := "" + var evidence []string + if dims != nil { + if d, ok := dims[k]; ok { + sum = strings.TrimSpace(d.Summary) + evidence = d.Evidence + } + } + if sum == "" { + sum = strings.TrimSpace(defaults[k]) + } + if sum == "" { + sum = "(待補)" + } + if len(evidence) == 0 { + evidence = append([]string(nil), ev...) + } + out[k] = domain.StyleDimension{Summary: sum, Evidence: evidence} + } + return out +} + +func joinNonEmpty(sep string, parts ...string) string { + var ok []string + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + ok = append(ok, p) + } + } + return strings.Join(ok, sep) +} + +func splitAvoidList(avoid string) []string { + var out []string + for _, a := range regexp.MustCompile(`[、,,;;/||]`).Split(avoid, -1) { + a = strings.TrimSpace(a) + if a != "" { + out = append(out, a) + } + } + if len(out) == 0 { + return []string{"硬銷", "客服腔", "條列說教", "AI 腔"} + } + return out +} + +func isPlaceholderName(name string) bool { + n := strings.TrimSpace(name) + if n == "" { + return true + } + // 前端新建預設/測試名 + switch n { + case "新人設", "新身份", "未命名人設", "未命名", "分析", "對標", "新", "New persona", "Untitled": + return true + } + return strings.HasPrefix(n, "人設 ") || strings.HasPrefix(n, "persona") +} + +func shortNameFromIdentity(identity, fallback string) string { + id := strings.TrimSpace(identity) + if id == "" { + return fallback + } + // 取第一句或前 16 字 + if i := strings.IndexAny(id, ",,。;;"); i > 0 && i <= 24 { + id = id[:i] + } + r := []rune(id) + if len(r) > 16 { + id = string(r[:16]) + } + id = strings.TrimSpace(id) + if id == "" { + return fallback + } + return id +} + +// finalizePersonaAnalysis 寫入概要 + 指紋 + 8D(分析完成後一律覆寫可分析欄位) +func finalizePersonaAnalysis( + p *domain.Persona, + draft domain.PersonaDraft, + samples []string, + source, username, sourceLabel, brief, nameHint string, + dims map[string]domain.StyleDimension, +) { + now := domain.NowNano() + p.Status = domain.PersonaReady + p.Style.Source = source + p.Style.BenchmarkUsername = username + p.Style.SourceLabel = sourceLabel + p.Style.SampleCount = len(samples) + p.Style.AnalyzedAt = now + p.Style.SamplePreviews = make([]string, 0, min(5, len(samples))) + for i, s := range samples { + if i >= 5 { + break + } + r := []rune(s) + if len(r) > 100 { + s = string(r[:100]) + "…" + } + p.Style.SamplePreviews = append(p.Style.SamplePreviews, s) + } + p.Style.Draft = draft + p.Style.DraftText = serializeDraftText(draft) + p.Style.Dimensions = buildDimensions(draft, samples, dims) + p.Voice = draft.Tone + + // 概要 brief:分析後一律填(LLM brief 優先) + brief = strings.TrimSpace(brief) + if brief == "" { + brief = joinNonEmpty("。", draft.Identity, draft.Tone+";對「"+draft.Audience+"」說話") + } + p.Brief = brief + + // 名稱:仍是佔位名時用 identity / hint 補上 + if isPlaceholderName(p.Name) { + hint := strings.TrimSpace(nameHint) + if hint == "" { + hint = shortNameFromIdentity(draft.Identity, p.Name) + } + if hint != "" && !isPlaceholderName(hint) { + p.Name = hint + } else if draft.Identity != "" { + p.Name = shortNameFromIdentity(draft.Identity, "寫作人設") + } + } + + // 護欄:分析後覆寫 avoid / 預設字數 / 禁 AI 腔 + p.Guard.BanAiTone = true + if p.Guard.MaxChars == 0 { + p.Guard.MaxChars = 280 + } + if draft.Avoid != "" { + p.Guard.Avoid = splitAvoidList(draft.Avoid) + } else if len(p.Guard.Avoid) == 0 { + p.Guard.Avoid = []string{"硬銷", "客服腔", "條列說教", "AI 腔"} + } + + // notes 放分析來源摘要(不覆寫使用者手動長註?分析後可寫來源) + if source == "benchmark" && username != "" { + p.Notes = fmt.Sprintf("對標 @%s · 樣本 %d 則 · 已寫入概要/8D/指紋", username, len(samples)) + } else if sourceLabel != "" { + p.Notes = fmt.Sprintf("來源:%s · 樣本 %d 則 · 已寫入概要/8D/指紋", sourceLabel, len(samples)) + } + + p.UpdatedAt = now +} + +// applyStyleFromSamples fills persona style from real post samples (中文啟發式,可接 AI 再精修). +func applyStyleFromSamples(p *domain.Persona, samples []string, source string, username, sourceLabel string) { + blob := strings.Join(samples, "\n") + tone := pickTone(blob) + hooks := pickHooks(blob) + + identity := p.Style.Draft.Identity + if identity == "" { + identity = p.Name + } + if isPlaceholderName(identity) { + identity = "生活觀察者" + } + if source == "benchmark" && username != "" { + identity = fmt.Sprintf("像 @%s 的發文者:%s", username, tone) + } + audience := p.Style.Draft.Audience + if audience == "" { + audience = "同溫層、會在 Threads 求經驗的人" + } + langFP := "短句、口語、少成語" + if regexp.MustCompile(`有時候|後來|其實|講真的`).MatchString(blob) { + langFP = "常用「有時候/後來/其實」當轉折" + } + rhythm := "一段講完再補一句問句" + if strings.Contains(blob, "\n") { + rhythm = "2~4 個短段落,段間空行" + } + punct := "逗號與問號為主,少驚嘆" + if strings.Contains(blob, "…") || strings.Contains(blob, "...") { + punct = "愛用省略號與問號" + } + avoid := "硬銷、條列教學、客服腔、假裝中立實則業配、AI 腔" + ex := "" + if len(samples) > 0 { + r := []rune(samples[0]) + if len(r) > 80 { + ex = string(r[:80]) + } else { + ex = samples[0] + } + } + + draft := domain.PersonaDraft{ + Identity: identity, + Tone: tone, + Audience: audience, + Hooks: hooks, + LanguageFingerprint: langFP, + Rhythm: rhythm, + Punctuation: punct, + ContentPatterns: "情境 → 感受或觀察 → 輕問一句", + KnowledgeTranslation: "先講使用情境,再講重點,不丟術語牆", + CtaStyle: "自然邀留言(「你們呢」「有人也…嗎」),不命令", + Examples: ex, + Avoid: avoid, + } + brief := fmt.Sprintf("%s。語氣%s,主要對「%s」說話。", identity, tone, audience) + finalizePersonaAnalysis(p, draft, samples, source, username, sourceLabel, brief, "", nil) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +type aiDimJSON struct { + Summary string `json:"summary"` + Evidence []string `json:"evidence"` +} + +type aiStyleJSON struct { + Identity string `json:"identity"` + Tone string `json:"tone"` + Audience string `json:"audience"` + Hooks string `json:"hooks"` + LanguageFingerprint string `json:"language_fingerprint"` + Rhythm string `json:"rhythm"` + Punctuation string `json:"punctuation"` + ContentPatterns string `json:"content_patterns"` + KnowledgeTranslation string `json:"knowledge_translation"` + CtaStyle string `json:"cta_style"` + Examples string `json:"examples"` + Avoid string `json:"avoid"` + Brief string `json:"brief"` + Name string `json:"name"` + Dimensions map[string]aiDimJSON `json:"dimensions"` + // 扁平 8D 後備 + D1Tone aiDimJSON `json:"d1Tone"` + D2Structure aiDimJSON `json:"d2Structure"` + D3Interaction aiDimJSON `json:"d3Interaction"` + D4Topics aiDimJSON `json:"d4Topics"` + D5Rhythm aiDimJSON `json:"d5Rhythm"` + D6Visual aiDimJSON `json:"d6Visual"` + D7Conversion aiDimJSON `json:"d7Conversion"` + D8Risk aiDimJSON `json:"d8Risk"` +} + +func buildStyleAnalysisPrompt(samples []string, username, sourceLabel string) string { + var b strings.Builder + b.WriteString("你是 Threads 寫作風格分析師。根據下列貼文樣本,用繁體中文(台灣用語)完整分析作者的寫作人設。\n") + if username != "" { + b.WriteString("對標帳號:@") + b.WriteString(username) + b.WriteString("\n") + } + if sourceLabel != "" { + b.WriteString("來源標籤:") + b.WriteString(sourceLabel) + b.WriteString("\n") + } + b.WriteString("\n【貼文樣本】\n") + for i, s := range samples { + b.WriteString(fmt.Sprintf("--- 樣本 %d ---\n%s\n", i+1, s)) + } + b.WriteString(` +【輸出要求】 +只輸出一個 JSON 物件(不要 markdown 代碼塊、不要前言)。必須填滿所有欄位,不可留空字串。 +{ + "name": "簡短人設名稱(4~12 字,不要帳號)", + "brief": "兩到三句定位:是誰、對誰說、核心訊息", + "identity": "發文身份/角色(一句完整描述,不要只寫帳號名)", + "tone": "語氣(具體、可模仿)", + "audience": "主要對誰說話", + "hooks": "常見開場鉤子手法", + "language_fingerprint": "用詞/口頭禪/轉折特徵", + "rhythm": "段落節奏、長短句", + "punctuation": "標點與 emoji 習慣", + "content_patterns": "內容結構模式", + "knowledge_translation": "如何把知識/經驗講成人話", + "cta_style": "如何邀互動/收尾", + "examples": "摘一句最像他的原句或改寫示範", + "avoid": "模仿時要避開的寫法(可用頓號分隔多項)", + "dimensions": { + "d1Tone": { "summary": "D1 語氣人格一句話", "evidence": ["樣本短摘"] }, + "d2Structure": { "summary": "D2 結構模板", "evidence": [] }, + "d3Interaction": { "summary": "D3 互動方式", "evidence": [] }, + "d4Topics": { "summary": "D4 主題分布", "evidence": [] }, + "d5Rhythm": { "summary": "D5 發文節奏", "evidence": [] }, + "d6Visual": { "summary": "D6 視覺語法(換行/emoji)", "evidence": [] }, + "d7Conversion": { "summary": "D7 轉換/邀約方式", "evidence": [] }, + "d8Risk": { "summary": "D8 風險紅線", "evidence": [] } + } +} +`) + return b.String() +} + +func collectDimsFromAI(parsed aiStyleJSON) map[string]domain.StyleDimension { + out := make(map[string]domain.StyleDimension) + put := func(k string, d aiDimJSON) { + sum := strings.TrimSpace(d.Summary) + if sum == "" { + return + } + out[k] = domain.StyleDimension{Summary: sum, Evidence: d.Evidence} + } + if parsed.Dimensions != nil { + for _, k := range dimKeys { + if d, ok := parsed.Dimensions[k]; ok { + put(k, d) + } + } + } + // 扁平後備 + put("d1Tone", parsed.D1Tone) + put("d2Structure", parsed.D2Structure) + put("d3Interaction", parsed.D3Interaction) + put("d4Topics", parsed.D4Topics) + put("d5Rhythm", parsed.D5Rhythm) + put("d6Visual", parsed.D6Visual) + put("d7Conversion", parsed.D7Conversion) + put("d8Risk", parsed.D8Risk) + return out +} + +// applyAIStyleJSON parses LLM JSON into persona style. Returns false if unusable. +func applyAIStyleJSON(p *domain.Persona, raw string, samples []string, source, username, sourceLabel string) bool { + raw = strings.TrimSpace(raw) + if raw == "" { + return false + } + // strip ```json fences if any + if i := strings.Index(raw, "{"); i >= 0 { + if j := strings.LastIndex(raw, "}"); j > i { + raw = raw[i : j+1] + } + } + var parsed aiStyleJSON + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return false + } + // need at least tone or identity + if strings.TrimSpace(parsed.Tone) == "" && strings.TrimSpace(parsed.Identity) == "" { + return false + } + coalesce := func(v, fallback string) string { + v = strings.TrimSpace(v) + if v == "" { + return fallback + } + return v + } + base := p.Style.Draft + draft := domain.PersonaDraft{ + Identity: coalesce(parsed.Identity, base.Identity), + Tone: coalesce(parsed.Tone, base.Tone), + Audience: coalesce(parsed.Audience, base.Audience), + Hooks: coalesce(parsed.Hooks, base.Hooks), + LanguageFingerprint: coalesce(parsed.LanguageFingerprint, base.LanguageFingerprint), + Rhythm: coalesce(parsed.Rhythm, base.Rhythm), + Punctuation: coalesce(parsed.Punctuation, base.Punctuation), + ContentPatterns: coalesce(parsed.ContentPatterns, base.ContentPatterns), + KnowledgeTranslation: coalesce(parsed.KnowledgeTranslation, base.KnowledgeTranslation), + CtaStyle: coalesce(parsed.CtaStyle, base.CtaStyle), + Examples: coalesce(parsed.Examples, base.Examples), + Avoid: coalesce(parsed.Avoid, base.Avoid), + } + // reject empty-shell identity like "有趣人設 · 對標 @x" + if strings.Contains(draft.Identity, "· 對標 @") || strings.Contains(draft.Identity, "· 对标 @") { + if parsed.Tone != "" { + draft.Identity = "像 @" + username + " 的發文者:" + parsed.Tone + } + } + // 補齊仍空的 draft 欄位(規則底稿) + if draft.Audience == "" { + draft.Audience = "同溫層、會在 Threads 求經驗的人" + } + if draft.Hooks == "" { + draft.Hooks = pickHooks(strings.Join(samples, "\n")) + } + if draft.LanguageFingerprint == "" { + draft.LanguageFingerprint = "短句、口語、少成語" + } + if draft.Rhythm == "" { + draft.Rhythm = "2~4 個短段落,段間空行" + } + if draft.Punctuation == "" { + draft.Punctuation = "逗號與問號為主" + } + if draft.ContentPatterns == "" { + draft.ContentPatterns = "情境 → 感受或觀察 → 輕問一句" + } + if draft.KnowledgeTranslation == "" { + draft.KnowledgeTranslation = "先講使用情境,再講重點" + } + if draft.CtaStyle == "" { + draft.CtaStyle = "自然邀留言,不命令" + } + if draft.Avoid == "" { + draft.Avoid = "硬銷、客服腔、AI 腔" + } + if draft.Examples == "" && len(samples) > 0 { + r := []rune(samples[0]) + if len(r) > 80 { + draft.Examples = string(r[:80]) + } else { + draft.Examples = samples[0] + } + } + + dims := collectDimsFromAI(parsed) + finalizePersonaAnalysis(p, draft, samples, source, username, sourceLabel, parsed.Brief, parsed.Name, dims) + return true +} diff --git a/apps/backend/internal/module/threads/domain/account.go b/apps/backend/internal/module/threads/domain/account.go new file mode 100644 index 0000000..9333129 --- /dev/null +++ b/apps/backend/internal/module/threads/domain/account.go @@ -0,0 +1,66 @@ +package domain + +import "time" + +const ( + ConnectionConnected = "connected" + ConnectionError = "error" + ConnectionDisconnected = "disconnected" +) + +// Account is a Threads OAuth binding (tokens server-only). +type Account struct { + ID string `bson:"_id" json:"id"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + Username string `bson:"username" json:"username"` + DisplayName string `bson:"display_name" json:"display_name"` + ThreadsUserID string `bson:"threads_user_id" json:"threads_user_id"` + Connection string `bson:"connection" json:"connection"` + IsUsable bool `bson:"is_usable" json:"is_usable"` + AvatarColor string `bson:"avatar_color,omitempty" json:"avatar_color,omitempty"` + AvatarURL string `bson:"avatar_url,omitempty" json:"avatar_url,omitempty"` + ErrorMessage string `bson:"error_message,omitempty" json:"error_message,omitempty"` + SessionExpiresAt int64 `bson:"session_expires_at,omitempty" json:"session_expires_at,omitempty"` + SessionRefreshedAt int64 `bson:"session_refreshed_at,omitempty" json:"session_refreshed_at,omitempty"` + // Encrypted tokens — never expose via public DTO + AccessTokenEnc string `bson:"access_token_enc,omitempty" json:"-"` + RefreshTokenEnc string `bson:"refresh_token_enc,omitempty" json:"-"` + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` +} + +// OAuthState binds CSRF state to member for callback. +type OAuthState struct { + State string `bson:"_id"` + OwnerUID int64 `bson:"owner_uid"` + CreatedAt int64 `bson:"created_at"` + ExpiresAt int64 `bson:"expires_at"` +} + +// TokenPair from provider exchange (in-memory only). +type TokenPair struct { + AccessToken string + RefreshToken string + ExpiresIn int64 // seconds + UserID string + Username string + DisplayName string + AvatarURL string +} + +func NowNano() int64 { return time.Now().UTC().UnixNano() } + +func AvatarColorFor(username string) string { + colors := []string{"#6dbf7a", "#5b8def", "#e8a54b", "#c77dff", "#f07178"} + if username == "" { + return colors[0] + } + var h int + for _, r := range username { + h = (h*31 + int(r)) % len(colors) + } + if h < 0 { + h = -h + } + return colors[h%len(colors)] +} diff --git a/apps/backend/internal/module/threads/domain/crypto.go b/apps/backend/internal/module/threads/domain/crypto.go new file mode 100644 index 0000000..9182ae3 --- /dev/null +++ b/apps/backend/internal/module/threads/domain/crypto.go @@ -0,0 +1,63 @@ +package domain + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "io" +) + +// Seal encrypts plaintext with AES-GCM derived from secret. +func Seal(secret, plaintext string) (string, error) { + if plaintext == "" { + return "", nil + } + key := sha256.Sum256([]byte(secret)) + block, err := aes.NewCipher(key[:]) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + out := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.RawURLEncoding.EncodeToString(out), nil +} + +// Open decrypts Seal output. +func Open(secret, sealed string) (string, error) { + if sealed == "" { + return "", nil + } + raw, err := base64.RawURLEncoding.DecodeString(sealed) + if err != nil { + return "", err + } + key := sha256.Sum256([]byte(secret)) + block, err := aes.NewCipher(key[:]) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + ns := gcm.NonceSize() + if len(raw) < ns { + return "", fmt.Errorf("ciphertext too short") + } + nonce, ct := raw[:ns], raw[ns:] + pt, err := gcm.Open(nil, nonce, ct, nil) + if err != nil { + return "", err + } + return string(pt), nil +} diff --git a/apps/backend/internal/module/threads/domain/errors.go b/apps/backend/internal/module/threads/domain/errors.go new file mode 100644 index 0000000..b989755 --- /dev/null +++ b/apps/backend/internal/module/threads/domain/errors.go @@ -0,0 +1,13 @@ +package domain + +import "errors" + +var ( + ErrNotFound = errors.New("threads account not found") + ErrForbidden = errors.New("threads account access denied") + ErrInvalidState = errors.New("invalid oauth state") + ErrOAuthDenied = errors.New("oauth denied by user") + ErrOAuthExchange = errors.New("oauth token exchange failed") + ErrRefreshFailed = errors.New("session refresh failed") + ErrNotConnected = errors.New("account not connected") +) diff --git a/apps/backend/internal/module/threads/domain/repository.go b/apps/backend/internal/module/threads/domain/repository.go new file mode 100644 index 0000000..2dd04ed --- /dev/null +++ b/apps/backend/internal/module/threads/domain/repository.go @@ -0,0 +1,29 @@ +package domain + +import "context" + +// Repository persists accounts + oauth states. +type Repository interface { + Insert(ctx context.Context, a *Account) error + Update(ctx context.Context, a *Account) error + FindByID(ctx context.Context, id string) (*Account, error) + // FindByOwnerAndThreadsUser returns existing binding for reconnect upsert. + FindByOwnerAndThreadsUser(ctx context.Context, ownerUID int64, threadsUserID string) (*Account, error) + ListByOwner(ctx context.Context, ownerUID int64) ([]*Account, error) + Delete(ctx context.Context, id string) error + + SaveState(ctx context.Context, st *OAuthState) error + TakeState(ctx context.Context, state string) (*OAuthState, error) // consume once +} + +// Provider abstracts Meta / fake OAuth. +type Provider interface { + // Name for logs + Name() string + // AuthorizeURL builds browser redirect URL for given state. + AuthorizeURL(state, redirectURI string) string + // ExchangeCode trades code for tokens + profile. + ExchangeCode(ctx context.Context, code, redirectURI string) (*TokenPair, error) + // Refresh extends session. + Refresh(ctx context.Context, refreshToken string) (*TokenPair, error) +} diff --git a/apps/backend/internal/module/threads/provider/fake.go b/apps/backend/internal/module/threads/provider/fake.go new file mode 100644 index 0000000..2609708 --- /dev/null +++ b/apps/backend/internal/module/threads/provider/fake.go @@ -0,0 +1,92 @@ +package provider + +import ( + "context" + "fmt" + "net/url" + "strings" + + "apps/backend/internal/module/threads/domain" +) + +// FakeProvider implements OAuth without Meta (dev + tests). +// AuthorizeURL points at callbackURL with code=fake&state=... +type FakeProvider struct { + // CallbackBase is full callback path e.g. http://127.0.0.1:8888/api/v1/threads-accounts/oauth/callback + CallbackBase string + // FailExchange forces TH-04 + FailExchange bool + // FailRefresh forces TH-07 + FailRefresh bool + // FixedUserID when set, Exchange always returns this Threads user id (reconnect tests) + FixedUserID string + // FixedUsername optional with FixedUserID + FixedUsername string +} + +func (f *FakeProvider) Name() string { return "fake" } + +func (f *FakeProvider) AuthorizeURL(state, redirectURI string) string { + base := f.CallbackBase + if base == "" { + base = redirectURI + } + u, err := url.Parse(base) + if err != nil { + return base + "?code=fake&state=" + url.QueryEscape(state) + } + q := u.Query() + q.Set("code", "fake") + q.Set("state", state) + u.RawQuery = q.Encode() + return u.String() +} + +func (f *FakeProvider) ExchangeCode(ctx context.Context, code, redirectURI string) (*domain.TokenPair, error) { + _ = ctx + _ = redirectURI + if f.FailExchange || code == "fail" { + return nil, domain.ErrOAuthExchange + } + if code != "fake" && !strings.HasPrefix(code, "fake") { + // still accept any code in pure fake mode for flexibility + } + id := f.FixedUserID + if id == "" { + id = fmt.Sprintf("fake_%d", domain.NowNano()%1_000_000) + } + user := f.FixedUsername + if user == "" { + user = "threads_" + id + } + return &domain.TokenPair{ + AccessToken: "fake-access-" + id + fmt.Sprintf("_%d", domain.NowNano()%10000), + RefreshToken: "fake-refresh-" + id, + ExpiresIn: 3600 * 24 * 60, // ~60d + UserID: id, + Username: user, + DisplayName: "Threads " + user, + AvatarURL: "", + }, nil +} + +func (f *FakeProvider) Refresh(ctx context.Context, refreshToken string) (*domain.TokenPair, error) { + _ = ctx + if f.FailRefresh || refreshToken == "" || strings.Contains(refreshToken, "bad") { + return nil, domain.ErrRefreshFailed + } + id := "refreshed" + if strings.HasPrefix(refreshToken, "fake-refresh-") { + id = strings.TrimPrefix(refreshToken, "fake-refresh-") + } + return &domain.TokenPair{ + AccessToken: "fake-access-" + id, + RefreshToken: refreshToken, + ExpiresIn: 3600 * 24 * 60, + UserID: id, + Username: "threads_" + id, + DisplayName: "Threads " + id, + }, nil +} + +var _ domain.Provider = (*FakeProvider)(nil) diff --git a/apps/backend/internal/module/threads/provider/media.go b/apps/backend/internal/module/threads/provider/media.go new file mode 100644 index 0000000..b421878 --- /dev/null +++ b/apps/backend/internal/module/threads/provider/media.go @@ -0,0 +1,488 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// RemoteThread — Graph Threads 列表/單則媒體 +type RemoteThread struct { + ID string + Text string + MediaType string + MediaURL string + ThumbnailURL string + Permalink string + Shortcode string + TopicTag string + Username string + Timestamp time.Time +} + +// RemoteInsights — media insights +type RemoteInsights struct { + Views int + Likes int + Replies int + Reposts int + Quotes int + Shares int + Status string // ok | partial | error | skipped + ErrorMsg string +} + +// RemoteReply — conversation / replies under a media +type RemoteReply struct { + ID string + Text string + Username string + Timestamp time.Time + IsMine bool + ParentMediaID string + LikeCount int +} + +// RemoteMention — GET /{user-id}/mentions(threads_manage_mentions) +type RemoteMention struct { + ID string + Text string + Username string + Permalink string + MediaType string + Timestamp time.Time + IsReply bool + IsQuotePost bool + HasReplies bool + RootPostID string + ParentID string +} + +// MediaClient lists own threads + insights + conversation (Meta Graph). +type MediaClient interface { + ListThreads(ctx context.Context, accessToken string, limit int) ([]RemoteThread, error) + GetInsights(ctx context.Context, accessToken, mediaID string) (RemoteInsights, error) + ListConversation(ctx context.Context, accessToken, mediaID string, limit int) ([]RemoteReply, error) + ListMentions(ctx context.Context, accessToken, threadsUserID string, limit int) ([]RemoteMention, error) + // ListProfilePosts — 公開帳號貼文(threads_profile_discovery / profile_posts) + ListProfilePosts(ctx context.Context, accessToken, username string, limit int) ([]RemoteThread, error) +} + +// ListThreads GET /v1.0/me/threads +func (m *MetaProvider) ListThreads(ctx context.Context, accessToken string, limit int) ([]RemoteThread, error) { + if limit <= 0 { + limit = 25 + } + if limit > 50 { + limit = 50 + } + fields := "id,media_type,media_url,permalink,username,text,topic_tag,timestamp,shortcode,thumbnail_url,is_quote_post" + q := url.Values{} + q.Set("fields", fields) + q.Set("limit", strconv.Itoa(limit)) + q.Set("access_token", accessToken) + u := strings.TrimRight(m.GraphBase, "/") + "/v1.0/me/threads?" + q.Encode() + body, err := m.getJSON(ctx, u) + if err != nil { + return nil, err + } + var resp struct { + Data []struct { + ID string `json:"id"` + MediaType string `json:"media_type"` + MediaURL string `json:"media_url"` + Permalink string `json:"permalink"` + Username string `json:"username"` + Text string `json:"text"` + TopicTag string `json:"topic_tag"` + Timestamp string `json:"timestamp"` + Shortcode string `json:"shortcode"` + ThumbnailURL string `json:"thumbnail_url"` + } `json:"data"` + Error *graphErr `json:"error"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("threads list parse: %w", err) + } + if resp.Error != nil { + return nil, resp.Error + } + out := make([]RemoteThread, 0, len(resp.Data)) + for _, d := range resp.Data { + out = append(out, RemoteThread{ + ID: d.ID, Text: d.Text, MediaType: d.MediaType, MediaURL: d.MediaURL, + ThumbnailURL: d.ThumbnailURL, Permalink: d.Permalink, Shortcode: d.Shortcode, + TopicTag: d.TopicTag, Username: d.Username, Timestamp: parseThreadsTime(d.Timestamp), + }) + } + return out, nil +} + +// GetInsights GET /v1.0/{media-id}/insights +func (m *MetaProvider) GetInsights(ctx context.Context, accessToken, mediaID string) (RemoteInsights, error) { + ins := RemoteInsights{Status: "ok"} + if mediaID == "" { + ins.Status = "skipped" + return ins, nil + } + q := url.Values{} + q.Set("metric", "views,likes,replies,reposts,quotes,shares") + q.Set("access_token", accessToken) + u := strings.TrimRight(m.GraphBase, "/") + "/v1.0/" + url.PathEscape(mediaID) + "/insights?" + q.Encode() + body, err := m.getJSON(ctx, u) + if err != nil { + ins.Status = "error" + ins.ErrorMsg = err.Error() + return ins, nil // 不整批失敗 + } + var resp struct { + Data []struct { + Name string `json:"name"` + Values []struct { + Value json.Number `json:"value"` + } `json:"values"` + TotalValue *struct { + Value json.Number `json:"value"` + } `json:"total_value"` + } `json:"data"` + Error *graphErr `json:"error"` + } + if err := json.Unmarshal(body, &resp); err != nil { + ins.Status = "error" + ins.ErrorMsg = err.Error() + return ins, nil + } + if resp.Error != nil { + ins.Status = "error" + ins.ErrorMsg = resp.Error.Error() + return ins, nil + } + for _, d := range resp.Data { + v := 0 + if d.TotalValue != nil { + v, _ = strconv.Atoi(string(d.TotalValue.Value)) + } else if len(d.Values) > 0 { + v, _ = strconv.Atoi(string(d.Values[0].Value)) + } + switch d.Name { + case "views": + ins.Views = v + case "likes": + ins.Likes = v + case "replies": + ins.Replies = v + case "reposts": + ins.Reposts = v + case "quotes": + ins.Quotes = v + case "shares": + ins.Shares = v + } + } + return ins, nil +} + +// ListConversation 先試 /replies(第一層留言),再試 /conversation(含巢狀)。 +// 失敗不整批炸掉,回 empty + 可診斷的 error(由上層 log)。 +func (m *MetaProvider) ListConversation(ctx context.Context, accessToken, mediaID string, limit int) ([]RemoteReply, error) { + if mediaID == "" { + return nil, nil + } + if limit <= 0 { + limit = 30 + } + if limit > 50 { + limit = 50 + } + + // 官方 example 欄位 + username / is_reply_owned_by_me(文件有列) + fields := "id,text,username,timestamp,media_type,has_replies,root_post,replied_to,is_reply,is_reply_owned_by_me,hide_status" + + // 1) top-level replies + replies, err1 := m.fetchReplyEdge(ctx, accessToken, mediaID, "replies", fields, limit) + if len(replies) > 0 { + return normalizeParents(replies, mediaID), nil + } + + // 2) full conversation (nested) + convo, err2 := m.fetchReplyEdge(ctx, accessToken, mediaID, "conversation", fields, limit) + if len(convo) > 0 { + return normalizeParents(convo, mediaID), nil + } + + // 兩者都空:若有錯誤回傳最後一個,方便 log + if err1 != nil { + return nil, err1 + } + if err2 != nil { + return nil, err2 + } + return nil, nil +} + +func (m *MetaProvider) fetchReplyEdge(ctx context.Context, accessToken, mediaID, edge, fields string, limit int) ([]RemoteReply, error) { + q := url.Values{} + q.Set("fields", fields) + q.Set("limit", strconv.Itoa(limit)) + q.Set("reverse", "false") + q.Set("access_token", accessToken) + u := strings.TrimRight(m.GraphBase, "/") + "/v1.0/" + url.PathEscape(mediaID) + "/" + edge + "?" + q.Encode() + body, err := m.getJSON(ctx, u) + if err != nil { + return nil, err + } + var resp struct { + Data []struct { + ID string `json:"id"` + Text string `json:"text"` + Username string `json:"username"` + Timestamp string `json:"timestamp"` + IsReplyOwnedByMe bool `json:"is_reply_owned_by_me"` + IsReply bool `json:"is_reply"` + HideStatus string `json:"hide_status"` + RepliedTo *struct { + ID string `json:"id"` + } `json:"replied_to"` + RootPost *struct { + ID string `json:"id"` + } `json:"root_post"` + } `json:"data"` + Error *graphErr `json:"error"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("threads %s parse: %w", edge, err) + } + if resp.Error != nil { + return nil, resp.Error + } + out := make([]RemoteReply, 0, len(resp.Data)) + for _, d := range resp.Data { + // 略過隱藏/封鎖 + hs := strings.ToUpper(d.HideStatus) + if hs == "HIDDEN" || hs == "BLOCKED" || hs == "RESTRICTED" || hs == "COVERED" { + continue + } + parent := "" + if d.RepliedTo != nil { + parent = d.RepliedTo.ID + } + out = append(out, RemoteReply{ + ID: d.ID, Text: d.Text, Username: d.Username, + Timestamp: parseThreadsTime(d.Timestamp), IsMine: d.IsReplyOwnedByMe, + ParentMediaID: parent, + }) + } + return out, nil +} + +// normalizeParents — 回覆根貼的 replied_to = root media id,對 UI 應視為第一層(parent 空) +func normalizeParents(list []RemoteReply, rootMediaID string) []RemoteReply { + for i := range list { + if list[i].ParentMediaID == rootMediaID || list[i].ParentMediaID == "" { + list[i].ParentMediaID = "" + } + } + return list +} + +type graphErr struct { + Message string `json:"message"` + Type string `json:"type"` + Code int `json:"code"` +} + +func (e *graphErr) Error() string { + if e == nil { + return "graph error" + } + return fmt.Sprintf("threads graph: %s (code %d)", e.Message, e.Code) +} + +func (m *MetaProvider) getJSON(ctx context.Context, fullURL string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + return nil, err + } + cli := m.HTTPClient + if cli == nil { + cli = &http.Client{Timeout: 25 * time.Second} + } + resp, err := cli.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode >= 300 { + var ge struct { + Error *graphErr `json:"error"` + } + _ = json.Unmarshal(body, &ge) + if ge.Error != nil { + return nil, ge.Error + } + return nil, fmt.Errorf("threads graph HTTP %d: %s", resp.StatusCode, truncateBody(string(body), 200)) + } + return body, nil +} + +func parseThreadsTime(s string) time.Time { + s = strings.TrimSpace(s) + if s == "" { + return time.Time{} + } + // 2023-10-17T05:42:03+0000 + layouts := []string{ + "2006-01-02T15:04:05-0700", + "2006-01-02T15:04:05+0000", + time.RFC3339, + } + for _, l := range layouts { + if t, err := time.Parse(l, s); err == nil { + return t.UTC() + } + } + // try fix +0000 → Z + if strings.HasSuffix(s, "+0000") { + if t, err := time.Parse(time.RFC3339, strings.TrimSuffix(s, "+0000")+"Z"); err == nil { + return t.UTC() + } + } + return time.Time{} +} + +// ListProfilePosts GET /v1.0/profile_posts?username=… — 公開個人頁貼文(threads_profile_discovery) +// 用「已連帳」的 access token 即可讀其他公開帳號的貼文,不需 Chrome 爬蟲。 +func (m *MetaProvider) ListProfilePosts(ctx context.Context, accessToken, username string, limit int) ([]RemoteThread, error) { + username = strings.TrimPrefix(strings.TrimSpace(username), "@") + if username == "" { + return nil, fmt.Errorf("username is required") + } + if limit <= 0 { + limit = 12 + } + if limit > 50 { + limit = 50 + } + fields := "id,media_type,media_url,permalink,username,text,topic_tag,timestamp,shortcode,thumbnail_url,is_quote_post" + q := url.Values{} + q.Set("username", username) + q.Set("fields", fields) + q.Set("limit", strconv.Itoa(limit)) + q.Set("access_token", accessToken) + u := strings.TrimRight(m.GraphBase, "/") + "/v1.0/profile_posts?" + q.Encode() + body, err := m.getJSON(ctx, u) + if err != nil { + return nil, err + } + var resp struct { + Data []struct { + ID string `json:"id"` + MediaType string `json:"media_type"` + MediaURL string `json:"media_url"` + Permalink string `json:"permalink"` + Username string `json:"username"` + Text string `json:"text"` + TopicTag string `json:"topic_tag"` + Timestamp string `json:"timestamp"` + Shortcode string `json:"shortcode"` + ThumbnailURL string `json:"thumbnail_url"` + } `json:"data"` + Error *graphErr `json:"error"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("profile_posts parse: %w", err) + } + if resp.Error != nil { + return nil, resp.Error + } + out := make([]RemoteThread, 0, len(resp.Data)) + for _, d := range resp.Data { + if strings.TrimSpace(d.ID) == "" && strings.TrimSpace(d.Text) == "" { + continue + } + out = append(out, RemoteThread{ + ID: d.ID, Text: d.Text, MediaType: d.MediaType, MediaURL: d.MediaURL, + ThumbnailURL: d.ThumbnailURL, Permalink: d.Permalink, Shortcode: d.Shortcode, + TopicTag: d.TopicTag, Username: d.Username, Timestamp: parseThreadsTime(d.Timestamp), + }) + } + return out, nil +} + +// ListMentions GET /v1.0/{user-id|me}/mentions — 別人 @ 你的貼文/回覆/引用 +func (m *MetaProvider) ListMentions(ctx context.Context, accessToken, threadsUserID string, limit int) ([]RemoteMention, error) { + if limit <= 0 { + limit = 25 + } + if limit > 100 { + limit = 100 + } + uid := strings.TrimSpace(threadsUserID) + if uid == "" { + uid = "me" + } + fields := "id,text,username,permalink,timestamp,media_type,is_reply,is_quote_post,has_replies,root_post,parent_id" + q := url.Values{} + q.Set("fields", fields) + q.Set("limit", strconv.Itoa(limit)) + q.Set("access_token", accessToken) + u := strings.TrimRight(m.GraphBase, "/") + "/v1.0/" + url.PathEscape(uid) + "/mentions?" + q.Encode() + body, err := m.getJSON(ctx, u) + if err != nil { + return nil, err + } + var resp struct { + Data []struct { + ID string `json:"id"` + Text string `json:"text"` + Username string `json:"username"` + Permalink string `json:"permalink"` + Timestamp string `json:"timestamp"` + MediaType string `json:"media_type"` + IsReply bool `json:"is_reply"` + IsQuotePost bool `json:"is_quote_post"` + HasReplies bool `json:"has_replies"` + ParentID string `json:"parent_id"` + RootPost *struct { + ID string `json:"id"` + } `json:"root_post"` + } `json:"data"` + Error *graphErr `json:"error"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("mentions parse: %w", err) + } + if resp.Error != nil { + return nil, resp.Error + } + out := make([]RemoteMention, 0, len(resp.Data)) + for _, d := range resp.Data { + if strings.TrimSpace(d.ID) == "" { + continue + } + rootID := "" + if d.RootPost != nil { + rootID = strings.TrimSpace(d.RootPost.ID) + } + out = append(out, RemoteMention{ + ID: d.ID, Text: d.Text, Username: d.Username, Permalink: d.Permalink, + MediaType: d.MediaType, Timestamp: parseThreadsTime(d.Timestamp), + IsReply: d.IsReply, IsQuotePost: d.IsQuotePost, HasReplies: d.HasReplies, + RootPostID: rootID, ParentID: strings.TrimSpace(d.ParentID), + }) + } + return out, nil +} + +// Ensure MetaProvider implements MediaClient. +var _ MediaClient = (*MetaProvider)(nil) diff --git a/apps/backend/internal/module/threads/provider/meta.go b/apps/backend/internal/module/threads/provider/meta.go new file mode 100644 index 0000000..89a67cd --- /dev/null +++ b/apps/backend/internal/module/threads/provider/meta.go @@ -0,0 +1,222 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "apps/backend/internal/module/threads/domain" +) + +// MetaProvider — Meta Threads OAuth (when AppId/Secret configured). +type MetaProvider struct { + AppID string + AppSecret string + HTTPClient *http.Client + AuthBase string // default https://threads.net + GraphBase string // default https://graph.threads.net +} + +func NewMeta(appID, appSecret string) *MetaProvider { + return &MetaProvider{ + AppID: appID, AppSecret: appSecret, + HTTPClient: &http.Client{Timeout: 20 * time.Second}, + AuthBase: "https://threads.net", + GraphBase: "https://graph.threads.net", + } +} + +func (m *MetaProvider) Name() string { return "meta" } + +func (m *MetaProvider) AuthorizeURL(state, redirectURI string) string { + q := url.Values{} + q.Set("client_id", m.AppID) + q.Set("redirect_uri", redirectURI) + // basic + 發文 + 回覆/對話 + insights + 提及 + 公開人設探索(對標帳號 profile_posts) + q.Set("scope", "threads_basic,threads_content_publish,threads_manage_replies,threads_manage_insights,threads_manage_mentions,threads_profile_discovery") + q.Set("response_type", "code") + q.Set("state", state) + return strings.TrimRight(m.AuthBase, "/") + "/oauth/authorize?" + q.Encode() +} + +func (m *MetaProvider) ExchangeCode(ctx context.Context, code, redirectURI string) (*domain.TokenPair, error) { + // 1) code → short-lived token + form := url.Values{} + form.Set("client_id", m.AppID) + form.Set("client_secret", m.AppSecret) + form.Set("grant_type", "authorization_code") + form.Set("redirect_uri", redirectURI) + form.Set("code", code) + endpoint := strings.TrimRight(m.GraphBase, "/") + "/oauth/access_token" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := m.HTTPClient.Do(req) + if err != nil { + return nil, domain.ErrOAuthExchange + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("%w: %s", domain.ErrOAuthExchange, string(body)) + } + var tok struct { + AccessToken string `json:"access_token"` + UserID int64 `json:"user_id"` + ExpiresIn int64 `json:"expires_in"` + } + if err := json.Unmarshal(body, &tok); err != nil || tok.AccessToken == "" { + return nil, domain.ErrOAuthExchange + } + // Meta short-lived 常不回 expires_in(約 1h) + expSec := tok.ExpiresIn + if expSec <= 0 { + expSec = 3600 + } + pair := &domain.TokenPair{ + AccessToken: tok.AccessToken, + ExpiresIn: expSec, + UserID: fmt.Sprintf("%d", tok.UserID), + } + // 2) short-lived → long-lived(之後「延長 token」才能用 th_refresh_token,無需重走授權頁) + if long, lerr := m.exchangeLongLived(ctx, pair.AccessToken); lerr == nil && long != nil { + pair.AccessToken = long.AccessToken + pair.RefreshToken = long.AccessToken // 長效 token 本身就是 refresh 把手 + if long.ExpiresIn > 0 { + pair.ExpiresIn = long.ExpiresIn + } else { + pair.ExpiresIn = 60 * 24 * 3600 // ~60d + } + } else { + // 長效失敗仍回短效,延長 token 可能失敗 → 使用者需重連 + pair.RefreshToken = pair.AccessToken + } + _ = m.fillProfile(ctx, pair) + return pair, nil +} + +// exchangeLongLived: GET /access_token?grant_type=th_exchange_token&client_secret=…&access_token=… +func (m *MetaProvider) exchangeLongLived(ctx context.Context, shortLived string) (*domain.TokenPair, error) { + q := url.Values{} + q.Set("grant_type", "th_exchange_token") + q.Set("client_secret", m.AppSecret) + q.Set("access_token", shortLived) + u := strings.TrimRight(m.GraphBase, "/") + "/access_token?" + q.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + resp, err := m.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("long-lived exchange failed: %s", string(body)) + } + var tok struct { + AccessToken string `json:"access_token"` + ExpiresIn int64 `json:"expires_in"` + TokenType string `json:"token_type"` + } + if err := json.Unmarshal(body, &tok); err != nil || tok.AccessToken == "" { + return nil, fmt.Errorf("long-lived exchange parse: %s", string(body)) + } + return &domain.TokenPair{ + AccessToken: tok.AccessToken, + RefreshToken: tok.AccessToken, + ExpiresIn: tok.ExpiresIn, + }, nil +} + +// Refresh uses the stored long-lived token (not browser re-auth). +// GET /refresh_access_token?grant_type=th_refresh_token&access_token={long-lived} +func (m *MetaProvider) Refresh(ctx context.Context, longLivedToken string) (*domain.TokenPair, error) { + if strings.TrimSpace(longLivedToken) == "" { + return nil, domain.ErrRefreshFailed + } + q := url.Values{} + q.Set("grant_type", "th_refresh_token") + q.Set("access_token", longLivedToken) + u := strings.TrimRight(m.GraphBase, "/") + "/refresh_access_token?" + q.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + resp, err := m.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: network", domain.ErrRefreshFailed) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("%w: %s", domain.ErrRefreshFailed, truncateBody(string(body), 240)) + } + var tok struct { + AccessToken string `json:"access_token"` + ExpiresIn int64 `json:"expires_in"` + } + if err := json.Unmarshal(body, &tok); err != nil || tok.AccessToken == "" { + return nil, fmt.Errorf("%w: empty token", domain.ErrRefreshFailed) + } + // 新長效 token 同時當 access 與下次 refresh 把手 + exp := tok.ExpiresIn + if exp <= 0 { + exp = 60 * 24 * 3600 + } + return &domain.TokenPair{ + AccessToken: tok.AccessToken, + RefreshToken: tok.AccessToken, + ExpiresIn: exp, + }, nil +} + +func truncateBody(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} + +func (m *MetaProvider) fillProfile(ctx context.Context, pair *domain.TokenPair) error { + u := strings.TrimRight(m.GraphBase, "/") + "/me?fields=id,username,name,threads_profile_picture_url&access_token=" + url.QueryEscape(pair.AccessToken) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return err + } + resp, err := m.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + var p struct { + ID string `json:"id"` + Username string `json:"username"` + Name string `json:"name"` + Pic string `json:"threads_profile_picture_url"` + } + if err := json.Unmarshal(body, &p); err != nil { + return err + } + if p.ID != "" { + pair.UserID = p.ID + } + pair.Username = p.Username + pair.DisplayName = p.Name + if pair.DisplayName == "" { + pair.DisplayName = p.Username + } + pair.AvatarURL = p.Pic + return nil +} + +var _ domain.Provider = (*MetaProvider)(nil) diff --git a/apps/backend/internal/module/threads/repository/memory.go b/apps/backend/internal/module/threads/repository/memory.go new file mode 100644 index 0000000..f0a7f71 --- /dev/null +++ b/apps/backend/internal/module/threads/repository/memory.go @@ -0,0 +1,115 @@ +package repository + +import ( + "context" + "sync" + + "apps/backend/internal/module/threads/domain" +) + +// MemoryStore for unit tests. +type MemoryStore struct { + mu sync.Mutex + byID map[string]*domain.Account + state map[string]*domain.OAuthState +} + +func NewMemory() *MemoryStore { + return &MemoryStore{ + byID: map[string]*domain.Account{}, + state: map[string]*domain.OAuthState{}, + } +} + +func (s *MemoryStore) Insert(_ context.Context, a *domain.Account) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *a + s.byID[a.ID] = &cp + return nil +} + +func (s *MemoryStore) Update(_ context.Context, a *domain.Account) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.byID[a.ID]; !ok { + return domain.ErrNotFound + } + cp := *a + s.byID[a.ID] = &cp + return nil +} + +func (s *MemoryStore) FindByID(_ context.Context, id string) (*domain.Account, error) { + s.mu.Lock() + defer s.mu.Unlock() + a, ok := s.byID[id] + if !ok { + return nil, domain.ErrNotFound + } + cp := *a + return &cp, nil +} + +func (s *MemoryStore) FindByOwnerAndThreadsUser(_ context.Context, ownerUID int64, threadsUserID string) (*domain.Account, error) { + s.mu.Lock() + defer s.mu.Unlock() + if threadsUserID == "" { + return nil, domain.ErrNotFound + } + for _, a := range s.byID { + if a.OwnerUID == ownerUID && a.ThreadsUserID == threadsUserID { + cp := *a + return &cp, nil + } + } + return nil, domain.ErrNotFound +} + +func (s *MemoryStore) ListByOwner(_ context.Context, ownerUID int64) ([]*domain.Account, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*domain.Account + for _, a := range s.byID { + if a.OwnerUID == ownerUID { + cp := *a + out = append(out, &cp) + } + } + return out, nil +} + +func (s *MemoryStore) Delete(_ context.Context, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.byID[id]; !ok { + return domain.ErrNotFound + } + delete(s.byID, id) + return nil +} + +func (s *MemoryStore) SaveState(_ context.Context, st *domain.OAuthState) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *st + s.state[st.State] = &cp + return nil +} + +func (s *MemoryStore) TakeState(_ context.Context, state string) (*domain.OAuthState, error) { + s.mu.Lock() + defer s.mu.Unlock() + st, ok := s.state[state] + if !ok { + return nil, domain.ErrInvalidState + } + delete(s.state, state) + if st.ExpiresAt > 0 && domain.NowNano() > st.ExpiresAt { + return nil, domain.ErrInvalidState + } + cp := *st + return &cp, nil +} + +var _ domain.Repository = (*MemoryStore)(nil) diff --git a/apps/backend/internal/module/threads/repository/mongo.go b/apps/backend/internal/module/threads/repository/mongo.go new file mode 100644 index 0000000..f3bdbca --- /dev/null +++ b/apps/backend/internal/module/threads/repository/mongo.go @@ -0,0 +1,119 @@ +package repository + +import ( + "context" + "time" + + libmongo "apps/backend/internal/lib/mongo" + "apps/backend/internal/module/threads/domain" + + "github.com/zeromicro/go-zero/core/stores/mon" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + colAccounts = "threads_accounts" + colStates = "threads_oauth_states" +) + +// MonStore uses go-zero mon (no entity cache for tokens). +type MonStore struct { + accounts *mon.Model + states *mon.Model +} + +func NewMonStore(uri, database string) *MonStore { + uri = libmongo.MustMongoURI(uri) + return &MonStore{ + accounts: mon.MustNewModel(uri, database, colAccounts), + states: mon.MustNewModel(uri, database, colStates), + } +} + +func (s *MonStore) Insert(ctx context.Context, a *domain.Account) error { + _, err := s.accounts.InsertOne(ctx, a) + return err +} + +func (s *MonStore) Update(ctx context.Context, a *domain.Account) error { + a.UpdatedAt = domain.NowNano() + res, err := s.accounts.ReplaceOne(ctx, bson.M{"_id": a.ID}, a) + if err != nil { + return err + } + if res.MatchedCount == 0 { + return domain.ErrNotFound + } + return nil +} + +func (s *MonStore) FindByID(ctx context.Context, id string) (*domain.Account, error) { + var a domain.Account + err := s.accounts.FindOne(ctx, &a, bson.M{"_id": id}) + if err != nil { + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + return nil, err + } + return &a, nil +} + +func (s *MonStore) FindByOwnerAndThreadsUser(ctx context.Context, ownerUID int64, threadsUserID string) (*domain.Account, error) { + if threadsUserID == "" { + return nil, domain.ErrNotFound + } + var a domain.Account + err := s.accounts.FindOne(ctx, &a, bson.M{"owner_uid": ownerUID, "threads_user_id": threadsUserID}) + if err != nil { + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + return nil, err + } + return &a, nil +} + +func (s *MonStore) ListByOwner(ctx context.Context, ownerUID int64) ([]*domain.Account, error) { + var list []*domain.Account + err := s.accounts.Find(ctx, &list, bson.M{"owner_uid": ownerUID}, + options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})) + if err != nil { + return nil, err + } + return list, nil +} + +func (s *MonStore) Delete(ctx context.Context, id string) error { + res, err := s.accounts.DeleteOne(ctx, bson.M{"_id": id}) + if err != nil { + return err + } + if res == 0 { + return domain.ErrNotFound + } + return nil +} + +func (s *MonStore) SaveState(ctx context.Context, st *domain.OAuthState) error { + _, err := s.states.InsertOne(ctx, st) + return err +} + +func (s *MonStore) TakeState(ctx context.Context, state string) (*domain.OAuthState, error) { + var st domain.OAuthState + err := s.states.FindOneAndDelete(ctx, &st, bson.M{"_id": state}) + if err != nil { + if err == mon.ErrNotFound { + return nil, domain.ErrInvalidState + } + return nil, err + } + if st.ExpiresAt > 0 && time.Now().UTC().UnixNano() > st.ExpiresAt { + return nil, domain.ErrInvalidState + } + return &st, nil +} + +var _ domain.Repository = (*MonStore)(nil) diff --git a/apps/backend/internal/module/threads/usecase/service.go b/apps/backend/internal/module/threads/usecase/service.go new file mode 100644 index 0000000..d268e8a --- /dev/null +++ b/apps/backend/internal/module/threads/usecase/service.go @@ -0,0 +1,324 @@ +package usecase + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "time" + + "apps/backend/internal/module/threads/domain" + + "github.com/google/uuid" +) + +// TokenRenewScheduler schedules delayed threads_token_renew jobs (optional). +type TokenRenewScheduler interface { + ScheduleTokenRenew(ctx context.Context, ownerUID int64, accountID string, runAfter int64) error +} + +// Service implements Threads OAuth + account lifecycle. +type Service struct { + Repo domain.Repository + Provider domain.Provider + TokenSecret string + // APIPublicBase for building fake authorize → callback (e.g. http://127.0.0.1:8888) + APIPublicBase string + // CallbackPath relative or absolute redirect_uri registered with Meta + CallbackPath string // default /api/v1/threads-accounts/oauth/callback + // WebBase for post-callback browser redirect + WebBase string + // Renew schedules worker job ~30d after bind (optional) + Renew TokenRenewScheduler +} + +func New(repo domain.Repository, provider domain.Provider, tokenSecret string) *Service { + return &Service{ + Repo: repo, Provider: provider, TokenSecret: tokenSecret, + CallbackPath: "/api/v1/threads-accounts/oauth/callback", + } +} + +func (s *Service) callbackURI() string { + if s.CallbackPath == "" { + s.CallbackPath = "/api/v1/threads-accounts/oauth/callback" + } + if len(s.CallbackPath) > 0 && s.CallbackPath[0] == '/' { + base := trimSlash(s.APIPublicBase) + if base == "" { + return s.CallbackPath + } + return base + s.CallbackPath + } + return s.CallbackPath +} + +func trimSlash(s string) string { + for len(s) > 0 && s[len(s)-1] == '/' { + s = s[:len(s)-1] + } + return s +} + +func randomState() string { + var b [16]byte + _, _ = rand.Read(b[:]) + return hex.EncodeToString(b[:]) +} + +// OAuthStart — TH-01 +func (s *Service) OAuthStart(ctx context.Context, ownerUID int64) (authorizeURL, state string, err error) { + if ownerUID <= 0 { + return "", "", domain.ErrForbidden + } + state = randomState() + now := domain.NowNano() + st := &domain.OAuthState{ + State: state, OwnerUID: ownerUID, + CreatedAt: now, ExpiresAt: now + int64(15*time.Minute), + } + if err := s.Repo.SaveState(ctx, st); err != nil { + return "", "", err + } + uri := s.callbackURI() + return s.Provider.AuthorizeURL(state, uri), state, nil +} + +// OAuthCallback — TH-02 / TH-03 / TH-04 +func (s *Service) OAuthCallback(ctx context.Context, code, state, oauthErr string) (*domain.Account, error) { + if oauthErr != "" { + return nil, domain.ErrOAuthDenied + } + if state == "" { + return nil, domain.ErrInvalidState + } + st, err := s.Repo.TakeState(ctx, state) + if err != nil { + return nil, err + } + if code == "" { + return nil, domain.ErrOAuthExchange + } + tok, err := s.Provider.ExchangeCode(ctx, code, s.callbackURI()) + if err != nil { + return nil, domain.ErrOAuthExchange + } + accEnc, err := domain.Seal(s.TokenSecret, tok.AccessToken) + if err != nil { + return nil, err + } + refEnc, err := domain.Seal(s.TokenSecret, tok.RefreshToken) + if err != nil { + return nil, err + } + if tok.RefreshToken == "" { + // long-lived access doubles as refresh handle + refEnc, _ = domain.Seal(s.TokenSecret, tok.AccessToken) + } + now := domain.NowNano() + exp := expiresAtFromSeconds(now, tok.ExpiresIn) + username := tok.Username + if username == "" { + username = "user_" + tok.UserID + } + display := tok.DisplayName + if display == "" { + display = username + } + + // Same Threads user under same member → overwrite session (do not create duplicates). + if tok.UserID != "" { + if existing, ferr := s.Repo.FindByOwnerAndThreadsUser(ctx, st.OwnerUID, tok.UserID); ferr == nil && existing != nil { + existing.Username = username + existing.DisplayName = display + existing.ThreadsUserID = tok.UserID + existing.Connection = domain.ConnectionConnected + existing.IsUsable = true + existing.ErrorMessage = "" + existing.AvatarColor = domain.AvatarColorFor(username) + if tok.AvatarURL != "" { + existing.AvatarURL = tok.AvatarURL + } + existing.SessionExpiresAt = exp + existing.SessionRefreshedAt = now + existing.AccessTokenEnc = accEnc + existing.RefreshTokenEnc = refEnc + existing.UpdatedAt = now + if err := s.Repo.Update(ctx, existing); err != nil { + return nil, err + } + s.scheduleRenew(ctx, existing) + return existing, nil + } + } + + a := &domain.Account{ + ID: uuid.NewString(), OwnerUID: st.OwnerUID, + Username: username, DisplayName: display, ThreadsUserID: tok.UserID, + Connection: domain.ConnectionConnected, IsUsable: true, + AvatarColor: domain.AvatarColorFor(username), AvatarURL: tok.AvatarURL, + SessionExpiresAt: exp, SessionRefreshedAt: now, + AccessTokenEnc: accEnc, RefreshTokenEnc: refEnc, + CreatedAt: now, UpdatedAt: now, + } + if err := s.Repo.Insert(ctx, a); err != nil { + return nil, err + } + s.scheduleRenew(ctx, a) + return a, nil +} + +func (s *Service) scheduleRenew(ctx context.Context, a *domain.Account) { + if s == nil || s.Renew == nil || a == nil { + return + } + // 約 30 天後 renew(長效 token ~60 天;若到期更近則提前) + runAfter := domain.NowNano() + int64(30*24*time.Hour) + if a.SessionExpiresAt > 0 { + // 到期前 30 天;若不足 7 天則到期前 1 天 + early := a.SessionExpiresAt - int64(30*24*time.Hour) + minLead := a.SessionExpiresAt - int64(7*24*time.Hour) + if early > domain.NowNano() { + runAfter = early + } else if minLead > domain.NowNano() { + runAfter = minLead + } else { + runAfter = domain.NowNano() + int64(time.Hour) // 幾乎到期,盡快 + } + } + _ = s.Renew.ScheduleTokenRenew(ctx, a.OwnerUID, a.ID, runAfter) +} + +// List — TH-05 +func (s *Service) List(ctx context.Context, ownerUID int64) ([]*domain.Account, error) { + return s.Repo.ListByOwner(ctx, ownerUID) +} + +// Get returns account by id (no ownership check — caller enforces). +func (s *Service) Get(ctx context.Context, accountID string) (*domain.Account, error) { + return s.Repo.FindByID(ctx, accountID) +} + +// AccessToken decrypts stored access token for publish transport. +func (s *Service) AccessToken(_ context.Context, a *domain.Account) (string, error) { + if a == nil { + return "", domain.ErrNotFound + } + return domain.Open(s.TokenSecret, a.AccessTokenEnc) +} + +// Refresh — TH-06 / TH-07 / TH-10 +// 用庫裡已存的長效 token 向 Threads 換新 token(不開授權頁、不重新 OAuth)。 +// 若 token 已失效無法 refresh,標記 error,需使用者再按「連帳」走授權。 +func (s *Service) Refresh(ctx context.Context, ownerUID int64, accountID string) (*domain.Account, error) { + a, err := s.Repo.FindByID(ctx, accountID) + if err != nil { + return nil, err + } + if a.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + // 優先用 refresh 把手;沒有則退回 access(兩者在長效模型下通常相同) + ref, err := domain.Open(s.TokenSecret, a.RefreshTokenEnc) + if err != nil || ref == "" { + ref, err = domain.Open(s.TokenSecret, a.AccessTokenEnc) + } + if err != nil || ref == "" { + a.Connection = domain.ConnectionError + a.IsUsable = false + a.ErrorMessage = "no stored token — reconnect account" + _ = s.Repo.Update(ctx, a) + return a, domain.ErrRefreshFailed + } + tok, err := s.Provider.Refresh(ctx, ref) + if err != nil { + a.Connection = domain.ConnectionError + a.IsUsable = false + a.ErrorMessage = "token refresh failed — reconnect account" + _ = s.Repo.Update(ctx, a) + return a, domain.ErrRefreshFailed + } + a.AccessTokenEnc, _ = domain.Seal(s.TokenSecret, tok.AccessToken) + // 長效 refresh:新 token 同時當下次 refresh 把手 + handle := tok.RefreshToken + if handle == "" { + handle = tok.AccessToken + } + a.RefreshTokenEnc, _ = domain.Seal(s.TokenSecret, handle) + now := domain.NowNano() + a.SessionRefreshedAt = now + a.SessionExpiresAt = expiresAtFromSeconds(now, tok.ExpiresIn) + a.Connection = domain.ConnectionConnected + a.IsUsable = true + a.ErrorMessage = "" + if tok.Username != "" { + a.Username = tok.Username + } + if tok.DisplayName != "" { + a.DisplayName = tok.DisplayName + } + a.UpdatedAt = now + if err := s.Repo.Update(ctx, a); err != nil { + return nil, err + } + return a, nil +} + +// Disconnect — TH-08 / TH-10:真刪綁定(列表不再出現) +func (s *Service) Disconnect(ctx context.Context, ownerUID int64, accountID string) error { + a, err := s.Repo.FindByID(ctx, accountID) + if err != nil { + return err + } + if a.OwnerUID != ownerUID { + return domain.ErrForbidden + } + return s.Repo.Delete(ctx, accountID) +} + +// WebRedirect builds FE redirect after callback. +func (s *Service) WebRedirect(ok bool, msg string) string { + base := trimSlash(s.WebBase) + if base == "" { + base = "http://127.0.0.1:5173" + } + if ok { + return base + "/app/crew?oauth=ok" + } + if msg == "" { + msg = "error" + } + return fmt.Sprintf("%s/app/crew?oauth=error&msg=%s", base, urlQueryEscape(msg)) +} + +// expiresAtFromSeconds: nowUnixNano + expiresInSec → absolute unix nanoseconds. +// expiresInSec<=0 → 預設 60 天(Threads 長效常見)。 +func expiresAtFromSeconds(nowNano, expiresInSec int64) int64 { + const nsPerSec = int64(1_000_000_000) + if expiresInSec <= 0 { + expiresInSec = 60 * 24 * 3600 + } + // 防呆:若誤傳毫秒級(> 10 年以秒計)當毫秒 + if expiresInSec > 10*365*24*3600 { + expiresInSec = expiresInSec / 1000 + } + return nowNano + expiresInSec*nsPerSec +} + +func urlQueryEscape(s string) string { + // minimal escape + r := make([]byte, 0, len(s)*2) + for i := 0; i < len(s); i++ { + c := s[i] + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' { + r = append(r, c) + } else if c == ' ' { + r = append(r, '+') + } else { + r = append(r, '%') + const hex = "0123456789ABCDEF" + r = append(r, hex[c>>4], hex[c&15]) + } + } + return string(r) +} diff --git a/apps/backend/internal/module/threads/usecase/service_test.go b/apps/backend/internal/module/threads/usecase/service_test.go new file mode 100644 index 0000000..ac0c061 --- /dev/null +++ b/apps/backend/internal/module/threads/usecase/service_test.go @@ -0,0 +1,170 @@ +package usecase_test + +import ( + "context" + "testing" + + "apps/backend/internal/module/threads/domain" + "apps/backend/internal/module/threads/provider" + "apps/backend/internal/module/threads/repository" + "apps/backend/internal/module/threads/usecase" + + "github.com/stretchr/testify/require" +) + +func newThreadsSvc(failEx, failRef bool) (*usecase.Service, *repository.MemoryStore, *provider.FakeProvider) { + store := repository.NewMemory() + fake := &provider.FakeProvider{ + CallbackBase: "http://127.0.0.1:8888/api/v1/threads-accounts/oauth/callback", + FailExchange: failEx, + FailRefresh: failRef, + } + svc := usecase.New(store, fake, "test-secret-key") + svc.APIPublicBase = "http://127.0.0.1:8888" + svc.WebBase = "http://127.0.0.1:5173" + return svc, store, fake +} + +func TestTH_01_OAuthStart(t *testing.T) { + svc, _, _ := newThreadsSvc(false, false) + url, state, err := svc.OAuthStart(context.Background(), 1_000_001) + require.NoError(t, err) + require.NotEmpty(t, state) + require.Contains(t, url, "state=") + require.Contains(t, url, "code=fake") +} + +func TestTH_02_CallbackSuccess(t *testing.T) { + svc, store, _ := newThreadsSvc(false, false) + uid := int64(1_000_002) + _, state, err := svc.OAuthStart(context.Background(), uid) + require.NoError(t, err) + acc, err := svc.OAuthCallback(context.Background(), "fake", state, "") + require.NoError(t, err) + require.Equal(t, domain.ConnectionConnected, acc.Connection) + require.True(t, acc.IsUsable) + require.Equal(t, uid, acc.OwnerUID) + require.NotEmpty(t, acc.AccessTokenEnc) + // token not equal plaintext in public sense + require.NotContains(t, acc.AccessTokenEnc, "fake-access") + list, err := store.ListByOwner(context.Background(), uid) + require.NoError(t, err) + require.Len(t, list, 1) +} + +func TestTH_03_UserDenied(t *testing.T) { + svc, _, _ := newThreadsSvc(false, false) + _, state, err := svc.OAuthStart(context.Background(), 1_000_003) + require.NoError(t, err) + _, err = svc.OAuthCallback(context.Background(), "", state, "access_denied") + require.ErrorIs(t, err, domain.ErrOAuthDenied) +} + +func TestTH_04_TokenExchangeFail(t *testing.T) { + svc, _, _ := newThreadsSvc(true, false) + _, state, err := svc.OAuthStart(context.Background(), 1_000_004) + require.NoError(t, err) + _, err = svc.OAuthCallback(context.Background(), "fake", state, "") + require.ErrorIs(t, err, domain.ErrOAuthExchange) +} + +func TestTH_05_List(t *testing.T) { + svc, _, _ := newThreadsSvc(false, false) + uid := int64(1_000_005) + _, st, _ := svc.OAuthStart(context.Background(), uid) + _, err := svc.OAuthCallback(context.Background(), "fake", st, "") + require.NoError(t, err) + list, err := svc.List(context.Background(), uid) + require.NoError(t, err) + require.Len(t, list, 1) + require.True(t, list[0].SessionExpiresAt > 0) +} + +func TestTH_06_RefreshOk(t *testing.T) { + svc, _, _ := newThreadsSvc(false, false) + uid := int64(1_000_006) + _, st, _ := svc.OAuthStart(context.Background(), uid) + acc, err := svc.OAuthCallback(context.Background(), "fake", st, "") + require.NoError(t, err) + out, err := svc.Refresh(context.Background(), uid, acc.ID) + require.NoError(t, err) + require.True(t, out.IsUsable) + require.Equal(t, domain.ConnectionConnected, out.Connection) + require.True(t, out.SessionRefreshedAt >= acc.SessionRefreshedAt) +} + +func TestTH_07_RefreshFail(t *testing.T) { + svc, store, fake := newThreadsSvc(false, false) + uid := int64(1_000_007) + _, st, _ := svc.OAuthStart(context.Background(), uid) + acc, err := svc.OAuthCallback(context.Background(), "fake", st, "") + require.NoError(t, err) + fake.FailRefresh = true + // poison refresh token + acc.RefreshTokenEnc, _ = domain.Seal("test-secret-key", "bad-token") + require.NoError(t, store.Update(context.Background(), acc)) + out, err := svc.Refresh(context.Background(), uid, acc.ID) + require.ErrorIs(t, err, domain.ErrRefreshFailed) + require.False(t, out.IsUsable) + require.Equal(t, domain.ConnectionError, out.Connection) +} + +func TestTH_08_Disconnect(t *testing.T) { + svc, _, _ := newThreadsSvc(false, false) + uid := int64(1_000_008) + _, st, _ := svc.OAuthStart(context.Background(), uid) + acc, err := svc.OAuthCallback(context.Background(), "fake", st, "") + require.NoError(t, err) + require.NoError(t, svc.Disconnect(context.Background(), uid, acc.ID)) + list, err := svc.List(context.Background(), uid) + require.NoError(t, err) + require.Empty(t, list) +} + +func TestTH_11_ReconnectUpsertsSession(t *testing.T) { + svc, _, fake := newThreadsSvc(false, false) + fake.FixedUserID = "threads_uid_fixed" + fake.FixedUsername = "harbor_main" + uid := int64(1_000_012) + + _, st1, _ := svc.OAuthStart(context.Background(), uid) + acc1, err := svc.OAuthCallback(context.Background(), "fake", st1, "") + require.NoError(t, err) + oldTok := acc1.AccessTokenEnc + + _, st2, _ := svc.OAuthStart(context.Background(), uid) + acc2, err := svc.OAuthCallback(context.Background(), "fake", st2, "") + require.NoError(t, err) + require.Equal(t, acc1.ID, acc2.ID, "same Threads user must keep same account id") + require.NotEqual(t, oldTok, acc2.AccessTokenEnc, "session tokens must be refreshed") + require.True(t, acc2.IsUsable) + require.Equal(t, domain.ConnectionConnected, acc2.Connection) + + list, err := svc.List(context.Background(), uid) + require.NoError(t, err) + require.Len(t, list, 1) +} + +func TestTH_09_RequiresCallbackNotBypass(t *testing.T) { + // Live completion requires OAuthCallback path (has encrypted token). + svc, _, _ := newThreadsSvc(false, false) + uid := int64(1_000_009) + _, st, _ := svc.OAuthStart(context.Background(), uid) + acc, err := svc.OAuthCallback(context.Background(), "fake", st, "") + require.NoError(t, err) + require.NotEmpty(t, acc.AccessTokenEnc) + require.True(t, acc.IsUsable) +} + +func TestTH_10_CrossUidDenied(t *testing.T) { + svc, _, _ := newThreadsSvc(false, false) + uidA := int64(1_000_010) + uidB := int64(1_000_011) + _, st, _ := svc.OAuthStart(context.Background(), uidA) + acc, err := svc.OAuthCallback(context.Background(), "fake", st, "") + require.NoError(t, err) + _, err = svc.Refresh(context.Background(), uidB, acc.ID) + require.ErrorIs(t, err, domain.ErrForbidden) + err = svc.Disconnect(context.Background(), uidB, acc.ID) + require.ErrorIs(t, err, domain.ErrForbidden) +} diff --git a/apps/backend/internal/module/usage/domain/repository.go b/apps/backend/internal/module/usage/domain/repository.go new file mode 100644 index 0000000..ff1d6e8 --- /dev/null +++ b/apps/backend/internal/module/usage/domain/repository.go @@ -0,0 +1,15 @@ +package domain + +import "context" + +type Repository interface { + InsertEvent(ctx context.Context, e *Event) error + ListEvents(ctx context.Context, uid int64, monthKey, keyMode string, limit int) ([]*Event, error) + ListEventsAll(ctx context.Context, monthKey string, limit int) ([]*Event, error) + + GetPrefs(ctx context.Context, uid int64) (*MemberPrefs, error) + SavePrefs(ctx context.Context, p *MemberPrefs) error + + InsertPurchase(ctx context.Context, p *Purchase) error + ListPurchases(ctx context.Context, uid int64, limit int) ([]*Purchase, error) +} diff --git a/apps/backend/internal/module/usage/domain/usage.go b/apps/backend/internal/module/usage/domain/usage.go new file mode 100644 index 0000000..16cb5b3 --- /dev/null +++ b/apps/backend/internal/module/usage/domain/usage.go @@ -0,0 +1,125 @@ +package domain + +import ( + "errors" + "time" +) + +const ( + KeyModePlatform = "platform" + KeyModeByok = "byok" + + MeterAICopy = "ai_copy" + MeterAIResearch = "ai_research" + MeterWebSearch = "web_search" + MeterAIImage = "ai_image" + + PlanFree = "free" + PlanStarter = "starter" + PlanPro = "pro" +) + +var ( + ErrNotFound = errors.New("usage not found") + ErrForbidden = errors.New("usage forbidden") + ErrNoKey = errors.New("no api key configured (platform or byok)") + ErrQuotaExceeded = errors.New("platform credits insufficient") + ErrInvalidPlan = errors.New("invalid plan") +) + +type Event struct { + ID string `bson:"_id" json:"id"` + UID int64 `bson:"uid" json:"uid"` + Meter string `bson:"meter" json:"meter"` + Credits int `bson:"credits" json:"credits"` // platform only; byok always 0 + KeyMode string `bson:"key_mode" json:"key_mode"` + Label string `bson:"label" json:"label"` + Source string `bson:"source" json:"source"` + MonthKey string `bson:"month_key" json:"month_key"` + CreatedAt int64 `bson:"created_at" json:"created_at"` +} + +type MemberPrefs struct { + UID int64 `bson:"uid" json:"uid"` + PlanID string `bson:"plan_id" json:"plan_id"` + Unlimited bool `bson:"unlimited" json:"unlimited"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` +} + +type Purchase struct { + ID string `bson:"_id" json:"id"` + UID int64 `bson:"uid" json:"uid"` + PlanID string `bson:"plan_id" json:"plan_id"` + MockRef string `bson:"mock_ref,omitempty" json:"mock_ref,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` +} + +type PlanDef struct { + ID string + MonthlyCredits int +} + +var Plans = map[string]PlanDef{ + PlanFree: {ID: PlanFree, MonthlyCredits: 80}, + PlanStarter: {ID: PlanStarter, MonthlyCredits: 500}, + PlanPro: {ID: PlanPro, MonthlyCredits: 2000}, +} + +var DefaultCreditCost = map[string]int{ + MeterAICopy: 1, MeterAIResearch: 3, MeterWebSearch: 1, MeterAIImage: 5, +} + +func NowNano() int64 { return time.Now().UTC().UnixNano() } + +func MonthKey(nano int64) string { + t := time.Unix(0, nano).UTC() + return t.Format("2006-01") +} + +func CurrentMonthKey() string { return MonthKey(NowNano()) } + +// Summary split — never merge platform+byok into one total_calls only. +type MeterCount struct { + Count int `json:"count"` + Credits int `json:"credits"` +} + +type PlatformBlock struct { + CreditsUsed int `json:"credits_used"` + CreditsRemaining int `json:"credits_remaining"` + CreditsTotal int `json:"credits_total"` + CallCount int `json:"call_count"` + ByMeter map[string]MeterCount `json:"by_meter"` +} + +type ByokBlock struct { + CallCount int `json:"call_count"` + ByMeter map[string]MeterCount `json:"by_meter"` +} + +type MonthSummary struct { + MonthKey string `json:"month_key"` + UID int64 `json:"uid"` + PlanID string `json:"plan_id"` + Unlimited bool `json:"unlimited"` + Platform PlatformBlock `json:"platform"` + Byok ByokBlock `json:"byok"` + // Legacy FE bars: only platform credits (not mixed) + TotalCredits int `json:"total_credits"` + RemainingCredits int `json:"remaining_credits"` +} + +type TenantRow struct { + UID int64 `json:"uid"` + PlanID string `json:"plan_id"` + Unlimited bool `json:"unlimited"` + PlatformCreditsUsed int `json:"platform_credits_used"` + ByokCallCount int `json:"byok_call_count"` +} + +type TenantSummary struct { + MonthKey string `json:"month_key"` + PlatformCreditsUsed int `json:"platform_credits_used"` + ByokCallCount int `json:"byok_call_count"` + Members []TenantRow `json:"members"` +} diff --git a/apps/backend/internal/module/usage/repository/memory.go b/apps/backend/internal/module/usage/repository/memory.go new file mode 100644 index 0000000..71b42c7 --- /dev/null +++ b/apps/backend/internal/module/usage/repository/memory.go @@ -0,0 +1,117 @@ +package repository + +import ( + "context" + "sync" + + "apps/backend/internal/module/usage/domain" +) + +type MemoryStore struct { + mu sync.Mutex + events []*domain.Event + prefs map[int64]*domain.MemberPrefs + purchases []*domain.Purchase +} + +func NewMemory() *MemoryStore { + return &MemoryStore{ + prefs: map[int64]*domain.MemberPrefs{}, + } +} + +func (s *MemoryStore) InsertEvent(_ context.Context, e *domain.Event) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *e + s.events = append(s.events, &cp) + return nil +} + +func (s *MemoryStore) ListEvents(_ context.Context, uid int64, monthKey, keyMode string, limit int) ([]*domain.Event, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*domain.Event + for i := len(s.events) - 1; i >= 0; i-- { + e := s.events[i] + if e.UID != uid { + continue + } + if monthKey != "" && e.MonthKey != monthKey { + continue + } + if keyMode != "" && keyMode != "all" && e.KeyMode != keyMode { + continue + } + cp := *e + out = append(out, &cp) + if limit > 0 && len(out) >= limit { + break + } + } + return out, nil +} + +func (s *MemoryStore) ListEventsAll(_ context.Context, monthKey string, limit int) ([]*domain.Event, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*domain.Event + for i := len(s.events) - 1; i >= 0; i-- { + e := s.events[i] + if monthKey != "" && e.MonthKey != monthKey { + continue + } + cp := *e + out = append(out, &cp) + if limit > 0 && len(out) >= limit { + break + } + } + return out, nil +} + +func (s *MemoryStore) GetPrefs(_ context.Context, uid int64) (*domain.MemberPrefs, error) { + s.mu.Lock() + defer s.mu.Unlock() + if p, ok := s.prefs[uid]; ok { + cp := *p + return &cp, nil + } + return &domain.MemberPrefs{UID: uid, PlanID: domain.PlanFree}, nil +} + +func (s *MemoryStore) SavePrefs(_ context.Context, p *domain.MemberPrefs) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *p + s.prefs[p.UID] = &cp + return nil +} + +func (s *MemoryStore) InsertPurchase(_ context.Context, p *domain.Purchase) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *p + s.purchases = append(s.purchases, &cp) + return nil +} + +func (s *MemoryStore) ListPurchases(_ context.Context, uid int64, limit int) ([]*domain.Purchase, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*domain.Purchase + for i := len(s.purchases) - 1; i >= 0; i-- { + p := s.purchases[i] + if p.UID != uid { + continue + } + cp := *p + out = append(out, &cp) + if limit > 0 && len(out) >= limit { + break + } + } + return out, nil +} + +var _ domain.Repository = (*MemoryStore)(nil) diff --git a/apps/backend/internal/module/usage/repository/mongo.go b/apps/backend/internal/module/usage/repository/mongo.go new file mode 100644 index 0000000..e55c2b8 --- /dev/null +++ b/apps/backend/internal/module/usage/repository/mongo.go @@ -0,0 +1,97 @@ +package repository + +import ( + "context" + + libmongo "apps/backend/internal/lib/mongo" + "apps/backend/internal/module/usage/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 { + events *mon.Model + prefs *mon.Model + purchases *mon.Model +} + +func NewMonStore(uri, database string) *MonStore { + uri = libmongo.MustMongoURI(uri) + return &MonStore{ + events: mon.MustNewModel(uri, database, "usage_events"), + prefs: mon.MustNewModel(uri, database, "usage_prefs"), + purchases: mon.MustNewModel(uri, database, "usage_purchases"), + } +} + +func (s *MonStore) InsertEvent(ctx context.Context, e *domain.Event) error { + _, err := s.events.InsertOne(ctx, e) + return err +} + +func (s *MonStore) ListEvents(ctx context.Context, uid int64, monthKey, keyMode string, limit int) ([]*domain.Event, error) { + filter := bson.M{"uid": uid} + if monthKey != "" { + filter["month_key"] = monthKey + } + if keyMode != "" && keyMode != "all" { + filter["key_mode"] = keyMode + } + opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}) + if limit > 0 { + opts.SetLimit(int64(limit)) + } + var list []*domain.Event + err := s.events.Find(ctx, &list, filter, opts) + return list, err +} + +func (s *MonStore) ListEventsAll(ctx context.Context, monthKey string, limit int) ([]*domain.Event, error) { + filter := bson.M{} + if monthKey != "" { + filter["month_key"] = monthKey + } + opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}) + if limit > 0 { + opts.SetLimit(int64(limit)) + } + var list []*domain.Event + err := s.events.Find(ctx, &list, filter, opts) + return list, err +} + +func (s *MonStore) GetPrefs(ctx context.Context, uid int64) (*domain.MemberPrefs, error) { + var p domain.MemberPrefs + err := s.prefs.FindOne(ctx, &p, bson.M{"uid": uid}) + if err != nil { + if err == mon.ErrNotFound { + return &domain.MemberPrefs{UID: uid, PlanID: domain.PlanFree}, nil + } + return nil, err + } + return &p, nil +} + +func (s *MonStore) SavePrefs(ctx context.Context, p *domain.MemberPrefs) error { + _, err := s.prefs.ReplaceOne(ctx, bson.M{"uid": p.UID}, p, options.Replace().SetUpsert(true)) + return err +} + +func (s *MonStore) InsertPurchase(ctx context.Context, p *domain.Purchase) error { + _, err := s.purchases.InsertOne(ctx, p) + return err +} + +func (s *MonStore) ListPurchases(ctx context.Context, uid int64, limit int) ([]*domain.Purchase, error) { + opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}) + if limit > 0 { + opts.SetLimit(int64(limit)) + } + var list []*domain.Purchase + err := s.purchases.Find(ctx, &list, bson.M{"uid": uid}, opts) + return list, err +} + +var _ domain.Repository = (*MonStore)(nil) diff --git a/apps/backend/internal/module/usage/usecase/resolver.go b/apps/backend/internal/module/usage/usecase/resolver.go new file mode 100644 index 0000000..258e938 --- /dev/null +++ b/apps/backend/internal/module/usage/usecase/resolver.go @@ -0,0 +1,94 @@ +package usecase + +import ( + "context" + "strings" + + "apps/backend/internal/module/ai" + memberDomain "apps/backend/internal/module/member/domain" + "apps/backend/internal/module/usage/domain" +) + +// SettingsResolver uses member settings + platform config. +type SettingsResolver struct { + Members memberDomain.Repository + PlatformAI string // legacy / xAI fallback + PlatformXAI string + PlatformOpenCode string + PlatformExa string +} + +func (r *SettingsResolver) platformAIKey(provider string) string { + switch ai.NormalizeProvider(provider) { + case ai.ProviderOpenCodeGo: + if k := strings.TrimSpace(r.PlatformOpenCode); k != "" { + return k + } + default: + if k := strings.TrimSpace(r.PlatformXAI); k != "" { + return k + } + if k := strings.TrimSpace(r.PlatformAI); k != "" { + return k + } + } + return "" +} + +func (r *SettingsResolver) Resolve(ctx context.Context, uid int64, meter string) (string, bool, error) { + st, err := r.Members.GetSettings(ctx, uid) + if err != nil || st == nil { + st = memberDomain.DefaultSettings(uid) + } + provider := ai.NormalizeProvider(st.Provider) + switch meter { + case domain.MeterAICopy, domain.MeterAIResearch, domain.MeterAIImage: + if st.KeyForProvider(provider) != "" { + return domain.KeyModeByok, true, nil + } + if r.platformAIKey(provider) != "" { + return domain.KeyModePlatform, true, nil + } + return "", false, nil + case domain.MeterWebSearch: + if st.ExaAPIKeyConfigured && st.ExaAPIKey != "" { + return domain.KeyModeByok, true, nil + } + if r.PlatformExa != "" { + return domain.KeyModePlatform, true, nil + } + return "", false, nil + default: + return "", false, nil + } +} + +// ResolveKey returns actual key material for call (never log). +func (r *SettingsResolver) ResolveKey(ctx context.Context, uid int64, meter string) (keyMode, apiKey string, err error) { + mode, has, err := r.Resolve(ctx, uid, meter) + if err != nil { + return "", "", err + } + if !has { + return "", "", domain.ErrNoKey + } + st, _ := r.Members.GetSettings(ctx, uid) + if st == nil { + st = memberDomain.DefaultSettings(uid) + } + provider := ai.NormalizeProvider(st.Provider) + switch mode { + case domain.KeyModeByok: + if meter == domain.MeterWebSearch { + return mode, st.ExaAPIKey, nil + } + return mode, st.KeyForProvider(provider), nil + default: + if meter == domain.MeterWebSearch { + return mode, r.PlatformExa, nil + } + return mode, r.platformAIKey(provider), nil + } +} + +var _ KeyResolver = (*SettingsResolver)(nil) diff --git a/apps/backend/internal/module/usage/usecase/service.go b/apps/backend/internal/module/usage/usecase/service.go new file mode 100644 index 0000000..3fe1c35 --- /dev/null +++ b/apps/backend/internal/module/usage/usecase/service.go @@ -0,0 +1,273 @@ +package usecase + +import ( + "context" + "fmt" + + "apps/backend/internal/module/usage/domain" + + "github.com/google/uuid" +) + +// KeyResolver decides platform vs byok for a meter. +type KeyResolver interface { + // Resolve returns key_mode and whether a usable key exists. + Resolve(ctx context.Context, uid int64, meter string) (keyMode string, hasKey bool, err error) +} + +// StaticResolver for tests / simple wiring. +type StaticResolver struct { + // ByUIDMeter: "uid:meter" -> keyMode (platform|byok); empty means no key + Map map[string]string +} + +func (s *StaticResolver) Resolve(_ context.Context, uid int64, meter string) (string, bool, error) { + if s == nil || s.Map == nil { + return "", false, nil + } + k := s.Map[fmt.Sprintf("%d:%s", uid, meter)] + if k == "" { + return "", false, nil + } + return k, true, nil +} + +type Service struct { + Repo domain.Repository + Resolver KeyResolver +} + +func New(repo domain.Repository, resolver KeyResolver) *Service { + return &Service{Repo: repo, Resolver: resolver} +} + +// RecordCall after successful external call (or for meter tests). +func (s *Service) RecordCall(ctx context.Context, uid int64, meter, keyMode, label, source string) (*domain.Event, error) { + if keyMode != domain.KeyModePlatform && keyMode != domain.KeyModeByok { + return nil, fmt.Errorf("invalid key_mode") + } + credits := 0 + if keyMode == domain.KeyModePlatform { + credits = domain.DefaultCreditCost[meter] + if credits == 0 { + credits = 1 + } + prefs, _ := s.ensurePrefs(ctx, uid) + if !prefs.Unlimited { + sum, err := s.GetSummary(ctx, uid, domain.CurrentMonthKey()) + if err != nil { + return nil, err + } + if sum.Platform.CreditsRemaining < credits { + return nil, domain.ErrQuotaExceeded + } + } + } + now := domain.NowNano() + e := &domain.Event{ + ID: uuid.NewString(), UID: uid, Meter: meter, Credits: credits, + KeyMode: keyMode, Label: label, Source: source, + MonthKey: domain.MonthKey(now), CreatedAt: now, + } + if err := s.Repo.InsertEvent(ctx, e); err != nil { + return nil, err + } + return e, nil +} + +// PrepareCall checks quota / key before external call; returns key_mode. +func (s *Service) PrepareCall(ctx context.Context, uid int64, meter string) (keyMode string, err error) { + if s.Resolver == nil { + return "", domain.ErrNoKey + } + mode, has, err := s.Resolver.Resolve(ctx, uid, meter) + if err != nil { + return "", err + } + if !has { + return "", domain.ErrNoKey + } + if mode == domain.KeyModePlatform { + prefs, _ := s.ensurePrefs(ctx, uid) + if !prefs.Unlimited { + sum, err := s.GetSummary(ctx, uid, domain.CurrentMonthKey()) + if err != nil { + return "", err + } + cost := domain.DefaultCreditCost[meter] + if cost == 0 { + cost = 1 + } + if sum.Platform.CreditsRemaining < cost { + return "", domain.ErrQuotaExceeded + } + } + } + return mode, nil +} + +func (s *Service) ensurePrefs(ctx context.Context, uid int64) (*domain.MemberPrefs, error) { + p, err := s.Repo.GetPrefs(ctx, uid) + if err != nil || p == nil { + p = &domain.MemberPrefs{UID: uid, PlanID: domain.PlanFree, UpdatedAt: domain.NowNano()} + _ = s.Repo.SavePrefs(ctx, p) + } + if p.PlanID == "" { + p.PlanID = domain.PlanFree + } + return p, nil +} + +func (s *Service) GetSummary(ctx context.Context, uid int64, monthKey string) (*domain.MonthSummary, error) { + if monthKey == "" { + monthKey = domain.CurrentMonthKey() + } + prefs, _ := s.ensurePrefs(ctx, uid) + plan := domain.Plans[prefs.PlanID] + if plan.ID == "" { + plan = domain.Plans[domain.PlanFree] + } + events, err := s.Repo.ListEvents(ctx, uid, monthKey, "all", 0) + if err != nil { + return nil, err + } + platBy := map[string]domain.MeterCount{} + byokBy := map[string]domain.MeterCount{} + platUsed, platCalls, byokCalls := 0, 0, 0 + for _, e := range events { + if e.KeyMode == domain.KeyModePlatform { + platUsed += e.Credits + platCalls++ + m := platBy[e.Meter] + m.Count++ + m.Credits += e.Credits + platBy[e.Meter] = m + } else if e.KeyMode == domain.KeyModeByok { + byokCalls++ + m := byokBy[e.Meter] + m.Count++ + byokBy[e.Meter] = m + } + } + remaining := plan.MonthlyCredits - platUsed + if remaining < 0 { + remaining = 0 + } + if prefs.Unlimited { + remaining = plan.MonthlyCredits // display full; not enforced + } + return &domain.MonthSummary{ + MonthKey: monthKey, UID: uid, PlanID: prefs.PlanID, Unlimited: prefs.Unlimited, + Platform: domain.PlatformBlock{ + CreditsUsed: platUsed, CreditsRemaining: remaining, CreditsTotal: plan.MonthlyCredits, + CallCount: platCalls, ByMeter: platBy, + }, + Byok: domain.ByokBlock{CallCount: byokCalls, ByMeter: byokBy}, + TotalCredits: plan.MonthlyCredits, RemainingCredits: remaining, + }, nil +} + +func (s *Service) ListEvents(ctx context.Context, uid int64, monthKey, keyMode string, limit int) ([]*domain.Event, error) { + if limit <= 0 { + limit = 100 + } + return s.Repo.ListEvents(ctx, uid, monthKey, keyMode, limit) +} + +func (s *Service) GetPrefs(ctx context.Context, uid int64) (*domain.MemberPrefs, error) { + return s.ensurePrefs(ctx, uid) +} + +func (s *Service) SetPrefs(ctx context.Context, actorUID int64, targetUID int64, isAdmin bool, planID string, unlimited *bool) (*domain.MemberPrefs, error) { + if !isAdmin && actorUID != targetUID { + return nil, domain.ErrForbidden + } + if !isAdmin && unlimited != nil { + // members cannot set unlimited + return nil, domain.ErrForbidden + } + p, _ := s.ensurePrefs(ctx, targetUID) + if planID != "" { + if _, ok := domain.Plans[planID]; !ok { + return nil, domain.ErrInvalidPlan + } + // member self can only change via purchase + if !isAdmin && actorUID == targetUID { + return nil, domain.ErrForbidden + } + p.PlanID = planID + } + if unlimited != nil && isAdmin { + p.Unlimited = *unlimited + } + p.UpdatedAt = domain.NowNano() + if err := s.Repo.SavePrefs(ctx, p); err != nil { + return nil, err + } + return p, nil +} + +func (s *Service) PurchasePlan(ctx context.Context, uid int64, planID, mockRef string) (*domain.Purchase, error) { + if _, ok := domain.Plans[planID]; !ok { + return nil, domain.ErrInvalidPlan + } + p, _ := s.ensurePrefs(ctx, uid) + p.PlanID = planID + p.UpdatedAt = domain.NowNano() + _ = s.Repo.SavePrefs(ctx, p) + pur := &domain.Purchase{ + ID: uuid.NewString(), UID: uid, PlanID: planID, MockRef: mockRef, CreatedAt: domain.NowNano(), + } + if err := s.Repo.InsertPurchase(ctx, pur); err != nil { + return nil, err + } + return pur, nil +} + +func (s *Service) ListMyPurchases(ctx context.Context, uid int64, limit int) ([]*domain.Purchase, error) { + if limit <= 0 { + limit = 50 + } + return s.Repo.ListPurchases(ctx, uid, limit) +} + +func (s *Service) TenantSummary(ctx context.Context, monthKey string) (*domain.TenantSummary, error) { + if monthKey == "" { + monthKey = domain.CurrentMonthKey() + } + events, err := s.Repo.ListEventsAll(ctx, monthKey, 0) + if err != nil { + return nil, err + } + type acc struct { + plat int + byok int + } + byUID := map[int64]*acc{} + platTotal, byokTotal := 0, 0 + for _, e := range events { + a := byUID[e.UID] + if a == nil { + a = &acc{} + byUID[e.UID] = a + } + if e.KeyMode == domain.KeyModePlatform { + a.plat += e.Credits + platTotal += e.Credits + } else if e.KeyMode == domain.KeyModeByok { + a.byok++ + byokTotal++ + } + } + var members []domain.TenantRow + for uid, a := range byUID { + prefs, _ := s.ensurePrefs(ctx, uid) + members = append(members, domain.TenantRow{ + UID: uid, PlanID: prefs.PlanID, Unlimited: prefs.Unlimited, + PlatformCreditsUsed: a.plat, ByokCallCount: a.byok, + }) + } + return &domain.TenantSummary{ + MonthKey: monthKey, PlatformCreditsUsed: platTotal, ByokCallCount: byokTotal, Members: members, + }, nil +} diff --git a/apps/backend/internal/module/usage/usecase/service_test.go b/apps/backend/internal/module/usage/usecase/service_test.go new file mode 100644 index 0000000..e5eebc4 --- /dev/null +++ b/apps/backend/internal/module/usage/usecase/service_test.go @@ -0,0 +1,174 @@ +package usecase_test + +import ( + "context" + "testing" + + "apps/backend/internal/module/usage/domain" + "apps/backend/internal/module/usage/repository" + "apps/backend/internal/module/usage/usecase" + + "github.com/stretchr/testify/require" +) + +func newUsage(uid int64, mode string) *usecase.Service { + r := repository.NewMemory() + res := &usecase.StaticResolver{Map: map[string]string{ + "1000001:ai_copy": mode, + "1000001:web_search": mode, + }} + // also allow mixed tests + if mode == "mixed" { + res.Map = map[string]string{ + "1000001:ai_copy": domain.KeyModeByok, + "1000001:web_search": domain.KeyModePlatform, + } + } + return usecase.New(r, res) +} + +func TestUSG_01_NewMemberSummarySplit(t *testing.T) { + svc := newUsage(1_000_001, domain.KeyModePlatform) + sum, err := svc.GetSummary(context.Background(), 1_000_001, "") + require.NoError(t, err) + require.Equal(t, domain.PlanFree, sum.PlanID) + require.Equal(t, 0, sum.Platform.CreditsUsed) + require.Equal(t, 0, sum.Byok.CallCount) + require.Greater(t, sum.Platform.CreditsRemaining, 0) +} + +func TestUSG_02_PlatformOnly(t *testing.T) { + svc := newUsage(1_000_001, domain.KeyModePlatform) + _, err := svc.RecordCall(context.Background(), 1_000_001, domain.MeterAICopy, domain.KeyModePlatform, "copy", "test") + require.NoError(t, err) + sum, err := svc.GetSummary(context.Background(), 1_000_001, domain.CurrentMonthKey()) + require.NoError(t, err) + require.Equal(t, 1, sum.Platform.CreditsUsed) + require.Equal(t, 0, sum.Byok.CallCount) +} + +func TestUSG_03_ByokOnly(t *testing.T) { + svc := newUsage(1_000_001, domain.KeyModeByok) + before, _ := svc.GetSummary(context.Background(), 1_000_001, "") + _, err := svc.RecordCall(context.Background(), 1_000_001, domain.MeterAICopy, domain.KeyModeByok, "copy", "test") + require.NoError(t, err) + sum, err := svc.GetSummary(context.Background(), 1_000_001, domain.CurrentMonthKey()) + require.NoError(t, err) + require.Equal(t, before.Platform.CreditsRemaining, sum.Platform.CreditsRemaining) + require.Equal(t, 1, sum.Byok.CallCount) + require.Equal(t, 0, sum.Platform.CreditsUsed) +} + +func TestUSG_04_MixedAIByokSearchPlatform(t *testing.T) { + svc := newUsage(1_000_001, "mixed") + _, _ = svc.RecordCall(context.Background(), 1_000_001, domain.MeterAICopy, domain.KeyModeByok, "ai", "t") + _, _ = svc.RecordCall(context.Background(), 1_000_001, domain.MeterWebSearch, domain.KeyModePlatform, "search", "t") + sum, err := svc.GetSummary(context.Background(), 1_000_001, domain.CurrentMonthKey()) + require.NoError(t, err) + require.Equal(t, 1, sum.Platform.CreditsUsed) // search only + require.Equal(t, 1, sum.Byok.CallCount) // ai only +} + +func TestUSG_05_ListEventsHasKeyMode(t *testing.T) { + svc := newUsage(1_000_001, domain.KeyModePlatform) + _, _ = svc.RecordCall(context.Background(), 1_000_001, domain.MeterAICopy, domain.KeyModePlatform, "x", "t") + list, err := svc.ListEvents(context.Background(), 1_000_001, "", "all", 10) + require.NoError(t, err) + require.NotEmpty(t, list) + require.Equal(t, domain.KeyModePlatform, list[0].KeyMode) + require.NotEmpty(t, list[0].Meter) +} + +func TestUSG_06_FilterByok(t *testing.T) { + svc := newUsage(1_000_001, "mixed") + _, _ = svc.RecordCall(context.Background(), 1_000_001, domain.MeterAICopy, domain.KeyModeByok, "a", "t") + _, _ = svc.RecordCall(context.Background(), 1_000_001, domain.MeterWebSearch, domain.KeyModePlatform, "s", "t") + list, err := svc.ListEvents(context.Background(), 1_000_001, "", domain.KeyModeByok, 50) + require.NoError(t, err) + require.Len(t, list, 1) + require.Equal(t, domain.KeyModeByok, list[0].KeyMode) +} + +func TestUSG_07_FilterPlatform(t *testing.T) { + svc := newUsage(1_000_001, "mixed") + _, _ = svc.RecordCall(context.Background(), 1_000_001, domain.MeterAICopy, domain.KeyModeByok, "a", "t") + _, _ = svc.RecordCall(context.Background(), 1_000_001, domain.MeterWebSearch, domain.KeyModePlatform, "s", "t") + list, err := svc.ListEvents(context.Background(), 1_000_001, "", domain.KeyModePlatform, 50) + require.NoError(t, err) + require.Len(t, list, 1) + require.Equal(t, domain.KeyModePlatform, list[0].KeyMode) +} + +func TestUSG_08_NoMergedTotalOnly(t *testing.T) { + svc := newUsage(1_000_001, "mixed") + _, _ = svc.RecordCall(context.Background(), 1_000_001, domain.MeterAICopy, domain.KeyModeByok, "a", "t") + _, _ = svc.RecordCall(context.Background(), 1_000_001, domain.MeterWebSearch, domain.KeyModePlatform, "s", "t") + sum, err := svc.GetSummary(context.Background(), 1_000_001, domain.CurrentMonthKey()) + require.NoError(t, err) + // Must have separate blocks; platform remaining is not reduced by byok + require.NotNil(t, sum.Platform) + require.NotNil(t, sum.Byok) + require.Equal(t, 1, sum.Platform.CallCount) + require.Equal(t, 1, sum.Byok.CallCount) + // total_credits is plan allotment (platform), not call merge + require.Equal(t, sum.Platform.CreditsTotal, sum.TotalCredits) +} + +func TestST_07_ByokNoCreditCharge(t *testing.T) { + svc := newUsage(1_000_001, domain.KeyModeByok) + before, _ := svc.GetSummary(context.Background(), 1_000_001, "") + mode, err := svc.PrepareCall(context.Background(), 1_000_001, domain.MeterAICopy) + require.NoError(t, err) + require.Equal(t, domain.KeyModeByok, mode) + _, err = svc.RecordCall(context.Background(), 1_000_001, domain.MeterAICopy, mode, "byok", "ai") + require.NoError(t, err) + after, _ := svc.GetSummary(context.Background(), 1_000_001, domain.CurrentMonthKey()) + require.Equal(t, before.Platform.CreditsRemaining, after.Platform.CreditsRemaining) + require.Equal(t, 1, after.Byok.CallCount) +} + +func TestST_09_NoKeyFails(t *testing.T) { + r := repository.NewMemory() + svc := usecase.New(r, &usecase.StaticResolver{Map: map[string]string{}}) + _, err := svc.PrepareCall(context.Background(), 1_000_001, domain.MeterAICopy) + require.ErrorIs(t, err, domain.ErrNoKey) +} + +func TestST_13_PlatformQuota(t *testing.T) { + svc := newUsage(1_000_001, domain.KeyModePlatform) + // burn free plan + for i := 0; i < 80; i++ { + _, err := svc.RecordCall(context.Background(), 1_000_001, domain.MeterAICopy, domain.KeyModePlatform, "x", "t") + require.NoError(t, err) + } + _, err := svc.PrepareCall(context.Background(), 1_000_001, domain.MeterAICopy) + require.ErrorIs(t, err, domain.ErrQuotaExceeded) +} + +func TestUSG_11_PurchasePlan(t *testing.T) { + svc := newUsage(1_000_001, domain.KeyModePlatform) + p, err := svc.PurchasePlan(context.Background(), 1_000_001, domain.PlanStarter, "mock-pay-1") + require.NoError(t, err) + require.Equal(t, domain.PlanStarter, p.PlanID) + prefs, _ := svc.GetPrefs(context.Background(), 1_000_001) + require.Equal(t, domain.PlanStarter, prefs.PlanID) + list, err := svc.ListMyPurchases(context.Background(), 1_000_001, 10) + require.NoError(t, err) + require.NotEmpty(t, list) +} + +func TestUSG_10_TenantAnalyticsSplit(t *testing.T) { + svc := newUsage(1_000_001, "mixed") + _, _ = svc.RecordCall(context.Background(), 1_000_001, domain.MeterAICopy, domain.KeyModeByok, "a", "t") + _, _ = svc.RecordCall(context.Background(), 1_000_001, domain.MeterWebSearch, domain.KeyModePlatform, "s", "t") + ten, err := svc.TenantSummary(context.Background(), domain.CurrentMonthKey()) + require.NoError(t, err) + require.Equal(t, 1, ten.PlatformCreditsUsed) + require.Equal(t, 1, ten.ByokCallCount) +} + +func TestUSG_14_MemberCannotSetOthersPrefs(t *testing.T) { + svc := newUsage(1_000_001, domain.KeyModePlatform) + _, err := svc.SetPrefs(context.Background(), 1_000_001, 1_000_002, false, domain.PlanPro, nil) + require.ErrorIs(t, err, domain.ErrForbidden) +} diff --git a/apps/backend/internal/response/response.go b/apps/backend/internal/response/response.go index fe4aaa2..78a3707 100644 --- a/apps/backend/internal/response/response.go +++ b/apps/backend/internal/response/response.go @@ -4,14 +4,45 @@ import ( "context" "errors" "net/http" + "strings" "apps/backend/internal/domain" + appnotifDomain "apps/backend/internal/module/appnotif/domain" + jobDomain "apps/backend/internal/module/job/domain" memberDomain "apps/backend/internal/module/member/domain" + inspireDomain "apps/backend/internal/module/inspire/domain" + scoutDomain "apps/backend/internal/module/scout/domain" + studioDomain "apps/backend/internal/module/studio/domain" + threadsDomain "apps/backend/internal/module/threads/domain" tokenDomain "apps/backend/internal/module/token/domain" + usageDomain "apps/backend/internal/module/usage/domain" "github.com/zeromicro/go-zero/rest/httpx" ) +// cleanBizMessage strips domain error prefixes so UI can show Chinese hints directly. +func cleanBizMessage(msg string) string { + msg = strings.TrimSpace(msg) + for _, p := range []string{ + "studio validation: ", + "inspire validation: ", + "scout validation: ", + "validation: ", + } { + if strings.HasPrefix(msg, p) { + return strings.TrimSpace(strings.TrimPrefix(msg, p)) + } + // also case where errors.Is wraps as "prefix: detail" + if i := strings.Index(msg, ": "); i > 0 { + head := msg[:i] + if strings.Contains(head, "validation") || strings.Contains(head, "ErrValidation") { + return strings.TrimSpace(msg[i+2:]) + } + } + } + return msg +} + // Envelope is the standard JSON body (AGENTS.md). type Envelope struct { Code int64 `json:"code"` @@ -82,6 +113,52 @@ func mapError(err error) (int, Envelope) { return http.StatusBadRequest, Envelope{Code: 400020, Message: err.Error()} case errors.Is(err, tokenDomain.ErrInvalidToken): return http.StatusUnauthorized, Envelope{Code: 401002, Message: "invalid token"} + case errors.Is(err, threadsDomain.ErrNotFound), errors.Is(err, jobDomain.ErrNotFound), + errors.Is(err, appnotifDomain.ErrNotFound): + return http.StatusNotFound, Envelope{Code: 404001, Message: "not found"} + case errors.Is(err, threadsDomain.ErrForbidden), errors.Is(err, jobDomain.ErrForbidden), + errors.Is(err, appnotifDomain.ErrForbidden): + return http.StatusForbidden, Envelope{Code: 403003, Message: "forbidden"} + case errors.Is(err, threadsDomain.ErrOAuthDenied): + return http.StatusBadRequest, Envelope{Code: 400031, Message: "oauth denied"} + case errors.Is(err, threadsDomain.ErrOAuthExchange), errors.Is(err, threadsDomain.ErrInvalidState): + return http.StatusBadRequest, Envelope{Code: 400032, Message: err.Error()} + case errors.Is(err, threadsDomain.ErrRefreshFailed): + return http.StatusBadRequest, Envelope{Code: 400033, Message: "session refresh failed"} + case errors.Is(err, jobDomain.ErrIllegalStatus): + return http.StatusConflict, Envelope{Code: 409010, Message: "illegal job status transition"} + case errors.Is(err, usageDomain.ErrNoKey): + return http.StatusBadRequest, Envelope{Code: 400040, Message: "no api key configured (platform or byok)"} + case errors.Is(err, usageDomain.ErrQuotaExceeded): + return http.StatusPaymentRequired, Envelope{Code: 402001, Message: "platform credits insufficient"} + case errors.Is(err, usageDomain.ErrForbidden): + return http.StatusForbidden, Envelope{Code: 403003, Message: "forbidden"} + case errors.Is(err, usageDomain.ErrInvalidPlan): + return http.StatusBadRequest, Envelope{Code: 400041, Message: "invalid plan"} + case errors.Is(err, studioDomain.ErrNotFound): + return http.StatusNotFound, Envelope{Code: 404001, Message: "not found"} + case errors.Is(err, studioDomain.ErrForbidden): + return http.StatusForbidden, Envelope{Code: 403003, Message: "forbidden"} + case errors.Is(err, studioDomain.ErrValidation), errors.Is(err, studioDomain.ErrBadURL): + return http.StatusBadRequest, Envelope{Code: 400050, Message: cleanBizMessage(err.Error())} + case errors.Is(err, studioDomain.ErrNoAccount): + return http.StatusBadRequest, Envelope{Code: 400051, Message: "no usable threads account"} + case errors.Is(err, studioDomain.ErrIllegalStatus), errors.Is(err, studioDomain.ErrRootBlocked): + return http.StatusConflict, Envelope{Code: 409020, Message: err.Error()} + case errors.Is(err, studioDomain.ErrFormulaRemove): + return http.StatusGone, Envelope{Code: 410001, Message: "generateFromFormula removed"} + case errors.Is(err, inspireDomain.ErrNotFound), errors.Is(err, scoutDomain.ErrNotFound): + return http.StatusNotFound, Envelope{Code: 404001, Message: "not found"} + case errors.Is(err, inspireDomain.ErrForbidden), errors.Is(err, scoutDomain.ErrForbidden): + return http.StatusForbidden, Envelope{Code: 403003, Message: "forbidden"} + case errors.Is(err, inspireDomain.ErrValidation), errors.Is(err, scoutDomain.ErrValidation): + return http.StatusBadRequest, Envelope{Code: 400060, Message: cleanBizMessage(err.Error())} + case errors.Is(err, inspireDomain.ErrRemoved), errors.Is(err, scoutDomain.ErrTopicRemoved): + return http.StatusGone, Envelope{Code: 410002, Message: err.Error()} + case errors.Is(err, scoutDomain.ErrNoCrawlerSession): + return http.StatusBadRequest, Envelope{Code: 400061, Message: "crawler session required when dev_mode enabled"} + case errors.Is(err, scoutDomain.ErrHasProducts): + return http.StatusConflict, Envelope{Code: 409030, Message: "brand has products; remove products first"} default: return http.StatusInternalServerError, Envelope{Code: 500000, Message: err.Error()} } diff --git a/apps/backend/internal/svc/service_context.go b/apps/backend/internal/svc/service_context.go index b7a5f40..6b41c11 100644 --- a/apps/backend/internal/svc/service_context.go +++ b/apps/backend/internal/svc/service_context.go @@ -1,39 +1,73 @@ package svc import ( + "context" + "fmt" "strings" + "os" + "path/filepath" + "apps/backend/internal/config" "apps/backend/internal/middleware" + "apps/backend/internal/module/ai" + appnotifRepo "apps/backend/internal/module/appnotif/repository" + appnotifUC "apps/backend/internal/module/appnotif/usecase" fsDomain "apps/backend/internal/module/filestorage/domain" "apps/backend/internal/module/filestorage/noop" "apps/backend/internal/module/filestorage/s3store" + jobRepo "apps/backend/internal/module/job/repository" + jobUC "apps/backend/internal/module/job/usecase" memberDomain "apps/backend/internal/module/member/domain" memberRepo "apps/backend/internal/module/member/repository" memberUC "apps/backend/internal/module/member/usecase" notifDomain "apps/backend/internal/module/notification/domain" notifUC "apps/backend/internal/module/notification/usecase" + "apps/backend/internal/module/search" + inspireRepo "apps/backend/internal/module/inspire/repository" + inspireUC "apps/backend/internal/module/inspire/usecase" + scoutRepo "apps/backend/internal/module/scout/repository" + scoutUC "apps/backend/internal/module/scout/usecase" + studioDomain "apps/backend/internal/module/studio/domain" + studioPublish "apps/backend/internal/module/studio/publish" + studioRepo "apps/backend/internal/module/studio/repository" + studioUC "apps/backend/internal/module/studio/usecase" + threadsDomain "apps/backend/internal/module/threads/domain" + threadsProv "apps/backend/internal/module/threads/provider" + threadsRepo "apps/backend/internal/module/threads/repository" + threadsUC "apps/backend/internal/module/threads/usecase" tokenUC "apps/backend/internal/module/token/usecase" + usageDomain "apps/backend/internal/module/usage/domain" + usageRepo "apps/backend/internal/module/usage/repository" + usageUC "apps/backend/internal/module/usage/usecase" "github.com/zeromicro/go-zero/core/logx" "github.com/zeromicro/go-zero/rest" ) // ServiceContext wires goctl middleware + modules. -// -// 持久化(關機不丟): -// - members / settings / refresh_tokens / auth_codes → Mongo -// Redis(可丟、可重建): -// - monc 實體快取 only(CacheRedis) type ServiceContext struct { - Config config.Config - AuthJWT rest.Middleware - AdminAuth rest.Middleware - Members memberDomain.Repository - Auth *memberUC.AuthService - Token *tokenUC.JWTIssuer - Notification notifDomain.UseCase - Storage fsDomain.Storage + Config config.Config + AuthJWT rest.Middleware + AdminAuth rest.Middleware + Members memberDomain.Repository + Auth *memberUC.AuthService + Token *tokenUC.JWTIssuer + Notification notifDomain.UseCase // email + Storage fsDomain.Storage + Threads *threadsUC.Service + Jobs *jobUC.Service + AppNotif *appnotifUC.Service + Usage *usageUC.Service + KeyResolver *usageUC.SettingsResolver + AI ai.Client + AIRegistry *ai.Registry + Search search.Client + Studio *studioUC.Service + PublishTransport studioPublish.Transport + Inspire *inspireUC.Service + Scout *scoutUC.Service + ExtensionZipPath string } func NewServiceContext(c config.Config) *ServiceContext { @@ -63,19 +97,191 @@ func NewServiceContext(c config.Config) *ServiceContext { notify := newNotification(c) store := newStorage(c) + appN := appnotifUC.New(appnotifRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database)) + jobs := jobUC.New(jobRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database)) + jobs.Notifier = appN + threadsSvc := newThreads(c) + // 連帳成功 → 排程 ~30 天後 token renew job + threadsSvc.Renew = jobs + + keyRes := &usageUC.SettingsResolver{ + Members: repo, + PlatformAI: c.Platform.AIKey, + PlatformXAI: c.Platform.XAIKey, + PlatformOpenCode: c.Platform.OpenCodeKey, + PlatformExa: c.Platform.ExaKey, + } + // Dev: empty platform keys still allow PrepareCall for quota path; real LLM needs real key + if keyRes.PlatformAI == "" && keyRes.PlatformXAI == "" && keyRes.PlatformOpenCode == "" { + keyRes.PlatformAI = "fake-platform-ai" + logx.Info("usage: no platform AI keys — using fake-platform-ai for quota only (persona LLM needs real key)") + } + if keyRes.PlatformExa == "" { + keyRes.PlatformExa = "fake-platform-exa" + logx.Info("usage: Platform.ExaKey empty — using fake-platform-exa for demo/proxy") + } + usageSvc := usageUC.New(usageRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), keyRes) + + // M4 studio: mon store + publish transport(有 Meta 憑證則真發文/同步) + var pub studioPublish.Transport + var mediaSource studioUC.ThreadsMediaSource + if strings.TrimSpace(c.Platform.ThreadsAppId) != "" && strings.TrimSpace(c.Platform.ThreadsAppSecret) != "" { + pub = studioPublish.NewMeta("https://graph.threads.net") + mediaSource = &metaMediaBridge{Meta: threadsProv.NewMeta(c.Platform.ThreadsAppId, c.Platform.ThreadsAppSecret)} + logx.Info("studio: meta publish + own-posts sync enabled") + } else { + pub = studioPublish.NewFake() + logx.Info("studio: fake publish transport (no Threads app credentials)") + } + aiRegistry := ai.NewRegistry() + aiClient := &ai.FakeClient{} // tests / synthetic keys only + var searchClient search.Client + if strings.TrimSpace(c.Platform.ExaKey) != "" { + searchClient = search.NewExa() + logx.Info("search: real Exa client") + } else { + searchClient = &search.FakeClient{} + logx.Info("search: FakeClient (no Platform.ExaKey)") + } + studioSvc := studioUC.New(studioRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), pub) + studioSvc.Accounts = threadsSvc // *threadsUC.Service implements AccountLookup + studioSvc.Usage = usageSvc + studioSvc.AI = aiClient + studioSvc.AIRegistry = aiRegistry + studioSvc.Keys = &studioAIKeys{Members: repo, Resolver: keyRes} + studioSvc.Media = mediaSource + studioSvc.Storage = store + studioSvc.StoragePublicBase = strings.TrimSpace(c.ObjectStorage.PublicBaseURL) + // 人設分析:API 只入列 job,worker 爬取/LLM 後存檔(離開頁面不中斷) + studioSvc.Jobs = &personaJobBridge{Jobs: jobs} + + // M5 inspire + scout(真 AI + stream) + inspireSvc := inspireUC.New(inspireRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database)) + inspireSvc.Usage = usageSvc + inspireSvc.AI = aiClient + inspireSvc.AIRegistry = aiRegistry + inspireSvc.ResolveAI = func(ctx context.Context, uid int64) (provider, model, apiKey string, err error) { + return (&studioAIKeys{Members: repo, Resolver: keyRes}).ResolveAI(ctx, uid) + } + inspireSvc.Search = searchClient + inspireSvc.ResolveKey = func(ctx context.Context, uid int64, meter string) (string, string, error) { + return keyRes.ResolveKey(ctx, uid, meter) + } + scoutSvc := scoutUC.New(scoutRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database)) + scoutSvc.Settings = &devModeFromMembers{Members: repo} + scoutSvc.Transport = pub + scoutSvc.Accounts = threadsSvc + scoutSvc.AI = aiClient + // 人設對標帳號分析:可用 Chrome 同步的 crawler session 讀公開頁 + studioSvc.Crawler = scoutSvc + // 靈感 chat 注入真人人設 + 品牌產品目錄 + inspireSvc.Personas = &inspirePersonaBridge{Studio: studioSvc} + inspireSvc.Brands = &inspireBrandBridge{Scout: scoutSvc} + + zipPath := findExtensionZip() + return &ServiceContext{ - Config: c, - Members: repo, - Auth: auth, - Token: issuer, - Notification: notify, - Storage: store, - AuthJWT: middleware.NewAuthJWTMiddleware(issuer, repo).Handle, - AdminAuth: middleware.NewAdminAuthMiddleware().Handle, + Config: c, + Members: repo, + Auth: auth, + Token: issuer, + Notification: notify, + Storage: store, + Threads: threadsSvc, + Jobs: jobs, + AppNotif: appN, + Usage: usageSvc, + KeyResolver: keyRes, + AI: aiClient, + AIRegistry: aiRegistry, + Search: searchClient, + Studio: studioSvc, + PublishTransport: pub, + Inspire: inspireSvc, + Scout: scoutSvc, + ExtensionZipPath: zipPath, + AuthJWT: middleware.NewAuthJWTMiddleware(issuer, repo).Handle, + AdminAuth: middleware.NewAdminAuthMiddleware().Handle, } } -// newStorage 只接 MinIO/S3(S3 API);不落本機磁碟。 +// devModeFromMembers adapts member settings for scout path selection. +type devModeFromMembers struct { + Members memberDomain.Repository +} + +func (d *devModeFromMembers) DevModeEnabled(ctx context.Context, uid int64) (bool, error) { + if d == nil || d.Members == nil { + return false, nil + } + st, err := d.Members.GetSettings(ctx, uid) + if err != nil || st == nil { + return false, nil + } + return st.DevModeEnabled, nil +} + +func findExtensionZip() string { + cands := []string{ + "../web/public/downloads/haixun-threads-sync.zip", + "../../apps/web/public/downloads/haixun-threads-sync.zip", + "apps/web/public/downloads/haixun-threads-sync.zip", + "/home/daniel/thread-master/apps/web/public/downloads/haixun-threads-sync.zip", + } + for _, c := range cands { + if st, err := os.Stat(c); err == nil && st.Size() > 0 { + abs, _ := filepath.Abs(c) + logx.Infof("extension zip: %s (%d bytes)", abs, st.Size()) + return abs + } + } + logx.Error("extension zip not found — ST-16 download will 404") + return "" +} + +func newThreads(c config.Config) *threadsUC.Service { + repo := threadsRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database) + secret := c.Auth.AccessSecret + if secret == "" { + secret = "dev-token-secret" + } + web := strings.TrimRight(c.PublicWebBase, "/") + if web == "" { + web = "http://127.0.0.1:5173" + } + port := c.Port + if port == 0 { + port = 8888 + } + // Meta 拒絕 http://127.0.0.1 redirect_uri(error 1349187)。 + // 優先 PublicAPIBase → 與 PublicWebBase 同 host(nginx 反代 /api)→ 本機 loopback。 + apiBase := strings.TrimRight(c.PublicAPIBase, "/") + if apiBase == "" && strings.HasPrefix(web, "https://") { + apiBase = web + } + if apiBase == "" { + apiBase = fmt.Sprintf("http://127.0.0.1:%d", port) + } + + var provider threadsDomain.Provider + if strings.TrimSpace(c.Platform.ThreadsAppId) != "" && strings.TrimSpace(c.Platform.ThreadsAppSecret) != "" { + provider = threadsProv.NewMeta(c.Platform.ThreadsAppId, c.Platform.ThreadsAppSecret) + logx.Infof("threads oauth: meta provider callback=%s/api/v1/threads-accounts/oauth/callback", apiBase) + if strings.HasPrefix(apiBase, "http://") { + logx.Error("threads oauth: callback is HTTP — Meta will block (use https PublicAPIBase / PublicWebBase)") + } + } else { + cb := apiBase + "/api/v1/threads-accounts/oauth/callback" + provider = &threadsProv.FakeProvider{CallbackBase: cb} + logx.Info("threads oauth: fake provider (set Platform.ThreadsAppId/Secret for Meta)") + } + svc := threadsUC.New(repo, provider, secret) + svc.APIPublicBase = apiBase + svc.WebBase = web + svc.CallbackPath = "/api/v1/threads-accounts/oauth/callback" + return svc +} + func newStorage(c config.Config) fsDomain.Storage { os := c.ObjectStorage if strings.TrimSpace(os.Endpoint) == "" { @@ -83,13 +289,9 @@ func newStorage(c config.Config) fsDomain.Storage { return noop.New() } s, err := s3store.New(s3store.Config{ - Endpoint: os.Endpoint, - Region: os.Region, - Bucket: os.Bucket, - AccessKey: os.AccessKey, - SecretKey: os.SecretKey, - UsePathStyle: os.UsePathStyle, - PublicBaseURL: os.PublicBaseURL, + Endpoint: os.Endpoint, Region: os.Region, Bucket: os.Bucket, + AccessKey: os.AccessKey, SecretKey: os.SecretKey, + UsePathStyle: os.UsePathStyle, PublicBaseURL: os.PublicBaseURL, }) if err != nil { logx.Errorf("object storage MinIO/S3 init failed: %v — uploads disabled", err) @@ -105,22 +307,17 @@ func newNotification(c config.Config) notifDomain.UseCase { base = "http://127.0.0.1:5173" } brand := notifDomain.Brand{ - Name: c.Brand.Name, - Link: base, - LogoURL: c.Brand.LogoURL, - Copyright: c.Brand.Copyright, + Name: c.Brand.Name, Link: base, LogoURL: c.Brand.LogoURL, Copyright: c.Brand.Copyright, } if brand.Name == "" { brand.Name = "Harbor Desk" } if brand.LogoURL == "" { - // apps/web/public/brand-mark.jpg — 郵件客戶端需絕對 URL brand.LogoURL = base + "/brand-mark.jpg" } if brand.Copyright == "" { brand.Copyright = "© Harbor Desk" } - cfg := notifUC.Config{ Sender: c.Mail.Sender, DevExposeCode: c.Mail.DevExposeCode, @@ -129,10 +326,8 @@ func newNotification(c config.Config) notifDomain.UseCase { if c.Mail.SMTP.Host != "" { cfg.Providers = []notifDomain.EmailDelivery{ notifUC.NewSMTPDelivery(notifUC.SMTPConfig{ - Host: c.Mail.SMTP.Host, - Port: c.Mail.SMTP.Port, - User: c.Mail.SMTP.User, - Password: c.Mail.SMTP.Password, + Host: c.Mail.SMTP.Host, Port: c.Mail.SMTP.Port, + User: c.Mail.SMTP.User, Password: c.Mail.SMTP.Password, }), } logx.Infof("notification mail: smtp host=%s port=%d logo=%s expose_code=%v", @@ -147,3 +342,270 @@ func newNotification(c config.Config) notifDomain.UseCase { type errString string func (e errString) Error() string { return string(e) } + +// metaMediaBridge adapts threads MetaProvider → studio.ThreadsMediaSource +type metaMediaBridge struct { + Meta *threadsProv.MetaProvider +} + +func (b *metaMediaBridge) ListThreads(ctx context.Context, accessToken string, limit int) ([]studioUC.FetchedThread, error) { + list, err := b.Meta.ListThreads(ctx, accessToken, limit) + if err != nil { + return nil, err + } + out := make([]studioUC.FetchedThread, 0, len(list)) + for _, t := range list { + pub := int64(0) + if !t.Timestamp.IsZero() { + pub = t.Timestamp.UnixNano() + } + out = append(out, studioUC.FetchedThread{ + ID: t.ID, Text: t.Text, MediaType: t.MediaType, MediaURL: t.MediaURL, + ThumbnailURL: t.ThumbnailURL, Permalink: t.Permalink, Shortcode: t.Shortcode, + TopicTag: t.TopicTag, Username: t.Username, PublishedAt: pub, + }) + } + return out, nil +} + +func (b *metaMediaBridge) GetInsights(ctx context.Context, accessToken, mediaID string) (studioUC.FetchedInsights, error) { + ins, err := b.Meta.GetInsights(ctx, accessToken, mediaID) + if err != nil { + return studioUC.FetchedInsights{Status: "error", ErrorMsg: err.Error()}, nil + } + return studioUC.FetchedInsights{ + Views: ins.Views, Likes: ins.Likes, Replies: ins.Replies, Reposts: ins.Reposts, + Quotes: ins.Quotes, Shares: ins.Shares, Status: ins.Status, ErrorMsg: ins.ErrorMsg, + }, nil +} + +func (b *metaMediaBridge) ListConversation(ctx context.Context, accessToken, mediaID string, limit int) ([]studioUC.FetchedReply, error) { + list, err := b.Meta.ListConversation(ctx, accessToken, mediaID, limit) + if err != nil { + return nil, nil + } + out := make([]studioUC.FetchedReply, 0, len(list)) + for _, r := range list { + pub := int64(0) + if !r.Timestamp.IsZero() { + pub = r.Timestamp.UnixNano() + } + out = append(out, studioUC.FetchedReply{ + ID: r.ID, Text: r.Text, Username: r.Username, ParentMediaID: r.ParentMediaID, + PublishedAt: pub, IsMine: r.IsMine, LikeCount: r.LikeCount, + }) + } + return out, nil +} + +func (b *metaMediaBridge) ListMentions(ctx context.Context, accessToken, threadsUserID string, limit int) ([]studioUC.FetchedMention, error) { + list, err := b.Meta.ListMentions(ctx, accessToken, threadsUserID, limit) + if err != nil { + return nil, err + } + out := make([]studioUC.FetchedMention, 0, len(list)) + for _, m := range list { + pub := int64(0) + if !m.Timestamp.IsZero() { + pub = m.Timestamp.UnixNano() + } + out = append(out, studioUC.FetchedMention{ + ID: m.ID, Text: m.Text, Username: m.Username, Permalink: m.Permalink, + RootPostID: m.RootPostID, ParentID: m.ParentID, + IsReply: m.IsReply, IsQuotePost: m.IsQuotePost, PublishedAt: pub, + }) + } + return out, nil +} + +func (b *metaMediaBridge) ListProfilePosts(ctx context.Context, accessToken, username string, limit int) ([]studioUC.FetchedThread, error) { + list, err := b.Meta.ListProfilePosts(ctx, accessToken, username, limit) + if err != nil { + return nil, err + } + out := make([]studioUC.FetchedThread, 0, len(list)) + for _, t := range list { + pub := int64(0) + if !t.Timestamp.IsZero() { + pub = t.Timestamp.UnixNano() + } + out = append(out, studioUC.FetchedThread{ + ID: t.ID, Text: t.Text, MediaType: t.MediaType, MediaURL: t.MediaURL, + ThumbnailURL: t.ThumbnailURL, Permalink: t.Permalink, Shortcode: t.Shortcode, + TopicTag: t.TopicTag, Username: t.Username, PublishedAt: pub, + }) + } + return out, nil +} + +// personaJobBridge adapts job.Service → studio.PersonaAnalyzeScheduler (jobID only). +type personaJobBridge struct { + Jobs *jobUC.Service +} + +func (b *personaJobBridge) SchedulePersonaAnalyzeAccount(ctx context.Context, ownerUID int64, personaID, username, lang string) (string, error) { + if b == nil || b.Jobs == nil { + return "", fmt.Errorf("jobs not configured") + } + j, err := b.Jobs.SchedulePersonaAnalyzeAccount(ctx, ownerUID, personaID, username, lang) + if err != nil { + return "", err + } + return j.ID, nil +} + +func (b *personaJobBridge) SchedulePersonaAnalyzeText(ctx context.Context, ownerUID int64, personaID, rawText, sourceLabel, lang string) (string, error) { + if b == nil || b.Jobs == nil { + return "", fmt.Errorf("jobs not configured") + } + j, err := b.Jobs.SchedulePersonaAnalyzeText(ctx, ownerUID, personaID, rawText, sourceLabel, lang) + if err != nil { + return "", err + } + return j.ID, nil +} + +// studioAIKeys adapts member settings + usage key resolver for persona LLM. +type studioAIKeys struct { + Members memberDomain.Repository + Resolver *usageUC.SettingsResolver +} + +func (k *studioAIKeys) ResolveAI(ctx context.Context, ownerUID int64) (provider, model, apiKey string, err error) { + st, err := k.Members.GetSettings(ctx, ownerUID) + if err != nil || st == nil { + st = memberDomain.DefaultSettings(ownerUID) + } + // 完全依會員 AI 設定,不寫死/不替換模型 id + provider = ai.NormalizeProvider(st.Provider) + model = strings.TrimSpace(st.Model) + if model == "" { + return provider, "", "", fmt.Errorf("請到設定選擇 AI 模型") + } + if k.Resolver != nil { + _, key, rerr := k.Resolver.ResolveKey(ctx, ownerUID, usageDomain.MeterAICopy) + if rerr == nil && strings.TrimSpace(key) != "" { + return provider, model, key, nil + } + } + // BYOK direct + if key := st.KeyForProvider(provider); key != "" { + return provider, model, key, nil + } + return provider, model, "", fmt.Errorf("no AI key(請到設定填寫 Key,或確認平台已配置)") +} + +// inspirePersonaBridge — studio 人設 → 靈感 prompt 完整快照 +type inspirePersonaBridge struct { + Studio *studioUC.Service +} + +func (b *inspirePersonaBridge) ResolvePersona(ctx context.Context, ownerUID int64, personaID string) (*inspireUC.PersonaSnapshot, error) { + if b == nil || b.Studio == nil { + return nil, nil + } + if strings.TrimSpace(personaID) != "" { + if got, err := b.Studio.GetPersona(ctx, ownerUID, personaID); err == nil && got != nil { + return studioPersonaToSnapshot(got), nil + } + } + if aid, err := b.Studio.GetActivePersonaID(ctx, ownerUID); err == nil && aid != "" { + if got, gerr := b.Studio.GetPersona(ctx, ownerUID, aid); gerr == nil && got != nil { + return studioPersonaToSnapshot(got), nil + } + } + list, err := b.Studio.ListPersonas(ctx, ownerUID) + if err != nil || len(list) == 0 { + return nil, nil + } + return studioPersonaToSnapshot(list[0]), nil +} + +func studioPersonaToSnapshot(p *studioDomain.Persona) *inspireUC.PersonaSnapshot { + if p == nil { + return nil + } + fp := strings.TrimSpace(p.Style.DraftText) + if fp == "" { + fp = serializePersonaDraft(p.Style.Draft) + } + dims := map[string]string{} + for k, d := range p.Style.Dimensions { + if sm := strings.TrimSpace(d.Summary); sm != "" { + dims[k] = sm + } + } + avoid := append([]string{}, p.Guard.Avoid...) + return &inspireUC.PersonaSnapshot{ + ID: p.ID, Name: p.Name, Brief: p.Brief, Status: p.Status, + DraftText: fp, Voice: p.Voice, GuardAvoid: avoid, + MaxChars: p.Guard.MaxChars, BanAiTone: p.Guard.BanAiTone, + DimSummaries: dims, + } +} + +func serializePersonaDraft(d studioDomain.PersonaDraft) string { + type pair struct{ label, value string } + parts := []pair{ + {"我是誰", d.Identity}, + {"語氣", d.Tone}, + {"對誰說", d.Audience}, + {"開場鉤子", d.Hooks}, + {"語言指紋", d.LanguageFingerprint}, + {"節奏", d.Rhythm}, + {"標點", d.Punctuation}, + {"內容套路", d.ContentPatterns}, + {"知識轉譯", d.KnowledgeTranslation}, + {"CTA", d.CtaStyle}, + {"像他會說的話", d.Examples}, + {"絕不怎麼說", d.Avoid}, + } + var lines []string + for _, p := range parts { + v := strings.TrimSpace(p.value) + if v == "" { + continue + } + lines = append(lines, "【"+p.label+"】\n"+v) + } + return strings.Join(lines, "\n\n") +} + +// inspireBrandBridge — scout 品牌/產品目錄 +type inspireBrandBridge struct { + Scout *scoutUC.Service +} + +func (b *inspireBrandBridge) ListCatalog(ctx context.Context, ownerUID int64) ([]inspireUC.BrandSnapshot, error) { + if b == nil || b.Scout == nil { + return nil, nil + } + brands, err := b.Scout.ListBrands(ctx, ownerUID) + if err != nil { + return nil, err + } + out := make([]inspireUC.BrandSnapshot, 0, len(brands)) + for _, br := range brands { + if br == nil { + continue + } + snap := inspireUC.BrandSnapshot{ + ID: br.ID, DisplayName: br.DisplayName, Brief: br.Brief, + Audience: br.TargetAudience, Goals: br.Goals, + } + prods, _ := b.Scout.ListProducts(ctx, ownerUID, br.ID) + for _, pr := range prods { + if pr == nil { + continue + } + snap.Products = append(snap.Products, inspireUC.ProductSnapshot{ + ID: pr.ID, Label: pr.Label, Context: pr.ProductContext, + Tags: append([]string{}, pr.MatchTags...), + Pains: append([]string{}, pr.PainPoints...), + }) + } + out = append(out, snap) + } + return out, nil +} + diff --git a/apps/backend/internal/types/m2_convert.go b/apps/backend/internal/types/m2_convert.go new file mode 100644 index 0000000..bc685ba --- /dev/null +++ b/apps/backend/internal/types/m2_convert.go @@ -0,0 +1,42 @@ +package types + +import ( + appnotif "apps/backend/internal/module/appnotif/domain" + jobDomain "apps/backend/internal/module/job/domain" + threadsDomain "apps/backend/internal/module/threads/domain" +) + +func ThreadsAccountFromModel(a *threadsDomain.Account) ThreadsAccountPublic { + if a == nil { + return ThreadsAccountPublic{} + } + return ThreadsAccountPublic{ + Id: a.ID, Username: a.Username, DisplayName: a.DisplayName, + Connection: a.Connection, IsUsable: a.IsUsable, + AvatarColor: a.AvatarColor, AvatarUrl: a.AvatarURL, + ErrorMessage: a.ErrorMessage, + SessionExpiresAt: a.SessionExpiresAt, SessionRefreshedAt: a.SessionRefreshedAt, + } +} + +func JobFromModel(j *jobDomain.Job) JobPublic { + if j == nil { + return JobPublic{} + } + return JobPublic{ + Id: j.ID, TemplateType: j.TemplateType, Status: j.Status, + ProgressSummary: j.ProgressSummary, ProgressPercent: j.ProgressPercent, + Error: j.Error, RefId: j.RefID, Payload: j.Payload, RunAfter: j.RunAfter, + CreatedAt: j.CreatedAt, UpdatedAt: j.UpdatedAt, CompletedAt: j.CompletedAt, + } +} + +func NotificationFromModel(n *appnotif.Notification) NotificationPublic { + if n == nil { + return NotificationPublic{} + } + return NotificationPublic{ + Id: n.ID, Title: n.Title, Body: n.Body, Kind: n.Kind, + RefType: n.RefType, RefId: n.RefID, ReadAt: n.ReadAt, CreatedAt: n.CreatedAt, + } +} diff --git a/apps/backend/internal/types/m3_convert.go b/apps/backend/internal/types/m3_convert.go new file mode 100644 index 0000000..bf09f15 --- /dev/null +++ b/apps/backend/internal/types/m3_convert.go @@ -0,0 +1,33 @@ +package types + +import "apps/backend/internal/module/usage/domain" + +func UsageSummaryFromDomain(s *domain.MonthSummary) *UsageSummaryData { + if s == nil { + return nil + } + platBy := map[string]UsageMeterCount{} + for k, v := range s.Platform.ByMeter { + platBy[k] = UsageMeterCount{Count: v.Count, Credits: v.Credits} + } + byokBy := map[string]UsageMeterCount{} + for k, v := range s.Byok.ByMeter { + byokBy[k] = UsageMeterCount{Count: v.Count, Credits: v.Credits} + } + return &UsageSummaryData{ + MonthKey: s.MonthKey, Uid: s.UID, PlanId: s.PlanID, Unlimited: s.Unlimited, + Platform: UsagePlatformBlock{ + CreditsUsed: s.Platform.CreditsUsed, CreditsRemaining: s.Platform.CreditsRemaining, + CreditsTotal: s.Platform.CreditsTotal, CallCount: s.Platform.CallCount, ByMeter: platBy, + }, + Byok: UsageByokBlock{CallCount: s.Byok.CallCount, ByMeter: byokBy}, + TotalCredits: s.TotalCredits, RemainingCredits: s.RemainingCredits, + } +} + +func UsageEventFromDomain(e *domain.Event) UsageEventPublic { + return UsageEventPublic{ + Id: e.ID, Uid: e.UID, Meter: e.Meter, Credits: e.Credits, KeyMode: e.KeyMode, + Label: e.Label, Source: e.Source, CreatedAt: e.CreatedAt, + } +} diff --git a/apps/backend/internal/types/m4_convert.go b/apps/backend/internal/types/m4_convert.go new file mode 100644 index 0000000..f349d18 --- /dev/null +++ b/apps/backend/internal/types/m4_convert.go @@ -0,0 +1,213 @@ +package types + +import "apps/backend/internal/module/studio/domain" + +func PersonaFromDomain(p *domain.Persona) PersonaPublic { + if p == nil { + return PersonaPublic{} + } + return PersonaPublic{ + Id: p.ID, Name: p.Name, Brief: p.Brief, Status: p.Status, + Style: PersonaStylePublic{ + Draft: PersonaDraftPublic{ + Identity: p.Style.Draft.Identity, Tone: p.Style.Draft.Tone, + Audience: p.Style.Draft.Audience, Hooks: p.Style.Draft.Hooks, + LanguageFingerprint: p.Style.Draft.LanguageFingerprint, + Rhythm: p.Style.Draft.Rhythm, Punctuation: p.Style.Draft.Punctuation, + ContentPatterns: p.Style.Draft.ContentPatterns, + KnowledgeTranslation: p.Style.Draft.KnowledgeTranslation, + CtaStyle: p.Style.Draft.CtaStyle, Examples: p.Style.Draft.Examples, + Avoid: p.Style.Draft.Avoid, + }, + DraftText: p.Style.DraftText, Source: p.Style.Source, + BenchmarkUsername: p.Style.BenchmarkUsername, SourceLabel: p.Style.SourceLabel, + SampleCount: p.Style.SampleCount, AnalyzedAt: p.Style.AnalyzedAt, + SamplePreviews: p.Style.SamplePreviews, + Dimensions: styleDimensionsFromDomain(p.Style.Dimensions), + }, + Guard: PersonaGuardPublic{ + Avoid: p.Guard.Avoid, MaxChars: p.Guard.MaxChars, BanAiTone: p.Guard.BanAiTone, + }, + Voice: p.Voice, Notes: p.Notes, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, + } +} + +func styleDimensionsFromDomain(in map[string]domain.StyleDimension) map[string]StyleDimensionPublic { + if len(in) == 0 { + return nil + } + out := make(map[string]StyleDimensionPublic, len(in)) + for k, v := range in { + out[k] = StyleDimensionPublic{Summary: v.Summary, Evidence: v.Evidence} + } + return out +} + +func styleDimensionsToDomain(in map[string]StyleDimensionPublic) map[string]domain.StyleDimension { + if len(in) == 0 { + return nil + } + out := make(map[string]domain.StyleDimension, len(in)) + for k, v := range in { + out[k] = domain.StyleDimension{Summary: v.Summary, Evidence: v.Evidence} + } + return out +} + +func PersonaToDomain(req *PersonaSaveReq, ownerUID int64) *domain.Persona { + p := &domain.Persona{ + ID: req.Id, OwnerUID: ownerUID, Name: req.Name, Brief: req.Brief, Status: req.Status, + Voice: req.Voice, Notes: req.Notes, + Style: domain.PersonaStyle{ + Draft: domain.PersonaDraft{ + Identity: req.Style.Draft.Identity, Tone: req.Style.Draft.Tone, + Audience: req.Style.Draft.Audience, Hooks: req.Style.Draft.Hooks, + LanguageFingerprint: req.Style.Draft.LanguageFingerprint, + Rhythm: req.Style.Draft.Rhythm, Punctuation: req.Style.Draft.Punctuation, + ContentPatterns: req.Style.Draft.ContentPatterns, + KnowledgeTranslation: req.Style.Draft.KnowledgeTranslation, + CtaStyle: req.Style.Draft.CtaStyle, Examples: req.Style.Draft.Examples, + Avoid: req.Style.Draft.Avoid, + }, + DraftText: req.Style.DraftText, Source: req.Style.Source, + BenchmarkUsername: req.Style.BenchmarkUsername, SourceLabel: req.Style.SourceLabel, + SampleCount: req.Style.SampleCount, AnalyzedAt: req.Style.AnalyzedAt, + SamplePreviews: req.Style.SamplePreviews, + Dimensions: styleDimensionsToDomain(req.Style.Dimensions), + }, + Guard: domain.PersonaGuard{ + Avoid: req.Guard.Avoid, MaxChars: req.Guard.MaxChars, BanAiTone: req.Guard.BanAiTone, + }, + } + return p +} + +func PlayFromDomain(p *domain.Play) PlayPublic { + if p == nil { + return PlayPublic{} + } + steps := make([]PlayStepPublic, 0, len(p.Steps)) + for _, s := range p.Steps { + steps = append(steps, PlayStepPublic{ + Id: s.ID, SortOrder: s.SortOrder, Kind: s.Kind, AccountId: s.AccountID, + Text: s.Text, DelayFromPreviousSec: s.DelayFromPreviousSec, + PersonaId: s.PersonaID, BrandId: s.BrandID, ImageUrls: s.ImageURLs, + }) + } + out := PlayPublic{ + Id: p.ID, Title: p.Title, Topic: p.Topic, Status: p.Status, + LeadAccountId: p.LeadAccountID, CastAccountIds: p.CastAccountIDs, + TargetOwnPostId: p.TargetOwnPostID, Steps: steps, + ScheduleStartAt: p.ScheduleStartAt, IntervalMinutes: p.IntervalMinutes, + CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, + } + if p.TargetExternal != nil { + out.TargetExternal = ExternalTargetPublic{ + Url: p.TargetExternal.URL, RawUrl: p.TargetExternal.RawURL, + Shortcode: p.TargetExternal.Shortcode, AuthorUsername: p.TargetExternal.AuthorUsername, + TextPreview: p.TargetExternal.TextPreview, MediaId: p.TargetExternal.MediaID, + ResolvedAt: p.TargetExternal.ResolvedAt, + } + } + return out +} + +func PlayToDomain(req *PlaySaveReq, ownerUID int64) *domain.Play { + steps := make([]domain.PlayStep, 0, len(req.Steps)) + for _, s := range req.Steps { + steps = append(steps, domain.PlayStep{ + ID: s.Id, SortOrder: s.SortOrder, Kind: s.Kind, AccountID: s.AccountId, + Text: s.Text, DelayFromPreviousSec: s.DelayFromPreviousSec, + PersonaID: s.PersonaId, BrandID: s.BrandId, ImageURLs: s.ImageUrls, + }) + } + p := &domain.Play{ + ID: req.Id, OwnerUID: ownerUID, Title: req.Title, Topic: req.Topic, Status: req.Status, + LeadAccountID: req.LeadAccountId, CastAccountIDs: req.CastAccountIds, + TargetOwnPostID: req.TargetOwnPostId, Steps: steps, + ScheduleStartAt: req.ScheduleStartAt, IntervalMinutes: req.IntervalMinutes, + } + if req.TargetExternal.Url != "" { + p.TargetExternal = &domain.ExternalTarget{ + URL: req.TargetExternal.Url, RawURL: req.TargetExternal.RawUrl, + Shortcode: req.TargetExternal.Shortcode, AuthorUsername: req.TargetExternal.AuthorUsername, + TextPreview: req.TargetExternal.TextPreview, MediaID: req.TargetExternal.MediaId, + ResolvedAt: req.TargetExternal.ResolvedAt, + } + } + return p +} + +func ExternalFromDomain(t *domain.ExternalTarget) ExternalTargetPublic { + if t == nil { + return ExternalTargetPublic{} + } + return ExternalTargetPublic{ + Url: t.URL, RawUrl: t.RawURL, Shortcode: t.Shortcode, AuthorUsername: t.AuthorUsername, + TextPreview: t.TextPreview, MediaId: t.MediaID, ResolvedAt: t.ResolvedAt, + } +} + +func OutboxFromDomain(b *domain.OutboxBundle) OutboxPublic { + if b == nil { + return OutboxPublic{} + } + steps := make([]OutboxStepPublic, 0, len(b.Steps)) + for _, s := range b.Steps { + steps = append(steps, OutboxStepPublic{ + Id: s.ID, StepId: s.StepID, SortOrder: s.SortOrder, Kind: s.Kind, + AccountId: s.AccountID, Text: s.Text, Status: s.Status, Error: s.Error, + ScheduledAt: s.ScheduledAt, PublishedAt: s.PublishedAt, + }) + } + return OutboxPublic{ + Id: b.ID, PlayId: b.PlayID, Title: b.Title, Status: b.Status, + Steps: steps, CreatedAt: b.CreatedAt, UpdatedAt: b.UpdatedAt, + } +} + +func OwnPostFromDomain(p *domain.OwnPost) OwnPostPublic { + if p == nil { + return OwnPostPublic{} + } + replies := make([]OwnPostReplyPublic, 0, len(p.Replies)) + for _, r := range p.Replies { + replies = append(replies, OwnPostReplyPublic{ + Id: r.ID, Username: r.Username, Text: r.Text, CreatedAt: r.CreatedAt, + LikeCount: r.LikeCount, ReplyStatus: r.ReplyStatus, RepliedBy: r.RepliedBy, + RepliedAt: r.RepliedAt, ParentReplyId: r.ParentReplyID, IsMine: r.IsMine, + }) + } + return OwnPostPublic{ + Id: p.ID, AccountId: p.AccountID, MediaId: p.MediaID, Text: p.Text, + MediaType: p.MediaType, MediaUrl: p.MediaURL, ThumbnailUrl: p.ThumbnailURL, + Permalink: p.Permalink, Shortcode: p.Shortcode, TopicTag: p.TopicTag, + LikeCount: p.LikeCount, ReplyCount: p.ReplyCount, RepostCount: p.RepostCount, + QuoteCount: p.QuoteCount, ViewCount: p.ViewCount, ShareCount: p.ShareCount, + InsightsStatus: p.InsightsStatus, FormulaSummary: p.FormulaSummary, + Insight: p.Insight, FormulaDetail: p.FormulaDetail, Replies: replies, + PublishedAt: p.PublishedAt, + } +} + +func MentionFromDomain(m *domain.Mention) MentionPublic { + if m == nil { + return MentionPublic{} + } + return MentionPublic{ + Id: m.ID, AccountId: m.AccountID, MediaId: m.MediaID, + FromUsername: m.FromUsername, Text: m.Text, + ContextSnippet: m.ContextSnippet, Permalink: m.Permalink, + Status: m.Status, DraftText: m.DraftText, CreatedAt: m.CreatedAt, + } +} + +func ViralFromDomain(v *domain.ViralAnalysis) ViralAnalysisPublic { + if v == nil { + return ViralAnalysisPublic{} + } + return ViralAnalysisPublic{ + Hooks: v.Hooks, Structure: v.Structure, Emotion: v.Emotion, + Summary: v.Summary, Cta: v.CTA, Copyable: v.Copyable, Risks: v.Risks, + } +} diff --git a/apps/backend/internal/types/m5_convert.go b/apps/backend/internal/types/m5_convert.go new file mode 100644 index 0000000..2207f76 --- /dev/null +++ b/apps/backend/internal/types/m5_convert.go @@ -0,0 +1,129 @@ +package types + +import ( + inspireDomain "apps/backend/internal/module/inspire/domain" + scoutDomain "apps/backend/internal/module/scout/domain" +) + +func TrendFromDomain(t *inspireDomain.TrendItem) TrendPublic { + if t == nil { + return TrendPublic{} + } + return TrendPublic{ + Id: t.ID, Kind: t.Kind, Label: t.Label, Summary: t.Summary, Heat: t.Heat, + Keywords: t.Keywords, Samples: t.Samples, SourceLabel: t.SourceLabel, ObservedAt: t.ObservedAt, + } +} + +func ElementFromDomain(e *inspireDomain.Element) InspireElementPublic { + if e == nil { + return InspireElementPublic{} + } + return InspireElementPublic{ + Id: e.ID, Kind: e.Kind, Title: e.Title, Body: e.Body, RefId: e.RefID, + Reusable: e.Reusable, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, + } +} + +func SessionFromDomain(s *inspireDomain.Session) InspireSessionPublic { + if s == nil { + return InspireSessionPublic{} + } + msgs := make([]InspireMessagePublic, 0, len(s.Messages)) + for _, m := range s.Messages { + mp := InspireMessagePublic{Id: m.ID, Role: m.Role, Text: m.Text, CreatedAt: m.CreatedAt} + if m.Draft != nil { + mp.Draft = InspireDraftPublic{Title: m.Draft.Title, Body: m.Draft.Body} + } + msgs = append(msgs, mp) + } + return InspireSessionPublic{ + Id: s.ID, Title: s.Title, Messages: msgs, PinnedElementIds: s.PinnedElementIDs, + CreatedAt: s.CreatedAt, UpdatedAt: s.UpdatedAt, + } +} + +func SessionSummaryFromDomain(s inspireDomain.SessionSummary) InspireSessionSummaryPublic { + return InspireSessionSummaryPublic{ + Id: s.ID, Title: s.Title, Preview: s.Preview, MessageCount: s.MessageCount, + UpdatedAt: s.UpdatedAt, CreatedAt: s.CreatedAt, Active: s.Active, + } +} + +func ResearchHitFromDomain(h *inspireDomain.ResearchHit) ResearchHitPublic { + if h == nil { + return ResearchHitPublic{} + } + return ResearchHitPublic{ + Id: h.ID, Title: h.Title, Snippet: h.Snippet, Url: h.URL, Summary: h.Summary, + LearnPoints: h.LearnPoints, ReplyHooks: h.ReplyHooks, SourceLabel: h.SourceLabel, + } +} + +func BrandFromDomain(b *scoutDomain.Brand) BrandPublic { + if b == nil { + return BrandPublic{} + } + return BrandPublic{ + Id: b.ID, DisplayName: b.DisplayName, Brief: b.Brief, + TargetAudience: b.TargetAudience, Goals: b.Goals, + } +} + +func ProductFromDomain(p *scoutDomain.Product) ProductPublic { + if p == nil { + return ProductPublic{} + } + return ProductPublic{ + Id: p.ID, BrandId: p.BrandID, Label: p.Label, ProductContext: p.ProductContext, + MatchTags: p.MatchTags, PainPoints: p.PainPoints, PlacementUrl: p.PlacementURL, + CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, + } +} + +func BriefFromDomain(b *scoutDomain.RunBrief) ScoutBriefPublic { + if b == nil { + return ScoutBriefPublic{} + } + return ScoutBriefPublic{ + Intent: b.Intent, Mode: b.Mode, BrandId: b.BrandID, ProductId: b.ProductID, + ProductLabel: b.ProductLabel, Pains: b.Pains, Tags: b.Tags, Periphery: b.Periphery, + ScanTerms: b.ScanTerms, PlacementNote: b.PlacementNote, ResponseStance: b.ResponseStance, + ThemeKey: b.ThemeKey, ThemeLabel: b.ThemeLabel, ProductContext: b.ProductContext, + } +} + +func BriefToDomain(b *ScoutBriefPublic) *scoutDomain.RunBrief { + if b == nil { + return nil + } + return &scoutDomain.RunBrief{ + Intent: b.Intent, Mode: b.Mode, BrandID: b.BrandId, ProductID: b.ProductId, + ProductLabel: b.ProductLabel, Pains: b.Pains, Tags: b.Tags, Periphery: b.Periphery, + ScanTerms: b.ScanTerms, PlacementNote: b.PlacementNote, ResponseStance: b.ResponseStance, + ThemeKey: b.ThemeKey, ThemeLabel: b.ThemeLabel, ProductContext: b.ProductContext, + } +} + +func ScoutPostFromDomain(p *scoutDomain.Post) ScoutPostPublic { + if p == nil { + return ScoutPostPublic{} + } + return ScoutPostPublic{ + Id: p.ID, BrandId: p.BrandID, Author: p.Author, Text: p.Text, SearchTag: p.SearchTag, + Opportunity: p.Opportunity, OutreachStatus: p.OutreachStatus, DraftText: p.DraftText, + Score: p.Score, MatchedProductId: p.MatchedProductID, MatchedProductLabel: p.MatchedProductLabel, + MatchReason: p.MatchReason, ScoutMode: p.ScoutMode, IntentSnippet: p.IntentSnippet, + ThemeKey: p.ThemeKey, ThemeLabel: p.ThemeLabel, ScanPath: p.ScanPath, CreatedAt: p.CreatedAt, + } +} + +func HomeworkFromDomain(h *scoutDomain.Homework) ScoutHomeworkPublic { + if h == nil { + return ScoutHomeworkPublic{} + } + return ScoutHomeworkPublic{ + ThemeKey: h.ThemeKey, ThemeLabel: h.ThemeLabel, Purpose: h.Purpose, + Brief: BriefFromDomain(&h.Brief), CreatedAt: h.CreatedAt, + } +} diff --git a/apps/backend/internal/types/types.go b/apps/backend/internal/types/types.go index 81bc5e0..3138c7f 100644 --- a/apps/backend/internal/types/types.go +++ b/apps/backend/internal/types/types.go @@ -3,6 +3,18 @@ package types +type AICompleteData struct { + Text string `json:"text"` + KeyMode string `json:"key_mode"` + Model string `json:"model"` +} + +type AICompleteReq struct { + Prompt string `json:"prompt"` + Model string `json:"model,optional"` + Meter string `json:"meter,optional"` // default ai_copy +} + type AdminCreateMemberData struct { User MemberPublic `json:"user"` TemporaryPassword string `json:"temporary_password"` @@ -67,13 +79,18 @@ type AdminUidPath struct { } type AiSettingsData struct { - Provider string `json:"provider"` - Model string `json:"model"` - ResearchProvider string `json:"research_provider"` - ResearchModel string `json:"research_model"` - ApiKeyConfigured bool `json:"api_key_configured"` - ResearchApiKeyConfigured bool `json:"research_api_key_configured"` - PlatformKeyAvailable bool `json:"platform_key_available"` + Provider string `json:"provider"` + Model string `json:"model"` + ResearchProvider string `json:"research_provider"` + ResearchModel string `json:"research_model"` + ApiKeyConfigured bool `json:"api_key_configured"` + ResearchApiKeyConfigured bool `json:"research_api_key_configured"` + PlatformKeyAvailable bool `json:"platform_key_available"` + Models []string `json:"models"` + ModelsProvider string `json:"models_provider"` + SelectedModel string `json:"selected_model"` + ModelsFromCache bool `json:"models_from_cache,optional"` + ModelsError string `json:"models_error,optional"` } type AuthBindReq struct { @@ -121,24 +138,24 @@ type AuthOAuthStartData struct { } type AuthProfilePatchReq struct { - DisplayName string `json:"display_name,optional"` - Nickname string `json:"nickname,optional"` - FullName string `json:"full_name,optional"` - Email string `json:"email,optional"` - Phone string `json:"phone,optional"` - Bio string `json:"bio,optional"` - Timezone string `json:"timezone,optional"` + DisplayName string `json:"display_name,optional"` + Nickname string `json:"nickname,optional"` + FullName string `json:"full_name,optional"` + Email string `json:"email,optional"` + Phone string `json:"phone,optional"` + Bio string `json:"bio,optional"` + Timezone string `json:"timezone,optional"` NotifyEmail *bool `json:"notify_email,optional"` AvatarUrl *string `json:"avatar_url,optional"` GenderCode *int `json:"gender_code,optional"` - Birthdate *int64 `json:"birthdate,optional"` - National string `json:"national,optional"` - Address string `json:"address,optional"` - PostCode string `json:"post_code,optional"` - PreferredLanguage string `json:"preferred_language,optional"` - Currency string `json:"currency,optional"` - CurrentPassword string `json:"current_password,optional"` - NewPassword string `json:"new_password,optional"` + Birthdate *int64 `json:"birthdate,optional"` + National string `json:"national,optional"` + Address string `json:"address,optional"` + PostCode string `json:"post_code,optional"` + PreferredLanguage string `json:"preferred_language,optional"` + Currency string `json:"currency,optional"` + CurrentPassword string `json:"current_password,optional"` + NewPassword string `json:"new_password,optional"` } type AuthRefreshReq struct { @@ -195,9 +212,104 @@ type AuthVerifyEmailReq struct { Code string `json:"code"` } +type BrandActiveData struct { + Id string `json:"id"` +} + +type BrandIdPath struct { + Id string `path:"id"` +} + +type BrandListData struct { + List []BrandPublic `json:"list"` +} + +type BrandPublic struct { + Id string `json:"id"` + DisplayName string `json:"display_name"` + Brief string `json:"brief"` + TargetAudience string `json:"target_audience,optional"` + Goals string `json:"goals,optional"` +} + +type BrandSaveReq struct { + Id string `json:"id,optional"` + DisplayName string `json:"display_name"` + Brief string `json:"brief,optional"` + TargetAudience string `json:"target_audience,optional"` + Goals string `json:"goals,optional"` +} + +type BrandSetActiveReq struct { + Id string `json:"id"` +} + +type ComposeMimicData struct { + Text string `json:"text,optional"` + JobId string `json:"job_id,optional"` + Async bool `json:"async,optional"` +} + +type ComposeMimicReq struct { + SourceText string `json:"source_text"` + PersonaId string `json:"persona_id,optional"` + StructureNotes string `json:"structure_notes,optional"` +} + +type ComposePersonaPreviewData struct { + Topic string `json:"topic"` + TopicSource string `json:"topic_source"` // news | manual | fallback + PostText string `json:"post_text"` + ReplyText string `json:"reply_text"` + Notes string `json:"notes,optional"` +} + +type ComposePersonaPreviewReq struct { + PersonaId string `json:"persona_id"` + Topic string `json:"topic,optional"` + UseNews bool `json:"use_news,optional"` +} + +type ComposePublishReq struct { + AccountId string `json:"account_id"` + Text string `json:"text"` + Title string `json:"title,optional"` + ImageUrls []string `json:"image_urls,optional"` + ScheduleStartAt int64 `json:"schedule_start_at,optional"` + TopicTag string `json:"topic_tag,optional"` +} + +type ComposeViralReq struct { + Text string `json:"text"` +} + type Empty struct { } +type ExternalTargetPublic struct { + Url string `json:"url"` + RawUrl string `json:"raw_url,optional"` + Shortcode string `json:"shortcode,optional"` + AuthorUsername string `json:"author_username,optional"` + TextPreview string `json:"text_preview,optional"` + MediaId string `json:"media_id,optional"` + ResolvedAt int64 `json:"resolved_at,optional"` +} + +type GenerateImageData struct { + Id string `json:"id"` + Url string `json:"url"` + Prompt string `json:"prompt"` +} + +type GenerateImageReq struct { + Prompt string `json:"prompt"` +} + +type GetAiReq struct { + Provider string `form:"provider,optional"` +} + type HealthData struct { Status string `json:"status"` Service string `json:"service"` @@ -210,9 +322,183 @@ type IdentityPublic struct { CreatedAt int64 `json:"created_at,optional"` } +type ImportProductData struct { + Label string `json:"label"` + ProductContext string `json:"product_context"` + PainPoints []string `json:"pain_points"` + MatchTags []string `json:"match_tags"` + PlacementUrl string `json:"placement_url"` + SourceNote string `json:"source_note"` +} + +type ImportProductReq struct { + Url string `json:"url"` +} + +type ImportThreadsAccountSessionData struct { + Success bool `json:"success"` + Valid bool `json:"valid"` + Synced bool `json:"synced"` + AccountId string `json:"account_id"` + Username string `json:"username,optional"` + Message string `json:"message"` + UpdateAt int64 `json:"update_at,optional"` +} + +type ImportThreadsAccountSessionReq struct { + Id string `path:"id"` + StorageState string `json:"storageState"` +} + +type InspireChatData struct { + Session InspireSessionPublic `json:"session"` + Messages []InspireMessagePublic `json:"messages"` +} + +type InspireChatReq struct { + Message string `json:"message"` + PinnedIds []string `json:"pinned_ids,optional"` + Mode string `json:"mode,optional"` // chat|generate + PersonaId string `json:"persona_id,optional"` + SessionId string `json:"session_id,optional"` // 空 = active + // generate:待改寫素材(鎖定內容) + Material string `json:"material,optional"` +} + +type InspireDraftPublic struct { + Title string `json:"title,optional"` + Body string `json:"body"` +} + +type InspireElementIdPath struct { + Id string `path:"id"` +} + +type InspireElementListData struct { + List []InspireElementPublic `json:"list"` +} + +type InspireElementPublic struct { + Id string `json:"id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + RefId string `json:"ref_id,optional"` + Reusable bool `json:"reusable"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type InspireElementSaveReq struct { + Id string `json:"id,optional"` + Kind string `json:"kind,optional"` + Title string `json:"title"` + Body string `json:"body"` + RefId string `json:"ref_id,optional"` + Reusable bool `json:"reusable,optional"` +} + +type InspireMessagePublic struct { + Id string `json:"id"` + Role string `json:"role"` + Text string `json:"text"` + Draft InspireDraftPublic `json:"draft,optional"` + CreatedAt int64 `json:"created_at"` +} + +type InspirePromptBlockPublic struct { + Title string `json:"title"` + Body string `json:"body"` +} + +type InspirePromptPinnedPublic struct { + Id string `json:"id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` +} + +type InspirePromptPreviewData struct { + Prompt string `json:"prompt"` + Mode string `json:"mode"` + Sections []string `json:"sections"` + Blocks []InspirePromptBlockPublic `json:"blocks"` + Fingerprint string `json:"fingerprint"` + CharCount int `json:"char_count"` + RuneCount int `json:"rune_count"` + Pinned []InspirePromptPinnedPublic `json:"pinned"` + Note string `json:"note,optional"` +} + +type InspirePromptPreviewReq struct { + Message string `json:"message,optional"` + PinnedIds []string `json:"pinned_ids,optional"` + Mode string `json:"mode,optional"` // chat|generate + PersonaId string `json:"persona_id,optional"` + SessionId string `json:"session_id,optional"` + Material string `json:"material,optional"` +} + +type InspireSessionCreateReq struct { + Title string `json:"title,optional"` +} + +type InspireSessionIdPath struct { + Id string `path:"id"` +} + +type InspireSessionListData struct { + List []InspireSessionSummaryPublic `json:"list"` +} + +type InspireSessionPublic struct { + Id string `json:"id"` + Title string `json:"title,optional"` + Messages []InspireMessagePublic `json:"messages"` + PinnedElementIds []string `json:"pinned_element_ids"` + CreatedAt int64 `json:"created_at,optional"` + UpdatedAt int64 `json:"updated_at"` +} + +type InspireSessionSummaryPublic struct { + Id string `json:"id"` + Title string `json:"title"` + Preview string `json:"preview"` + MessageCount int `json:"message_count"` + UpdatedAt int64 `json:"updated_at"` + CreatedAt int64 `json:"created_at"` + Active bool `json:"active"` +} + +type JobIdPath struct { + Id string `path:"id"` +} + +type JobListData struct { + List []JobPublic `json:"list"` +} + +type JobPublic struct { + Id string `json:"id"` + TemplateType string `json:"template_type"` + Status string `json:"status"` + ProgressSummary string `json:"progress_summary"` + ProgressPercent int `json:"progress_percent"` + Error string `json:"error,optional"` + RefId string `json:"ref_id,optional"` + Payload string `json:"payload,optional"` + RunAfter int64 `json:"run_after,optional"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + CompletedAt int64 `json:"completed_at,optional"` +} + type ListModelsData struct { - List []string `json:"list"` - Provider string `json:"provider"` + List []string `json:"list"` + Provider string `json:"provider"` + SelectedModel string `json:"selected_model,optional"` + FromCache bool `json:"from_cache,optional"` + ModelsError string `json:"models_error,optional"` } type ListModelsReq struct { @@ -262,11 +548,186 @@ type MemberPublic struct { UpdatedAt int64 `json:"updated_at,optional"` } +type MentionGenerateReq struct { + Id string `path:"id"` + PersonaId string `json:"persona_id,optional"` +} + +type MentionIdPath struct { + Id string `path:"id"` +} + +type MentionListData struct { + List []MentionPublic `json:"list"` +} + +type MentionListReq struct { + AccountId string `form:"account_id,optional"` +} + +type MentionMarkRepliedReq struct { + Id string `path:"id"` + Text string `json:"text,optional"` + ImageUrls []string `json:"image_urls,optional"` +} + +type MentionPublic struct { + Id string `json:"id"` + AccountId string `json:"account_id"` + MediaId string `json:"media_id,optional"` + FromUsername string `json:"from_username"` + Text string `json:"text"` + ContextSnippet string `json:"context_snippet"` + Permalink string `json:"permalink,optional"` + Status string `json:"status"` + DraftText string `json:"draft_text,optional"` + CreatedAt int64 `json:"created_at"` +} + +type MentionSyncReq struct { + AccountId string `json:"account_id"` +} + +type NotificationIdPath struct { + Id string `path:"id"` +} + +type NotificationListData struct { + List []NotificationPublic `json:"list"` +} + +type NotificationPublic struct { + Id string `json:"id"` + Title string `json:"title"` + Body string `json:"body"` + Kind string `json:"kind"` // job|system + RefType string `json:"ref_type"` + RefId string `json:"ref_id,optional"` + ReadAt int64 `json:"read_at,optional"` + CreatedAt int64 `json:"created_at"` +} + type OkData struct { Ok bool `json:"ok"` Message string `json:"message,optional"` } +type OutboxIdPath struct { + Id string `path:"id"` +} + +type OutboxListData struct { + List []OutboxPublic `json:"list"` +} + +type OutboxPublic struct { + Id string `json:"id"` + PlayId string `json:"play_id"` + Title string `json:"title"` + Status string `json:"status"` + Steps []OutboxStepPublic `json:"steps"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type OutboxRetryPath struct { + Id string `path:"id"` + StepId string `path:"stepId"` +} + +type OutboxStepPublic struct { + Id string `json:"id"` + StepId string `json:"step_id"` + SortOrder int `json:"sort_order"` + Kind string `json:"kind"` + AccountId string `json:"account_id"` + Text string `json:"text"` + Status string `json:"status"` + Error string `json:"error,optional"` + ScheduledAt int64 `json:"scheduled_at,optional"` + PublishedAt int64 `json:"published_at,optional"` +} + +type OwnPostAnalyzeReq struct { + PostId string `json:"post_id"` +} + +type OwnPostGenerateReplyData struct { + Text string `json:"text"` +} + +type OwnPostGenerateReplyReq struct { + PostId string `json:"post_id"` + ReplyId string `json:"reply_id,optional"` + PersonaId string `json:"persona_id,optional"` +} + +type OwnPostLastSyncedData struct { + LastSyncedAt int64 `json:"last_synced_at"` +} + +type OwnPostListData struct { + List []OwnPostPublic `json:"list"` +} + +type OwnPostListReq struct { + AccountId string `form:"account_id,optional"` +} + +type OwnPostLoadRepliesReq struct { + PostId string `json:"post_id"` +} + +type OwnPostPublic struct { + Id string `json:"id"` + AccountId string `json:"account_id"` + MediaId string `json:"media_id,optional"` + Text string `json:"text"` + MediaType string `json:"media_type"` + MediaUrl string `json:"media_url,optional"` + ThumbnailUrl string `json:"thumbnail_url,optional"` + Permalink string `json:"permalink,optional"` + Shortcode string `json:"shortcode,optional"` + TopicTag string `json:"topic_tag,optional"` + LikeCount int `json:"like_count"` + ReplyCount int `json:"reply_count"` + RepostCount int `json:"repost_count"` + QuoteCount int `json:"quote_count"` + ViewCount int `json:"view_count"` + ShareCount int `json:"share_count"` + InsightsStatus string `json:"insights_status,optional"` + FormulaSummary string `json:"formula_summary,optional"` + Insight string `json:"insight,optional"` + FormulaDetail string `json:"formula_detail,optional"` + Replies []OwnPostReplyPublic `json:"replies"` + PublishedAt int64 `json:"published_at"` +} + +type OwnPostReplyPublic struct { + Id string `json:"id"` + Username string `json:"username"` + Text string `json:"text"` + CreatedAt int64 `json:"created_at"` + LikeCount int `json:"like_count,optional"` + ReplyStatus string `json:"reply_status,optional"` + RepliedBy string `json:"replied_by,optional"` + RepliedAt int64 `json:"replied_at,optional"` + ParentReplyId string `json:"parent_reply_id,optional"` + IsMine bool `json:"is_mine,optional"` +} + +type OwnPostSendReplyReq struct { + PostId string `json:"post_id"` + ReplyId string `json:"reply_id,optional"` + Text string `json:"text"` + AccountId string `json:"account_id,optional"` + ImageUrls []string `json:"image_urls,optional"` +} + +type OwnPostSyncReq struct { + AccountId string `json:"account_id"` +} + type Pagination struct { Page int `json:"page"` PageSize int `json:"pageSize"` @@ -274,6 +735,90 @@ type Pagination struct { TotalPages int `json:"totalPages"` } +type PersonaActiveData struct { + Id string `json:"id"` +} + +type PersonaAnalyzeAccountReq struct { + Id string `path:"id"` + Username string `json:"username"` +} + +type PersonaAnalyzeTextReq struct { + Id string `path:"id"` + RawText string `json:"raw_text"` + SourceLabel string `json:"source_label,optional"` +} + +type PersonaDraftPublic struct { + Identity string `json:"identity"` + Tone string `json:"tone"` + Audience string `json:"audience"` + Hooks string `json:"hooks"` + LanguageFingerprint string `json:"language_fingerprint"` + Rhythm string `json:"rhythm"` + Punctuation string `json:"punctuation"` + ContentPatterns string `json:"content_patterns"` + KnowledgeTranslation string `json:"knowledge_translation"` + CtaStyle string `json:"cta_style"` + Examples string `json:"examples"` + Avoid string `json:"avoid"` +} + +type PersonaGuardPublic struct { + Avoid []string `json:"avoid"` + MaxChars int `json:"max_chars"` + BanAiTone bool `json:"ban_ai_tone"` +} + +type PersonaIdPath struct { + Id string `path:"id"` +} + +type PersonaListData struct { + List []PersonaPublic `json:"list"` +} + +type PersonaPublic struct { + Id string `json:"id"` + Name string `json:"name"` + Brief string `json:"brief"` + Status string `json:"status"` + Style PersonaStylePublic `json:"style"` + Guard PersonaGuardPublic `json:"guard"` + Voice string `json:"voice,optional"` + Notes string `json:"notes,optional"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type PersonaSaveReq struct { + Id string `json:"id,optional"` + Name string `json:"name"` + Brief string `json:"brief,optional"` + Status string `json:"status,optional"` + Style PersonaStylePublic `json:"style,optional"` + Guard PersonaGuardPublic `json:"guard,optional"` + Voice string `json:"voice,optional"` + Notes string `json:"notes,optional"` +} + +type PersonaSetActiveReq struct { + Id string `json:"id"` +} + +type PersonaStylePublic struct { + Draft PersonaDraftPublic `json:"draft"` + DraftText string `json:"draft_text"` + Source string `json:"source"` + BenchmarkUsername string `json:"benchmark_username,optional"` + SourceLabel string `json:"source_label,optional"` + SampleCount int `json:"sample_count"` + AnalyzedAt int64 `json:"analyzed_at,optional"` + SamplePreviews []string `json:"sample_previews,optional"` + Dimensions map[string]StyleDimensionPublic `json:"dimensions,optional"` +} + type PlacementSettingsData struct { WebSearchProvider string `json:"web_search_provider"` ExpandStrategy string `json:"expand_strategy"` @@ -283,6 +828,139 @@ type PlacementSettingsData struct { PlatformKeyAvailable bool `json:"platform_key_available"` } +type PlayByExternalReq struct { + Url string `form:"url"` +} + +type PlayByPostReq struct { + OwnPostId string `form:"own_post_id"` +} + +type PlayGenerateScriptData struct { + JobId string `json:"job_id"` + Async bool `json:"async"` +} + +type PlayGenerateStepData struct { + Text string `json:"text"` +} + +type PlayGenerateStepReq struct { + PersonaId string `json:"persona_id,optional"` + Context string `json:"context"` // 主貼/上一則/目標文 + Topic string `json:"topic,optional"` + SpeakerLabel string `json:"speaker_label,optional"` + IsLead bool `json:"is_lead,optional"` + Mode string `json:"mode,optional"` +} + +type PlayIdPath struct { + Id string `path:"id"` +} + +type PlayListData struct { + List []PlayPublic `json:"list"` +} + +type PlayPublic struct { + Id string `json:"id"` + Title string `json:"title"` + Topic string `json:"topic"` + Status string `json:"status"` + LeadAccountId string `json:"lead_account_id"` + CastAccountIds []string `json:"cast_account_ids"` + TargetOwnPostId string `json:"target_own_post_id,optional"` + TargetExternal ExternalTargetPublic `json:"target_external,optional"` + Steps []PlayStepPublic `json:"steps"` + ScheduleStartAt int64 `json:"schedule_start_at"` + IntervalMinutes int `json:"interval_minutes,optional"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type PlayResolveReq struct { + Url string `json:"url"` +} + +type PlaySaveReq struct { + Id string `json:"id,optional"` + Title string `json:"title"` + Topic string `json:"topic,optional"` + Status string `json:"status,optional"` + LeadAccountId string `json:"lead_account_id"` + CastAccountIds []string `json:"cast_account_ids,optional"` + TargetOwnPostId string `json:"target_own_post_id,optional"` + TargetExternal ExternalTargetPublic `json:"target_external,optional"` + Steps []PlayStepPublic `json:"steps"` + ScheduleStartAt int64 `json:"schedule_start_at,optional"` + IntervalMinutes int `json:"interval_minutes,optional"` +} + +type PlayStepPublic struct { + Id string `json:"id"` + SortOrder int `json:"sort_order"` + Kind string `json:"kind"` + AccountId string `json:"account_id"` + Text string `json:"text"` + DelayFromPreviousSec int `json:"delay_from_previous_sec"` + PersonaId string `json:"persona_id,optional"` + BrandId string `json:"brand_id,optional"` + ImageUrls []string `json:"image_urls,optional"` +} + +type ProductIdPath struct { + Id string `path:"id"` +} + +type ProductListData struct { + List []ProductPublic `json:"list"` +} + +type ProductListReq struct { + BrandId string `form:"brand_id,optional"` +} + +type ProductPublic struct { + Id string `json:"id"` + BrandId string `json:"brand_id"` + Label string `json:"label"` + ProductContext string `json:"product_context"` + MatchTags []string `json:"match_tags"` + PainPoints []string `json:"pain_points"` + PlacementUrl string `json:"placement_url,optional"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type ProductSaveReq struct { + Id string `json:"id,optional"` + BrandId string `json:"brand_id"` + Label string `json:"label"` + ProductContext string `json:"product_context,optional"` + MatchTags []string `json:"match_tags,optional"` + PainPoints []string `json:"pain_points,optional"` + PlacementUrl string `json:"placement_url,optional"` +} + +type ResearchHitPublic struct { + Id string `json:"id"` + Title string `json:"title"` + Snippet string `json:"snippet"` + Url string `json:"url"` + Summary string `json:"summary,optional"` + LearnPoints []string `json:"learn_points,optional"` + ReplyHooks []string `json:"reply_hooks,optional"` + SourceLabel string `json:"source_label,optional"` +} + +type ResearchSearchData struct { + List []ResearchHitPublic `json:"list"` +} + +type ResearchSearchReq struct { + Query string `json:"query"` +} + type SaveAiReq struct { Provider string `json:"provider,optional"` Model string `json:"model,optional"` @@ -301,9 +979,311 @@ type SavePlacementReq struct { ExaApiKeyConfigured *bool `json:"exa_api_key_configured,optional"` } +type ScoutBriefPublic struct { + Intent string `json:"intent"` + Mode string `json:"mode"` + BrandId string `json:"brand_id,optional"` + ProductId string `json:"product_id,optional"` + ProductLabel string `json:"product_label,optional"` + Pains []string `json:"pains"` + Tags []string `json:"tags"` + Periphery []string `json:"periphery"` + ScanTerms []string `json:"scan_terms"` + PlacementNote string `json:"placement_note,optional"` + ResponseStance string `json:"response_stance,optional"` + ThemeKey string `json:"theme_key,optional"` + ThemeLabel string `json:"theme_label,optional"` + ProductContext string `json:"product_context,optional"` +} + +type ScoutBriefReq struct { + Intent string `json:"intent"` + BrandId string `json:"brand_id,optional"` + ProductId string `json:"product_id,optional"` + Purpose string `json:"purpose,optional"` + Deep bool `json:"deep,optional"` +} + +type ScoutCrawlerSessionReq struct { + Token string `json:"token"` +} + +type ScoutDraftPathReq struct { + Id string `path:"id"` + PersonaId string `json:"persona_id,optional"` +} + +type ScoutHomeworkListData struct { + List []ScoutHomeworkPublic `json:"list"` +} + +type ScoutHomeworkPublic struct { + ThemeKey string `json:"theme_key"` + ThemeLabel string `json:"theme_label"` + Purpose string `json:"purpose"` + Brief ScoutBriefPublic `json:"brief"` + CreatedAt int64 `json:"created_at"` +} + +type ScoutHomeworkSaveReq struct { + ThemeKey string `json:"theme_key"` + ThemeLabel string `json:"theme_label"` + Purpose string `json:"purpose,optional"` + Brief ScoutBriefPublic `json:"brief"` +} + +type ScoutPostIdPath struct { + Id string `path:"id"` +} + +type ScoutPostListData struct { + List []ScoutPostPublic `json:"list"` +} + +type ScoutPostListReq struct { + BrandId string `form:"brand_id,optional"` +} + +type ScoutPostPublic struct { + Id string `json:"id"` + BrandId string `json:"brand_id,optional"` + Author string `json:"author"` + Text string `json:"text"` + SearchTag string `json:"search_tag"` + Opportunity string `json:"opportunity"` + OutreachStatus string `json:"outreach_status"` + DraftText string `json:"draft_text,optional"` + Score int `json:"score"` + MatchedProductId string `json:"matched_product_id,optional"` + MatchedProductLabel string `json:"matched_product_label,optional"` + MatchReason string `json:"match_reason,optional"` + ScoutMode string `json:"scout_mode,optional"` + IntentSnippet string `json:"intent_snippet,optional"` + ThemeKey string `json:"theme_key,optional"` + ThemeLabel string `json:"theme_label,optional"` + ScanPath string `json:"scan_path,optional"` + CreatedAt int64 `json:"created_at"` +} + +type ScoutScanReq struct { + Brief ScoutBriefPublic `json:"brief"` +} + +type ScoutSendPathReq struct { + Id string `path:"id"` + Text string `json:"text,optional"` + AccountId string `json:"account_id,optional"` +} + +type ScoutThemePath struct { + ThemeKey string `path:"themeKey"` +} + +type SearchData struct { + List []SearchHit `json:"list"` + KeyMode string `json:"key_mode"` +} + +type SearchHit struct { + Title string `json:"title"` + Url string `json:"url"` + Snippet string `json:"snippet"` +} + +type SearchReq struct { + Query string `json:"query"` + Limit int `json:"limit,optional"` +} + +type StyleDimensionPublic struct { + Summary string `json:"summary"` + Evidence []string `json:"evidence,optional"` +} + +type ThreadsAccountIdPath struct { + Id string `path:"id"` +} + +type ThreadsAccountListData struct { + List []ThreadsAccountPublic `json:"list"` +} + +type ThreadsAccountPublic struct { + Id string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Connection string `json:"connection"` // connected|error|disconnected + IsUsable bool `json:"is_usable"` + AvatarColor string `json:"avatar_color,optional"` + AvatarUrl string `json:"avatar_url,optional"` + ErrorMessage string `json:"error_message,optional"` + SessionExpiresAt int64 `json:"session_expires_at,optional"` + SessionRefreshedAt int64 `json:"session_refreshed_at,optional"` +} + +type ThreadsOAuthCallbackData struct { + Ok bool `json:"ok"` + Message string `json:"message,optional"` + RedirectUrl string `json:"redirect_url,optional"` +} + +type ThreadsOAuthCallbackReq struct { + Code string `form:"code,optional"` + State string `form:"state,optional"` + Error string `form:"error,optional"` + ErrorReason string `form:"error_reason,optional"` + ErrorDescription string `form:"error_description,optional"` +} + +type ThreadsOAuthStartData struct { + AuthorizeUrl string `json:"authorize_url"` + State string `json:"state"` +} + +type ThreadsSettingsData struct { + Provider string `json:"provider"` // fake | meta + Configured bool `json:"configured"` + OauthReady bool `json:"oauth_ready"` + ConnectPath string `json:"connect_path"` // 前端連帳入口 + CallbackUrl string `json:"callback_url"` + PublicWebBase string `json:"public_web_base"` + AppIdMasked string `json:"app_id_masked,optional"` + Hint string `json:"hint"` +} + type TokenPair struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` TokenType string `json:"token_type"` ExpiresIn int64 `json:"expires_in"` } + +type TrendListData struct { + List []TrendPublic `json:"list"` +} + +type TrendPublic struct { + Id string `json:"id"` + Kind string `json:"kind"` + Label string `json:"label"` + Summary string `json:"summary"` + Heat int `json:"heat"` + Keywords []string `json:"keywords"` + Samples []string `json:"samples"` + SourceLabel string `json:"source_label"` + ObservedAt int64 `json:"observed_at"` +} + +type UnreadCountData struct { + Count int64 `json:"count"` +} + +type UsageByokBlock struct { + CallCount int `json:"call_count"` + ByMeter map[string]UsageMeterCount `json:"by_meter,optional"` +} + +type UsageEventPublic struct { + Id string `json:"id"` + Uid int64 `json:"uid"` + Meter string `json:"meter"` + Credits int `json:"credits"` + KeyMode string `json:"key_mode"` + Label string `json:"label"` + Source string `json:"source"` + CreatedAt int64 `json:"created_at"` +} + +type UsageEventsData struct { + List []UsageEventPublic `json:"list"` +} + +type UsageEventsReq struct { + MonthKey string `form:"month_key,optional"` + KeyMode string `form:"key_mode,optional"` // platform|byok|all + Limit int `form:"limit,optional"` +} + +type UsageMeterCount struct { + Count int `json:"count"` + Credits int `json:"credits"` +} + +type UsagePlatformBlock struct { + CreditsUsed int `json:"credits_used"` + CreditsRemaining int `json:"credits_remaining"` + CreditsTotal int `json:"credits_total"` + CallCount int `json:"call_count"` + ByMeter map[string]UsageMeterCount `json:"by_meter,optional"` +} + +type UsagePrefsData struct { + PlanId string `json:"plan_id"` + Unlimited bool `json:"unlimited"` +} + +type UsagePurchasePublic struct { + Id string `json:"id"` + PlanId string `json:"plan_id"` + MockRef string `json:"mock_ref,optional"` + CreatedAt int64 `json:"created_at"` +} + +type UsagePurchaseReq struct { + PlanId string `json:"plan_id"` + MockRef string `json:"mock_ref,optional"` +} + +type UsagePurchasesData struct { + List []UsagePurchasePublic `json:"list"` +} + +type UsageSetPrefsReq struct { + Uid string `json:"uid"` + PlanId string `json:"plan_id,optional"` + Unlimited *bool `json:"unlimited,optional"` +} + +type UsageSummaryData struct { + MonthKey string `json:"month_key"` + Uid int64 `json:"uid"` + PlanId string `json:"plan_id"` + Unlimited bool `json:"unlimited"` + Platform UsagePlatformBlock `json:"platform"` + Byok UsageByokBlock `json:"byok"` + TotalCredits int `json:"total_credits"` + RemainingCredits int `json:"remaining_credits"` +} + +type UsageSummaryReq struct { + MonthKey string `form:"month_key,optional"` +} + +type UsageTenantRow struct { + Uid int64 `json:"uid"` + PlanId string `json:"plan_id"` + Unlimited bool `json:"unlimited"` + PlatformCreditsUsed int `json:"platform_credits_used"` + ByokCallCount int `json:"byok_call_count"` +} + +type UsageTenantSummaryData struct { + MonthKey string `json:"month_key"` + PlatformCreditsUsed int `json:"platform_credits_used"` + ByokCallCount int `json:"byok_call_count"` + Members []UsageTenantRow `json:"members"` +} + +type UsageTenantSummaryReq struct { + MonthKey string `form:"month_key,optional"` +} + +type ViralAnalysisPublic struct { + Hooks string `json:"hooks"` + Structure string `json:"structure"` + Emotion string `json:"emotion"` + Summary string `json:"summary"` + Cta string `json:"cta,optional"` + Copyable string `json:"copyable,optional"` + Risks string `json:"risks,optional"` +} diff --git a/apps/backend/scripts/threads-profile/package-lock.json b/apps/backend/scripts/threads-profile/package-lock.json new file mode 100644 index 0000000..3305dcc --- /dev/null +++ b/apps/backend/scripts/threads-profile/package-lock.json @@ -0,0 +1,60 @@ +{ + "name": "threads-profile", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "threads-profile", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "playwright": "^1.49.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz", + "integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.49.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz", + "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/apps/backend/scripts/threads-profile/package.json b/apps/backend/scripts/threads-profile/package.json new file mode 100644 index 0000000..aef5874 --- /dev/null +++ b/apps/backend/scripts/threads-profile/package.json @@ -0,0 +1,15 @@ +{ + "name": "threads-profile", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "playwright": "^1.49.1" + } +} diff --git a/apps/backend/scripts/threads-profile/scrape.mjs b/apps/backend/scripts/threads-profile/scrape.mjs new file mode 100644 index 0000000..1d314d9 --- /dev/null +++ b/apps/backend/scripts/threads-profile/scrape.mjs @@ -0,0 +1,177 @@ +#!/usr/bin/env node +/** + * 抓取 Threads 公開個人頁貼文文字。 + * Usage: + * node scrape.mjs --username zuck [--storage /path/state.json] [--limit 12] + * stdout: JSON { "ok": true, "username": "...", "posts": ["..."] } + */ +import { chromium } from "playwright"; +import fs from "node:fs"; +import path from "node:path"; + +function arg(name, fallback = "") { + const i = process.argv.indexOf(name); + if (i >= 0 && process.argv[i + 1]) return process.argv[i + 1]; + return fallback; +} + +function fail(msg) { + process.stdout.write(JSON.stringify({ ok: false, error: msg, posts: [] }) + "\n"); + process.exit(0); +} + +function walkJson(data, visit) { + if (!data || typeof data !== "object") return; + if (Array.isArray(data)) { + for (const item of data) walkJson(item, visit); + return; + } + visit(data); + for (const v of Object.values(data)) { + if (v && typeof v === "object") walkJson(v, visit); + } +} + +function postText(obj) { + return ( + obj?.caption?.text ?? + obj?.text_post_app_info?.text ?? + (typeof obj?.text === "string" ? obj.text : undefined) + ); +} + +function collectFromJson(data, out, seen) { + walkJson(data, (obj) => { + const t = postText(obj); + if (!t || typeof t !== "string") return; + const text = t.trim(); + if (text.length < 8 || text.length > 2500) return; + if (text.startsWith("http://") || text.startsWith("https://")) return; + if (seen.has(text)) return; + seen.add(text); + out.push(text); + }); +} + +async function main() { + const username = arg("--username", "").replace(/^@/, "").trim(); + const storagePath = arg("--storage", ""); + const limit = Math.max(2, Math.min(30, Number(arg("--limit", "12")) || 12)); + if (!username || /[\s/]/.test(username) || username.includes("threads.")) { + fail("username 無效:請只填帳號名,例如 kupi.cat.31"); + } + + let storageState; + if (storagePath) { + try { + const raw = fs.readFileSync(path.resolve(storagePath), "utf8"); + storageState = JSON.parse(raw); + // Playwright expects cookies/origins shape + if (!storageState.cookies) storageState = undefined; + } catch { + storageState = undefined; + } + } + + const browser = await chromium.launch({ + headless: true, + args: ["--disable-blink-features=AutomationControlled", "--no-sandbox"], + }); + + try { + const context = await browser.newContext({ + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + locale: "zh-TW", + viewport: { width: 1280, height: 900 }, + storageState: storageState || undefined, + }); + await context.addInitScript(() => { + Object.defineProperty(navigator, "webdriver", { get: () => false }); + }); + + const page = await context.newPage(); + const collected = []; + const seen = new Set(); + + page.on("response", async (response) => { + try { + const url = response.url(); + if (!/graphql|threads|instagram/i.test(url)) return; + const ct = response.headers()["content-type"] || ""; + if (!ct.includes("json")) return; + const json = await response.json(); + collectFromJson(json, collected, seen); + } catch { + // ignore + } + }); + + const profileUrl = `https://www.threads.com/@${encodeURIComponent(username)}`; + await page.goto(profileUrl, { waitUntil: "domcontentloaded", timeout: 45_000 }); + await page.waitForTimeout(1200); + + // not found? + const bodyText = await page.locator("body").innerText().catch(() => ""); + if (/走丟|找不到|Page not found|isn't available|無法使用/i.test(bodyText) && collected.length === 0) { + fail(`找不到 @${username}(帳號可能不存在或頁面不可用)`); + } + + await page.waitForSelector('a[href*="/post/"]', { timeout: 12_000 }).catch(() => undefined); + + // scroll to trigger more network + for (let i = 0; i < 4; i++) { + await page.mouse.wheel(0, 1400); + await page.waitForTimeout(700); + if (collected.length >= limit) break; + } + + // embedded JSON scripts + if (collected.length < 2) { + const scripts = await page.locator('script[type="application/json"]').allTextContents(); + for (const raw of scripts) { + if (!raw || raw.length < 20) continue; + try { + collectFromJson(JSON.parse(raw), collected, seen); + } catch { + // ignore + } + } + } + + // DOM fallback: post link cards text + if (collected.length < 2) { + const links = page.locator('a[href*="/post/"]'); + const n = await links.count(); + for (let i = 0; i < Math.min(n, 25); i++) { + try { + const t = (await links.nth(i).innerText()).trim(); + if (t.length >= 8 && !seen.has(t)) { + seen.add(t); + collected.push(t); + } + } catch { + // ignore + } + if (collected.length >= limit) break; + } + } + + const posts = collected.slice(0, limit); + process.stdout.write( + JSON.stringify({ + ok: posts.length >= 2, + username, + posts, + error: + posts.length >= 2 + ? "" + : `可讀取貼文不足 2 篇(目前 ${posts.length})。可先在設定同步 Chrome Session,或改貼參考文字分析。`, + }) + "\n", + ); + } finally { + await browser.close(); + } +} + +main().catch((e) => fail(e instanceof Error ? e.message : String(e))); diff --git a/apps/extension/haixun-threads-sync/README.md b/apps/extension/haixun-threads-sync/README.md new file mode 100644 index 0000000..6eb4575 --- /dev/null +++ b/apps/extension/haixun-threads-sync/README.md @@ -0,0 +1,44 @@ +# 巡樓 Threads Session 同步(Chrome 擴充) + +從你已登入的 Chrome Threads 帳號,一鍵把 session 傳到巡樓後端,供**開發模式(爬蟲)測試海巡**使用。 + +## 安裝 + +1. 設定頁 → 開啟「開發模式」→ 下載 ZIP 並解壓 +2. Chrome 打開 `chrome://extensions` → 開發人員模式 → 載入未封裝項目 +3. 選解壓後的 `haixun-threads-sync` 資料夾 +4. 擴充**選項**填入巡樓網址(本機 `http://localhost:5173` 或遠端 `https://…`)並儲存 +5. **重新整理**巡樓分頁(F5) + +## 必要條件 + +- 後端 gateway 在跑(本機預設 `http://127.0.0.1:8888`) +- 前端在跑(本機 `http://localhost:5173`) +- Chrome 已登入 `threads.com` / `threads.net` +- 巡樓網頁已登入 + +## 使用 + +**A. 設定頁(推薦)** +開啟開發模式 → 出現「擴充已偵測」→ 按「從 Chrome 同步 Session」 + +**B. 擴充圖示** +先開著巡樓分頁 → 點擴充 →「同步到巡樓」 + +同步成功後,後端會寫入 `POST /api/v1/scout/crawler-session`(Playwright storageState)。 + +## 常見問題 + +| 現象 | 處理 | +|------|------| +| 尚未偵測擴充 | 擴充重新載入 + F5;選項網址須與分頁一致(`localhost` ≠ `127.0.0.1`) | +| Cannot access contents of the page… | v1.2.0 起 localStorage 注入失敗會略過,改只靠 cookies;請更新擴充並重新載入 | +| 找不到 cookies | 先開 threads.com 並登入 | +| 找不到登入狀態 | 在設定頁按同步(會帶 JWT);或確認已登入巡樓 | +| 無法連線 :8888 | 確認 gateway 有跑;遠端站請用同源 `/api` | + +## 技術 + +- 網頁 `postMessage` ↔ `content-haixun.js` ↔ service worker +- cookies → Playwright `storageState` → `scout/crawler-session` +- 新版 JWT key:`hb_live_tokens`(舊:`haixun.access_token`) diff --git a/apps/extension/haixun-threads-sync/content-haixun.js b/apps/extension/haixun-threads-sync/content-haixun.js new file mode 100644 index 0000000..260f149 --- /dev/null +++ b/apps/extension/haixun-threads-sync/content-haixun.js @@ -0,0 +1,109 @@ +(() => { + const ROOT = document.documentElement; + // 每次注入都寫標記(方便偵測);監聽器只掛一次 + ROOT.dataset.haixunExtension = "1"; + ROOT.dataset.haixunExtensionVersion = "4"; + + function readPageAccessToken() { + try { + const legacy = localStorage.getItem("haixun.access_token"); + if (legacy) return legacy; + const raw = localStorage.getItem("hb_live_tokens"); + if (!raw) return ""; + const parsed = JSON.parse(raw); + return String(parsed?.access_token ?? ""); + } catch { + return ""; + } + } + + function announceReady() { + window.postMessage( + { type: "HAIXUN_EXTENSION_READY", version: 4, origin: location.origin }, + "*", + ); + } + + if (!globalThis.__HAIXUN_THREADS_BRIDGE__) { + globalThis.__HAIXUN_THREADS_BRIDGE__ = true; + + window.addEventListener("message", (event) => { + if (event.source !== window) return; + + if (event.data?.type === "HAIXUN_PING_EXTENSION") { + announceReady(); + return; + } + + // 只收集 cookies,回傳 storageState 給網頁(網頁用自己的 JWT 上傳) + if (event.data?.type === "HAIXUN_REQUEST_THREADS_COLLECT") { + chrome.runtime + .sendMessage({ action: "collect" }) + .then((result) => { + window.postMessage( + { + type: "HAIXUN_THREADS_COLLECT_RESULT", + ...(result ?? {}), + }, + "*", + ); + }) + .catch((error) => { + const message = + chrome.runtime.lastError?.message ?? + (error instanceof Error ? error.message : "收集失敗"); + window.postMessage( + { + type: "HAIXUN_THREADS_COLLECT_RESULT", + success: false, + message, + }, + "*", + ); + }); + return; + } + + if (event.data?.type !== "HAIXUN_REQUEST_THREADS_SYNC") return; + + const accessToken = + String(event.data.accessToken ?? "").trim() || readPageAccessToken(); + + chrome.runtime + .sendMessage({ + action: "sync", + serverUrl: event.data.serverUrl ?? window.location.origin, + accountId: String(event.data.accountId ?? "").trim(), + accessToken, + apiVersion: event.data.apiVersion ?? "go-v1", + }) + .then((result) => { + window.postMessage( + { + type: "HAIXUN_THREADS_SYNC_RESULT", + ...(result ?? {}), + }, + "*", + ); + }) + .catch((error) => { + const message = + chrome.runtime.lastError?.message ?? + (error instanceof Error ? error.message : "同步失敗"); + window.postMessage( + { + type: "HAIXUN_THREADS_SYNC_RESULT", + success: false, + valid: false, + message, + }, + "*", + ); + }); + }); + } + + announceReady(); + setTimeout(announceReady, 500); + setTimeout(announceReady, 2000); +})(); diff --git a/apps/extension/haixun-threads-sync/manifest.json b/apps/extension/haixun-threads-sync/manifest.json new file mode 100644 index 0000000..0de08b6 --- /dev/null +++ b/apps/extension/haixun-threads-sync/manifest.json @@ -0,0 +1,65 @@ +{ + "manifest_version": 3, + "name": "巡樓 Threads Session 同步", + "version": "1.2.3", + "description": "從 Chrome 已登入的 Threads 一鍵同步 session 到巡樓(開發模式爬蟲)", + "permissions": ["cookies", "storage", "tabs", "scripting"], + "host_permissions": [ + "https://www.threads.com/*", + "https://threads.com/*", + "https://www.threads.net/*", + "https://threads.net/*", + "https://www.instagram.com/*", + "https://instagram.com/*", + "https://*.facebook.com/*", + "http://localhost:3000/*", + "http://127.0.0.1:3000/*", + "http://localhost:4173/*", + "http://127.0.0.1:4173/*", + "http://localhost:5173/*", + "http://127.0.0.1:5173/*", + "http://localhost:8888/*", + "http://127.0.0.1:8888/*", + "http://localhost:8890/*", + "http://127.0.0.1:8890/*", + "https://threads-tool-dev.30cm.net/*", + "https://*.30cm.net/*" + ], + "optional_host_permissions": ["http://*/*", "https://*/*"], + "action": { + "default_popup": "popup.html", + "default_title": "同步 Threads Session" + }, + "background": { + "service_worker": "service-worker.js", + "type": "module" + }, + "content_scripts": [ + { + "matches": [ + "http://localhost:3000/*", + "http://127.0.0.1:3000/*", + "http://localhost:4173/*", + "http://127.0.0.1:4173/*", + "http://localhost:5173/*", + "http://127.0.0.1:5173/*", + "http://localhost:8888/*", + "http://127.0.0.1:8888/*", + "http://localhost:8890/*", + "http://127.0.0.1:8890/*", + "https://threads-tool-dev.30cm.net/*", + "https://*.30cm.net/*" + ], + "js": ["content-haixun.js"], + "run_at": "document_start", + "all_frames": false + } + ], + "web_accessible_resources": [ + { + "resources": ["content-haixun.js"], + "matches": ["http://*/*", "https://*/*"] + } + ], + "options_page": "options.html" +} diff --git a/apps/extension/haixun-threads-sync/options.html b/apps/extension/haixun-threads-sync/options.html new file mode 100644 index 0000000..79b4072 --- /dev/null +++ b/apps/extension/haixun-threads-sync/options.html @@ -0,0 +1,82 @@ + + + + + 巡樓同步設定 + + + +

巡樓網站網址

+

+ 必須與瀏覽器網址列完全一致(含 + httpslocalhost 與 + 127.0.0.1 不可混用)。 +

+

+ 遠端例:https://threads-tool-dev.30cm.net
+ 本機例:http://localhost:5173 +

+ + + +
+
+

+ 儲存後請回到巡樓分頁按 F5。設定頁應顯示「擴充已偵測」。 + 若仍未偵測:chrome://extensions → 重新載入此擴充 → 再開一次本選項按儲存 → F5。 +

+
+ + + diff --git a/apps/extension/haixun-threads-sync/options.js b/apps/extension/haixun-threads-sync/options.js new file mode 100644 index 0000000..2dd2e88 --- /dev/null +++ b/apps/extension/haixun-threads-sync/options.js @@ -0,0 +1,97 @@ +const input = document.getElementById("serverUrl"); +const saveBtn = document.getElementById("save"); +const savedEl = document.getElementById("saved"); + +const DEFAULT_URL = "https://threads-tool-dev.30cm.net"; + +chrome.storage.sync.get(["serverUrl"], ({ serverUrl }) => { + input.value = serverUrl ?? DEFAULT_URL; +}); + +function originPatterns(origin) { + const url = new URL(origin); + const port = url.port ? `:${url.port}` : ""; + const origins = new Set([`${origin}/*`]); + if (url.hostname === "localhost") { + origins.add(`${url.protocol}//127.0.0.1${port}/*`); + } else if (url.hostname === "127.0.0.1") { + origins.add(`${url.protocol}//localhost${port}/*`); + } + return [...origins]; +} + +saveBtn.addEventListener("click", async () => { + const value = input.value.trim(); + if (!value) return; + + let origin; + try { + origin = new URL(value).origin; + } catch { + savedEl.textContent = "網址格式不正確"; + savedEl.style.color = "#b45309"; + return; + } + + await chrome.storage.sync.set({ serverUrl: origin }); + + const patterns = originPatterns(origin); + let granted = false; + try { + granted = await chrome.permissions.contains({ origins: patterns }); + if (!granted) { + granted = await chrome.permissions.request({ origins: patterns }); + } + } catch (e) { + savedEl.textContent = `授權失敗:${e instanceof Error ? e.message : String(e)}`; + savedEl.style.color = "#b45309"; + return; + } + + if (!granted) { + savedEl.textContent = + "已儲存網址,但未授權存取該網站。請在彈窗按「允許」,否則巡樓頁無法偵測擴充。"; + savedEl.style.color = "#b45309"; + return; + } + + try { + try { + await chrome.scripting.unregisterContentScripts({ ids: ["haixun-bridge"] }); + } catch { + // ignore + } + await chrome.scripting.registerContentScripts([ + { + id: "haixun-bridge", + matches: patterns, + js: ["content-haixun.js"], + runAt: "document_start", + }, + ]); + + const groups = await Promise.all( + patterns.map((pattern) => chrome.tabs.query({ url: pattern })), + ); + const seen = new Set(); + let injected = 0; + for (const tab of groups.flat()) { + if (!tab.id || seen.has(tab.id)) continue; + seen.add(tab.id); + try { + await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + files: ["content-haixun.js"], + }); + injected += 1; + } catch { + // ignore restricted tabs + } + } + savedEl.textContent = `已儲存:${origin}(已授權,注入 ${injected} 個分頁)。請回巡樓分頁按 F5。`; + savedEl.style.color = "#166534"; + } catch (e) { + savedEl.textContent = `注入失敗:${e instanceof Error ? e.message : String(e)}`; + savedEl.style.color = "#b45309"; + } +}); diff --git a/apps/extension/haixun-threads-sync/popup.html b/apps/extension/haixun-threads-sync/popup.html new file mode 100644 index 0000000..10022ae --- /dev/null +++ b/apps/extension/haixun-threads-sync/popup.html @@ -0,0 +1,62 @@ + + + + + 巡樓 Session 同步 + + + +

同步 Threads Session

+

+ 1. 在 Chrome 登入 threads.com
+ 2. 開啟巡樓網頁並登入、切換帳號
+ 3. 按下方按鈕(會自動讀取巡樓 JWT) +

+ +
+

設定 server 網址

+ + + \ No newline at end of file diff --git a/apps/extension/haixun-threads-sync/popup.js b/apps/extension/haixun-threads-sync/popup.js new file mode 100644 index 0000000..c44eae5 --- /dev/null +++ b/apps/extension/haixun-threads-sync/popup.js @@ -0,0 +1,42 @@ +const syncBtn = document.getElementById("sync"); +const statusEl = document.getElementById("status"); + +function setStatus(text, isError = false) { + statusEl.textContent = text; + statusEl.style.color = isError ? "#b45309" : "#166534"; +} + +syncBtn.addEventListener("click", async () => { + syncBtn.disabled = true; + setStatus("同步中…"); + + try { + const { serverUrl } = await chrome.storage.sync.get(["serverUrl"]); + const response = await chrome.runtime.sendMessage({ + action: "sync", + serverUrl, + apiVersion: "go-v1", + }); + + if (!response) { + throw new Error( + chrome.runtime.lastError?.message ?? + "擴充背景程序無回應。請到 chrome://extensions 重新載入擴充" + ); + } + + if (response.success !== false && response.valid !== false) { + setStatus( + response.username + ? `成功:@${response.username}\n${response.message ?? ""}` + : response.message ?? "同步成功" + ); + } else { + setStatus(response.message ?? "同步失敗", true); + } + } catch (error) { + setStatus(error instanceof Error ? error.message : "同步失敗", true); + } finally { + syncBtn.disabled = false; + } +}); \ No newline at end of file diff --git a/apps/extension/haixun-threads-sync/service-worker.js b/apps/extension/haixun-threads-sync/service-worker.js new file mode 100644 index 0000000..54a0c7f --- /dev/null +++ b/apps/extension/haixun-threads-sync/service-worker.js @@ -0,0 +1,396 @@ +import { buildStorageState } from "./storage-state.js"; + +const CONTENT_SCRIPT_ID = "haixun-bridge"; +const GO_API_SUCCESS_CODE = 102000; +/** 前端 dev ports → gateway 預設 8888(舊 8890 仍相容) */ +const DEV_WEB_PORTS = new Set(["3000", "4173", "5173"]); +const DEV_API_PORTS = ["8888", "8890"]; + +function unwrapGoApiResponse(raw) { + if (raw && typeof raw === "object" && "code" in raw) { + if (raw.code !== GO_API_SUCCESS_CODE) { + throw new Error(raw.message || `API error ${raw.code}`); + } + return raw.data && typeof raw.data === "object" ? raw.data : {}; + } + return raw && typeof raw === "object" ? raw : {}; +} + +function parseApiError(raw, status) { + if (raw && typeof raw === "object") { + if (typeof raw.message === "string" && raw.message.trim()) return raw.message; + if (typeof raw.error === "string" && raw.error.trim()) return raw.error; + } + return `匯入失敗(HTTP ${status})`; +} + +function equivalentOrigins(origin) { + const url = new URL(origin); + const port = url.port ? `:${url.port}` : ""; + const origins = new Set([url.origin]); + if (url.hostname === "localhost") { + origins.add(`${url.protocol}//127.0.0.1${port}`); + } else if (url.hostname === "127.0.0.1") { + origins.add(`${url.protocol}//localhost${port}`); + } + return [...origins]; +} + +/** + * 開發前端 :5173 等 → 後端 gateway :8888;正式部署 same-origin。 + */ +function resolveApiBases(serverUrl) { + const url = new URL(serverUrl); + if (DEV_WEB_PORTS.has(url.port)) { + const host = url.hostname === "localhost" ? "127.0.0.1" : url.hostname; + return DEV_API_PORTS.map((p) => `${url.protocol}//${host}:${p}`); + } + // 遠端 / reverse-proxy:同源即可 + return [url.origin]; +} + +async function requestHostPermission(serverOrigin) { + const origins = equivalentOrigins(serverOrigin).map((item) => `${item}/*`); + try { + const granted = await chrome.permissions.contains({ origins }); + if (granted) return true; + // 無使用者手勢時 request 可能失敗;設定頁從 content script 進來常無 gesture + return await chrome.permissions.request({ origins }); + } catch { + return false; + } +} + +async function queryHaixunTabs(serverOrigin) { + const tabsById = new Map(); + for (const origin of equivalentOrigins(serverOrigin)) { + try { + const tabs = await chrome.tabs.query({ url: `${origin}/*` }); + for (const tab of tabs) { + if (tab.id != null) tabsById.set(tab.id, tab); + } + } catch { + // no permission for that origin + } + } + return [...tabsById.values()]; +} + +async function ensureContentScript(serverOrigin) { + const patterns = equivalentOrigins(serverOrigin).map((item) => `${item}/*`); + try { + const existing = await chrome.scripting.getRegisteredContentScripts(); + if (existing.some((script) => script.id === CONTENT_SCRIPT_ID)) { + try { + await chrome.scripting.unregisterContentScripts({ ids: [CONTENT_SCRIPT_ID] }); + } catch { + // ignore + } + } + await chrome.scripting.registerContentScripts([ + { + id: CONTENT_SCRIPT_ID, + matches: patterns, + js: ["content-haixun.js"], + runAt: "document_start", + }, + ]); + } catch (error) { + console.warn("[haixun] registerContentScripts", error); + } +} + +async function injectBridgeIntoTabs(serverOrigin) { + const tabs = await queryHaixunTabs(serverOrigin); + for (const tab of tabs) { + if (!tab.id) continue; + try { + await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + files: ["content-haixun.js"], + }); + } catch { + // Ignore: no host permission / restricted page / already injected + } + } +} + +/** 從巡樓分頁讀 JWT;支援舊 key 與新版 hb_live_tokens */ +async function readAuthFromHaixunTab(serverOrigin) { + const tabs = await queryHaixunTabs(serverOrigin); + for (const tab of tabs) { + if (!tab.id) continue; + try { + const [result] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: () => { + let accessToken = localStorage.getItem("haixun.access_token") ?? ""; + if (!accessToken) { + try { + const raw = localStorage.getItem("hb_live_tokens"); + if (raw) { + const parsed = JSON.parse(raw); + accessToken = String(parsed?.access_token ?? ""); + } + } catch { + // ignore + } + } + const pathname = location.pathname ?? ""; + const fromPath = pathname.match(/\/threads\/([^/]+)/)?.[1] ?? ""; + const accountId = + localStorage.getItem("haixun.active_threads_account_id") || + localStorage.getItem("hb_active_threads_account_id") || + fromPath || + ""; + return { + accessToken, + accountId: String(accountId), + origin: location.origin, + }; + }, + }); + const payload = result?.result; + if (payload?.accessToken) { + return payload; + } + } catch (error) { + console.warn("[haixun] readAuth tab failed (host permission?)", tab.url, error); + } + } + return null; +} + +async function postJSON(apiBase, path, accessToken, body) { + const res = await fetch(`${apiBase}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(body), + }); + const raw = await res.json().catch(() => ({})); + if (!res.ok) { + const err = new Error(parseApiError(raw, res.status)); + err.status = res.status; + err.raw = raw; + throw err; + } + return unwrapGoApiResponse(raw); +} + +/** + * 優先 scout crawler-session;失敗再試舊 threads session/import(後端已相容)。 + */ +async function postSessionToBackend(apiBase, accessToken, storageState, accountId) { + try { + await postJSON(apiBase, "/api/v1/scout/crawler-session", accessToken, { + token: storageState, + }); + return { + valid: true, + synced: true, + account_id: accountId || "", + message: `Chrome session 已同步到巡樓爬蟲(${apiBase})`, + apiBase, + }; + } catch (first) { + // 404/405:舊後端或路由未就緒 → 走 session/import + const status = first?.status; + if (status !== 404 && status !== 405) { + throw first; + } + if (!accountId) { + throw first; + } + const data = await postJSON( + apiBase, + `/api/v1/threads-accounts/${encodeURIComponent(accountId)}/session/import`, + accessToken, + { storageState }, + ); + return { + valid: data.valid !== false, + synced: data.synced !== false, + account_id: data.account_id || accountId, + username: data.username, + message: data.message || `Chrome session 已同步(${apiBase})`, + apiBase, + }; + } +} + +/** + * 同步:收集 Threads cookies → 寫入巡樓 scout crawler-session(dev 模式海巡用) + */ +export async function syncThreadsSessionToGo(serverUrl, accountId, accessToken) { + const origin = new URL(serverUrl).origin; + + if (!accessToken) { + const hint = equivalentOrigins(origin).join(" 或 "); + throw new Error( + `找不到巡樓登入狀態。請在 Chrome 開啟並登入 ${hint},或在設定頁按「從 Chrome 同步」(會帶上 JWT)`, + ); + } + + let storageState; + try { + storageState = await buildStorageState(); + } catch (error) { + throw error; + } + + const bases = resolveApiBases(serverUrl); + let lastErr = null; + for (const apiBase of bases) { + try { + return await postSessionToBackend(apiBase, accessToken, storageState, accountId); + } catch (error) { + lastErr = error; + const msg = error instanceof Error ? error.message : String(error); + if (/fetch|network|failed|Failed to fetch/i.test(msg)) { + continue; // try next api base + } + // auth / business error — don't retry other ports + throw error; + } + } + + const tried = bases.join("、"); + const detail = lastErr instanceof Error ? lastErr.message : String(lastErr ?? ""); + throw new Error( + `無法連線後端(已試 ${tried})。請確認 gateway 有在跑,且擴充已授權該網址。${detail ? ` ${detail}` : ""}`, + ); +} + +async function resolveServerUrl(partial) { + if (partial) return new URL(partial).origin; + + const stored = await chrome.storage.sync.get(["serverUrl"]); + if (stored.serverUrl) return new URL(stored.serverUrl).origin; + + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const activeUrl = tabs[0]?.url; + if (activeUrl) { + try { + const origin = new URL(activeUrl).origin; + const port = new URL(activeUrl).port; + if (DEV_WEB_PORTS.has(port) || activeUrl.includes("/app/") || activeUrl.includes("/threads/")) { + return origin; + } + } catch { + // ignore + } + } + + throw new Error("請在擴充功能選項設定巡樓網址(例如 http://localhost:5173),或先開啟巡樓分頁"); +} + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + // 僅收集 cookies / storageState,由網頁用自己的 JWT 上傳(避免 401) + if (message?.action === "collect") { + (async () => { + try { + const storageState = await buildStorageState(); + sendResponse({ + success: true, + storageState, + message: "已收集 Threads cookies", + }); + } catch (error) { + sendResponse({ + success: false, + message: error instanceof Error ? error.message : "收集失敗", + }); + } + })(); + return true; + } + + if (message?.action !== "sync") return undefined; + + (async () => { + try { + const serverUrl = await resolveServerUrl(message.serverUrl); + // 權限請求可能因無 user gesture 失敗;不因此中斷(cookies 仍可能夠用) + await requestHostPermission(serverUrl); + await ensureContentScript(serverUrl); + await injectBridgeIntoTabs(serverUrl); + + let accountId = String(message.accountId ?? "").trim(); + let accessToken = String(message.accessToken ?? "").trim(); + if (!accountId || !accessToken) { + const auth = await readAuthFromHaixunTab(serverUrl); + if (auth) { + accountId = accountId || String(auth.accountId ?? "").trim(); + accessToken = accessToken || String(auth.accessToken ?? "").trim(); + } + } + if (!accessToken) { + throw new Error( + "找不到巡樓 JWT。請在設定頁按「從 Chrome 同步」(由網頁帶登入態上傳),或先確認已登入巡樓。", + ); + } + + const result = await syncThreadsSessionToGo(serverUrl, accountId, accessToken); + sendResponse({ + success: true, + valid: result.valid !== false, + synced: result.synced ?? true, + account_id: result.account_id, + username: result.username, + message: result.message || "Chrome session 已同步", + }); + } catch (error) { + sendResponse({ + success: false, + valid: false, + message: error instanceof Error ? error.message : "同步失敗", + }); + } + })(); + + return true; +}); + +async function bootstrapBridgeFromStorage() { + try { + const { serverUrl } = await chrome.storage.sync.get(["serverUrl"]); + // 預設同時覆蓋本機與遠端 dev + const defaults = [ + "http://localhost:5173", + "http://127.0.0.1:5173", + "https://threads-tool-dev.30cm.net", + ]; + const primary = serverUrl || defaults[0]; + if (!serverUrl) { + await chrome.storage.sync.set({ serverUrl: primary }); + } + // 註冊並注入目前設定 + 常見網址 + const origins = new Set([primary, ...defaults]); + for (const origin of origins) { + try { + await ensureContentScript(origin); + await injectBridgeIntoTabs(origin); + } catch (e) { + console.warn("[haixun] bootstrap", origin, e); + } + } + } catch (e) { + console.warn("[haixun] bootstrapBridgeFromStorage", e); + } +} + +chrome.runtime.onInstalled.addListener(() => { + void bootstrapBridgeFromStorage(); +}); + +chrome.runtime.onStartup.addListener(() => { + void bootstrapBridgeFromStorage(); +}); + +// service worker 冷啟動也試一次 +void bootstrapBridgeFromStorage(); + diff --git a/apps/extension/haixun-threads-sync/storage-state.js b/apps/extension/haixun-threads-sync/storage-state.js new file mode 100644 index 0000000..468295e --- /dev/null +++ b/apps/extension/haixun-threads-sync/storage-state.js @@ -0,0 +1,131 @@ +/** @typedef {{ name: string; value: string; domain: string; path: string; expires: number; httpOnly: boolean; secure: boolean; sameSite: string }} PlaywrightCookie */ + +const COOKIE_DOMAINS = [ + "threads.com", + "threads.net", + "instagram.com", + "facebook.com", +]; + +const THREADS_TAB_URLS = [ + "https://www.threads.com/*", + "https://threads.com/*", + "https://www.threads.net/*", + "https://threads.net/*", +]; + +function mapSameSite(sameSite) { + if (sameSite === "no_restriction") return "None"; + if (sameSite === "lax") return "Lax"; + if (sameSite === "strict") return "Strict"; + return "Lax"; +} + +/** @param {chrome.cookies.Cookie} cookie */ +function toPlaywrightCookie(cookie) { + return { + name: cookie.name, + value: cookie.value, + domain: cookie.domain, + path: cookie.path || "/", + expires: cookie.expirationDate ?? -1, + httpOnly: cookie.httpOnly ?? false, + secure: cookie.secure ?? false, + sameSite: mapSameSite(cookie.sameSite), + }; +} + +export async function collectThreadsCookies() { + /** @type {PlaywrightCookie[]} */ + const merged = []; + const seen = new Set(); + + for (const domain of COOKIE_DOMAINS) { + const batch = await chrome.cookies.getAll({ domain }); + for (const cookie of batch) { + const key = `${cookie.domain}|${cookie.path}|${cookie.name}`; + if (seen.has(key)) continue; + seen.add(key); + merged.push(toPlaywrightCookie(cookie)); + } + } + + // Some Chrome builds store Meta login cookies only on exact host URLs. + const urls = [ + "https://www.threads.com/", + "https://threads.com/", + "https://www.threads.net/", + "https://threads.net/", + "https://www.instagram.com/", + ]; + for (const url of urls) { + try { + const batch = await chrome.cookies.getAll({ url }); + for (const cookie of batch) { + const key = `${cookie.domain}|${cookie.path}|${cookie.name}`; + if (seen.has(key)) continue; + seen.add(key); + merged.push(toPlaywrightCookie(cookie)); + } + } catch { + // host permission missing for that URL — skip + } + } + + return merged; +} + +/** + * localStorage 非必要;cookies 已可組成 storageState。 + * 缺 host 權限時不能 executeScript,必須 soft-fail,否則設定頁同步會整段炸掉。 + */ +export async function collectThreadsLocalStorage() { + let tabs = []; + try { + tabs = await chrome.tabs.query({ url: THREADS_TAB_URLS }); + } catch { + return []; + } + + if (!tabs.length || tabs[0].id == null) { + return []; + } + + try { + const [injection] = await chrome.scripting.executeScript({ + target: { tabId: tabs[0].id }, + func: () => { + const localStorageItems = []; + for (let i = 0; i < localStorage.length; i += 1) { + const name = localStorage.key(i); + if (!name) continue; + localStorageItems.push({ name, value: localStorage.getItem(name) ?? "" }); + } + return { origin: location.origin, localStorage: localStorageItems }; + }, + }); + + const payload = injection?.result; + if (!payload || !Array.isArray(payload.localStorage)) { + return []; + } + return [payload]; + } catch (error) { + // Chrome: "Cannot access contents of the page. Extension manifest must request permission..." + console.warn("[haixun] skip threads localStorage (no host access)", error); + return []; + } +} + +export async function buildStorageState() { + const cookies = await collectThreadsCookies(); + const origins = await collectThreadsLocalStorage(); + + if (!cookies.length) { + throw new Error( + "找不到 Threads / Instagram cookies。請先在 Chrome 開啟 threads.com 或 threads.net 並完成登入", + ); + } + + return JSON.stringify({ cookies, origins }); +} diff --git a/apps/web/public/downloads/haixun-threads-sync.zip b/apps/web/public/downloads/haixun-threads-sync.zip index f7d9fbc..d6f62f6 100644 Binary files a/apps/web/public/downloads/haixun-threads-sync.zip and b/apps/web/public/downloads/haixun-threads-sync.zip differ diff --git a/apps/web/src/auth/RequireAdmin.tsx b/apps/web/src/auth/RequireAdmin.tsx index 12e5d75..b6d3103 100644 --- a/apps/web/src/auth/RequireAdmin.tsx +++ b/apps/web/src/auth/RequireAdmin.tsx @@ -1,10 +1,10 @@ import { Navigate } from "react-router-dom"; +import { can, Permission } from "../lib/permissions"; import { useAuth } from "./AuthContext"; -import { memberRoleSummary } from "../lib/memberRole"; /** - * 管理員 permission:roles 含 admin。 - * 無權限一律導向今日,不渲染目標頁(避免 flash 出「需要管理員」空狀態)。 + * 需 permission.AdminAccess(admin 角色預設授予)。 + * 無此能力 → 導向 /app/today,不顯示「無權限」文案。 */ export function RequireAdmin({ children }: { children: React.ReactNode }) { const { member, loading } = useAuth(); @@ -21,7 +21,7 @@ export function RequireAdmin({ children }: { children: React.ReactNode }) { return ; } - if (!memberRoleSummary(member).isAdmin) { + if (!can(member.roles, Permission.AdminAccess)) { return ; } diff --git a/apps/web/src/components/layout/AccountMenu.tsx b/apps/web/src/components/layout/AccountMenu.tsx index 31ddd62..f86622a 100644 --- a/apps/web/src/components/layout/AccountMenu.tsx +++ b/apps/web/src/components/layout/AccountMenu.tsx @@ -4,6 +4,7 @@ import { useAuth } from "../../auth/AuthContext"; import { AccountAvatar } from "../ui/AccountAvatar"; import { useI18n } from "../../i18n/I18nContext"; import { memberRoleSummary } from "../../lib/memberRole"; +import { can, Permission } from "../../lib/permissions"; import { useTheme } from "../../theme/ThemeContext"; /** @@ -74,7 +75,7 @@ export function AccountMenu() {